From b9cb5060d2ba5aa7f7a9d6d97e3bced112e30fed Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 16:31:15 +0200 Subject: test(of): validate Phase 1's Ordinary Time resumption against litcal Adds a sixth-lineage external validation layer for the OF rite module's Phase 1 temporal cycle, targeting the single highest-risk function that report named: the two-block Ordinary Time week arithmetic (Normae n. 44), which has no external witness anywhere in Phase 1's own suite and whose output selects the Mass formulary, not merely a label. Witness: litcal (Liturgical Calendar API), Apache-2.0, already archived and SHA-256-pinned by the sibling lectio project. AELF was considered and correctly excluded earlier (its CGU forbids redistribution) -- litcal is what it was replaced with. - tools/extract_litcal_ordo.py verifies all twelve archived calendar-YYYY-en.json files (2024-2035; the task brief said eleven, but twelve are actually pinned in lectio's manifest -- all twelve are used) against lectio's own manifest.tsv, then builds one fixture spanning the contiguous 4382-day range those twelve liturgical-year files cover. - test/fixtures/litcal-temporal-2024-2035.sexp: one row per day, season and (where witnessed) Ordinary Time week, with full provenance, licence, per-year Easter dates and the row-selection rules in its own header. - test/test_litcal_of.ml calls Temporal_of.temporal directly (no Rite.t) and compares 1:1 against the fixture: zero Ordinary Time week mismatches on all 1876 witnessed days (zero tolerance, no allow-list); the only season divergence class is the Sacred Triduum, which litcal tags with a season value colitur's vocabulary deliberately has none of -- one cited allow-list entry, data/of/expected-divergences-litcal.sexp, 36 rows, exact. - Mutation-tested: dropping the backward-from-34 adjustment reddens this layer (1511 of 1876 days) plus three pre-existing Phase 1 tests; the mutation was not committed. Does not touch lib/rites/rite_ef/, data/ef/, or lib/rites/rite_of/ -- this validates Phase 1, it does not change it. Full suite: 786 tests via dune test, 791 via make check, exit 0 both; lib/bin diff empty throughout. --- tools/extract_litcal_ordo.py | 450 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 tools/extract_litcal_ordo.py (limited to 'tools/extract_litcal_ordo.py') diff --git a/tools/extract_litcal_ordo.py b/tools/extract_litcal_ordo.py new file mode 100644 index 0000000..5aca076 --- /dev/null +++ b/tools/extract_litcal_ordo.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +# tools/extract_litcal_ordo.py -- of-litcal-layer task (2026-08-25), external +# validation of the OF rite module's Phase 1 temporal cycle +# (lib/rites/rite_of/temporal_of.ml), specifically the two-block Ordinary +# Time week arithmetic named as the single highest-risk function in +# .superpowers/sdd/of-phase1-report.md's own "Concerns" section. +# +# Turns the ALREADY-ARCHIVED, SHA-pinned litcal JSON in the sibling project +# (~/git/projects/lectio/sources/litcal/calendar-YYYY-en.json, YYYY = +# 2024..2035, recorded in ~/git/projects/lectio/sources/manifest.tsv) into +# ONE sexp fixture, test/fixtures/litcal-temporal-2024-2035.sexp: one row +# per civil day, giving litcal's own season and (where witnessed) Ordinary +# Time week number. +# +# PYTHON, NOT OCAML -- same reasoning tools/extract_missalemeum_oracle.py +# and tools/extract_efdotorg_ordo.py already give: no JSON library in this +# project's frozen deps. +# +# Usage: +# python3 tools/extract_litcal_ordo.py \ +# ~/git/projects/lectio/sources test/fixtures/litcal-temporal-2024-2035.sexp +# +# --------------------------------------------------------------------------- +# WHY 12 FILES, NOT 11. The task brief that started this layer said "2024 +# through 2034" / "eleven available years". The manifest and the directory +# both actually carry TWELVE files, 2024 through 2035 inclusive -- checked +# directly (`grep litcal manifest.tsv | wc -l` = 12; `ls litcal/*.json | wc +# -l` = 12), every one SHA-256-verified against the manifest below before +# use. All twelve are used: they are already archived and pinned, "breadth +# of years is the point" per the task's own instruction, and there is no +# reason to discard a pinned, verified file to match a miscount in the +# brief. Recorded here, and in the fixture header, so a future reader does +# not go looking for an "eleventh year" that was never dropped. +# +# --------------------------------------------------------------------------- +# EACH FILE IS A LITURGICAL YEAR, NOT A CIVIL ONE. litcal's own +# `calendar/YYYY` endpoint returns the liturgical year that OPENS the +# Sunday nearest 30 November in civil year YYYY-1 and closes the Saturday +# before the next such Sunday in civil year YYYY -- e.g. calendar-2026 +# covers 2025-11-30 (Advent I) through 2026-11-28 (the last Ordinary Time +# Saturday). Checked programmatically below (`_check_contiguous`): the +# twelve files' own non-vigil date ranges are EXACTLY contiguous, no gap, +# no overlap, covering 2023-12-03 through 2035-12-01 as one unbroken +# 4 382-day span -- confirmed, not assumed, before a single row is written. +# +# --------------------------------------------------------------------------- +# TWO ROW-SELECTION RULES, both found by direct inspection of the JSON +# (`is_vigil_mass` co-occurrence and same-date `liturgical_season` +# disagreement), not guessed: +# +# 1. VIGIL MASSES ARE EXCLUDED (`is_vigil_mass: true`). litcal lists an +# anticipated Mass (Advent I, Christmas, Epiphany and so on) UNDER THE +# CIVIL DATE IT ANTICIPATES, alongside that date's own real office -- +# e.g. 2025-11-29 (the Saturday before Advent I) carries BOTH +# `OrdWeekday34Saturday` (season ORDINARY_TIME, litcal's own file for +# civil year 2025) and, in the FOLLOWING file (calendar-2026), +# `Advent1_vigil` (season ADVENT) for the identical civil date. Since +# each file only ever carries ITS OWN non-vigil rows for a date (the +# vigil is the file's own first entry, appended to an otherwise-absent +# date), simply dropping every `is_vigil_mass: true` row removes this +# source of cross-file/same-date season disagreement entirely -- no +# file-merging logic was needed beyond that. +# +# 2. HOLY THURSDAY'S OWN CHRISM MASS (`HolyThursChrism`) IS ALSO EXCLUDED, +# by name, as a single documented exception -- the ONLY same-date, +# same-file pair found where two NON-vigil rows disagree on +# `liturgical_season`: `HolyThursChrism` (LENT, the diocesan-cathedral +# morning rite) and `HolyThurs` (EASTER_TRIDUUM, the evening Mass of +# the Lord's Supper, the day's actual parish liturgy) share Holy +# Thursday's own civil date in every one of the twelve files. Excluding +# the Chrism row leaves `HolyThurs` as Holy Thursday's sole +# representative, EASTER_TRIDUUM -- the same season Good Friday and +# Holy Saturday already carry with no ambiguity at all (each has only +# ONE non-vigil row, and it is always EASTER_TRIDUUM). This groups all +# three Triduum days into ONE uniform, cleanly-citable divergence class +# (colitur's `Vocab_of.season` has no Triduum value at all -- +# temporal_of.ml's own top-of-module comment already argues this from +# the Normae's own prose, keeping the whole civil day of Holy Thursday/ +# Good Friday/Holy Saturday inside `Lent`) rather than an inconsistent +# "2 of 3 Triduum days diverge" shape that would follow from picking +# whichever Holy-Thursday row happens to agree with colitur. +# +# Checked exhaustively (not sampled) after applying both exclusions: zero +# remaining same-date `liturgical_season` disagreements anywhere in the +# 4 382-day span, and at most one event_key per date matches the Ordinary +# Time regex (`^Ord(Sunday|Weekday)\d+`) -- both asserted in +# `_check_contiguous`/`build_rows` below, not merely claimed here. +# +# --------------------------------------------------------------------------- +# WHY THE ORDINARY TIME WEEK IS SOMETIMES UNWITNESSED, two genuinely +# different shapes, both found by inspection, neither guessed: +# +# (a) An OBLIGATORY celebration (a solemnity, feast, or obligatory +# memorial) suppresses the weekday's own `OrdWeekday`/`OrdSunday` +# row outright -- litcal prints ONLY the celebration, e.g. +# 25 May 2026 shows only `MaryMotherChurch` ("Memorial"), no +# `OrdWeekday8Monday` at all. THIS is why the task's own hand-check +# compares the first Ordinary Time WEEKDAY after Pentecost, not the +# Monday itself: Mary, Mother of the Church (obligatory since 2018) +# falls on that Monday in all three of the task's worked years +# (2024-05-20, 2025-06-09, 2026-05-25), and is unwitnessed for the +# same structural reason on every occurrence in this corpus. +# +# (b) An OPTIONAL celebration does NOT suppress the weekday row -- +# litcal prints BOTH, e.g. 2025-06-14 carries both +# `OrdWeekday10Saturday` and `SatMemBVM6` ("optional memorial"). So +# an optional memorial's presence is never, by itself, a reason for +# an unwitnessed week; only an obligatory one is. +# +# A third, rarer shape (5 dates total, all in the Immaculate-Heart-of- +# Mary window across the twelve years) shows TWO 'optional memorial' +# rows and no weekday row at all -- litcal's own data omits the weekday +# there for reasons this script does not adjudicate (plausibly a +# version-5.7 data quirk, since the General Roman Calendar has treated +# Immaculate Heart of Mary as OBLIGATORY since 2021, but litcal's own +# `grade_lcl` still reads "optional memorial" here). Handled the same +# as shape (a): week unwitnessed, [None], classified by the comparator, +# never silently dropped. +# +# Lent's own PRIVILEGED ferias show a THIRD pattern that never produces an +# unwitnessed week and needed no special handling: a Lenten optional +# memorial is downgraded in print to a bare "commemoration" grade, but +# ALWAYS co-listed alongside its own weekday row (e.g. 2026-03-17 carries +# both `LentWeekday4Tuesday` and `StPatrick`, grade "commemoration") -- +# structurally the same as shape (b) above, just Lent's own grade word for +# it. Ordinary Time week is irrelevant during Lent regardless, so this +# only matters for confirming SEASON is never ambiguous there either +# (checked, see above). +# --------------------------------------------------------------------------- + +import glob +import hashlib +import json +import os +import re +import sys + +ORD_RE = re.compile(r"^Ord(Sunday|Weekday)(\d+)") +EXCLUDED_EVENT_KEYS = {"HolyThursChrism"} + + +def sha256_of(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def load_manifest(sources_dir): + manifest_path = os.path.join(sources_dir, "manifest.tsv") + rows = {} + with open(manifest_path) as f: + header = f.readline() + assert header.strip().split("\t") == ["path", "url", "sha256", "bytes"], header + for line in f: + path, url, sha, nbytes = line.rstrip("\n").split("\t") + if path.startswith("litcal/"): + rows[path] = (url, sha, int(nbytes)) + return rows + + +def verify_and_load(sources_dir, manifest_rows): + """Verify every litcal/*.json on disk matches the manifest's SHA-256 and + byte count, then return [(year, path, url, sha, data), ...] sorted by + year. Fails loudly (non-zero exit) on any mismatch or missing file -- + this task's own brief says "verify those SHAs before using them".""" + litcal_dir = os.path.join(sources_dir, "litcal") + on_disk = sorted(glob.glob(os.path.join(litcal_dir, "calendar-*-en.json"))) + if not on_disk: + print(f"ERROR: no calendar-*-en.json files found in {litcal_dir}", file=sys.stderr) + sys.exit(1) + out = [] + for path in on_disk: + rel = "litcal/" + os.path.basename(path) + if rel not in manifest_rows: + print(f"ERROR: {rel} is on disk but not in manifest.tsv", file=sys.stderr) + sys.exit(1) + url, sha, nbytes = manifest_rows[rel] + actual_sha = sha256_of(path) + actual_bytes = os.path.getsize(path) + if actual_sha != sha or actual_bytes != nbytes: + print( + f"ERROR: {rel} does not match manifest.tsv\n" + f" manifest: sha256={sha} bytes={nbytes}\n" + f" on disk: sha256={actual_sha} bytes={actual_bytes}", + file=sys.stderr, + ) + sys.exit(1) + m = re.match(r"calendar-(\d{4})-en\.json$", os.path.basename(path)) + year = int(m.group(1)) + with open(path) as f: + data = json.load(f) + out.append((year, rel, url, sha, data)) + out.sort(key=lambda t: t[0]) + # Confirm every manifest litcal/ row was also found on disk (catches a + # partial archive silently under-covering the manifest's own claim). + missing = set(manifest_rows) - {rel for _, rel, _, _, _ in out} + if missing: + print(f"ERROR: manifest.tsv names litcal files not found on disk: {sorted(missing)}", file=sys.stderr) + sys.exit(1) + return out + + +def easter_date_of(data): + for r in data["litcal"]: + if r["event_key"] == "Easter": + return r["date"][:10] + return None + + +def collect_rows(files): + """[(year, path, url, sha, data), ...] -> dict date -> list of raw litcal + rows, after dropping is_vigil_mass rows and EXCLUDED_EVENT_KEYS.""" + by_date = {} + for _year, _rel, _url, _sha, data in files: + for r in data["litcal"]: + if r.get("is_vigil_mass"): + continue + if r["event_key"] in EXCLUDED_EVENT_KEYS: + continue + by_date.setdefault(r["date"][:10], []).append(r) + return by_date + + +def check_contiguous(by_date): + """Asserts the combined date set is one unbroken span with no gap, and + that every date's rows agree on liturgical_season. Returns + (first_date, last_date, n_dates).""" + import datetime + + dates = sorted(by_date) + first, last = dates[0], dates[-1] + d0 = datetime.date.fromisoformat(first) + for i, dt in enumerate(dates): + expect = (d0 + datetime.timedelta(days=i)).isoformat() + if dt != expect: + print(f"ERROR: date span is not contiguous -- expected {expect}, got {dt} at index {i}", file=sys.stderr) + sys.exit(1) + for dt, rows in by_date.items(): + seasons = {r["liturgical_season"] for r in rows} + if len(seasons) > 1: + print(f"ERROR: {dt} has rows disagreeing on liturgical_season: {seasons}", file=sys.stderr) + sys.exit(1) + return first, last, len(dates) + + +def pick_representative(rows): + """Which row's event_key/grade_lcl the fixture records for a date, for + audit purposes ONLY -- comparison correctness never depends on this + choice (season is already verified unique per date; the Ordinary Time + week, if any, comes from the Ord* regex match directly, independent of + which row is "representative"). Prefers the Ord* row; else the + lowest-event_idx row that is not an 'optional memorial'; else the + lowest-event_idx row of any kind.""" + for r in rows: + if ORD_RE.match(r["event_key"]): + return r + non_optional = [r for r in rows if r.get("grade_lcl") != "optional memorial"] + pool = non_optional if non_optional else rows + return min(pool, key=lambda r: r["event_idx"]) + + +def ordinary_time_week(rows): + matches = [m for r in rows for m in [ORD_RE.match(r["event_key"])] if m] + if not matches: + return None + if len(matches) > 1: + dt = rows[0]["date"][:10] + print(f"ERROR: {dt} has more than one Ord* event_key: {[r['event_key'] for r in rows]}", file=sys.stderr) + sys.exit(1) + return int(matches[0].group(2)) + + +def build_rows(by_date): + out = [] + for dt in sorted(by_date): + rows = by_date[dt] + season = rows[0]["liturgical_season"].lower() + week = ordinary_time_week(rows) + rep = pick_representative(rows) + out.append((dt, season, week, rep["event_key"], rep.get("grade_lcl") or "")) + return out + + +def sexp_string(s): + out = [] + for ch in s: + b = ch.encode("utf-8") + if ch == '"': + out.append('\\"') + elif ch == "\\": + out.append("\\\\") + elif len(b) == 1 and 32 <= b[0] < 127: + out.append(ch) + else: + for byte in b: + out.append("\\%03d" % byte) + return '"' + "".join(out) + '"' + + +def sexp_opt_int(n): + return f"({n})" if n is not None else "()" + + +def write_fixture(out_path, rows, files, first, last): + import datetime + + manifest_lines = [] + for year, rel, url, sha, data in files: + easter = easter_date_of(data) + manifest_lines.append(f"; {rel} Easter {easter} sha256={sha}") + easter_dates = [easter_date_of(data) for _, _, _, _, data in files] + easter_min, easter_max = min(easter_dates), max(easter_dates) + easter_span_days = (datetime.date.fromisoformat(easter_max) - datetime.date.fromisoformat(easter_min)).days + easter_list_str = ", ".join(f"{y}: {e}" for y, e in zip([f[0] for f in files], easter_dates)) + n_ot = sum(1 for _, s, w, _, _ in rows if s == "ordinary_time") + n_ot_witnessed = sum(1 for _, s, w, _, _ in rows if s == "ordinary_time" and w is not None) + n_triduum = sum(1 for _, s, _, _, _ in rows if s == "easter_triduum") + + header = f"""; test/fixtures/litcal-temporal-2024-2035.sexp -- of-litcal-layer task +; (2026-08-25): external validation fixture for the OF rite module's Phase 1 +; temporal cycle (lib/rites/rite_of/temporal_of.ml), specifically the +; two-block Ordinary Time week-resumption arithmetic Normae n. 44 requires +; (see .superpowers/sdd/of-litcal-layer-report.md for the full account). +; +; SOURCE: litcal -- the Liturgical Calendar API, by Fr John Romano +; D'Orazio (github.com/Liturgical-Calendar/LiturgicalCalendarAPI), +; Apache-2.0 licensed, built for reuse. Data already archived and +; SHA-256-pinned by the sibling project lectio at +; ~/git/projects/lectio/sources/litcal/, recorded in that project's own +; sources/manifest.tsv -- verified against that manifest (not re-fetched) +; by tools/extract_litcal_ordo.py before this fixture was built: +; +{chr(10).join(manifest_lines)} +; +; TWELVE files, not eleven: the task brief that started this layer said +; "2024 through 2034" / "eleven available years", but both the manifest +; and the archive actually carry 2024 through 2035 (twelve files). All +; twelve are used -- see tools/extract_litcal_ordo.py's own header for the +; full account of why nothing pinned and verified was discarded to match +; the miscount. +; +; EASTER DATES ACTUALLY EXERCISED (the risk this layer validates is +; Easter-dependent, so this is the real measure of what these twelve years +; cover -- {easter_min} through {easter_max}, {len(set(easter_dates))} distinct +; dates across a {easter_span_days}-day span within the canonical 35-day +; Easter window (22 March - 25 April)): +; {easter_list_str} +; NOT covered: the extremes of the window (no Easter in 22-24 March or +; 22-25 April among these twelve) -- a real gap, named rather than +; papered over; a future extension of the litcal archive to more years +; would close it. +; +; COVERAGE: {len(rows)} day-rows, {first}..{last} (one unbroken civil-date span -- +; each file is a LITURGICAL year, opening near 30 November of the civil +; year before its own number, so the twelve files' own non-vigil date +; ranges are exactly contiguous; verified programmatically, not assumed). +; {n_ot} of those rows are litcal's own ORDINARY_TIME season; of those, +; {n_ot_witnessed} carry a witnessed Ordinary Time week number (an +; `OrdSundayN`/`OrdWeekdayN` event_key on that date) and +; {n_ot - n_ot_witnessed} do not (an obligatory celebration suppresses the +; weekday's own entry that day -- see tools/extract_litcal_ordo.py's own +; header for the two shapes this takes, both counted, neither silent). +; {n_triduum} rows are litcal's own EASTER_TRIDUUM season (Holy Thursday's +; evening Mass, Good Friday, Holy Saturday, all twelve years) -- a season +; colitur's own `Vocab_of.season` has no value for at all (see +; temporal_of.ml's own top-of-module comment); a documented, expected +; divergence class, not a defect on either side. +; +; SECOND-IMPLEMENTATION, NOT A SECOND PUBLICATION. litcal is software that +; reads the same universal Normae colitur's own temporal_of.ml was built +; from -- agreement between the two means two independent engines read +; nn. 43-44 the same way, which catches an IMPLEMENTATION bug (the actual +; risk Phase 1's own report names). It is weaker evidence than a published +; ordo, which is what the EF module's own layers 3-7 mostly rest on +; instead (CLAUDE.md's "know what each layer cannot see" section, and +; test_fiuv_ordo.ml/test_lms_ordo.ml's own headers, on why that gradation +; matters). Confirmed to be reading the UNIVERSAL calendar, not a national +; variant: every one of the twelve files' own `settings` block reads +; `epiphany: JAN6, ascension: THURSDAY, corpus_christi: THURSDAY` with no +; `national_calendar`/`diocesan_calendar` parameter in the archived URL -- +; the same universal choices colitur's own temporal_of.ml makes (Epiphany +; fixed 6 January, Ascension and Corpus Christi both on their primary +; Thursday, never the widespread local Sunday-transfer option) -- checked +; directly against all twelve files' own `settings.epiphany`/`ascension`/ +; `corpus_christi` fields, not assumed from one sample. +; +; LINEAGE: litcal and catholic-resources were previously established as +; ONE lineage for READING citations (both downstream of the USA-English +; edition) -- docs/superpowers/specs/2026-08-24-colitur-of-rite-module- +; design.md section 5. That finding is about WORDING/versification, which +; a translation can introduce independently of the calendar structure +; underneath it. For the TEMPORAL cycle specifically the concern is +; weaker: week numbering derives from the universal Normae (nn. 43-44), +; not from any translated edition, so a translation-lineage argument does +; not, by itself, implicate the week arithmetic the same way it implicates +; wording. Stated as a real distinction, not used to claim the lineage +; concern is fully dissolved -- litcal is still one compiler's own +; reading of the Normae, not a second one. +; +; Generator: tools/extract_litcal_ordo.py -- do not hand-edit; re-run +; against the same archived JSON (or a refreshed lectio archive/manifest) +; and commit the diff instead: +; python3 tools/extract_litcal_ordo.py ~/git/projects/lectio/sources \\ +; test/fixtures/litcal-temporal-2024-2035.sexp +; +; Row shape: ((date D) (season S) (ot_week W) (event_key K) (grade_lcl G)). +; date ISO-8601 atom. +; season litcal's own `liturgical_season`, lowercased atom +; (advent|christmas|lent|easter|ordinary_time|easter_triduum). +; ot_week Some n when an Ord* event_key witnesses the Ordinary Time +; week on this date, else None -- see this generator's own +; header for the two/three shapes an unwitnessed week takes. +; event_key the representative litcal event_key (audit only -- see +; [pick_representative]'s own docstring: never load-bearing +; for season/ot_week correctness, only for a human reading +; this fixture to see what was on that date). +; grade_lcl that same row's own grade word, raw from litcal. +""" + with open(out_path, "w") as f: + f.write(header) + f.write("(\n") + for dt, season, week, event_key, grade_lcl in rows: + f.write( + f" ((date {dt}) (season {season}) (ot_week {sexp_opt_int(week)}) " + f"(event_key {sexp_string(event_key)}) (grade_lcl {sexp_string(grade_lcl)}))\n" + ) + f.write(")\n") + print(f"wrote {len(rows)} rows to {out_path}", file=sys.stderr) + print(f"SHA-256 of {out_path}: {sha256_of(out_path)}", file=sys.stderr) + + +def main(): + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + sources_dir, out_path = sys.argv[1], sys.argv[2] + manifest_rows = load_manifest(sources_dir) + files = verify_and_load(sources_dir, manifest_rows) + print(f"verified {len(files)} litcal files against manifest.tsv, years {files[0][0]}..{files[-1][0]}", file=sys.stderr) + by_date = collect_rows(files) + first, last, n = check_contiguous(by_date) + print(f"contiguous span {first}..{last}, {n} dates, all same-date rows agree on season", file=sys.stderr) + rows = build_rows(by_date) + write_fixture(out_path, rows, files, first, last) + + +if __name__ == "__main__": + main() -- cgit v1.3