summaryrefslogtreecommitdiff
path: root/scripts/validate-engine.py
blob: 6857b03ed069538ebfb991828adfe3121c5858f8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
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]]()