aboutsummaryrefslogtreecommitdiff
path: root/scripts/validate-engine.py
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 15:14:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 15:14:33 +0200
commitf7344bb3de2885323a785bdc8d087bc034407fc4 (patch)
tree9f7e4edba2b44e6a96917e74b84f0aa72cd138f0 /scripts/validate-engine.py
parent310ac404f25b412c5c1ead12247b4eb36e9f0025 (diff)
downloadlectio-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-xscripts/validate-engine.py253
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]]()