aboutsummaryrefslogtreecommitdiff
path: root/tools/extract_missalemeum_oracle.py
blob: 1abc574e5244e762f06dd56906fc053165cf63e1 (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
#!/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 <snapshot-dir> <out-file>
#
# <snapshot-dir> 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 <snapshot-dir>/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|commemoration_ids|first|gospel
#
# - 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).
# - commemoration_ids (Task B, branch ef-rg16a) is the ";"-joined info.
#   commemorations[*].id, index-aligned with the commemorations field ("-"
#   for an empty list, same convention) -- e.g. "sancti:01-05:4:r". Added so
#   test_oracle.ml's identity comparison has a second, independent signal
#   beyond the title text (the id encodes the commemorated saint's own rank
#   and colour too, verified across the whole fixture to always carry the
#   SAME calendar date as the day itself -- a commemoration's own natural
#   fixed date, by construction, since only a saint impeded on their own day
#   is ever commemorated there). Never parsed for its date component by this
#   fixture or the comparator (info.rank/colors already give the day's own
#   values); kept opaque and compared as a plain string.
# - first/gospel (Task 9) are the day's own Epistle/Lesson and Gospel
#   citations, extracted from entry[0]["sections"] -- the section with
#   id "Lectio" ("Epistle") and "Evangelium" ("Gospel") respectively. Each
#   section's own body is a [[english, latin]] pair; the ENGLISH text
#   (body[0][0]) embeds the citation as one of possibly several
#   "*...*"-wrapped (markdown-italic) spans -- e.g. "Lesson from the letter
#   of St. Paul... \n*Titus 2:11-15*\nBeloved: ...". It is NOT always the
#   FIRST such span: Holy Saturday's own Gospel section opens with a rubric
#   note, itself "*"-wrapped ("*While singing the Gospel candles are not
#   being hold.*"), before the real citation "*Matt 28:1-7*" -- checked
#   directly against the raw JSON, not assumed. The citation span is
#   identified structurally instead of positionally: the first "*...*" span
#   under 40 characters that contains a chapter:verse-shaped digit pair
#   (`\d+\s*[:,.]\s*\d+`, matching all three separators actually used in
#   this source -- "Titus 2:11-15", "4 Kings, 5:1-15", "John 20. 19-31").
#   Verified exhaustively over the whole fixture (all 1 456 Lectio/
#   Evangelium sections, 728 days x 2) -- CORRECTED, fix round 1
#   (coordinator review): this used to claim
#   "exactly one candidate per section, zero ambiguous". WRONG: 2 of the
#   1 456 sections (Holy Saturday's own "Lectio", both years) yield TWO
#   candidates, ['Col 3:1-4', 'Ps 117, 1'] -- the second is the Tractus
#   verse that follows the real Epistle citation in the same paragraph,
#   also matching the verse-shaped regex. The fixture is still correct on
#   both rows (Col 3:1-4 IS the real citation) because [extract_citation]
#   below returns candidates[0] and the real citation happens to come
#   FIRST in the text -- correctness here rests on ORDERING, not
#   uniqueness, and this header previously overclaimed the latter. TWO
#   days (Good Friday, both years -- the "Missa Praesanctificatorum"
#   liturgy) have no "Lectio"/"Evangelium" section at
#   all (a multi-lesson structure instead, "Lectiones"/"Passio", with no
#   single reading occupying the Epistle/Gospel slot this schema assumes,
#   the SAME shape colitur's own test_lectionary.ml records for its own
#   hand-authored Holy Week data) -- "-" for both fields on those two rows,
#   a WARNING on stderr naming the date, never a silent guess. Extracted
#   VERBATIM (only .strip()ped of surrounding whitespace) -- not normalized,
#   not re-punctuated: the comparator's job, not this extractor's, matching
#   the same "verbatim, not translated or slugified" discipline the fields
#   above already state.
# - 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 re
import sys

STAR_RE = re.compile(r"\*([^*]+)\*")
VERSE_RE = re.compile(r"\d+\s*[:,.]\s*\d+")


def extract_citation(sections, section_id, date, label):
    for sec in sections:
        if sec["id"] != section_id:
            continue
        text = sec["body"][0][0]
        candidates = [s.strip() for s in STAR_RE.findall(text) if VERSE_RE.search(s) and len(s) < 40]
        if candidates:
            return candidates[0]
        print(f"WARNING: {date}: {section_id!r} section found but no citation-shaped span in it ({label})",
              file=sys.stderr)
        return "-"
    print(f"WARNING: {date}: no {section_id!r} section ({label})", file=sys.stderr)
    return "-"


def main():
    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} <snapshot-dir> <out-file>", 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"]]
        comm_ids = [c["id"] for c in first["commemorations"]]
        disp_titles = [d["title"] for d in first["displaced"]]
        for t in [first["title"]] + comm_titles + comm_ids + 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 "-"
        ids = ";".join(comm_ids) or "-"
        sections = data[0]["sections"]
        cit_first = extract_citation(sections, "Lectio", date, "Epistle")
        cit_gospel = extract_citation(sections, "Evangelium", date, "Gospel")
        for t in (cit_first, cit_gospel):
            if "|" in t:
                print(f"ERROR: {date}: citation {t!r} contains a delimiter this fixture uses", file=sys.stderr)
                return 1
        rows.append(
            f"{date}|{first['rank']}|{colors}|{first['title']}|{tempora}|{comms}|{disp}|{len(data)}|{ids}"
            f"|{cit_first}|{cit_gospel}"
        )

    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())