aboutsummaryrefslogtreecommitdiff
path: root/tools/extract_litcal_ordo.py
blob: 5aca07635679507838a9e31d207023df532a9a99 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
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<Weekday>` 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]} <lectio-sources-dir> <out-fixture-path>", 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()