aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/of/calendar-2002.sexp28
-rw-r--r--test/test_calendar_of_data.ml76
-rw-r--r--tools/extract_of_calendar.py259
3 files changed, 298 insertions, 65 deletions
diff --git a/data/of/calendar-2002.sexp b/data/of/calendar-2002.sexp
index 4972f0d..0b4b3f0 100644
--- a/data/of/calendar-2002.sexp
+++ b/data/of/calendar-2002.sexp
@@ -14,7 +14,7 @@
; Extracted lines (pdftotext -layout, 0-based): 3992-4461
; Extraction date (UTC): 2026-08-25
;
-; 206 entries. 1 entry needed letter-spacing repair
+; 208 entries. 1 entry needed letter-spacing repair
; (collapse_letterspacing's own mechanical pass, then hand-verified -- see
; the module docstring's EXTRACTION ARTIFACT note).
;
@@ -22,11 +22,15 @@
; 01-01: of-mary-mother-of-god (Temporal_of.named, m=1 dd=1)
; 01-06: of-epiphany (Temporal_of.named, m=1 dd=6)
; 12-25: of-nativity (Temporal_of.named, m=12 dd=25)
-; Movable universal solemnities (Baptism of the Lord, Holy Family,
-; Trinity, Corpus Christi, Christ the King -- Temporal_of code; the
-; Sacred Heart of Jesus and the Immaculate Heart of Mary -- present in
-; the 2002 table but covered by NEITHER Temporal_of NOR this file, a
-; genuine gap this task found and reports but does not fix).
+; Movable universal solemnities STILL excluded, correctly (Baptism of
+; the Lord, Holy Family, Trinity, Corpus Christi, Christ the King) --
+; Temporal_of code already covers each; see the module docstring's
+; MOVABLE-HEADING AUDIT for the full 7-heading accounting.
+;
+; Movable entries produced from a prose heading, not a (kalends, day) row
+; (fix round 1, 2026-08-25 -- see MOVABLE_ENTRY_HEADINGS):
+; Easter_offset 68 (Fri): 'SACRATISSIMI CORDIS IESU'
+; Easter_offset 69 (Sat): 'Immaculati Cordis B. MariƦ Virginis'
;
; lectio cross-check: 206 dates paired; rank agrees on 205/206.
; 03-07 COLOUR colitur=Red lectio=Violet ['Ss. PerpetuƦ et Felicitatis, martyrum']
@@ -565,6 +569,12 @@
(names ((en "Immaculate Conception of the Blessed Virgin Mary") (la "IN CONCEPTIONE IMMACULATA BEATAE MARIAE VIRGINIS")))
(rank Sollemnitas) (status Feast) (colour White)
(subject Bvm) (citations ()) (layer of-universal))))
+ ((date (Easter_offset 69))
+ (cel
+ ((slug immaculate-heart-of-mary)
+ (names ((la "Immaculati Cordis B. Mariae Virginis")))
+ (rank Memoria_obligatoria) (status Feast) (colour White)
+ (subject Bvm) (citations ()) (layer of-universal))))
((date (Fixed (month 6) (day 28)))
(cel
((slug irenaeus-bishop-and-martyr)
@@ -1021,6 +1031,12 @@
(names ((en "Saint Rose of Lima, virgin") (la "S. Rosae de Lima, virginis")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
(subject Saint) (citations ()) (layer of-universal))))
+ ((date (Easter_offset 68))
+ (cel
+ ((slug sacred-heart-of-jesus)
+ (names ((la "SACRATISSIMI CORDIS IESU")))
+ (rank Sollemnitas) (status Feast) (colour White)
+ (subject Lord) (citations ()) (layer of-universal))))
((date (Fixed (month 1) (day 2)))
(cel
((slug saints-basil-the-great-and-gregory-nazianzen-bishops-and-doctors)
diff --git a/test/test_calendar_of_data.ml b/test/test_calendar_of_data.ml
index 01a757e..d4f8a8b 100644
--- a/test/test_calendar_of_data.ml
+++ b/test/test_calendar_of_data.ml
@@ -5,9 +5,20 @@
'(slug', grep -oP rank/status/colour/subject | sort | uniq -c -- see
.superpowers/sdd/2026-08-25-colitur-of-phases-3-5/task-1-report.md), plus
named spot-checks read straight off the Missal's own printed page so a
- passing count can't hide the wrong 206 entries having been transcribed
+ passing count can't hide the wrong entries having been transcribed
(the same "live hazard" test_sanctoral_ef.ml's own header names).
+ Fix round 1 (2026-08-25): 206 -> 208. Two movable universal celebrations
+ -- the Sacred Heart of Jesus and the Immaculate Heart of Mary -- are
+ printed in the SAME 2002 table (as prose headings, not (kalends, day)
+ rows) but were missed by the first pass; both are now produced as
+ Date_spec.Easter_offset entries (68/69) by
+ tools/extract_of_calendar.py's own MOVABLE_ENTRY_HEADINGS, the same
+ mechanism EF's Rogation Wednesday already uses. See
+ test_movable_entries below, which verifies the Easter+68/Easter+69
+ arithmetic against three REAL Easter dates rather than trusting the
+ offset on its own say-so.
+
SOURCE DISCIPLINE, asserted structurally here, not merely stated in the
tool's own header: every entry's rank is one of the four Vocab_of.rank
grades NEVER Feria (that rank is temporal-only, see vocab_of.mli), and
@@ -58,7 +69,7 @@ let sha256_of_file path =
don't transcribe it from output" pinning discipline. *)
let test_sha256 () =
Alcotest.(check string) "data/of/calendar-2002.sexp SHA-256"
- "b2bf3fa9d27f5bc6e47225c477c158f629eaab6c280e0b69fa04c8a55db9f612" (sha256_of_file path)
+ "1b4f2117133dea1c70989708d94e6f292f3ce23fce6221391191d6fcf6042d19" (sha256_of_file path)
(* Counts independently re-derived from the shipped file
(`grep -c '(slug'`; `grep -oP '(?<=\(rank )[A-Za-z_]+' | sort | uniq -c`).
@@ -68,19 +79,19 @@ let test_sha256 () =
asserting the count moved. *)
let test_load_and_counts () =
let l = load () in
- Alcotest.(check int) "206 entries" 206 (List.length l.L.entries);
+ Alcotest.(check int) "208 entries" 208 (List.length l.L.entries);
let count pred = List.length (List.filter pred l.L.entries) in
- Alcotest.(check int) "8 Sollemnitas" 8 (count (fun e -> e.L.cel.Cel.rank = V.Sollemnitas));
+ Alcotest.(check int) "9 Sollemnitas" 9 (count (fun e -> e.L.cel.Cel.rank = V.Sollemnitas));
Alcotest.(check int) "23 Festum" 23 (count (fun e -> e.L.cel.Cel.rank = V.Festum));
- Alcotest.(check int) "67 Memoria_obligatoria" 67
+ Alcotest.(check int) "68 Memoria_obligatoria" 68
(count (fun e -> e.L.cel.Cel.rank = V.Memoria_obligatoria));
Alcotest.(check int) "108 Memoria_ad_libitum" 108
(count (fun e -> e.L.cel.Cel.rank = V.Memoria_ad_libitum));
Alcotest.(check int) "0 Feria (temporal-only rank, never a calendar entry)" 0
(count (fun e -> e.L.cel.Cel.rank = V.Feria));
- Alcotest.(check int) "206 Feast (OF admits no Commemoration_only status)" 206
+ Alcotest.(check int) "208 Feast (OF admits no Commemoration_only status)" 208
(count (fun e -> e.L.cel.Cel.status = Cel.Feast));
- Alcotest.(check int) "every entry tagged Precedence_of.universal_layer" 206
+ Alcotest.(check int) "every entry tagged Precedence_of.universal_layer" 208
(count (fun e -> e.L.cel.Cel.layer = PO.universal_layer))
let test_slugs_unique () =
@@ -183,6 +194,52 @@ let test_excludes_temporal_of_dates () =
| Some (_, _, _, rank) -> Alcotest.(check bool) "25 Dec: Sollemnitas" true (rank = V.Sollemnitas)
| None -> Alcotest.fail "25 Dec: Temporal_of.named returned None"
+(* Fix round 1: the two movable entries (Sacred Heart of Jesus, Immaculate
+ Heart of Mary) resolve to the right weekday AND date in three real
+ years, computed from Colitur_kernel.Computus.gregorian_easter, not
+ hand-typed dates -- so a wrong offset in the data would fail this even
+ if it "looked" plausible. Also independently confirms the coordinator's
+ own +68/+69 derivation (Pentecost = Easter+49, so the Friday/Saturday
+ after the Second Sunday after Pentecost are Easter+68/+69) rather than
+ trusting it. *)
+let test_movable_entries () =
+ let l = load () in
+ let heart = find l "sacred-heart-of-jesus" in
+ Alcotest.(check bool) "Sacred Heart: date spec is Easter_offset 68" true
+ (heart.L.date = DS.Easter_offset 68);
+ Alcotest.(check bool) "Sacred Heart: Sollemnitas" true
+ (heart.L.cel.Cel.rank = V.Sollemnitas);
+ Alcotest.(check bool) "Sacred Heart: white" true (heart.L.cel.Cel.colour = Col.White);
+ Alcotest.(check bool) "Sacred Heart: subject Lord" true (heart.L.cel.Cel.subject = Sub.Lord);
+ let immaculate = find l "immaculate-heart-of-mary" in
+ Alcotest.(check bool) "Immaculate Heart: date spec is Easter_offset 69" true
+ (immaculate.L.date = DS.Easter_offset 69);
+ Alcotest.(check bool) "Immaculate Heart: Memoria_obligatoria" true
+ (immaculate.L.cel.Cel.rank = V.Memoria_obligatoria);
+ Alcotest.(check bool) "Immaculate Heart: white" true (immaculate.L.cel.Cel.colour = Col.White);
+ Alcotest.(check bool) "Immaculate Heart: subject Bvm" true (immaculate.L.cel.Cel.subject = Sub.Bvm);
+ List.iter
+ (fun year ->
+ let easter = Colitur_kernel.Computus.gregorian_easter year in
+ let resolve spec =
+ match DS.resolve spec ~year ~easter with
+ | Some d -> d
+ | None -> Alcotest.failf "year %d: date spec did not resolve" year
+ in
+ let sacred_heart_date = resolve heart.L.date in
+ let immaculate_heart_date = resolve immaculate.L.date in
+ Alcotest.(check bool)
+ (Printf.sprintf "%d: Sacred Heart (Easter+68) falls on a Friday" year)
+ true (D.weekday sacred_heart_date = D.Fri);
+ Alcotest.(check bool)
+ (Printf.sprintf "%d: Immaculate Heart (Easter+69) falls on a Saturday" year)
+ true (D.weekday immaculate_heart_date = D.Sat);
+ (* Immaculate Heart is the day right after Sacred Heart, every year. *)
+ Alcotest.(check bool)
+ (Printf.sprintf "%d: Immaculate Heart is the day after Sacred Heart" year)
+ true (immaculate_heart_date = D.add_days sacred_heart_date 1))
+ [ 2026; 2027; 2035 ]
+
let suite =
( "Calendar_of (data/of/calendar-2002.sexp)",
[ Alcotest.test_case "SHA-256 pinned" `Quick test_sha256;
@@ -204,4 +261,7 @@ let suite =
test_spot_check_angela_merici;
Alcotest.test_case
"1/6 Jan and 25 Dec deliberately excluded -- Temporal_of.named covers them" `Quick
- test_excludes_temporal_of_dates ] )
+ test_excludes_temporal_of_dates;
+ Alcotest.test_case
+ "fix round 1: Sacred Heart of Jesus (Easter+68) and Immaculate Heart of Mary (Easter+69)" `Quick
+ test_movable_entries ] )
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py
index 864c409..50b570e 100644
--- a/tools/extract_of_calendar.py
+++ b/tools/extract_of_calendar.py
@@ -26,30 +26,54 @@ pdftotext).
WHAT THIS FILE DELIBERATELY EXCLUDES, and why (each is a real design
decision, not an oversight):
-1. Every MOVABLE universal solemnity/feast printed in the calendar table
- under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading
- (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus Christi, the
- Sacred Heart of Jesus, the Immaculate Heart of Mary, Christ the King).
- None of these has a (month, day) Date_spec.Fixed key at all -- they are
- Easter-relative, and Date_spec.Fixed is this file's only key shape (see
- the task interface). Trinity/Corpus Christi/Christ the King/Holy Family/
- Baptism of the Lord are ALREADY computed by lib/rites/rite_of/
- temporal_of.ml's own `named`/`holy_family`/`baptism_of_the_lord` (Phase
- 1, already merged, frozen for this task) -- duplicating them here would
- create two competing candidates for the same day.
+1. MOVABLE universal solemnities/feasts printed in the calendar table
+ under a "Dominica ... :" / "Feria ... :" / "Sabbato ... :" heading. MOST
+ of these (Baptism of the Lord, Holy Family, Trinity Sunday, Corpus
+ Christi, Christ the King) have no (month, day) Date_spec.Fixed key at
+ all, are ALREADY computed by lib/rites/rite_of/temporal_of.ml's own
+ `named`/`holy_family`/`baptism_of_the_lord` (Phase 1, already merged),
+ and are excluded here for that reason -- shipping them too would create
+ two competing candidates for the same day.
- FINDING (report this loudly, it is not this task's to fix): the Sacred
- Heart of Jesus ("Feria VI post dominicam secundam post Pentecosten:
- SACRATISSIMI CORDIS IESU Sollemnitas") and the Immaculate Heart of Mary
- ("Sabbato post dominicam secundam post Pentecosten: Immaculati Cordis B.
- Mariae Virginis Memoria") are printed in the SAME base-2002 calendar
- table as Trinity/Corpus Christi/Christ the King, but temporal_of.ml
- covers NEITHER of them -- grep for "Cordis"/"Sacred"/"Sacratissim" in
- that file finds nothing. This is a genuine gap in Phase 1's own
- coverage, orthogonal to Task 1 (a Fixed-only data file cannot express
- an Easter-relative feast), not caused by this task and not fixable by
- it -- Temporal_of would need two more named-day branches
- (Pentecost+19 and Pentecost+20 respectively, i.e. Easter+68/+69).
+ MOVABLE-HEADING AUDIT (fix round 1, 2026-08-25 -- the coordinator asked
+ for a full accounting after the gap below was found, not just the two
+ entries that closed it). There are EXACTLY 7 such headings in the whole
+ calendar table (verified mechanically: every "Dominica/Feria/Sabbato
+ ... :" line the table contains, listed by MOVABLE_HEADING_RE with no
+ heuristic filtering). All 7, and their fate:
+ - Baptism of the Lord (Jan) -> Temporal_of.baptism_of_the_lord
+ - Trinity Sunday (end of May) -> Temporal_of.named (off 56)
+ - Corpus Christi (end of May) -> Temporal_of.named (off 60)
+ - Sacred Heart of Jesus (Jun) -> THIS FILE, Easter_offset 68 (below)
+ - Immaculate Heart of Mary (Jun) -> THIS FILE, Easter_offset 69 (below)
+ - Christ the King (Nov) -> Temporal_of.christ_the_king
+ - Holy Family (Dec) -> Temporal_of.holy_family (its own
+ heading carries a FIXED-DATE FALLBACK, "vel, ea deficiente, die 30
+ decembris" -- correctly Temporal_of's job, not this file's, despite
+ the prose shape; left alone on the coordinator's own confirmation)
+ No 8th heading exists -- the audit found nothing else of this shape.
+
+ RESOLVED (fix round 1): the Sacred Heart of Jesus ("Feria VI post
+ dominicam secundam post Pentecosten: SACRATISSIMI CORDIS IESU
+ Sollemnitas") and the Immaculate Heart of Mary ("Sabbato post dominicam
+ secundam post Pentecosten: Immaculati Cordis B. Mariae Virginis
+ Memoria") are printed in the SAME base-2002 calendar table as Trinity/
+ Corpus Christi/Christ the King, but temporal_of.ml covered NEITHER --
+ found and reported by this extractor's own first pass; researched and
+ confirmed by the coordinator (extracted lines ~4219-4222, immediately
+ after 30 June). They belong HERE, not in temporal_of.ml: both are
+ printed in the Calendarium Romanum Generale itself, the exact table this
+ file transcribes, so adding them completes the transcription rather than
+ amending it. Produced as ordinary Date_spec.Easter_offset entries (the
+ same variant EF's Rogation Wednesday already uses, and Task 2 will reuse
+ for Mary, Mother of the Church -- no kernel change). Pentecost is
+ Easter+49 (Normae n. 22-23); the Second Sunday after Pentecost is
+ Easter+63; the Friday after it is Easter+68 (Sacred Heart), the Saturday
+ after it Easter+69 (Immaculate Heart) -- verified against three real
+ Easter dates in test_calendar_of_data.ml, not merely computed on paper.
+ See MOVABLE_ENTRY_HEADINGS/matching_movable_entry_heading below for how
+ the extractor recognises these two headings specifically (and only
+ these two) among the other 5 it still correctly skips.
2. Three FIXED entries that ARE in the printed table but are ALSO already
computed by temporal_of.ml's own `named`: 1 January (Mary, Mother of
@@ -244,9 +268,79 @@ def is_month_header(line):
return line.strip("\x0c").strip() in MONTH_NUM
+def extract_grade(text):
+ """Splits a trailing grade word off `text`. Shared by fixed-date rows
+ and movable-heading entries (parse_rows below) so both go through the
+ identical rule: a printed grade word (Sollemnitas/Festum/Memoria) is
+ stripped; its absence, or Jan 3's own trailing footnote asterisk, both
+ fall through to the calendar's blank-grade footnote (Memoria ad
+ libitum)."""
+ grade = None
+ for g in GRADE_WORDS:
+ if text == g or text.endswith(" " + g):
+ grade = g
+ text = text[: -len(g)].strip()
+ break
+ if text.endswith("*"):
+ text = text[:-1].strip()
+ return text, grade
+
+
+# Fix round 1 (coordinator, 2026-08-25): two movable universal celebrations
+# are printed as prose HEADINGS between fixed-date rows, not as (kalends,
+# day, title, grade) rows -- exactly the shape parse_rows' generic
+# MOVABLE_HEADING_RE skip-and-discard branch was built for, which is why the
+# original extraction silently dropped them (found and reported by the
+# extractor's own author; researched and confirmed by the coordinator).
+# Both belong HERE, not in temporal_of.ml: they are printed in the
+# Calendarium Romanum Generale itself, the exact table this file
+# transcribes, so adding them completes the transcription rather than
+# amending it (contrast Holy Family, whose heading is likewise prose but
+# whose FIXED-DATE FALLBACK, "vel, ea deficiente, die 30 decembris", makes
+# it correctly Temporal_of's job -- see the re-audit note in the module
+# docstring's "MOVABLE-HEADING AUDIT" section for the full accounting of
+# all 7 such headings in the table).
+#
+# Feria VI post dominicam secundam post Pentecosten:
+# SACRATISSIMI CORDIS IESU Sollemnitas
+# Sabbato post dominicam secundam post Pentecosten:
+# Immaculati Cordis B. Mariae Virginis Memoria
+#
+# (extracted lines ~4219-4222, immediately after 30 June). Pentecost is
+# Easter+49 (Normae n. 22-23, already temporal_of.ml's own citation); "the
+# Second Sunday after Pentecost" is therefore Easter+63, the Friday after it
+# Easter+68, the Saturday after it Easter+69 -- independently verified
+# against three real Easter dates (2026-04-05, 2027-03-28, 2035-03-25) in
+# test_calendar_of_data.ml, not merely computed on paper here.
+#
+# Date_spec.Easter_offset (built for EF's Rogation Wednesday) needs no
+# kernel change and is the same mechanism Task 2 will use for Mary, Mother
+# of the Church -- so these become ordinary Easter_offset entries in this
+# file, produced by the extractor like every other entry, not hand-appended:
+# a heading this specific is no less mechanical to recognise than a
+# (kalends, day) row, and hand-appending would be the one entry pair in this
+# file with no SHA-256-verifiable path back to the PDF text.
+MOVABLE_ENTRY_HEADINGS = [
+ # (heading-substring, Easter offset, weekday name for the test/sanity check)
+ ("Feria VI post dominicam secundam post Pentecosten", 68, "Fri"),
+ ("Sabbato post dominicam secundam post Pentecosten", 69, "Sat"),
+]
+
+
+def matching_movable_entry_heading(line):
+ for substring, offset, weekday in MOVABLE_ENTRY_HEADINGS:
+ if line.startswith(substring):
+ return offset, weekday
+ return None
+
+
def parse_rows(table_lines):
- """Returns (entries, letterspace_count) where entries is a list of dicts
- with month, day, latin (raw joined title), grade (str or None)."""
+ """Returns (entries, movable_entries, letterspace_count, movable_audit)
+ where entries is a list of dicts with month, day, latin, grade;
+ movable_entries is the same shape but with easter_offset instead of
+ month/day; movable_audit lists EVERY "Dominica/Feria/Sabbato ... :"
+ heading found, with its own fate (produced here, or the reason it is
+ skipped), for the re-audit this fix round asked for."""
# Pass 1: strip form feeds and collapse letter-spacing line by line.
letterspace_count = 0
lines = []
@@ -258,6 +352,8 @@ def parse_rows(table_lines):
lines.append(l2)
entries = []
+ movable_entries = []
+ movable_audit = []
current_month = None
i = 0
n = len(lines)
@@ -274,13 +370,26 @@ def parse_rows(table_lines):
i += 1
continue
if MOVABLE_HEADING_RE.match(line):
+ heading = line
i += 1
+ block = []
while i < n:
nxt = lines[i].strip()
if (nxt == "" or is_month_header(nxt) or ROW_RE.match(nxt)
or MOVABLE_HEADING_RE.match(nxt) or FOOTNOTE_RE.match(nxt)):
break
+ block.append(nxt)
i += 1
+ match = matching_movable_entry_heading(heading)
+ if match is None:
+ movable_audit.append({"heading": heading, "content": block, "produced": False})
+ continue
+ offset, weekday = match
+ text = " ".join(block).strip()
+ text, grade = extract_grade(text)
+ movable_entries.append({"easter_offset": offset, "weekday": weekday, "latin": text, "grade": grade})
+ movable_audit.append({"heading": heading, "content": block, "produced": True,
+ "easter_offset": offset})
continue
rm = ROW_RE.match(line)
if rm:
@@ -303,27 +412,17 @@ def parse_rows(table_lines):
else:
blocks[-1].append(nxt)
i += 1
- for block in blocks:
- if not block:
+ for cblock in blocks:
+ if not cblock:
continue
- text = " ".join(block).strip()
- grade = None
- for g in GRADE_WORDS:
- if text == g or text.endswith(" " + g):
- grade = g
- text = text[: -len(g)].strip()
- break
- # Jan 3: "Ss.mi Nominis Iesu *" -- the footnote's own marker
- # asterisk, not a grade word. Strip it; the footnote's blank
- # -> Memoria_ad_libitum mapping still applies.
- if text.endswith("*"):
- text = text[:-1].strip()
+ text = " ".join(cblock).strip()
+ text, grade = extract_grade(text)
if text:
entries.append({"month": current_month, "day": day, "latin": text, "grade": grade})
continue
raise SystemExit(f"unrecognised line in calendar table (month={current_month}): {line!r}")
- return entries, letterspace_count
+ return entries, movable_entries, letterspace_count, movable_audit
LATIN_TO_ASCII = str.maketrans({
@@ -557,11 +656,15 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
lines.append(f"; Excluded, deliberately (see module docstring for the full reasoning):")
for (m, d), why in sorted(skipped.items()):
lines.append(f"; {m:02d}-{d:02d}: {why}")
- lines.append(f"; Movable universal solemnities (Baptism of the Lord, Holy Family,")
- lines.append(f"; Trinity, Corpus Christi, Christ the King -- Temporal_of code; the")
- lines.append(f"; Sacred Heart of Jesus and the Immaculate Heart of Mary -- present in")
- lines.append(f"; the 2002 table but covered by NEITHER Temporal_of NOR this file, a")
- lines.append(f"; genuine gap this task found and reports but does not fix).")
+ lines.append(f"; Movable universal solemnities STILL excluded, correctly (Baptism of")
+ lines.append(f"; the Lord, Holy Family, Trinity, Corpus Christi, Christ the King) --")
+ lines.append(f"; Temporal_of code already covers each; see the module docstring's")
+ lines.append(f"; MOVABLE-HEADING AUDIT for the full 7-heading accounting.")
+ lines.append(f";")
+ lines.append(f"; Movable entries produced from a prose heading, not a (kalends, day) row")
+ lines.append(f"; (fix round 1, 2026-08-25 -- see MOVABLE_ENTRY_HEADINGS):")
+ for me in meta.get("movable_entries", []):
+ lines.append(f"; Easter_offset {me['easter_offset']} ({me['weekday']}): {me['latin']!r}")
lines.append(f";")
for l in meta["cross_check_report"]:
lines.append(f"; {l}")
@@ -582,11 +685,21 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
names_parts.append(f'(la "{esc(la)}")')
names_str = " ".join(names_parts)
rank = GRADE_TO_RANK[e["grade"]]
- colour = classify_colour(e["month"], e["day"], e["latin"])
- subject = classify_subject(e["month"], e["day"], e["latin"])
+ if "colour" in e:
+ colour = e["colour"]
+ else:
+ colour = classify_colour(e["month"], e["day"], e["latin"])
+ if "subject" in e:
+ subject = e["subject"]
+ else:
+ subject = classify_subject(e["month"], e["day"], e["latin"])
slug = e["slug"]
prefix = " " if idx > 0 else " "
- lines.append(f"{prefix}((date (Fixed (month {e['month']}) (day {e['day']})))")
+ if "easter_offset" in e:
+ date_sexp = f"(Easter_offset {e['easter_offset']})"
+ else:
+ date_sexp = f"(Fixed (month {e['month']}) (day {e['day']}))"
+ lines.append(f"{prefix}((date {date_sexp})")
lines.append(f" (cel")
lines.append(f" ((slug {slug})")
lines.append(f" (names ({names_str}))")
@@ -596,6 +709,27 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
return "\n".join(lines) + "\n"
+# Fix round 1: hand-assigned, clean English-style slugs for the two
+# movable entries, keyed by Easter offset. Not run through title_slug's
+# Latin-mechanical fallback (which would produce "sacratissimi-cordis-iesu")
+# because lectio -- the only source this file ever takes a slug FROM when
+# one exists (see the "Slugs:" comment in main() below) -- carries neither
+# entry at all (its own header states both are "computed by internal/
+# calendar", so its roman-calendar.ini has no bracket slug to reuse). This
+# is the one place in the file a slug is authored rather than derived --
+# recorded here rather than left implicit.
+MOVABLE_SLUG_OVERRIDE = {68: "sacred-heart-of-jesus", 69: "immaculate-heart-of-mary"}
+
+# Fix round 1: subject/colour for the two movable entries, set directly
+# rather than through classify_colour/classify_subject (which key off
+# (month, day) -- meaningless for an Easter_offset entry). Both white
+# (IGMR 346(a): "celebrationibus Domini quae non sint de eius Passione" for
+# the Sacred Heart; the same clause's "beatae Mariae Virginis" for the
+# Immaculate Heart -- neither is a Passion or martyr celebration). Subject
+# Lord/Bvm respectively, read directly off each title's own referent.
+MOVABLE_SUBJECT_OVERRIDE = {68: "Lord", 69: "Bvm"}
+
+
def main():
if len(sys.argv) > 1:
with open(sys.argv[1], encoding="utf-8") as f:
@@ -604,7 +738,7 @@ def main():
text = get_text(PDF_PATH)
start, end, table_lines = bound_table(text)
- entries, letterspace_count = parse_rows(table_lines)
+ entries, movable_entries, letterspace_count, movable_audit = parse_rows(table_lines)
# Hand-verified correction for the one letter-spaced entry the
# mechanical pass cannot fully resolve (see module docstring).
@@ -627,6 +761,11 @@ def main():
if (e["month"], e["day"]) == ALL_SOULS_OVERRIDE:
e["grade"] = "Sollemnitas"
+ for e in movable_entries:
+ e["slug"] = MOVABLE_SLUG_OVERRIDE[e["easter_offset"]]
+ e["colour"] = "White"
+ e["subject"] = MOVABLE_SUBJECT_OVERRIDE[e["easter_offset"]]
+
lectio_entries = parse_lectio_ini(LECTIO_INI)
cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries)
@@ -654,6 +793,16 @@ def main():
seen[base] = 1
e["slug"] = base
+ # Movable entries carry their own hand-assigned slugs already (see
+ # MOVABLE_SLUG_OVERRIDE); still registered in `seen` and defensively
+ # collision-checked like everything else, rather than assumed safe.
+ for e in movable_entries:
+ base = e["slug"]
+ if base in seen:
+ raise SystemExit(f"movable entry slug {base!r} collides with a fixed-date entry")
+ seen[base] = 1
+
+ entries = entries + movable_entries
entries.sort(key=lambda e: e["slug"])
meta = {
@@ -662,13 +811,21 @@ def main():
"end": end,
"extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"cross_check_report": cross_check_report,
+ "movable_entries": movable_entries,
}
sys.stdout.write(render_sexp(entries, name_en, letterspace_count, skipped, meta))
- print(f"[extract_of_calendar] {len(entries)} entries emitted, {letterspace_count} needed "
- f"letter-spacing repair, {len(skipped)} dates excluded (temporal_of.ml coverage)",
+ print(f"[extract_of_calendar] {len(entries)} entries emitted ({len(movable_entries)} of them "
+ f"movable, Easter_offset), {letterspace_count} needed letter-spacing repair, "
+ f"{len(skipped)} dates excluded (temporal_of.ml coverage)",
file=sys.stderr)
+ print(f"[extract_of_calendar] MOVABLE-HEADING AUDIT: {len(movable_audit)} \"Dominica/Feria/"
+ f"Sabbato ... :\" headings found in the table:", file=sys.stderr)
+ for ma in movable_audit:
+ fate = (f"PRODUCED as Easter_offset {ma['easter_offset']}" if ma["produced"]
+ else "skipped -- covered by Temporal_of code")
+ print(f"[extract_of_calendar] {ma['heading']!r} -> {fate}", file=sys.stderr)
for l in cross_check_report:
print(f"[extract_of_calendar] {l}", file=sys.stderr)