summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:51:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 23:51:36 +0200
commit0994587b3694bb87d9a8ee32d60babf25c784acf (patch)
tree5585419d69e0b89fa839aba94ef50a3297251fbc
parent98d698b261c75a287ba3992744032babfb6ad763 (diff)
downloadcolitur-0994587b3694bb87d9a8ee32d60babf25c784acf.tar.gz
colitur-0994587b3694bb87d9a8ee32d60babf25c784acf.zip
fix(of): correct subject over-match, provenance truth, and stale hazard comment
Four fixes from fix round 2 review: classify_subject's "maria"/"b.m.v" substring test over-matched 12 saints who merely carry "Maria" in their own name (Maximilian Mary Kolbe, John Mary Vianney, Mary Magdalene among them) as Bvm. Replaced with an explicit BVM_DATES table of the 13 genuinely Marian entries. Bvm 25 -> 13. The provenance header claimed lectio is "never a source", which omitted that slugs and English names both come from it. Corrected to state exactly what does (slugs, en names) and does not (every substantive field) come from lectio. A missing lectio file used to degrade silently to a different SHA-256 with no en names and no warning; it now fails loudly. lectio's own SHA-256 is now pinned alongside the Missal PDF's. Four more entries carried a second pdftotext artifact distinct from the letter-spacing already handled -- a combining diacritic rendered as a stray spacing character (Kim Taegŏn/Chŏng Ha-sang, Đũng Lạc, Makhlūf, Kęty). Hand-repaired the same way Peter Damian was, each corroborated by the identical corruption recurring in the Missal's own index. precedence_of.ml's PHASE 3 HAZARD comment about All Souls (2 November) was asserting a gap that no longer exists now that OF sanctoral data ships. Added test_all_souls_beats_an_ordinary_sunday, which resolves the shipped All Souls entry against a real per-annum Sunday (2025-11-02) through the actual Precedence.resolve pipeline, and updated the comment to record the resolution instead of demanding it.
-rw-r--r--data/of/calendar-2002.sexp61
-rw-r--r--lib/rites/rite_of/precedence_of.ml57
-rw-r--r--test/test_calendar_of_data.ml38
-rw-r--r--test/test_precedence_of.ml49
-rw-r--r--tools/extract_of_calendar.py182
5 files changed, 303 insertions, 84 deletions
diff --git a/data/of/calendar-2002.sexp b/data/of/calendar-2002.sexp
index 0b4b3f0..50af02f 100644
--- a/data/of/calendar-2002.sexp
+++ b/data/of/calendar-2002.sexp
@@ -1,22 +1,33 @@
; data/of/calendar-2002.sexp -- OF (post-1970) General Roman Calendar,
; transcribed from the Missale Romanum, editio typica tertia (2002). The
-; 2002 Missal is the AUTHORITY; this file is never edited afterwards --
-; spec sec4.1. lectio's roman-calendar.ini is a cross-check only, never a
-; source (see tools/extract_of_calendar.py's own header for the full
-; source-discipline statement and every classification citation).
+; 2002 Missal is the SOLE authority for every substantive field (date, rank,
+; colour, subject, every character of `la`); this file is never edited
+; afterwards -- spec sec4.1. lectio's roman-calendar.ini supplies `en` names
+; and, when a confident match exists, the slug (an engineering key, not
+; content -- see tools/extract_of_calendar.py's own docstring, "WHAT
+; ACTUALLY COMES FROM LECTIO", for exactly what and why) -- corrected fix
+; round 2, 2026-08-25: a prior header here said lectio was "never a
+; source", which omitted slugs and was not quite true.
;
; Generator: tools/extract_of_calendar.py -- do not hand-edit; re-run against the same
-; PDF (this file is pinned, so a re-run should reproduce it byte-for-byte)
-; and diff before committing.
+; PDF AND the same lectio snapshot (its SHA-256 is pinned below; a missing
+; or different lectio file now fails loudly rather than silently changing
+; slugs/en-names/SHA-256 -- fix round 2) to reproduce this file byte-for-
+; byte, and diff before committing.
;
-; Source: docs/research/of/missale-romanum-2002.pdf
+; Source (authority): docs/research/of/missale-romanum-2002.pdf
; SHA-256: 0b458944824d2ee92854b9664f83e553af0342e0b15a9dc14b00e846405523d7
; Extracted lines (pdftotext -layout, 0-based): 3992-4461
+; Source (slugs + en names only, never content -- see above): ../lectio/internal/caldata/roman-calendar.ini
+; SHA-256: 6c6035124b126c4942719cb2c1d8db4a0f29c3747f44acaa7ad48d2bf3a29dcf
; Extraction date (UTC): 2026-08-25
;
; 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).
+; the module docstring's EXTRACTION ARTIFACT note); 5
+; more carry a SEPARATE pdftotext artifact (a combining diacritic rendered
+; as a stray spacing character), also hand-repaired -- fix round 2, see
+; main()'s own diacritic_repairs table.
;
; Excluded, deliberately (see module docstring for the full reasoning):
; 01-01: of-mary-mother-of-god (Temporal_of.named, m=1 dd=1)
@@ -48,7 +59,7 @@
; 11-03 COLOUR colitur=White lectio=Red ['S. Martini de Porres, religiosi']
; 11-11 COLOUR colitur=White lectio=Red ['S. Martini Turonensis, episcopi']
; 12-21 COLOUR colitur=White lectio=Violet ['S. Petri Canisii, presbyteri et Ecclesiæ doctoris']
-; 12-23 COLOUR colitur=White lectio=Violet ['S. Ioannis de Ke¸ty, presbyteri']
+; 12-23 COLOUR colitur=White lectio=Violet ['S. Ioannis de Kęty, presbyteri']
; 12-29 COLOUR colitur=Red lectio=White ['S. Thomæ Becket, episcopi et martyris']
; 09-23 LECTIO-ONLY 'S. Pii de Pietrelcina, presbyteri [*]' rank=memorial slug=pio-of-pietrelcina-padre-pio-priest
; 10-11 LECTIO-ONLY 'S. Ioannis XXIII, papae' rank=optional slug=john-xxiii-pope
@@ -106,7 +117,7 @@
((slug alphonsus-maria-de-liguori-bishop-and-doctor-of-the-church)
(names ((en "Saint Alphonsus Maria de Liguori, bishop and doctor of the Church") (la "S. Alfonsi Mariae de’ Liguori, episcopi et Ecclesiae doctoris")))
(rank Memoria_obligatoria) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 12) (day 7)))
(cel
((slug ambrose-bishop-and-doctor)
@@ -116,13 +127,13 @@
((date (Fixed (month 11) (day 24)))
(cel
((slug andrew-dung-lac-and-his-companions-martyrs)
- (names ((en "Saint Andrew Dung Lac and his companions, martyrs") (la "Ss. Andreae Du˜ ng La.c, presbyteri, et sociorum, martyrum")))
+ (names ((en "Saint Andrew Dung Lac and his companions, martyrs") (la "Ss. Andreae Dũng Lạc, presbyteri, et sociorum, martyrum")))
(rank Memoria_obligatoria) (status Feast) (colour Red)
(subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 9) (day 20)))
(cel
((slug andrew-kim-taegon-priest-and-paul-chong-hasang-and-companions-martyrs)
- (names ((en "Saint Andrew Kim Taegon, priest, and Paul Chong Hasang and companions, martyrs") (la "Ss. Andreae Kim Tae-go¡ n, presbyteri, Pauli Cho¡ ng Ha-sang et sociorum, martyrum")))
+ (names ((en "Saint Andrew Kim Taegon, priest, and Paul Chong Hasang and companions, martyrs") (la "Ss. Andreae Kim Tae-gŏn, presbyteri, Pauli Chŏng Ha-sang et sociorum, martyrum")))
(rank Memoria_obligatoria) (status Feast) (colour Red)
(subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 11) (day 30)))
@@ -160,7 +171,7 @@
((slug anthony-mary-claret-bishop)
(names ((en "Saint Anthony Mary Claret, bishop") (la "S. Antonii Mariae Claret, episcopi")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 1) (day 17)))
(cel
((slug anthony-of-egypt-abbot)
@@ -178,7 +189,7 @@
((slug anthony-zaccaria-priest)
(names ((en "Saint Anthony Zaccaria, priest") (la "S. Antonii Mariae Zaccaria, presbyteri")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 7) (day 20)))
(cel
((slug apollinaris-bishop-and-martyr)
@@ -610,7 +621,7 @@
((slug jean-vianney-the-cure-of-ars-priest)
(names ((en "Saint Jean Vianney (the Curé of Ars), priest") (la "S. Ioannis Mariae Vianney, presbyteri")))
(rank Memoria_obligatoria) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 2) (day 8)))
(cel
((slug jerome-emiliani-priest)
@@ -680,7 +691,7 @@
((date (Fixed (month 12) (day 23)))
(cel
((slug john-of-kanty-priest)
- (names ((en "Saint John of Kanty, priest") (la "S. Ioannis de Ke¸ty, presbyteri")))
+ (names ((en "Saint John of Kanty, priest") (la "S. Ioannis de Kęty, presbyteri")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
(subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 12) (day 14)))
@@ -760,7 +771,7 @@
((slug louis-grignon-de-montfort-priest)
(names ((en "Saint Louis Grignon de Montfort, priest") (la "S. Ludovici Mariae Grignion de Montfort, presbyteri")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 12) (day 13)))
(cel
((slug lucy-of-syracuse-virgin-and-martyr)
@@ -778,7 +789,7 @@
((slug margaret-mary-alacoque-virgin)
(names ((en "Saint Margaret Mary Alacoque, virgin") (la "S. Margaritae Mariae Alacoque, virginis")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 11) (day 16)))
(cel
((slug margaret-of-scotland)
@@ -790,7 +801,7 @@
((slug maria-goretti-virgin-and-martyr)
(names ((en "Saint Maria Goretti, virgin and martyr") (la "S. Mariae Goretti, virginis et martyris")))
(rank Memoria_ad_libitum) (status Feast) (colour Red)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 4) (day 25)))
(cel
((slug mark-the-evangelist)
@@ -826,13 +837,13 @@
((slug mary-magdalene)
(names ((en "Saint Mary Magdalene") (la "S. Mariae Magdalenae")))
(rank Memoria_obligatoria) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 5) (day 25)))
(cel
((slug mary-magdalene-de-pazzi-virgin)
(names ((en "Saint Mary Magdalene de Pazzi, virgin") (la "S. Mariae Magdalenae de’ Pazzi, virginis")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 9) (day 21)))
(cel
((slug matthew-the-evangelist-apostle-evangelist)
@@ -850,7 +861,7 @@
((slug maximilian-mary-kolbe-priest-and-martyr)
(names ((en "Saint Maximilian Mary Kolbe, priest and martyr") (la "S. Maximiliani Mariae Kolbe, presbyteri et martyris")))
(rank Memoria_obligatoria) (status Feast) (colour Red)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 8) (day 27)))
(cel
((slug monica)
@@ -1078,7 +1089,7 @@
((slug saints-joachim-and-anne)
(names ((en "Saints Joachim and Anne") (la "Ss. Ioachim et Annae, parentum B.M.V.")))
(rank Memoria_obligatoria) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 6) (day 22)))
(cel
((slug saints-john-fisher-bishop-and-martyr-and-thomas-more-martyr)
@@ -1162,11 +1173,11 @@
((slug seven-holy-founders-of-the-servite-order)
(names ((en "Seven Holy Founders of the Servite Order") (la "Ss. septem Fundatorum Ordinis Servorum B.M.V.")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
- (subject Bvm) (citations ()) (layer of-universal))))
+ (subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 7) (day 24)))
(cel
((slug sharbel-makhluf-hermit)
- (names ((en "Saint Sharbel Makhluf, hermit") (la "S. Sarbelii Makhlu‾ f, presbyteri")))
+ (names ((en "Saint Sharbel Makhluf, hermit") (la "S. Sarbelii Makhlūf, presbyteri")))
(rank Memoria_ad_libitum) (status Feast) (colour White)
(subject Saint) (citations ()) (layer of-universal))))
((date (Fixed (month 10) (day 28)))
diff --git a/lib/rites/rite_of/precedence_of.ml b/lib/rites/rite_of/precedence_of.ml
index ab5e394..0587f0a 100644
--- a/lib/rites/rite_of/precedence_of.ml
+++ b/lib/rites/rite_of/precedence_of.ml
@@ -140,25 +140,44 @@ let band (ctx : Vocab_of.season Precedence.context)
30 (* Tabula I.3 -- universal or temporal-origin, never proper; see the
[is_proper] comment above for why this is not [is_universal].
- ENTRY 3 HAS A SECOND CLAUSE THIS BRANCH DOES NOT IMPLEMENT (transcription
- line 840, identical in the 1969 Calendarium at OCR line 936):
- "Commemoratio omnium fidelium defunctorum" -- All Souls, 2 November.
- Nothing constructs it: there is no sanctoral data yet (Phase 3), and
- Vocab_of.rank has no constructor for it -- the 2002 Calendarium prints
- 2 November UNGRADED ("Omnium Fidelium defunctorum" / "Commemoratio
- omnium Fidelium defunctorum"), so the natural rank tag is ambiguous:
- Memoria_obligatoria (band 100, loses to a per annum Sunday, WRONG),
- Memoria_ad_libitum (band 120, worse), or Sollemnitas (band 30, the
- right precedence but a rank word the source never applies to this
- day). The 1969 Calendarium's own printing of this clause additionally
- carries a qualifier the 2002 text drops -- "quae tamen locum cedit
- dominicae occurrenti" -- an edition difference Phase 3 will also need.
- PHASE 3 HAZARD: transcribing 2 November with the natural ungraded
- rank will silently make it an ordinary weekday whenever it falls on a
- Sunday, with every test in this branch still green, because no test
- here knows entry 3 has a second clause. Phase 3 must settle how this
- day is tagged (or add a dedicated slug test here, mirroring
- [entry_2_named]) before shipping OF sanctoral data. *)
+ ENTRY 3'S SECOND CLAUSE -- RESOLVED, fix round 2, Task 1
+ (2026-08-25; transcription line 840, identical in the 1969
+ Calendarium at OCR line 936): "Commemoratio omnium fidelium
+ defunctorum" -- All Souls, 2 November. The 2002 Calendarium
+ prints 2 November UNGRADED ("Commemoratio omnium Fidelium
+ defunctorum"), so no grade word could be transcribed literally;
+ data/of/calendar-2002.sexp (tools/extract_of_calendar.py's own
+ ALL_SOULS_OVERRIDE) tags it [Sollemnitas] anyway, on this table's
+ own authority -- entry 3 itself names "Commemoratio omnium
+ fidelium defunctorum" in the SAME line as "Sollemnitates ...
+ Sanctorum in Calendario generali inscriptae", i.e. this table
+ places it at THIS precedence tier regardless of what word the
+ calendar page prints next to it. (For the record, the two
+ rejected alternatives were Memoria_obligatoria, band 100, loses
+ to a per annum Sunday, WRONG; and Memoria_ad_libitum, band 120,
+ worse.) Proved, not merely asserted:
+ test/test_precedence_of.ml's [test_all_souls_beats_an_ordinary_
+ sunday] loads the SHIPPED "all-souls" entry and resolves it
+ against a real per-annum Sunday (2 November 2025, itself a
+ Sunday) through {!Colitur_kernel.Precedence.resolve} and
+ [rules] below -- not [band] in isolation -- confirming All Souls
+ is [observed], not the Sunday.
+
+ STILL OPEN, deliberately out of this task's scope: the 1969
+ Calendarium's own printing of this clause carries a qualifier
+ the 2002 text drops -- "quae tamen locum cedit dominicae
+ occurrenti" -- an edition difference not reconciled here.
+
+ A LOSING All Souls -- unreachable on universal data (nothing
+ shipped outranks a Tabula I.3 Sollemnitas except the Triduum's
+ own Easter-bound window, which can never reach 2 November) but
+ real the moment an overlay puts something higher there --
+ would be TRANSFERRED, not omitted: [disposition] below sends
+ any losing Sollemnitas forward via Normae n. 60, and nothing
+ distinguishes "Commemoratio" from an ordinary Sollemnitas for
+ that purpose. The same shape as [Rite_ef.Precedence_ef]'s own
+ [entry_14_movable_band] (CLAUDE.md, "Carried into Plan 4") --
+ recorded here rather than built defensively against. *)
else if c.cel.rank = Vocab_of.Sollemnitas && is_proper layer then
40 (* Tabula I.4 *)
else if c.cel.rank = Vocab_of.Festum && c.cel.subject = Subject.Lord
diff --git a/test/test_calendar_of_data.ml b/test/test_calendar_of_data.ml
index d4f8a8b..9261ebc 100644
--- a/test/test_calendar_of_data.ml
+++ b/test/test_calendar_of_data.ml
@@ -69,14 +69,16 @@ 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"
- "1b4f2117133dea1c70989708d94e6f292f3ce23fce6221391191d6fcf6042d19" (sha256_of_file path)
+ "91479d8b5d92d8e35c2ae34f239d5623a2a59cca2f02d51a8cbbad52edd46b3a" (sha256_of_file path)
(* Counts independently re-derived from the shipped file
- (`grep -c '(slug'`; `grep -oP '(?<=\(rank )[A-Za-z_]+' | sort | uniq -c`).
- 206 = 209 raw table rows minus the 3 dates (1 Jan, 6 Jan, 25 Dec) already
- computed by Temporal_of.named -- see test_excludes_temporal_of_dates
- below, which closes the loop on that exclusion rather than merely
- asserting the count moved. *)
+ (`grep -c '((slug'`; `grep -oP '(?<=\(rank )[A-Za-z_]+' | sort | uniq -c`).
+ 208 = 209 raw FIXED-date table rows minus the 3 dates (1 Jan, 6 Jan, 25
+ Dec) already computed by Temporal_of.named (see
+ test_excludes_temporal_of_dates below, which closes the loop on that
+ exclusion) PLUS the 2 movable entries fix round 1 added (Sacred Heart
+ of Jesus, Immaculate Heart of Mary -- see test_movable_entries): 209 -
+ 3 + 2 = 208. *)
let test_load_and_counts () =
let l = load () in
Alcotest.(check int) "208 entries" 208 (List.length l.L.entries);
@@ -92,7 +94,19 @@ let test_load_and_counts () =
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" 208
- (count (fun e -> e.L.cel.Cel.layer = PO.universal_layer))
+ (count (fun e -> e.L.cel.Cel.layer = PO.universal_layer));
+ (* Fix round 2: classify_subject's "maria"/"b.m.v" SUBSTRING heuristic
+ over-matched 12 saints who merely carry "Maria" in their own name
+ (Maximilian Mary Kolbe, John Mary Vianney, Mary Magdalene, ...) as
+ Bvm -- replaced with an explicit BVM_DATES table of the 13 genuinely
+ Marian entries (12 fixed + the movable Immaculate Heart of Mary).
+ Pinned here so a regression to the old substring rule -- or a new one
+ with the same failure shape -- reddens immediately rather than only
+ showing up if/when a future Subject.Bvm-keyed rule is added. *)
+ Alcotest.(check int) "13 Bvm (12 fixed + Immaculate Heart of Mary)" 13
+ (count (fun e -> e.L.cel.Cel.subject = Sub.Bvm));
+ Alcotest.(check int) "9 Lord" 9 (count (fun e -> e.L.cel.Cel.subject = Sub.Lord));
+ Alcotest.(check int) "186 Saint" 186 (count (fun e -> e.L.cel.Cel.subject = Sub.Saint))
let test_slugs_unique () =
let l = load () in
@@ -109,9 +123,13 @@ let test_slugs_revalidate () =
| Error msg -> Alcotest.failf "slug %s failed re-validation: %s" (S.to_string e.L.cel.Cel.slug) msg)
l.L.entries
-(* Every date is a Fixed month/day; DS.fixed already rejects an out-of-range
- day against the leap-year maximum, so this also proves no entry claims a
- nonexistent day (e.g. 30 February). *)
+(* Every FIXED-date entry's month/day pair already went through DS.fixed,
+ which rejects an out-of-range day against the leap-year maximum, so
+ resolving every entry (fixed AND the 2 movable Easter_offset ones, via
+ DS.resolve's own Easter_offset branch) in a real leap year also proves
+ no fixed entry claims a nonexistent day (e.g. 30 February) and that both
+ movable entries resolve at all. Not "every date is a Fixed month/day"
+ (stale since fix round 1 -- 2 of 208 are Easter_offset). *)
let test_dates_resolve_in_a_leap_year () =
let l = load () in
List.iter
diff --git a/test/test_precedence_of.ml b/test/test_precedence_of.ml
index 610cbfb..e1af486 100644
--- a/test/test_precedence_of.ml
+++ b/test/test_precedence_of.ml
@@ -436,6 +436,50 @@ let test_target_is_free_of_entries_1_to_8 () =
true (b > 80))
[ "2027-03-25"; "2035-03-25"; "2026-03-25"; "2029-03-25" ]
+(* Fix round 2 (Task 1, 2026-08-25): the shipped All Souls entry (2
+ November, data/of/calendar-2002.sexp) actually beats an ordinary
+ per-annum Sunday when they coincide -- the exact hazard the [band]
+ comment above named ("PHASE 3 HAZARD ... before shipping OF sanctoral
+ data"). Loads the REAL shipped entry (not a synthetic reconstruction)
+ and resolves it through {!Colitur_kernel.Precedence.resolve} with
+ [Rite_of.Precedence_of.rules] -- not [band] in isolation -- against a
+ real per-annum Sunday context, 2 November 2025 (independently a real
+ Sunday: date -d confirms it, and it is the same date CLAUDE.md's own
+ EF golden pin "All Souls falling on a Sunday" already uses). *)
+let test_all_souls_beats_an_ordinary_sunday () =
+ let path = "../data/of/calendar-2002.sexp" in
+ let l =
+ match Colitur_kernel.Layer.load Vocab_of.rank_of_sexp path with
+ | Ok l -> l
+ | Error e -> Alcotest.failf "%s: failed to load: %s" path e
+ in
+ let all_souls =
+ match Colitur_kernel.Layer.find l (Slug.of_string_exn "all-souls") with
+ | Some e -> e.Colitur_kernel.Layer.cel
+ | None -> Alcotest.fail "\"all-souls\" not found in the shipped calendar"
+ in
+ Alcotest.(check bool) "shipped All Souls is tagged Sollemnitas" true
+ (all_souls.Celebration.rank = Vocab_of.Sollemnitas);
+ let all_souls_candidate = { Precedence.cel = all_souls; origin = Precedence.Sanctoral } in
+ let d = Date.of_iso8601 "2025-11-02" |> Result.get_ok in
+ Alcotest.(check bool) "2025-11-02 is really a Sunday" true (Date.weekday d = Date.Sun);
+ let ctx = { Precedence.date = d; season = Vocab_of.Ordinary_time; weekday = Date.Sun } in
+ (* An ordinary per-annum Sunday's own candidate, shaped exactly as
+ Temporal_of.temporal builds one (rank Festum, Tabula II.6) --
+ independent of temporal_candidate/ctx_of_date above, which read the
+ office off Temporal_of.temporal directly; this test instead builds it
+ by hand so it does not depend on 2025-11-02 having no competing named
+ temporal office of its own. *)
+ let sunday_candidate =
+ mk ~slug:"of-ordinary-time-sunday" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal ()
+ in
+ let resolution =
+ Precedence.resolve Rite_of.Precedence_of.rules ctx ~temporal:sunday_candidate
+ ~sanctoral:[ all_souls_candidate ]
+ in
+ Alcotest.(check bool) "All Souls, not the Sunday, is observed" true
+ (resolution.Precedence.observed = all_souls_candidate)
+
let suite =
( "precedence-of",
[ Alcotest.test_case "Tabula part I (entries 1-4)" `Quick test_part1;
@@ -455,5 +499,8 @@ let suite =
Alcotest.test_case "transfer_target is always strictly later" `Quick
test_target_is_strictly_later;
Alcotest.test_case "transfer_target lands free of Tabula entries 1-8" `Quick
- test_target_is_free_of_entries_1_to_8 ]
+ test_target_is_free_of_entries_1_to_8;
+ Alcotest.test_case
+ "fix round 2: shipped All Souls beats an ordinary per-annum Sunday (2025-11-02)" `Quick
+ test_all_souls_beats_an_ordinary_sunday ]
@ List.map QCheck_alcotest.to_alcotest [ prop_band_total ] )
diff --git a/tools/extract_of_calendar.py b/tools/extract_of_calendar.py
index 50b570e..7d7c5aa 100644
--- a/tools/extract_of_calendar.py
+++ b/tools/extract_of_calendar.py
@@ -6,17 +6,39 @@ typica tertia, into data/of/calendar-2002.sexp: a Colitur_kernel.Layer.t of
Colitur_kernel.Celebration.t entries, one per FIXED (month, day) universal
calendar entry.
-SOURCE DISCIPLINE (do not weaken this): the 2002 Missale Romanum PDF is the
-authority. lectio's ~/git/projects/lectio/internal/caldata/roman-calendar.ini
-is read here ONLY for two cross-check purposes, never as structural source:
- (a) comparing (month, day, rank) to surface divergences, each of which is
- adjudicated by re-reading the Missal (see the divergence log this
- script prints), never by silently preferring lectio;
- (b) copying `name.en` -- lectio's OWN `name.la` field is independently
- known to be unreliable (it contains English text for at least one
- entry, verified during planning: mary-mother-of-god-octave-of-christmas
- has name.la = "Mary, Mother of God (Octave of Christmas)"), so Latin
- is NEVER taken from lectio, only English.
+SOURCE DISCIPLINE (do not weaken this). The 2002 Missale Romanum PDF is the
+SOLE authority for every SUBSTANTIVE field: which day exists at all, its
+(month, day), its rank/grade, its colour, its subject, and every character
+of its `la` name. None of those is ever taken from, adjusted by, or
+defaulted to lectio.
+
+WHAT ACTUALLY COMES FROM LECTIO, stated exactly rather than summarised
+(fix round 2, coordinator, 2026-08-25: a prior version of this note said
+lectio is read "ONLY for two cross-check purposes", which undercounted --
+slugs were a third, unlisted use, and are the file's own PRIMARY KEY, not
+a minor detail):
+ (a) `en` names -- lectio's OWN `name.la` field is independently known to
+ be unreliable (it contains English text for at least one entry,
+ verified during planning: mary-mother-of-god-octave-of-christmas has
+ name.la = "Mary, Mother of God (Octave of Christmas)"), so Latin is
+ NEVER taken from lectio, only English, and only when a confident
+ per-date match exists (see cross_check's own pairing logic);
+ (b) SLUGS -- reused verbatim from lectio's own bracket identifier when a
+ confident match exists (main()'s own "Slugs:" comment explains why
+ this is not an authority violation: a slug is an engineering key,
+ not liturgical content); falls back to a mechanical slug built from
+ the Latin title otherwise;
+ (c) the (month, day, rank) COMPARISON itself, whose divergences are
+ listed in this file's own provenance header, each adjudicated by
+ RE-READING THE MISSAL, never by preferring lectio's answer.
+Both (a) and (b) are ENGINEERING/PRESENTATION conveniences layered on top
+of Missal-sourced content, not competing sources for that content -- but
+they are real dependencies on an external, unpinned-by-Git sibling repo,
+which is why lectio's own SHA-256 is now pinned in the emitted provenance
+header (below) and why a missing lectio file is now a hard error (see
+parse_lectio_ini) rather than a silent zero-entries degradation: running
+without lectio present used to exit 0 with a DIFFERENT SHA-256, no `en`
+names, and 206 mechanical Latin slugs, with nothing printed to say so.
Run: `python3 tools/extract_of_calendar.py [pretext-file] > data/of/calendar-2002.sexp`
With no argument, runs `pdftotext -layout` on the PDF itself. A pre-extracted
@@ -135,6 +157,7 @@ entries needing this repair is printed to stderr.
"""
import hashlib
+import os
import re
import subprocess
import sys
@@ -190,9 +213,18 @@ ALL_SOULS_OVERRIDE = (11, 2)
RED_OVERRIDES = {(9, 14)}
# subject = Lord: celebrations of the Lord not already excluded above,
-# plus the three church-dedication feasts (a dedication is itself
-# classified a "festum Domini" -- see CLAUDE.md's EF precedent, and
-# lectio's own roman-calendar.ini tags all three `class = lord`).
+# plus the three church-dedication feasts. The dedication classification
+# is THIS FILE'S OWN READING, not lectio's: CLAUDE.md records a primary-
+# source argument for the EF side (the Common of the Dedication of a
+# Church is itself titled "Festum Dedicationis Ecclesiae est festum
+# Domini" in the Missal) that applies with equal force here -- a
+# dedication feast commemorates Christ's own house, not a saint. Fix
+# round 2 (coordinator, 2026-08-25): a prior version of this comment cited
+# lectio's own `class = lord` tag on these three dates as corroboration,
+# which is the wrong way round for a file whose SOLE authority is the
+# Missal -- lectio agreeing is worth recording as an observation, never as
+# the reason. (It happens to: roman-calendar.ini tags 08-05/11-09/11-18
+# `class = lord`, checked, not merely asserted.)
LORD_DATES = {(2, 2), (3, 25), (8, 6), (1, 3), (9, 14)} | DEDICATION_DATES
@@ -497,24 +529,70 @@ def classify_colour(month, day, latin):
return "White"
+# Fix round 2 (coordinator, 2026-08-25): the previous rule ("maria"/"b.m.v"
+# substring anywhere in the Latin title) over-matched -- 12 of the 25
+# resulting Bvm entries were saints who happen to carry "Maria" as part of
+# their OWN given name (Maximilian MARY Kolbe, John MARY Vianney, Alphonsus
+# MARIA de Liguori, MARY Magdalene, MARIA Goretti, Margaret MARY Alacoque,
+# Anthony MARY Claret, Anthony MARIA Zaccaria, Louis Grignion de Montfort's
+# own "MARIAE", Mary Magdalene de Pazzi) or belong to a religious order whose
+# NAME contains "B.M.V." (the Seven Holy Founders of the Servite Order), or
+# are Mary's own parents commemorated as themselves (Joachim and Anne) --
+# none of these celebrations is OF Mary; the subject of a celebration is
+# whom it commemorates, not who is merely named in its title. Replaced with
+# an explicit table of the 12 genuinely Marian FIXED-date entries (read off
+# the Missal page by hand, each a feast/memorial of Mary herself), keyed by
+# (month, day) rather than slug -- consistent with every other override
+# table in this file and independent of lectio's own slug text, which this
+# file does not treat as authoritative for anything but the identifier
+# itself (see the module docstring's "What actually comes from lectio").
+# The Immaculate Heart of Mary (the one movable Marian entry) is tagged
+# separately via MOVABLE_SUBJECT_OVERRIDE, not this table.
+BVM_DATES = {
+ (2, 11): "our-lady-of-lourdes",
+ (5, 13): "our-lady-of-fatima",
+ (5, 31): "visitation-of-the-blessed-virgin-mary",
+ (7, 16): "our-lady-of-mount-carmel",
+ (8, 15): "assumption-of-the-blessed-virgin-mary",
+ (8, 22): "queenship-of-blessed-virgin-mary",
+ (9, 8): "birth-of-the-blessed-virgin-mary",
+ (9, 12): "holy-name-of-the-blessed-virgin-mary",
+ (9, 15): "our-lady-of-sorrows",
+ (10, 7): "our-lady-of-the-rosary",
+ (11, 21): "presentation-of-the-blessed-virgin-mary",
+ (12, 8): "immaculate-conception-of-the-blessed-virgin-mary",
+}
+
+
def classify_subject(month, day, latin):
key = (month, day)
if key in LORD_DATES:
return "Lord"
- lower = normalize_latin(latin).lower()
- if key != (3, 19) and ("maria" in lower or "b.m.v" in lower or "bmv" in lower):
+ if key in BVM_DATES:
return "Bvm"
return "Saint"
def parse_lectio_ini(path):
+ # Fix round 2: this used to catch FileNotFoundError and return [],
+ # letting main() run to completion with zero lectio-derived data (no
+ # en names, mechanical Latin slugs) and exit 0 -- SILENTLY, with a
+ # DIFFERENT SHA-256 than the shipped file, and nothing printed to
+ # explain why. lectio is a real, load-bearing dependency for slugs and
+ # en names (see the module docstring's "WHAT ACTUALLY COMES FROM
+ # LECTIO"), so its absence is now fatal, not degraded-and-silent.
+ if not os.path.exists(path):
+ raise SystemExit(
+ f"extract_of_calendar: lectio's cross-check file is required and was not found: "
+ f"{path!r}. Running without it would silently change the output (no `en` names, "
+ f"mechanical Latin slugs instead of lectio's, a different SHA-256) with no warning "
+ f"-- exactly the failure mode fix round 2 closed. Clone/update "
+ f"~/git/projects/lectio and re-run."
+ )
+ with open(path, encoding="utf-8") as f:
+ raw = f.read()
entries = []
cur = None
- try:
- with open(path, encoding="utf-8") as f:
- raw = f.read()
- except FileNotFoundError:
- return entries
for line in raw.split("\n"):
line = line.rstrip("\n")
if line.startswith("[") and line.endswith("]"):
@@ -635,23 +713,34 @@ def render_sexp(entries, name_en, letterspace_count, skipped, meta):
lines = []
lines.append(f"; data/of/calendar-2002.sexp -- OF (post-1970) General Roman Calendar,")
lines.append(f"; transcribed from the Missale Romanum, editio typica tertia (2002). The")
- lines.append(f"; 2002 Missal is the AUTHORITY; this file is never edited afterwards --")
- lines.append(f"; spec sec4.1. lectio's roman-calendar.ini is a cross-check only, never a")
- lines.append(f"; source (see tools/extract_of_calendar.py's own header for the full")
- lines.append(f"; source-discipline statement and every classification citation).")
+ lines.append(f"; 2002 Missal is the SOLE authority for every substantive field (date, rank,")
+ lines.append(f"; colour, subject, every character of `la`); this file is never edited")
+ lines.append(f"; afterwards -- spec sec4.1. lectio's roman-calendar.ini supplies `en` names")
+ lines.append(f"; and, when a confident match exists, the slug (an engineering key, not")
+ lines.append(f"; content -- see tools/extract_of_calendar.py's own docstring, \"WHAT")
+ lines.append(f"; ACTUALLY COMES FROM LECTIO\", for exactly what and why) -- corrected fix")
+ lines.append(f"; round 2, 2026-08-25: a prior header here said lectio was \"never a")
+ lines.append(f"; source\", which omitted slugs and was not quite true.")
lines.append(f";")
lines.append(f"; Generator: {TOOL_PATH} -- do not hand-edit; re-run against the same")
- lines.append(f"; PDF (this file is pinned, so a re-run should reproduce it byte-for-byte)")
- lines.append(f"; and diff before committing.")
+ lines.append(f"; PDF AND the same lectio snapshot (its SHA-256 is pinned below; a missing")
+ lines.append(f"; or different lectio file now fails loudly rather than silently changing")
+ lines.append(f"; slugs/en-names/SHA-256 -- fix round 2) to reproduce this file byte-for-")
+ lines.append(f"; byte, and diff before committing.")
lines.append(f";")
- lines.append(f"; Source: {PDF_PATH}")
+ lines.append(f"; Source (authority): {PDF_PATH}")
lines.append(f"; SHA-256: {meta['pdf_sha256']}")
lines.append(f"; Extracted lines (pdftotext -layout, 0-based): {meta['start']}-{meta['end']}")
+ lines.append(f"; Source (slugs + en names only, never content -- see above): {LECTIO_INI}")
+ lines.append(f"; SHA-256: {meta['lectio_sha256']}")
lines.append(f"; Extraction date (UTC): {meta['extraction_date']}")
lines.append(f";")
lines.append(f"; {len(entries)} entries. {letterspace_count} entry needed letter-spacing repair")
lines.append(f"; (collapse_letterspacing's own mechanical pass, then hand-verified -- see")
- lines.append(f"; the module docstring's EXTRACTION ARTIFACT note).")
+ lines.append(f"; the module docstring's EXTRACTION ARTIFACT note); {meta['diacritic_repair_count']}")
+ lines.append(f"; more carry a SEPARATE pdftotext artifact (a combining diacritic rendered")
+ lines.append(f"; as a stray spacing character), also hand-repaired -- fix round 2, see")
+ lines.append(f"; main()'s own diacritic_repairs table.")
lines.append(f";")
lines.append(f"; Excluded, deliberately (see module docstring for the full reasoning):")
for (m, d), why in sorted(skipped.items()):
@@ -746,6 +835,36 @@ def main():
if (e["month"], e["day"]) == (2, 21) and "episcopiet" in e["latin"]:
e["latin"] = "S. Petri Damiani, episcopi et Ecclesiae doctoris"
+ # Fix round 2 (coordinator, 2026-08-25): a SECOND, different pdftotext
+ # artifact -- distinct from the letter-spacing above -- affects four
+ # more entries. Each is a proper noun carrying a COMBINING diacritic
+ # (macron/breve/tilde/ogonek/dot-below) the font's cmap maps to a
+ # SEPARATE spacing character positioned after its base letter with a
+ # stray space, instead of combining onto it (e.g. "Makhlu‾ f" where
+ # ‾ is U+203E OVERLINE standing in for a macron over "u"). Not
+ # mechanically recoverable the way collapse_letterspacing's own runs
+ # are (there is no generic rule for "which glyph substitutes for which
+ # combining mark"), so each is hand-repaired here, exactly like Peter
+ # Damian above -- and each is corroborated by the IDENTICAL corruption
+ # recurring a second time elsewhere in the same extracted text (the
+ # Missal's own alphabetical index of saints, lines ~33857/34079: the
+ # stray mark pairs with the same base letter both times, not assumed
+ # from outside knowledge alone), then checked against the standard
+ # Latin liturgical/romanised spelling for each canonised saint.
+ diacritic_repairs = [
+ (7, 24, "Makhlu‾ f", "Makhlūf"), # OVERLINE -> u-macron: Sarbelii Makhlūf
+ (9, 20, "Tae-go¡ n", "Tae-gŏn"), # INV. EXCL. MARK -> o-breve: Kim Taegŏn
+ (9, 20, "Cho¡ ng", "Chŏng"), # same artifact, same entry: Chŏng Ha-sang
+ (11, 24, "Du˜ ng La.c", "Dũng Lạc"), # TILDE -> u-tilde; "."-> a-dot-below: Dũng Lạc
+ (12, 23, "Ke¸ty", "Kęty"), # CEDILLA -> e-ogonek: Ioannis de Kęty
+ ]
+ diacritic_repair_count = 0
+ for e in entries:
+ for m, d, bad, good in diacritic_repairs:
+ if (e["month"], e["day"]) == (m, d) and bad in e["latin"]:
+ e["latin"] = e["latin"].replace(bad, good)
+ diacritic_repair_count += 1
+
skipped = {}
kept = []
for e in entries:
@@ -767,6 +886,7 @@ def main():
e["subject"] = MOVABLE_SUBJECT_OVERRIDE[e["easter_offset"]]
lectio_entries = parse_lectio_ini(LECTIO_INI)
+ lectio_sha256 = sha256_file(LECTIO_INI)
cross_check_report, name_en, lectio_slug = cross_check(entries, lectio_entries)
# Slugs: prefer lectio's OWN identifier when a confident match exists --
@@ -807,19 +927,23 @@ def main():
meta = {
"pdf_sha256": sha256_file(PDF_PATH),
+ "lectio_sha256": lectio_sha256,
"start": start,
"end": end,
"extraction_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"cross_check_report": cross_check_report,
"movable_entries": movable_entries,
+ "diacritic_repair_count": diacritic_repair_count,
}
sys.stdout.write(render_sexp(entries, name_en, letterspace_count, skipped, meta))
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"{diacritic_repair_count} needed diacritic repair, "
f"{len(skipped)} dates excluded (temporal_of.ml coverage)",
file=sys.stderr)
+ print(f"[extract_of_calendar] lectio SHA-256: {lectio_sha256}", 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: