From cb200a3f95e24f67c1d08b2ceb09404bf57a1636 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 04:36:36 +0200 Subject: test: oracle vs missalemeum 2026-2027; audit the sanctoral Validation layer 4: an oracle harness against missalemeum (Divinum Officium data), independent of the lectio bootstrap chain colitur's own sanctoral data comes from -- the only layer that can catch an error inherited from that bootstrap, and the only one that can validate commemorations at all (the lectio differential explicitly excludes them, per its own header comment). tools/extract_missalemeum_oracle.py shapes the fixture from lectio's sources/snapshot.tar.gz outside the test (no JSON library in this project's frozen deps, same reasoning test_differential.ml's own fixture already documents). test/fixtures/missalemeum-ef-2026-2027 .txt (730 days, SHA-256 pinned and asserted) + its own .provenance note record exactly how to regenerate it. test_oracle.ml compares three axes the oracle actually supports: rank, colour (SET MEMBERSHIP -- 14 of 730 days carry two colours, e.g. rose+violet on Gaudete/Laetare, which independently vindicates this project's own rose reading against lectio's violet-only one, recorded in the register), and commemoration presence/count. Slug identity is deliberately out of scope (needs a title->slug mapping, the data audit's own business, not the automated comparator's). Of 730 days, 688 matched cleanly outright. The remaining 42 are all named in data/ef/expected-divergences-missalemeum.sexp (14 cited entries, M1-M14): most are genuine primary-source-confirmed findings this task adjudicated and fixed in the two preceding commits (RG 33, RG 109/111, Holy Thursday's colour); the rest are real, cited, deferred feature/data gaps (RG 91 entry 27's BVM-Saturday office, RG 110's inseparable Peter/Paul commemoration, four sanctoral entries missing from lectio's own source) or genuine oracle-side artifacts -- honestly verdicted against whichever side this task's own primary-source research actually backs, never defaulted to colitur. One entry (M13, St Joseph vs the Friday of Passion Week 2027) is verdict open: adjudicated as unresolved after real search effort, not guessed past. The data audit: every sanctoral entry the comparison flagged was hand-checked against the 1962 calendarium, plus a 20-entry deterministic random control sample (seed 20260812) drawn independently of the flagged set. The control sample caught two entries (benedict, frances-rome) marked Commemoration_only in the bootstrapped data when the primary calendarium lists them as plain III-class feasts with their own Office -- traced to lectio's own source, not fixable here, and reported as a signal (10% of a random sample) rather than a blanket claim. Coverage recorded honestly in docs/research/rules-register.md's own three buckets: confirmed by oracle (200/322), confirmed by hand (23/322, 2 of them wrong), unverified (115/322) -- the unverified bucket stated explicitly rather than left implicit. Harness teeth demonstrated and reverted (not committed): a fixture rank/colour edit on a previously-clean day fails both the checksum pin and the no-unexplained-differences assertion independently; an expected_rows drift on the allow-list fails the citation-count assertion. Both captured with their exact failure messages, both reverted before this commit. --- tools/extract_missalemeum_oracle.py | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tools/extract_missalemeum_oracle.py (limited to 'tools') diff --git a/tools/extract_missalemeum_oracle.py b/tools/extract_missalemeum_oracle.py new file mode 100644 index 0000000..044aa54 --- /dev/null +++ b/tools/extract_missalemeum_oracle.py @@ -0,0 +1,87 @@ +#!/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 +# +# - 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). +# - 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 sys + + +def main(): + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + snapshot_dir, out_path = sys.argv[1], sys.argv[2] + 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"]] + disp_titles = [d["title"] for d in first["displaced"]] + for t in [first["title"]] + comm_titles + 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 "-" + rows.append(f"{date}|{first['rank']}|{colors}|{first['title']}|{tempora}|{comms}|{disp}|{len(data)}") + + if len(rows) != 730: + print(f"ERROR: expected 730 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()) -- cgit v1.3