#!/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]]()