diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 15:14:33 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 15:14:33 +0200 |
| commit | f7344bb3de2885323a785bdc8d087bc034407fc4 (patch) | |
| tree | 9f7e4edba2b44e6a96917e74b84f0aa72cd138f0 /scripts/validate-engine.py | |
| parent | 310ac404f25b412c5c1ead12247b4eb36e9f0025 (diff) | |
| download | lectio-f7344bb3de2885323a785bdc8d087bc034407fc4.tar.gz lectio-f7344bb3de2885323a785bdc8d087bc034407fc4.zip | |
scripts: add validate-engine.py per-day oracles (litcal/missalemeum)
Manual QA validators (not run by go test) that build the CLI and compare its
computed calendar/readings day-by-day against authoritative online sources:
of-calendar -- OF season/cycle/observed vs the LiturgicalCalendar API (litcal)
of-readings -- every OF reading resolves against a corpus (offline)
ef -- EF reading gaps + tail/resumed-Sunday gospels vs missalemeum
ranks -- OF sanctoral ranks vs litcal
These found the defects fixed in the flawlessness pass; keep them as standing
regression oracles. API responses cache under scripts/.oracle-cache/ (ignored).
Diffstat (limited to 'scripts/validate-engine.py')
| -rwxr-xr-x | scripts/validate-engine.py | 253 |
1 files changed, 253 insertions, 0 deletions
diff --git a/scripts/validate-engine.py b/scripts/validate-engine.py new file mode 100755 index 0000000..6857b03 --- /dev/null +++ b/scripts/validate-engine.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""validate-engine.py -- per-day validators for lectio's offline calendar and +readings engine against authoritative online sources. These are manual QA +oracles (NOT run by `go test`); they build the CLI, then compare its computed +output day-by-day and report mismatches ranked by whether they are real. + +Sources: + - OF calendar: the LiturgicalCalendar API (litcal.johnromanodorazio.com), the + General Roman Calendar with English names (?locale=en). (calapi.inadiutorium + and universalis are IPv6-only and may be unreachable; USCCB bot-blocks 403.) + - EF calendar + readings: missalemeum.com per-date proper API. + - OF readings resolution needs no network -- it renders every reading through + the daily view and flags any that fail to resolve against a corpus. + +Requires network + Python 3 (stdlib only). Run from the repo root: + + scripts/validate-engine.py of-calendar 2025 2050 # OF season/cycle/observed vs litcal + scripts/validate-engine.py of-readings 2026 2028 # OF readings resolve? (offline) + scripts/validate-engine.py ef 2025 2050 # EF identity + gospels vs missalemeum + scripts/validate-engine.py ranks 2026 2035 # OF sanctoral ranks vs litcal + +Set LECTIO_BIN to reuse an existing binary; otherwise the CLI is built once to +a temp dir. API responses cache under scripts/.oracle-cache/ (git-ignored). +""" +import collections, datetime, json, os, re, subprocess, sys, tempfile, time, urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CACHE = os.path.join(ROOT, "scripts", ".oracle-cache") + +# ---------------------------------------------------------------- infrastructure + +def lectio_bin(): + b = os.environ.get("LECTIO_BIN") + if b and os.path.exists(b): + return b + b = os.path.join(tempfile.gettempdir(), "lectio-oracle") + subprocess.run(["go", "build", "-o", b, "./cmd/lectio"], cwd=ROOT, check=True) + return b + +def _cached(path, fetch): + full = os.path.join(CACHE, path) + if os.path.exists(full): + return json.load(open(full)) + os.makedirs(os.path.dirname(full), exist_ok=True) + data = fetch() + json.dump(data, open(full, "w")) + time.sleep(0.3) # be polite to the API + return data + +def _get(url): + req = urllib.request.Request(url, headers={"User-Agent": "lectio-validate", "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=40) as r: + return json.loads(r.read()) + +def litcal_year(y): + return _cached(f"litcal-{y}.json", + lambda: _get(f"https://litcal.johnromanodorazio.com/api/dev/calendar/{y}?locale=en"))["litcal"] + +def missalemeum(date): + return _cached(f"mm/{date}.json", lambda: _get(f"https://www.missalemeum.com/en/api/v5/proper/{date}")) + +def lectio_year(form, y): + out = subprocess.run([L, "--format", "json", "--form", form, "--year", str(y)], + cwd=ROOT, capture_output=True, text=True, timeout=60) + return {d["date"]: d for d in json.loads(out.stdout)["days"]} + +def lectio_day(form, date): + out = subprocess.run([L, date, "--format", "json", "--form", form], + cwd=ROOT, capture_output=True, text=True, timeout=20) + return json.loads(out.stdout)["days"][0] + +def years(): + a = int(sys.argv[2]) if len(sys.argv) > 2 else 2025 + b = int(sys.argv[3]) if len(sys.argv) > 3 else a + return range(a, b + 1) + +# ---------------------------------------------------------------- OF calendar + +SEASON = {"ADVENT": "advent", "CHRISTMAS": "christmas", "ORDINARY_TIME": "ordinary", + "LENT": "lent", "EASTER_TRIDUUM": "triduum", "EASTER": "easter"} +OBSERVABLE = {0, 3, 4, 5, 6, 7} # grades that can be the day's default observed + +def litcal_observed(events): + by = {} + for e in events: + if e.get("is_vigil_mass"): + continue + d = str(e["date"])[:10] + g = e.get("grade", 0) + if e.get("liturgical_season") == "LENT" and g in (2, 3): + g = -2 # a memorial is optional on a Lenten weekday -> the feria is observed + rec = by.get(d) or {"season": SEASON.get(e.get("liturgical_season"), "?"), + "cycle": (e.get("liturgical_year") or "").strip()[-1:], + "grade": -1, "name": "", "key": ""} + if g in OBSERVABLE and g > rec["grade"]: + rec.update(grade=g, name=e.get("name", ""), key=e.get("event_key", "")) + by.setdefault(d, rec) + return by + +def _norm_key(s): + return re.sub(r"[^a-z0-9]", "", s.lower()) + +def _toks(s): + s = re.sub(r"«[^»]*»", "", s.lower()) + return set(re.findall(r"[a-z0-9]+", s)) - { + "the", "of", "st", "ss", "saint", "saints", "blessed", "our", "lord", "in", + "and", "de", "beata", "sancti", "sanctae", "dominica", "feria"} + +def cmd_of_calendar(): + grand = collections.Counter() + for y in years(): + lit = litcal_observed(litcal_year(y)) + lec = lectio_year("new", y) + miss = {"season": [], "cycle": [], "observed": []} + for date, ld in sorted(lec.items()): + lc = lit.get(date) + if not lc or lc["grade"] == -1: + continue + grand["days"] += 1 + if ld["season"] != lc["season"]: + miss["season"].append((date, ld["season"], lc["season"])) + if ld["weekday"] == "Sunday" and lc["cycle"] and ld["cycles"]["sunday"] != lc["cycle"]: + miss["cycle"].append((date, ld["cycles"]["sunday"], lc["cycle"])) + lslug, lname = ld["observed"]["slug"].replace("ef-", ""), ld["observed"]["name"] + lk, ck = _norm_key(lslug), _norm_key(lc["key"]) + ok = (ck and (ck in lk or lk in ck)) or bool(_toks(lname) & _toks(lc["name"])) + if not ok and not ("chrism" in lc["name"].lower() or "easter vigil" in lc["name"].lower()): + miss["observed"].append((date, f"{lslug}|{lname}", f"g{lc['grade']} {lc['key']}|{lc['name']}")) + for k in ("season", "cycle", "observed"): + grand[k] += len(miss[k]) + print(f"{y}: season={len(miss['season'])} cycle={len(miss['cycle'])} observed={len(miss['observed'])}") + for k in ("season", "cycle", "observed"): + for row in miss[k]: + print(f" {k}: {row[0]} lectio={row[1]!r} litcal={row[2]!r}") + print(f"\nTOTAL {grand['days']} days: season={grand['season']} cycle={grand['cycle']} observed={grand['observed']}") + print("(a Holy-Thursday season flag is litcal's lent-vs-triduum convention; a lone Jun-24 " + "observed flag is John the Baptist transferring off a Lord's solemnity -- both benign.)") + +# ---------------------------------------------------------------- OF readings (offline) + +def cmd_of_readings(): + bad = collections.Counter() + days = 0 + env = dict(os.environ, LECTIO_CONFIG=os.devnull) + for y in years(): + d = datetime.date(y, 1, 1) + while d.year == y: + days += 1 + out = subprocess.run([L, d.isoformat(), "-a", "-b", "vul", "-l", "new", "-r"], + cwd=ROOT, capture_output=True, text=True, timeout=20, env=env) + for m in re.findall(r"\((?:not in|no reference)[^)]*\)", out.stdout): + bad[re.sub(r"\d", "N", m)] += 1 + if bad[re.sub(r"\d", "N", m)] == 1: + print(f" {d.isoformat()} {m}") + d += datetime.timedelta(days=1) + print(f"\nrendered {days} days; distinct unresolved readings: {len(bad)} (total {sum(bad.values())})") + +# ---------------------------------------------------------------- EF vs missalemeum + +def _mm_gospel(mass): + for s in mass.get("sections", []): + if s.get("id") == "Evangelium": + m = re.search(r"\*([^*]+)\*", s["body"][0][0]) + return m.group(1) if m else "" + return "" + +def _cite_key(c): + c = re.sub(r"[.\s]", "", c or "").lower() + m = re.match(r"([a-z]+)(\d+):(\d+)", c) + return m.groups() if m else (c,) + +def cmd_ef(): + total = gap = gm_ok = gm_tot = 0 + diffs = [] + for y in years(): + for date, ld in lectio_year("old", y).items(): + total += 1 + if not ld.get("readings"): + gap += 1 + if gap <= 15: + print(f" GAP {date} {ld['observed']['slug']}") + # gospel correctness on the last 4 Sundays before Advent (resumed-Sunday zone) + adv = datetime.date(y, 12, 25) + while adv.weekday() != 6: + adv -= datetime.timedelta(days=1) + adv -= datetime.timedelta(days=21) + for k in range(1, 5): + ds = (adv - datetime.timedelta(days=7 * k)).isoformat() + ld = lectio_day("old", ds) + if not ld["observed"]["slug"].startswith("ef-time-after"): + continue + try: + mg = _mm_gospel(missalemeum(ds)[0]) + except Exception: + continue + lg = next((r["citation"] for r in ld.get("readings", []) if r["part"] == "gospel"), "") + gm_tot += 1 + if _cite_key(lg) == _cite_key(mg): + gm_ok += 1 + else: + diffs.append(f" DIFF {ds} lectio={lg}({ld['observed']['slug']}) mm={mg}") + print(f"\nEF {total} days, {gap} reading gaps; tail/resumed Sunday gospels {gm_ok}/{gm_tot} match") + for x in diffs[:20]: + print(x) + +# ---------------------------------------------------------------- sanctoral ranks + +def cmd_ranks(): + def tier_lit(g): + return {2: "optional", 3: "memorial", 4: "feast", 6: "solemnity", 7: "solemnity"}.get(g) + + def tier_lec(r): + return {"optional memorial": "optional", "optional": "optional", "memorial": "memorial", + "feast": "feast", "solemnity": "solemnity", "feria": "ferial", "": "ferial"}.get(r, r) + + seen = set() + for y in years(): + bydate = collections.defaultdict(list) + for e in litcal_year(y): + if not e.get("is_vigil_mass"): + bydate[str(e["date"])[:10]].append(e) + for date, ld in lectio_year("new", y).items(): + es = bydate.get(date, []) + if not es or es[0].get("liturgical_season") in ("LENT", "ADVENT", "EASTER_TRIDUUM"): + continue + sanct = [e for e in es if e.get("grade") in (2, 3, 4) + and str(e.get("event_key", "")).startswith(("St", "Bl", "Ss", "Our", "Holy", "Most", "Immaculate", "Guardian"))] + if not sanct: + continue + top = max(sanct, key=lambda e: e["grade"]) + if top["event_key"] in seen: + continue + seen.add(top["event_key"]) + exp, got = tier_lit(top["grade"]), tier_lec(ld["observed"]["rank"]) + if exp in ("memorial", "feast", "solemnity") and got == "ferial": + print(f" {date} {top['event_key']} ({top.get('name')}): litcal={exp} but lectio shows FERIAL") + elif exp == "optional" and got in ("memorial", "feast"): + print(f" {date} {top['event_key']}: litcal=optional but lectio observes {got}") + elif exp in ("memorial", "feast", "solemnity") and got != exp and got != "ferial": + print(f" {date} {top['event_key']}: lectio={got} litcal={exp}") + print(f"\naudited {len(seen)} distinct sanctoral celebrations") + +# ---------------------------------------------------------------- + +CMDS = {"of-calendar": cmd_of_calendar, "of-readings": cmd_of_readings, + "ef": cmd_ef, "ranks": cmd_ranks} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in CMDS: + print(__doc__) + sys.exit(2) + L = lectio_bin() + CMDS[sys.argv[1]]() |
