From 9e113530f4a1ab5a943afd291bbc98a13618dabb Mon Sep 17 00:00:00 2001 From: Brian Glass Date: Fri, 11 Sep 2026 10:11:44 -0400 Subject: [PATCH] Measure the Greek grant list's coverage; it is complete for the harvest The fixed-date grant list was left carrying a warning that it was incomplete by construction -- a date only surfaces when it lands on a Wednesday or Friday, so some grants must still be hidden. That was an expected-value argument rather than a measurement. Measuring it shows the list is complete with respect to the ten years harvested, so the follow-up harvest it implied is not needed. The measurement has to be made against the season, not the app's output. Asking "where does the app say strict?" is circular: a granted date no longer says strict, so every grant looks like a gap. Tried that way first, it reported 113 blind dates, most of which were the grants themselves. A grant leaves a trace only where the season's own floor is strict and its cap would let a claim reach wine and oil. Of 366 calendar dates, 329 are probed two or more times, 12 once, and 25 never. The 25 are two structural blind spots and neither is a real gap. Dec 25 to Jan 4 and Jan 6 are fast-free, so no grant can exist there. Aug 1-14, the Dormition fast, caps claims below rank 7 to strict, so a grant would be invisible -- and rather than leave that an unknown it was checked against goarch.org directly: across ten years its Dormition weekdays are strict 93 times and fish 7, and all seven are Aug 6, the Transfiguration, which the shared data already grants. Of the 12 thinly-probed dates, two showed wine and oil on their single probe, Feb 17 and Jun 3, and both are already right -- Feb 17 through its override and Jun 3 from the Paschal season's own allowance rather than from any grant. Adds tools/fasting/grant_coverage.py so the question can be re-asked after any future harvest, and corrects the claim in docs/fasting-refactor-scope.md. Documentation and a tool; no application code or data changed. 197 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019nQRjJpPqW89xqzAf1vsEX --- docs/fasting-refactor-scope.md | 42 +++++++++++- tools/fasting/grant_coverage.py | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tools/fasting/grant_coverage.py diff --git a/docs/fasting-refactor-scope.md b/docs/fasting-refactor-scope.md index 7680166..614d416 100644 --- a/docs/fasting-refactor-scope.md +++ b/docs/fasting-refactor-scope.md @@ -693,7 +693,8 @@ an ordinary-time day is already fast-free. A given date lands on Wed or Fri in roughly 2 years out of 7, so **three years of data exposes only about a third of the list.** Fully enumerating it needs on the order of ten years of harvest. Inside the fasting seasons the problem does not arise -- every day is a fast -day, so Dec 15 shows up in all four harvested Decembers. +day, so Dec 15 shows up in all four harvested Decembers. (Ten years did prove +enough; the coverage measurement at the end of this document is the evidence.) **8 -- fish capped to wine and oil on ordinary Wed/Fri.** With three years the confound noted earlier is broken: 2028-01-07 is a **Friday** that keeps fish, @@ -835,3 +836,42 @@ express them cleanly: the best-evidenced of the three and the natural first behaviour change once the refactor lands -- but it is still a behaviour change, and belongs in its own commit with the characterisation diff visible. + + +## Is the grant list complete? Measured, 2026-09-11 + +The list was left carrying a warning that it was incomplete by construction: a +date only surfaces when it lands on a Wednesday or Friday, so some grants must +still be hidden. **That was an expected-value argument rather than a +measurement, and measuring it shows the list is complete with respect to the ten +years harvested.** `tools/fasting/grant_coverage.py` reproduces it. + +The measurement has to be made against the *season*, not the app's output. +Asking "where does the app say strict?" is circular -- a granted date no longer +says strict, so every grant looks like a gap. It was tried that way first and +reported 113 blind dates, most of which were the grants themselves. A grant +leaves a trace only where the season's own floor is strict **and** its cap would +let a claim reach wine and oil. + +| | of 366 calendar dates | +|---|---| +| probed two or more times | **329** | +| probed once | 12 | +| never probed | 25 | + +The 25 are two structural blind spots, and neither is a real gap: + +- **Dec 25 - Jan 4 and Jan 6** are fast-free, so no grant can exist there. +- **Aug 1-14**, the Dormition fast, caps claims below rank 7 to strict, so a + grant would be invisible. Rather than leave that an unknown it was checked + against goarch.org directly: across ten years its Dormition weekdays are + **strict 93 times and fish 7**, and all seven are Aug 6, the Transfiguration, + which the shared data already grants. There is nothing there to miss. + +Of the 12 thinly-probed dates, two showed wine and oil on their single probe -- +Feb 17 and Jun 3 -- and both are already right: Feb 17 through its override, and +Jun 3 from the Paschal season's own allowance rather than from any grant. + +So no gap remains that this data can reveal. More years would buy more probes +for those 12 dates, which is the only place a further harvest could help; the +route is documented in `tools/fasting/goarch_audit.py`. diff --git a/tools/fasting/grant_coverage.py b/tools/fasting/grant_coverage.py new file mode 100644 index 0000000..186e79d --- /dev/null +++ b/tools/fasting/grant_coverage.py @@ -0,0 +1,111 @@ +"""How much of the Greek fixed-date grant list the harvest can actually see. + + docker compose run --rm local python tools/fasting/grant_coverage.py + +The grant list in the fixture -- sparse `greek` `Day` rows overriding +`fast_exception` -- was derived from goarch.org, and a date can only enter it if +the harvest ever caught it relaxed. So the obvious worry is that the list is +incomplete: a grant on a date that never surfaced would be invisible and +untestable. + +This measures that worry instead of assuming it. **Last run 2026-09-11: the +list is complete with respect to the ten years harvested.** + +The measurement has to be made against the *season*, not against the app's +output. Asking "where does the app say strict?" is circular -- a granted date no +longer says strict, so every grant would look like a gap. A grant leaves a trace +only where the season's own floor is strict *and* its cap would let a claim +reach wine and oil; anywhere else the answer is the same with or without it. +Those occurrences are the probes below. + +Two structural blind spots fall out, and neither is a real gap: + + * **Dec 25 - Jan 4 and Jan 6** are fast-free, so no grant can exist there. + * **Aug 1-14**, the Dormition fast, caps claims below rank 7 to strict, so a + grant there would be invisible. Checked directly against goarch.org rather + than left as an unknown: across ten years its Dormition weekdays are strict + 93 times and fish 7, and all seven are Aug 6, the Transfiguration, which the + shared data already grants. + +What would extend the list is more years, which buys more probes for the dates +currently probed once or twice. `tools/fasting/goarch_audit.py` documents the +harvest route. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +os.chdir(sys.path[0]) + +import calendar +import collections +import datetime +import json + +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'orthocal.settings') +django.setup() + +from calendarium import fasting # noqa: E402 +from calendarium.datetools import ( # noqa: E402 + DietaryAllowance as D, FastLevels, Tradition, +) +from calendarium.liturgics import Day # noqa: E402 + +GOARCH = 'data/goarch_fasting.json' +CODES = {'S': 'strict', 'W': 'wine+oil', 'F': 'fish', 'D': 'meat fast', + 'N': 'fast free'} + + +def years(goa): + return sorted({int(k[:4]) for k in goa if not k.startswith('_')}) + + +def main(): + with open(GOARCH) as f: + goa = json.load(f) + span = [y for y in years(goa) if all(f'{y}-{m:02d}' in goa for m in range(1, 13))] + + probes = collections.Counter() + for year in span: + date = datetime.date(year, 1, 1) + while date.year == year: + day = Day(year, date.month, date.day, tradition=Tradition.Greek) + day.initialize() + season = fasting.greek_season(day) + if season is not None and day.fast_level != FastLevels.NoFast: + floor = season.floor_for(day.weekday) + cap = season.cap_for(day.weekday, day.feast_level) + if floor <= D.Strict and cap >= D.WineAndOil: + probes[(date.month, date.day)] += 1 + date += datetime.timedelta(days=1) + + all_dates = [(m, d) for m in range(1, 13) + for d in range(1, calendar.monthrange(2028, m)[1] + 1)] + never = [md for md in all_dates if probes[md] == 0] + thin = [md for md in all_dates if probes[md] == 1] + + print(f'\n {len(span)} full years harvested: {span[0]}-{span[-1]}\n') + print(f' dates probed 2+ times : {len(all_dates) - len(never) - len(thin)}') + print(f' probed once : {len(thin)}') + print(f' never probed : {len(never)}') + + print('\n never probed (a grant here would leave no trace):') + for md in never: + print(f' {md[0]:02d}-{md[1]:02d}') + + # The Dormition blind spot, checked rather than assumed. + tally = collections.Counter() + for year in span: + codes = goa.get(f'{year}-08') + for day_of_month in range(1, 15): + date = datetime.date(year, 8, day_of_month) + if date.weekday() > 4: # weekends already take wine and oil + continue + tally[CODES[codes[day_of_month - 1]]] += 1 + print(f'\n goarch.org on Dormition weekdays across {len(span)} years: {dict(tally)}') + + +if __name__ == '__main__': + main()