From e5cbc662227de4f3ab0089fe73f639f4f84601a4 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 14:28:50 +0200 Subject: fix(citation): make the well-formed reading-part set rite-supplied I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5): OF Sundays and solemnities shipped without the Second reading, and the lectionary data header called the First reading an "Epistle" -- EF vocabulary. The sole blocker was Validate's own kernel-hardcoded assertion that a well-formed day's sorted citation parts are exactly [First; Gospel]; Citation.part already carried Second. Rite.t gains citation_shapes : Citation.part list list, the fourth EF-shaped kernel misfit (after Preface.t, Mass_formulary.source, transfer_target's strictly-later contract) and the first fixed rather than only documented. EF supplies its one shape unchanged; OF supplies two, [First; Gospel] on a feria/feast/memorial and [First; Second; Gospel] on a Sunday or solemnity (OLM 1981 Praenotanda n. 66.1 vs n. 69.1, n. 84(b)/(c), page-image verified against olm-1981.pdf). Validate's "citations" check now matches against the rite's own declared shapes rather than a single literal pattern, preserving all three invariants it always enforced: zero or one complete shape, never partial; no part outside what the rite declares; no part repeated. bootstrap_lectionary_of.ml's cite now extracts a Second reading from Sunday-cycle ini sections only, correctly excluding the one stray `second` field lectio's snapshot carries on a weekday-cycle section (a content-verified scraping artifact). The hand-authored Christmas Day entry gains its own Second reading from the same OLM passage already cited for its First/Gospel. Regenerated data/of/lectionary.sexp: 771 entries unchanged, citation fields 1542 -> 1725 (+183, all Second readings), SHA-256 re-pinned, header wording fixed and a new coverage bullet added. Responsorial Psalm is deliberately still not emitted -- OLM 1981 Praenotanda n. 71 itself lists it apart from the readings proper as a chant with its own refrain, and the finding driving this change names only the missing Second reading. EF output verified byte-identical to the branch start (empty lib/rites/rite_ef + data/ef diff since v1.0.0, plus a direct binary comparison of `day`/`readings` for 2026, 1583 and 9999 against a worktree build of the pre-change commit). Tests: three new fixtures in test_validate.ml prove the generalisation holds both directions (a multi-shape rite accepts either of its declared shapes; a rite that never declared a shape still rejects it). test_citation_coverage_of.ml's pinned residual grows 41 -> 49, all new entries the same already-understood chapter-crossing shape. --- lib/kernel/rite.ml | 1 + lib/kernel/rite.mli | 36 ++++++++++++++++++++++++++++++++---- lib/kernel/validate.ml | 31 ++++++++++++++++++++----------- lib/kernel/validate.mli | 22 ++++++++++++++-------- 4 files changed, 67 insertions(+), 23 deletions(-) (limited to 'lib/kernel') diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml index e44c2c6..adca8fe 100644 --- a/lib/kernel/rite.ml +++ b/lib/kernel/rite.ml @@ -19,6 +19,7 @@ type ('s, 'r) t = { date:Date.t -> temporal_at:(Date.t -> ('s, 'r) Temporal.t) -> Mass_formulary.t option * Citation.t list; + citation_shapes : Citation.part list list; creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; preface : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> Preface.t option; diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index d53e4e6..ec850f8 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -100,10 +100,12 @@ type ('s, 'r) t = { temporal_at:(Date.t -> ('s, 'r) Temporal.t) -> Mass_formulary.t option * Citation.t list; (** The Mass actually said -- which formulary, and how that was decided - -- paired with its Epistle and Gospel citations. Rite-supplied for - the same reason [transfer_target] is: what a day with no proper of - its own falls back to is a rubric of a particular rite, not a - universal. + -- paired with its reading citations (EF: Epistle and Gospel; OF: + First, and on Sundays/solemnities also Second, and Gospel -- see + {!citation_shapes} for the rite-supplied shape [Validate] holds + this list to). Rite-supplied for the same reason [transfer_target] + is: what a day with no proper of its own falls back to is a rubric + of a particular rite, not a universal. The [Mass_formulary.t option] is [None] exactly when the rite's lectionary is not built at all (the citation list is then also @@ -119,6 +121,32 @@ type ('s, 'r) t = { temporal identity (the preceding Sunday's, for the ferial rule) without re-implementing the temporal cycle -- the same shape [transfer_target]'s own [occupant] callback established. *) + citation_shapes : Citation.part list list; + (** Every well-formed shape a resolved day's [citations] may take, each + already sorted in {!Citation.part}'s own declaration order (the + order [List.sort compare] gives it, and the order {!Validate}'s own + ["citations"] check sorts a day's actual parts into before + comparing). A day passes that check when its sorted parts are + EMPTY (nothing resolved -- the separate ["citations-unresolved"] + coverage check) or equal one of these shapes EXACTLY -- no more + parts, no fewer, none repeated. + + Generalises what used to be a single kernel-hardcoded + [[First; Gospel]], the fourth EF-shaped kernel misfit (after + {!Preface.t}, {!Mass_formulary.source}, [transfer_target]'s + strictly-later contract) and the first fixed rather than only + documented: the OF's Mass has TWO legitimate sizes, not one -- + [[First; Gospel]] on a feria, feast or memorial, and + [[First; Second; Gospel]] on a Sunday or solemnity (OLM 1981 + Praenotanda n. 66.1 vs n. 69.1, page-image verified, + docs/research/of/olm-1981.pdf pp.32-33/"XXXII-XXXIII": "Quaelibet + Missa tres exhibet lectiones" against "Quaelibet Missa duas + exhibet lectiones"; n. 84(b)/(c), pp.37-38/"XXXVII-XXXVIII", + extends the three-reading shape to every solemnity of a + particular calendar and confines feasts/memorials to two). EF + still supplies exactly the one shape it always had -- see + rite_ef.ml's own [citation_shapes] -- so EF's resolved output is + unaffected letter for letter. *) creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; (** Whether the Creed is said, post-Gospel/homily, at this day's Mass (EF: RG 475-476). A [bool], not an [option]: this is a decision, diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index f523b39..c7b6093 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -414,18 +414,26 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = is why it is a match on the sorted part list rather than three separate tests: - - zero or two, never one -- an Epistle without a Gospel, or the - reverse, is a malformed Mass, not a partial one; - - only [First] and [Gospel] ever appear -- the chants (Psalm, - Second, Tract, Alleluia, Sequence) are deliberately unbuilt, - with no source and no oracle, so a citation carrying one is a - defect and not a feature arriving early; + - zero, or exactly one of the rite's own well-formed shapes, + never a partial one -- an Epistle without a Gospel, or the + reverse, is a malformed Mass regardless of which shapes the + rite declares; + - no part outside the rite's own {!Rite.t.citation_shapes} ever + appears -- for EF that is still only [First]/[Gospel] (the + other chants remain deliberately unbuilt there, with no + source and no oracle, so one appearing is a defect, not a + feature arriving early); for the OF it is [First]/[Second]/ + [Gospel], [Second] only ever alongside both of the others (its + own doc comment has the full citation and argument); - no part appears twice -- two Epistles and no Gospel has length two and would slip past a bare cardinality test. - [Citation.part]'s own constructor order puts [First] before - [Gospel], so the sorted well-formed list is literally - [[First; Gospel]] and nothing else. *) + Each of [rite.Rite.citation_shapes]'s own shapes is normalised + with the SAME [List.sort compare] a day's actual parts are put + through, so a rite may list them in any order without silently + failing to match -- robustness the single hardcoded + [[First; Gospel]] this replaced never needed to worry about. *) + let citation_shapes = List.map (List.sort compare) rite.Rite.citation_shapes in let year_has_citations = Array.exists (fun (d : ('s, 'r) Liturgical_day.t) -> d.Liturgical_day.citations <> []) @@ -439,7 +447,6 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = List.map (fun (c : Citation.t) -> c.Citation.part) d.Liturgical_day.citations in match List.sort compare parts with - | [ Citation.First; Citation.Gospel ] -> () | [] -> (* Separate check name from the malformed case below on purpose: this is the lectionary chain falling through @@ -449,9 +456,11 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = fail date "citations-unresolved" "no reading citations resolved for this day: the lectionary chain fell through \ every step" + | sorted when List.mem sorted citation_shapes -> () | sorted -> fail date "citations" - (Printf.sprintf "expected exactly one First and one Gospel, got [%s]" + (Printf.sprintf + "citation parts [%s] match none of this rite's own well-formed shapes" (String.concat "," (List.map Citation.part_to_string sorted)))) resolved; (* ---- Formulary invariant (Task 3, celebrant-rubrics-phase1) ---- diff --git a/lib/kernel/validate.mli b/lib/kernel/validate.mli index 385e111..9ad6b59 100644 --- a/lib/kernel/validate.mli +++ b/lib/kernel/validate.mli @@ -84,14 +84,20 @@ val failure_to_string : failure -> string {!Rite.readings} on every day and is held to neither, so this stays a check on rites that HAVE readings rather than a demand that every rite have them. - - ["citations"]: well-formedness. A day's citation parts, sorted, are - exactly [[First; Gospel]]. This single condition carries three - invariants at once: zero or two but never one (an Epistle without a - Gospel, or the reverse, is a malformed Mass rather than a partial one); - no part outside [First]/[Gospel] (the chants -- Psalm, Second, Tract, - Alleluia, Sequence -- are deliberately unbuilt, so one appearing is a - defect, not a feature arriving early); and no part twice (two Epistles - and no Gospel has length two and would pass a bare cardinality test). + - ["citations"]: well-formedness. A day's citation parts, sorted, must be + either empty or equal ONE of [rite.Rite.citation_shapes] exactly -- + rite-supplied ({!Rite.t.citation_shapes}'s own doc comment has the + full citation and argument for why: EF has one shape, [[First; + Gospel]]; the OF has two, that same pair plus [[First; Second; + Gospel]] on Sundays/solemnities). This single condition carries three + invariants at once: zero, or exactly one of the rite's own complete + shapes, never a partial one (an Epistle without a Gospel, or the + reverse, is a malformed Mass under any rite's shape list); no part + outside what the rite itself declares (EF: the other chants remain + deliberately unbuilt, so one appearing is a defect, not a feature + arriving early); and no part twice (two Epistles and no Gospel has + length two and would pass a bare cardinality test, but matches none of + a rite's own declared shapes). - ["citations-unresolved"]: coverage. A day resolved NO citations at all, i.e. the rite's own lookup chain fell through every one of its steps. Deliberately a separate label from ["citations"] above: a coverage gap -- cgit v1.3