#!/usr/bin/env python3 # A documented one-shot, not part of the OCaml build (spec's "no hand-written # JSON parser" rule is about colitur's OWN sexp format -- reading someone # else's JSON needs a reader, same reasoning as tools/bootstrap_sanctoral.ml's # own header comment on its INI reader). Task 16: extracts the missalemeum # oracle fixture (test/fixtures/missalemeum-ef-2026-2027.txt) that # test/test_oracle.ml compares colitur against -- there is no JSON library in # this project's frozen deps, so the fixture is shaped here, once, outside # the test, exactly as test/test_differential.ml's lectio fixture already is # (see that file's own header comment). # # Usage: # python3 tools/extract_missalemeum_oracle.py # # is ~/git/projects/lectio/sources/ already unpacked (`tar xzf # snapshot.tar.gz` in that directory -- lectio's own scripts/snapshot- # sources.sh does this), so /missalemeum/en/YYYY-MM-DD.json # exists for every day. # # One line per day, pipe-separated: # date|rank|colors|title|tempora|commemorations|displaced|n_masses|commemoration_ids|first|gospel # # - rank/colors/title/tempora/commemorations/displaced are info.rank, # info.colors (sorted, concatenated, e.g. "pv"), info.title, info.tempora # ("-" for JSON null), the ";"-joined titles of info.commemorations and # info.displaced ("-" for an empty list), verbatim from the JSON's own # English strings -- not translated or slugified, so the comparator (and a # human auditor) can match colitur's own celebration names against them. # - n_masses is len(the day's JSON array). Four days (Christmas, All Souls) # carry more than one Mass; entry[0]'s info block is used throughout (rank/ # colors/tempora/commemorations/displaced are IDENTICAL across every Mass # of the same day for all 730 days -- checked below, not assumed; see the # task report for why entry[0] loses nothing). # - commemoration_ids (Task B, branch ef-rg16a) is the ";"-joined info. # commemorations[*].id, index-aligned with the commemorations field ("-" # for an empty list, same convention) -- e.g. "sancti:01-05:4:r". Added so # test_oracle.ml's identity comparison has a second, independent signal # beyond the title text (the id encodes the commemorated saint's own rank # and colour too, verified across the whole fixture to always carry the # SAME calendar date as the day itself -- a commemoration's own natural # fixed date, by construction, since only a saint impeded on their own day # is ever commemorated there). Never parsed for its date component by this # fixture or the comparator (info.rank/colors already give the day's own # values); kept opaque and compared as a plain string. # - first/gospel (Task 9) are the day's own Epistle/Lesson and Gospel # citations, extracted from entry[0]["sections"] -- the section with # id "Lectio" ("Epistle") and "Evangelium" ("Gospel") respectively. Each # section's own body is a [[english, latin]] pair; the ENGLISH text # (body[0][0]) embeds the citation as one of possibly several # "*...*"-wrapped (markdown-italic) spans -- e.g. "Lesson from the letter # of St. Paul... \n*Titus 2:11-15*\nBeloved: ...". It is NOT always the # FIRST such span: Holy Saturday's own Gospel section opens with a rubric # note, itself "*"-wrapped ("*While singing the Gospel candles are not # being hold.*"), before the real citation "*Matt 28:1-7*" -- checked # directly against the raw JSON, not assumed. The citation span is # identified structurally instead of positionally: the first "*...*" span # under 40 characters that contains a chapter:verse-shaped digit pair # (`\d+\s*[:,.]\s*\d+`, matching all three separators actually used in # this source -- "Titus 2:11-15", "4 Kings, 5:1-15", "John 20. 19-31"). # Verified exhaustively over the whole fixture (all 1 456 Lectio/ # Evangelium sections, 728 days x 2) -- CORRECTED, fix round 1 # (coordinator review): this used to claim # "exactly one candidate per section, zero ambiguous". WRONG: 2 of the # 1 456 sections (Holy Saturday's own "Lectio", both years) yield TWO # candidates, ['Col 3:1-4', 'Ps 117, 1'] -- the second is the Tractus # verse that follows the real Epistle citation in the same paragraph, # also matching the verse-shaped regex. The fixture is still correct on # both rows (Col 3:1-4 IS the real citation) because [extract_citation] # below returns candidates[0] and the real citation happens to come # FIRST in the text -- correctness here rests on ORDERING, not # uniqueness, and this header previously overclaimed the latter. TWO # days (Good Friday, both years -- the "Missa Praesanctificatorum" # liturgy) have no "Lectio"/"Evangelium" section at # all (a multi-lesson structure instead, "Lectiones"/"Passio", with no # single reading occupying the Epistle/Gospel slot this schema assumes, # the SAME shape colitur's own test_lectionary.ml records for its own # hand-authored Holy Week data) -- "-" for both fields on those two rows, # a WARNING on stderr naming the date, never a silent guess. Extracted # VERBATIM (only .strip()ped of surrounding whitespace) -- not normalized, # not re-punctuated: the comparator's job, not this extractor's, matching # the same "verbatim, not translated or slugified" discipline the fields # above already state. # - Spaces in tempora are turned to "_" (matching the "no field has an # internal space" convention test/fixtures/lectio-ef-2005-2050.txt already # uses, so this fixture can be read the same simple way -- split on '|', # not on whitespace, so title/commemoration/displaced text keeps its own # spaces; only tempora, which nothing in this project parses further than # pass-through display, is space-collapsed for a cheap column-count check). import json import os import re import sys STAR_RE = re.compile(r"\*([^*]+)\*") VERSE_RE = re.compile(r"\d+\s*[:,.]\s*\d+") def extract_citation(sections, section_id, date, label): for sec in sections: if sec["id"] != section_id: continue text = sec["body"][0][0] candidates = [s.strip() for s in STAR_RE.findall(text) if VERSE_RE.search(s) and len(s) < 40] if candidates: return candidates[0] print(f"WARNING: {date}: {section_id!r} section found but no citation-shaped span in it ({label})", file=sys.stderr) return "-" print(f"WARNING: {date}: no {section_id!r} section ({label})", file=sys.stderr) return "-" def main(): # (added 2026-08-17, the 2038 oracle extension) is the # number of day-files this run MUST produce, stated by the caller rather # than hardcoded. It used to be a bare `!= 730`, which silently forbade # extracting any window other than the original 2026-2027 pair. It stays # MANDATORY-in-effect via its default so the existing documented command # keeps its own guard unchanged: a partial fetch (a dropped HTTP request, # an interrupted unpack) must fail loudly here rather than quietly # produce a short fixture that then "passes" a comparison it never ran. if len(sys.argv) not in (3, 4): print(f"usage: {sys.argv[0]} [expected-days]", file=sys.stderr) return 2 snapshot_dir, out_path = sys.argv[1], sys.argv[2] expected_days = int(sys.argv[3]) if len(sys.argv) == 4 else 730 src = os.path.join(snapshot_dir, "missalemeum", "en") rows = [] for fname in sorted(os.listdir(src)): if not fname.endswith(".json"): continue date = fname[:-5] with open(os.path.join(src, fname), encoding="utf-8") as f: data = json.load(f) first = data[0]["info"] for other in data[1:]: o = other["info"] for k in ("rank", "colors", "tempora", "commemorations", "displaced"): if first[k] != o[k]: print(f"WARNING: {date} masses disagree on {k}: {first[k]!r} vs {o[k]!r}", file=sys.stderr) comm_titles = [c["title"] for c in first["commemorations"]] comm_ids = [c["id"] for c in first["commemorations"]] disp_titles = [d["title"] for d in first["displaced"]] for t in [first["title"]] + comm_titles + comm_ids + disp_titles: if "|" in t or ";" in t: print(f"ERROR: {date}: field {t!r} contains a delimiter this fixture uses", file=sys.stderr) return 1 colors = "".join(sorted(first["colors"])) tempora = (first["tempora"] or "-").replace(" ", "_") comms = ";".join(comm_titles) or "-" disp = ";".join(disp_titles) or "-" ids = ";".join(comm_ids) or "-" sections = data[0]["sections"] cit_first = extract_citation(sections, "Lectio", date, "Epistle") cit_gospel = extract_citation(sections, "Evangelium", date, "Gospel") for t in (cit_first, cit_gospel): if "|" in t: print(f"ERROR: {date}: citation {t!r} contains a delimiter this fixture uses", file=sys.stderr) return 1 rows.append( f"{date}|{first['rank']}|{colors}|{first['title']}|{tempora}|{comms}|{disp}|{len(data)}|{ids}" f"|{cit_first}|{cit_gospel}" ) if len(rows) != expected_days: print(f"ERROR: expected {expected_days} days, got {len(rows)}", file=sys.stderr) return 1 with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(rows) + "\n") print(f"wrote {len(rows)} lines to {out_path}", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())