From 12b97761019cfa02ca0da8a5fb50ef815d07685c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 14:42:30 +0200 Subject: feat(ef): implement RG 33's third omission trigger RG 33 omits a II/III-class vigil in three cases: it falls on a Sunday, it falls on a I-class feast, "vel si festum cui praemittitur in alium diem transferri aut ad commemorationem reduci contingat". Only the first two were built; the third was recorded in precedence_ef.ml as unimplemented on the grounds that no witness existed in the shipped data. That reasoning was wrong, and the rule fires on 1 744 days across 1583-9999. Both halves of the clause reduce to one observable question -- is the feast the OBSERVED office on the following day (RG 34 puts it there) -- so the kernel asks it once per candidate, after place_transfers has settled the year. No fixed point is needed: a vigil is a candidate only on its own day, never on its feast's, so suppressing it cannot change what the next day observes. Precedence.rules gains vigil_feast, which returns the slug of the feast a vigil precedes; the kernel cannot infer that itself, because only two of the five vigil/feast pairs share a slug stem. Blast radius, measured pre-change binary vs HEAD over the whole domain and classified: 1 744 days, three shapes, zero unexplained. 1 199 are the feast reduced to a commemoration (10 August on a Sunday, St Lawrence); 478 and 67 are the feast transferred under RG 96 after the Sacred Heart or Corpus Christi takes its day. The Assumption's and the Ascension's vigils never qualify -- their I-class feasts always keep their own day. Independently witnessed, which is unusual here. The published Ordo -- the only witness outside the Divinum Officium -> missalemeum -> lectio lineage -- omits St Lawrence's vigil on 2025-08-09, agreeing with colitur against both engines. That date had been read earlier as an Ordo gap; the Ordo was right, and correcting the misreading is what surfaced this clause. On 2027-08-09 the feast does keep its day and the Ordo omits a vigil colitur correctly keeps, which is a genuine Ordo gap. Allow-lists: C39 (lectio, 10 rows) and a 2038 oracle class citing the register, the 2026-2027 window having no instance. The golden pin asserting St Lawrence's vigil is violet moved 2025 -> 2027; its own comment had reasoned about the vigil's weekday and missed that RG 33 also looks at the feast's. Two new pins cover both shapes of the clause. The vigil/feast table is built with Slug.of_string_exn: mutation testing showed that of_string plus Result.to_option turns a typo into None, which this hook's contract reads as "not a vigil", switching the rule off in silence. Two tests assert the table against the shipped data in both directions. --- data/ef/expected-divergences.sexp | 13 ++++ lib/kernel/calendar.ml | 133 ++++++++++++++++++++++++++++++++++-- lib/kernel/precedence.ml | 6 ++ lib/kernel/precedence.mli | 40 +++++++++++ lib/rites/rite_ef/precedence_ef.ml | 55 +++++++++++++++ lib/rites/rite_ef/precedence_ef.mli | 35 ++++++++++ lib/rites/rite_ef/rite_ef.ml | 3 +- test/test_calendar.ml | 2 +- test/test_differential.ml | 23 +++++++ test/test_golden.ml | 80 +++++++++++++++++++--- test/test_oracle.ml | 18 +++++ test/test_precedence.ml | 2 +- test/test_precedence_ef.ml | 10 ++- test/test_sanctoral_ef.ml | 72 ++++++++++++++++++- test/test_validate.ml | 12 ++-- 15 files changed, 478 insertions(+), 26 deletions(-) diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index d6cbc5b..f99add0 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -87,6 +87,19 @@ VERDICT, split by the SAME 138-row population, not two entries: on the 119 rows Gated on the literal date window (month=12, day in {29,30,31}) AND colitur's own slug family (`nativity_octave_day_slugs`) AND `subset diffs [ Slug_f; First_f; Gospel_f ]` (widened this task from the exact-match `diffs = [ Slug_f ]` the pre-citation-comparison entry used -- narrowing back to exact-match would have silently re-excluded the very citation divergence this task exists to surface). Derived directly from the OCaml comparator's own failure output (test_layer_c_counts_match_citations), not hand-counted first and cross-checked after: 138. NARROWED (Nativity-Octave fix, 2026-08-18): 138 -> 119. Nineteen of these days are 26-28 December, which colitur now builds as days WITHIN the Octave of the Nativity (RG 67) rather than generic Christmastide ferias, so their temporal identity -- and with it their divergence shape -- changed. Not a re-adjudication: the same RG 69 disagreement on the days that remain. VERDICT CLOSED 2026-08-18, open -> colitur, because the defect this entry named was FIXED rather than re-argued. The note above splits the old 138 rows into 119 weekday rows, where it already recorded colitur's citation as CONFIRMED CORRECT and said verdict colitur would be accurate for that subset alone, and 19 Sunday rows where colitur emitted the weekday formulary in defiance of RG 69 and was plainly wrong -- a genuine Temporal_ef defect it deliberately left out of scope. Temporal_ef now excludes Sundays from the octave-day branch (register 6.17, confirmed against the oracle: missalemeum shows Sunday in the Octave of Christmas on 2035-12-30 where colitur showed a feria), so those 19 rows no longer occur at all and the count fell 138 -> 119 -- exactly the population this note had already enumerated by date. What remains is only the subset this entry itself certified correct: colitur says the Missal's own Diebus infra octavam formulary, lectio falls back to whichever civil Sunday precedes the date. Verdict colitur, on the reasoning already written here rather than on anything new. NARROWED, not closed, 2026-08-18: still 119 rows, but the CAUSE has halved. lectio now serves the Missal's own 'Diebus infra octavam Nativitatis Domini' Mass on 29-31 December (Titus 3:4-7 / Luke 2:15-20) instead of falling back to the Sunday's, so First_f and Gospel_f no longer differ on any of these days -- what remains is Slug_f alone, colitur's ef-nativity-octave-day-N against lectio's ordinary ef-christmas-N-weekday. That is naming, not disagreement: the two engines now read the same Mass on the same day. This entry's own gate already admits a Slug_f-only subset, which is why the count did not move even though the substantive half of the divergence did.") (expected_rows 119)) + ((id C39) + (citation "RG 33, primary text, docs/research/LT.txt: \"Vigilia II aut III classis penitus omittitur, si occurrat in dominica quavis, aut in festo I classis, vel si festum cui praemittitur in alium diem transferri aut ad commemorationem reduci contingat\" -- \"A vigil of the II or III class is entirely omitted if it occurs on any Sunday whatsoever, or on a feast of the I class, OR IF THE FEAST IT PRECEDES happens to be transferred to another day or reduced to a commemoration.\" The third clause is the one at issue; RG 34 places the feast on the day after its vigil, which is how the rule is evaluated. Register §6.31.") + (verdict colitur) + (note "colitur implements all three of RG 33's omission triggers; lectio implements the first two only. On these 10 days the vigil's own weekday is unremarkable -- it is the FEAST that fails to keep its day, either transferred away under RG 96 or reduced to a commemoration under RG 94 -- so lectio still emits the vigil and colitur omits it entirely. + +Two shapes, both in the rubric's own words. SEVEN rows are 'ad commemorationem reduci': 10 August falls on a Sunday, the II-class Sunday outranks St Lawrence's II-class feast (RG 15), the feast is commemorated rather than celebrated, and the vigil of 9 August goes (2008, 2014, 2025, 2031, 2036, 2042 -- and the same shape reaches the 2038 row through the other half). THREE are 'in alium diem transferri': the Sacred Heart takes 24 June, the Nativity of St John the Baptist transfers under RG 96, and its vigil of 23 June goes (2022, 2033, 2038, 2044). + +Domain-wide this clause fires on 1 744 of the 3 074 246 days in 1583-9999 -- 1 199 St Lawrence, 347 St John the Baptist, 198 Sts Peter and Paul -- measured by diffing a pre-change binary against HEAD over the whole domain and classifying every changed day: three shapes, zero unexplained, and every one independently re-checked to confirm the feast really is absent from its own day. + +INDEPENDENTLY WITNESSED, which is rare in this file and is the reason this entry is verdict colitur rather than open. The published Ordo -- the project's only witness outside the Divinum Officium -> missalemeum -> lectio lineage (see CLAUDE.md's own account of why layers 3 and 4 are one lineage, not two) -- omits the vigil on 2025-08-09, agreeing with colitur against both engines. It had been examined earlier in the same session that implemented this rule and misread as an Ordo GAP, on the reasoning that the Missal's calendarium lists the vigil; the calendarium lists it as a fixed entry, which is not a claim about whether it survives a given year's occurrence. The Ordo was right. Two dates were compared and they part company for the correct reason: on 2027-08-09 (10 August a Tuesday, the feast keeps its day) the Ordo omits a vigil colitur correctly keeps, and that one IS an Ordo gap. + +Gated on lectio's own slug being one of the three vigils this clause can reach and colitur's not being a vigil -- identity on both sides, not a diff-set shape. The Assumption's and the Ascension's vigils are absent from that list because their I-class feasts always keep their own day: 0 occurrences across the whole domain, measured rather than assumed.") + (expected_rows 10)) ; C8 -- CLOSED, REMOVED (2026-08-18): 0 of 16801 rows, was 26. lectio builds ; Rogation Monday and Tuesday now (RG 87, Easter+36/+37, violet under ; RG 128(d)). The Wednesday is NOT built there and deliberately so: Easter+38 diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index 99992b3..98e9032 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -58,7 +58,20 @@ let year_bounds (rite : ('s, 'r) Rite.t) (y : int) : Date.t * Date.t = [injected] is keyed by [Date.to_rata] rather than [Date.t] directly: [Date.t] carries no [compare]-respecting hash, and rata-die is already the canonical total order this module uses for date arithmetic. *) -let resolve_with_injected (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) +(* RG 33's third trigger, third stage: the set of (date, slug) pairs whose + vigil the rule suppresses, keyed by rata die. Empty for a rite whose + [vigil_feast] is the constant [None], and empty on the EF's own shipped + data -- see {!rg33_suppressed} for both. *) +let no_suppression : (int, string list) Hashtbl.t = Hashtbl.create 1 + +let is_suppressed (suppressed : (int, string list) Hashtbl.t) (date : Date.t) + (c : 'r Precedence.candidate) = + match Hashtbl.find_opt suppressed (Date.to_rata date) with + | None -> false + | Some slugs -> List.mem (Slug.to_string c.Precedence.cel.Celebration.slug) slugs + +let resolve_with_injected ?(suppressed = no_suppression) (rite : ('s, 'r) Rite.t) + (idx : 'r Layer.index) (injected : (int, 'r Precedence.candidate list) Hashtbl.t) (date : Date.t) : ('s, 'r) Temporal.t * 's Precedence.context * 'r Precedence.resolution = let temporal = rite.Rite.temporal date in @@ -72,8 +85,22 @@ let resolve_with_injected (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) in let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in let ctx = { Precedence.date; season = temporal.Temporal.season; weekday = temporal.Temporal.weekday } in + (* RG 33: a suppressed vigil is not a losing candidate, it is not a + candidate at all -- "penitus omittitur". Filtering here rather than + leaving it to [disposition] is deliberate and is what the rubric says: + were it left in the contest it could still claim the day's single + commemoration slot (RG 111) ahead of a saint genuinely entitled to it, + which is precisely the defect the SECOND trigger's own fix corrected for + the Sunday case. Note this drops it from [omitted] too -- the + suppression is reported by {!build_day} instead, with its own reason, so + nothing vanishes unaccounted for. *) + let sanctoral = + match Hashtbl.length suppressed with + | 0 -> natural @ arrived + | _ -> List.filter (fun c -> not (is_suppressed suppressed date c)) (natural @ arrived) + in let resolution = - Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral:(natural @ arrived) + Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral in (temporal, ctx, resolution) @@ -115,6 +142,13 @@ let unconverged_reason = silently dropped". This reason makes that failure mode visible instead. *) let out_of_range_reason = "omitted: transfer target falls outside the liturgical year (RG 96)" +(* RG 33: "Vigilia II aut III classis penitus omittitur... vel si festum cui + praemittitur in alium diem transferri aut ad commemorationem reduci + contingat." The vigil is not demoted or commemorated -- it is dropped + whole, which is what "penitus" says. *) +let rg33_vigil_reason = + "omitted: the feast this vigil precedes does not keep its own day (RG 33)" + (* Rebuilds the per-date injection index from [assignment] (slug -> (origin, target)) fresh each round, rather than accumulating it incrementally as candidates are placed. A candidate re-deferred in a later round (its first @@ -273,13 +307,77 @@ let place_transfers (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) ~(start : Da went on to win) and [transferred_out] (whichever candidates' settled placements originated here -- RG 97-98 lets that be more than one; see [Liturgical_day.transferred_out]). *) +(* RG 33's third omission trigger -- "vel si festum cui praemittitur in alium + diem transferri aut ad commemorationem reduci contingat" ("or if the feast + it precedes happens to be transferred to another day or reduced to a + commemoration"). See {!Precedence.rules.vigil_feast} for the contract and + for why the RITE names the feast instead of the kernel inferring it. + + ONE PASS, NO FIXED POINT. Both halves of the clause reduce to the same + observable question -- is the named feast the OBSERVED office on the + following day? -- and the answer cannot depend on the vigil, because a + vigil is a candidate only on its own day and never on its feast's. So + resolving D+1 here WITHOUT applying this rule is exact, not an + approximation, and the recursion an eager reading would suggest (D asks + D+1, which asks D+2...) never arises. Contrast RG 96's transfers, which + genuinely do need [place_transfers]' iteration. + + RUNS AFTER [place_transfers], and must: the whole point is to see the + post-transfer placement, so the [injected] table this receives is the + settled one. + + D+1 MAY FALL OUTSIDE THE LITURGICAL YEAR -- the last day of the year is + resolved against the first day of the next, which [Layer.index] already + covers (it indexes [y-1; y; y+1]) and {!Rite.temporal} answers for any + in-domain date. Only the domain edge itself is refused, where [Date.add_days] + would leave the representable range; a vigil there keeps its office, the + same conservative direction the rest of this module takes at the boundary. *) +let rg33_suppressed (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) + (injected : (int, 'r Precedence.candidate list) Hashtbl.t) (dates : Date.t array) : + (int, string list) Hashtbl.t = + let suppressed : (int, string list) Hashtbl.t = Hashtbl.create 8 in + Array.iter + (fun date -> + let candidates = + Layer.on_date idx date + |> List.map (fun (e : 'r Layer.entry) -> + { Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral }) + in + let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in + let temporal = rite.Rite.temporal date in + let temporal_candidate = + { Precedence.cel = temporal.Temporal.office; origin = Precedence.Temporal } + in + (* The temporal cycle produces vigils too (the Ascension vigil is one), + so it is checked alongside the sanctoral candidates. *) + List.iter + (fun c -> + match rite.Rite.rules.Precedence.vigil_feast c with + | None -> () + | Some feast -> + if Date.compare date domain_max_date < 0 then begin + let morrow = Date.add_days date 1 in + let _, _, r = resolve_with_injected rite idx injected morrow in + let observed = r.Precedence.observed.Precedence.cel.Celebration.slug in + if not (Slug.equal observed feast) then begin + let key = Date.to_rata date in + let slug = Slug.to_string c.Precedence.cel.Celebration.slug in + let prior = try Hashtbl.find suppressed key with Not_found -> [] in + Hashtbl.replace suppressed key (slug :: prior) + end + end) + (temporal_candidate :: (candidates @ arrived))) + dates; + suppressed + let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) (assignment : (string, Date.t * Date.t) Hashtbl.t) (out_of_range : (string, Date.t * Date.t) Hashtbl.t) (injected : (int, 'r Precedence.candidate list) Hashtbl.t) + (suppressed : (int, string list) Hashtbl.t) (transferred_out_of : (int, ('r Celebration.t * Date.t) list) Hashtbl.t) (date : Date.t) : ('s, 'r) Liturgical_day.t = - let temporal, _ctx, resolution = resolve_with_injected rite idx injected date in + let temporal, _ctx, resolution = resolve_with_injected ~suppressed rite idx injected date in let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in let transferred_in = arrived @@ -399,7 +497,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) Major Litanies transfer as [Commemoration_only], and RG 96's search guarantees a transferred FEAST an unblocked target. *) let settled_at target slug = - let _, _, target_resolution = resolve_with_injected rite idx injected target in + let _, _, target_resolution = resolve_with_injected ~suppressed rite idx injected target in let matches (c : 'r Precedence.candidate) = Slug.equal c.Precedence.cel.Celebration.slug slug in @@ -420,10 +518,30 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) out_of_range_reason else unconverged_reason in + (* RG 33's third trigger removes the vigil from the contest entirely + ({!resolve_with_injected} filters it before {!Precedence.resolve} ever + sees it), so it cannot appear in [resolution.omitted] the way an + ordinary loser does. Re-derived here from the same table, with its own + reason, so that the day's accounting stays complete: every candidate the + date carries is still reported somewhere. *) + let rg33_omitted = + match Hashtbl.find_opt suppressed (Date.to_rata date) with + | None -> [] + | Some slugs -> + let on_date = + Layer.on_date idx date |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel) + in + let temporal_office = temporal.Temporal.office in + (temporal_office :: (on_date @ List.map (fun c -> c.Precedence.cel) arrived)) + |> List.filter (fun (cel : 'r Celebration.t) -> + List.mem (Slug.to_string cel.Celebration.slug) slugs) + |> List.map (fun cel -> (cel, rg33_vigil_reason)) + in let omitted = List.map (fun (c, reason) -> (c.Precedence.cel, reason)) resolution.Precedence.omitted @ (resolution.Precedence.deferred |> List.filter unresolved |> List.map (fun c -> (c.Precedence.cel, reason_for c))) + @ rg33_omitted in { Liturgical_day.date; @@ -489,7 +607,12 @@ let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) : Hashtbl.iter (fun k v -> Hashtbl.replace transferred_out_of k (List.sort by_target_then_slug v)) transferred_out_of; - Array.map (build_day rite idx assignment out_of_range injected transferred_out_of) dates + (* RG 33's third trigger, computed once for the whole year and AFTER + [place_transfers], because the question it asks -- did the vigil's feast + keep its own day? -- is only answerable against the settled placement. + See {!rg33_suppressed}. *) + let suppressed = rg33_suppressed rite idx injected dates in + Array.map (build_day rite idx assignment out_of_range injected suppressed transferred_out_of) dates let day (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (date : Date.t) : ('s, 'r) Liturgical_day.t = diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml index 2c55817..c207117 100644 --- a/lib/kernel/precedence.ml +++ b/lib/kernel/precedence.ml @@ -25,6 +25,12 @@ type ('s, 'r) rules = { has no [context] of its own to compute it with) -- see [resolve]'s own comment for why this is a KERNEL-level policy, not a rite- specific rule threaded in as data. *) + vigil_feast : 'r candidate -> Slug.t option; + (** RG 33's third omission trigger: the slug of the feast this candidate + is a vigil OF, when the rite subjects that vigil to omission; + [None] for every other candidate. See the .mli for the contract and + for why the kernel asks the rite to NAME the feast rather than infer + it. *) } type 'r resolution = { diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli index 09376d7..a0b4b1a 100644 --- a/lib/kernel/precedence.mli +++ b/lib/kernel/precedence.mli @@ -86,6 +86,46 @@ type ('s, 'r) rules = { own module documentation (Rite_ef.Precedence_ef.admit); stated here because this signature -- not any one rite's implementation of it -- is what an author of the next rite reads. *) + vigil_feast : 'r candidate -> Slug.t option; + (** The feast this candidate is a VIGIL OF, when the rite subjects that + vigil to omission because its feast did not keep its own day; + [None] for every other candidate, which is what a rite with no such + rule returns unconditionally. + + Exists for RG 33's third omission trigger -- "vel si festum cui + praemittitur in alium diem transferri aut ad commemorationem reduci + contingat", "or if the feast it precedes happens to be transferred + to another day or reduced to a commemoration". Both halves of that + clause reduce to ONE observable question, which is why this hook + returns a slug rather than a verdict: is the named feast the + OBSERVED office on the following day? {!Calendar} asks it and + suppresses the vigil when the answer is no. A feast transferred away + (RG 96) and a feast outranked into a bare commemoration (RG 94) both + fail that test; so does a feast omitted outright, which RG 33 does + not enumerate but which is strictly the stronger case. + + WHY THE RITE NAMES THE FEAST. The kernel could not infer it. RG 34 + fixes the vigil on the day BEFORE its feast, so the date is known, + but nothing in {!Celebration.t} links the two and the slugs do not + reliably derive from one another -- in the EF's own shipped data + only two of five vigils ("ef-ascension-vigil"/"ef-ascension", + "vigil-of-sts-peter-paul"/"sts-peter-paul") share a stem, while + "vigil-of-st-lawrence" precedes "lawrence" and + "vigil-of-the-assumption" precedes + "assumption-of-the-blessed-virgin-mary". Deriving the feast by + string surgery would be wrong for three of the five. Asking "is a + Class1 sanctoral office observed tomorrow?" would be a PROXY, and + would fire on a day where some UNRELATED I-class feast had + transferred in on top of the real one -- the vigil's feast would be + absent and the vigil wrongly kept. + + CALLED ONCE PER CANDIDATE PER DAY, and the resolution of the + following day that {!Calendar} performs to answer it does NOT + itself apply this rule. That is not an approximation: a vigil is a + candidate only on its own day, never on its feast's, so suppressing + it cannot change what is observed the day after. The check is + therefore a single pass with no fixed point and no recursion -- + unlike RG 96's transfers, which genuinely need one. *) } (** The outcome of resolving one day's candidates. *) diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index b69ee52..36aea83 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -600,6 +600,61 @@ let is_bvm_office (c : Vocab_ef.rank Precedence.candidate) = (§6) rather than guessed. *) let is_omissible_vigil (rank : Vocab_ef.rank) = rank = Vocab_ef.Class2 || rank = Vocab_ef.Class3 +(* RG 33's THIRD omission trigger, the clause [is_omissible_vigil]'s own + comment above records as unimplemented: "vel si festum cui praemittitur in + alium diem transferri aut ad commemorationem reduci contingat" -- "or if + the feast it precedes happens to be transferred to another day or reduced + to a commemoration". + + Each pair is (vigil slug, the slug of the feast it precedes). RG 34 fixes + the feast on the day AFTER the vigil ("Vigiliae... celebrantur die + praecedenti festum"), so the kernel needs no date here, only the identity + -- and it must be given the identity rather than deriving it, because + these slugs do not derive from one another: two of the five share a stem + with their feast and three do not (see {!Precedence.rules.vigil_feast}). + + The five are exhaustive for the 1962 universal calendar. The four + sanctoral ones are the only entries in data/ef/sanctoral.sexp matching + [is_vigil]; the fifth is temporal_ef's own Ascension vigil. The two + I-class vigils (Nativity Eve, Pentecost Vigil) are deliberately ABSENT: + RG 33 governs only "Vigilia II aut III classis", and RG 30 puts a I-class + vigil beyond losing in the first place -- the same argument + [is_omissible_vigil] already makes for the other two triggers. + + A user overlay adding a diocesan vigil is NOT covered by this table and + its vigil will not be omitted. That is a data limit, not an architectural + one -- the kernel hook takes any candidate -- and it is stated here rather + than papered over with a slug-shape heuristic that would be wrong for + three of the five universal cases it can already be checked against. *) +(* Built with [Slug.of_string_exn], deliberately, and at module initialisation + rather than per call. A malformed literal here is a PROGRAMMING error in a + static table, not untrusted input, and the alternative found by mutation + testing is worse than a crash: with [Slug.of_string] and [Result.to_option] + a typo (an uppercase letter is enough -- slugs are lowercase-only) collapses + to [None], which this function's own contract reads as "not a vigil", and + the whole rule switches itself off for that entry in silence. That failure + was reproduced: mutating "lawrence" to "lawrence-WRONG" reddened FOUR tests, + all of them the ones that notice the rule missing, and none that notice it + pointing at the wrong feast -- indistinguishable from deleting the row. + The same mutation with a well-formed "lawrence-wrong" reddens eighteen. + Raising at startup keeps a typo loud; [Rite_ef]'s own bundle is constructed + at initialisation too, so the failure surfaces before any calendar is + resolved. *) +let vigil_feast_table = + List.map + (fun (vigil, feast) -> (vigil, Slug.of_string_exn feast)) + [ ("ef-ascension-vigil", "ef-ascension"); + ("vigil-of-the-nativity-of-st-john-the-baptist", "nativity-of-st-john-the-baptist"); + ("vigil-of-sts-peter-paul", "sts-peter-paul"); + ("vigil-of-st-lawrence", "lawrence"); + ("vigil-of-the-assumption", "assumption-of-the-blessed-virgin-mary") ] + +let vigil_feast (c : Vocab_ef.rank Precedence.candidate) : Slug.t option = + let cel = c.Precedence.cel in + if not (is_omissible_vigil cel.Celebration.rank) then None + else List.assoc_opt (Slug.to_string cel.Celebration.slug) vigil_feast_table + + (* Every Sunday slug this rite's temporal cycle produces -- named (temporal_ef.ml's [named], e.g. "ef-easter-sunday") or the generic "ef--sunday-" fallback ([sunday_slug]) -- contains this diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 419613a..4c2523a 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -217,6 +217,41 @@ val disposition : loser:Vocab_ef.rank Precedence.candidate -> Precedence.disposition +(** RG 33's third omission trigger. Given a candidate, the feast it is a + VIGIL of, when that vigil is one this rule can omit; [None] otherwise -- + which is every candidate that is not one of the five II/III-class vigils + in the 1962 universal calendar. + + Answers only the identity question. Whether the feast actually kept its + own day is {!Colitur_kernel.Calendar}'s to determine, because only the + kernel holds the settled post-transfer placement of the whole year; see + {!Colitur_kernel.Precedence.rules.vigil_feast} for the division of labour + and for why the feast is NAMED here rather than inferred there. + + The I-class vigils (Nativity Eve, Pentecost Vigil) are outside this rule + by RG 33's own wording and outside losing at all by RG 30, so they are + absent from the table and this returns [None] for them -- the same + argument {!is_omissible_vigil} already makes for the rule's other two + triggers. *) +val vigil_feast : Vocab_ef.rank Precedence.candidate -> Slug.t option + +(** The (vigil slug, feast slug) pairs {!vigil_feast} answers from, exposed so + the test suite can assert both directions against the shipped sanctoral + data: that every slug named here exists, and that no II/III-class vigil in + the data is missing from it. Neither is checkable by a type, and both fail + SILENTLY -- an unmatched slug simply makes RG 33 inert for that entry. *) +val vigil_feast_table : (string * Slug.t) list + +(** Whether a slug names a vigil, by either of the two conventions this + calendar's data uses (a "-vigil" suffix from the temporal cycle, a + "vigil-of-" prefix from the sanctoral bootstrap). Exposed for the same + table-drift assertions as {!vigil_feast_table}. *) +val is_vigil : string -> bool + +(** Whether a rank is one RG 33 can omit -- II or III class. I-class vigils are + outside the rule (RG 30). Exposed alongside {!is_vigil}. *) +val is_omissible_vigil : Vocab_ef.rank -> bool + (** Slug prefix marking a celebration as one of RG 91 entry 17's days within the Octave of the Nativity (29-31 Dec -- 26-28 Dec are Stephen, John, the Innocents, sanctoral, never this prefix). Also colitur's own convention diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index a8a9703..ec6d2b9 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -41,7 +41,8 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = rules = { Precedence.band = Precedence_ef.band; disposition = Precedence_ef.disposition; - admit = Precedence_ef.admit }; + admit = Precedence_ef.admit; + vigil_feast = Precedence_ef.vigil_feast }; season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; readings = Lectionary_ef.readings ~lectionary ~commons } diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 59624aa..132a466 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -82,7 +82,7 @@ module Fixture = struct test below a genuine Precedence-native omission (distinct from a deferred one) to exercise. *) let rules : (season, rank) P.rules = - { P.band; disposition; + { P.vigil_feast = (fun _ -> None); band; disposition; admit = (fun ~observed:_ ~temporal:_ cs -> List.filteri (fun i _ -> i < 1) cs |> List.map (fun (c, p, (_ : int)) -> (c, p))) } diff --git a/test/test_differential.ml b/test/test_differential.ml index 8423888..8c1dd8d 100644 --- a/test/test_differential.ml +++ b/test/test_differential.ml @@ -1089,6 +1089,13 @@ let rg69_one_hop_slugs = [ "ef-christmas-1-saturday" ] (* [layer_c_reason l c diffs] returns the [data/ef/expected-divergences.sexp] [id] this row-pair's remaining (post Layer A/B) diff set belongs to, or [None] if nothing here explains it (a genuine, uncovered failure). *) +(* The three vigils RG 33's third trigger can actually reach on the shipped + universal calendar -- see C39 in [layer_c_reason]. Named here rather than + written inline so a future slug rename fails in one visible place. *) +let rg33_vigil_slugs = + [ "vigil-of-st-lawrence"; "vigil-of-the-nativity-of-st-john-the-baptist"; + "vigil-of-sts-peter-paul" ] + let layer_c_reason (l : row) (c : row) diffs = let m = month_of_date l.date and d = day_of_date l.date in if diffs = [] then None @@ -1135,6 +1142,22 @@ let layer_c_reason (l : row) (c : row) diffs = colour-only Saturday still falls to C17 and cannot be absorbed here. *) else if is_bvm_saturday_row c diffs then Some "C17" else if diffs = [ Colour_f ] && List.mem c.slug audit_colour_corrected_slugs then Some "C18" + (* C39 -- RG 33's THIRD omission trigger, implemented 2026-08-21. lectio + omits a II/III-class vigil that itself falls on a Sunday (the rule's + SECOND trigger) but not one whose FEAST fails to keep its own day, so on + these days lectio still shows the vigil and colitur no longer does. + + Gated on LECTIO's own slug being one of the three vigils this clause can + reach, and on colitur's NOT being a vigil -- identity on both sides, not + merely a diff-set shape, the same discipline C6 and C14 were given. The + three are exhaustive here: the Assumption's and the Ascension's vigils + can never qualify, because their I-class feasts always keep their own + day (measured over the whole 1583-9999 domain, 0 occurrences). *) + else if + List.mem l.slug rg33_vigil_slugs + && (not (List.mem c.slug rg33_vigil_slugs)) + && subset diffs [ Slug_f; Rank; Colour_f; First_f; Gospel_f ] + then Some "C39" (* C25 -- CLOSED, BVM Saturday Mass (2026-08-17), predicate removed: 0 rows. Its days are unoccupied IV-class Saturdays, so they carry RG 78's office and now RG 309(a)'s Mass for it, which answers before step 3's walkback diff --git a/test/test_golden.ml b/test/test_golden.ml index 33ca362..b882596 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -1327,14 +1327,70 @@ let test_rogation_wednesday_yields_to_a_feast_2026 () = white" -- which colitur already emits correctly (ef-ascension-vigil, white, pinned by the two Rogation Wednesday cases just above). - Pinned in years where each vigil is actually OBSERVED rather than impeded: - 9 August 2025 is a Saturday and 14 August 2025 a Thursday, both checked - with `date -d`. In 2026, 9 August is a Sunday and the vigil never appears. *) -let test_vigil_of_st_lawrence_is_violet_2025 () = - check ~msg:"RG 128: III-class vigil outside Paschaltide is violet, not red" 2025 8 9 - "2025-08-09 saturday season=time-after-pentecost week=8 slug=vigil-of-st-lawrence \ + Pinned in years where each vigil is actually OBSERVED rather than impeded. + 14 August 2025 is a Thursday and the Assumption always keeps 15 August (I + class, nothing in the universal calendar outranks it), so that pin is + stable in any year the vigil's own day is free. + + ST LAWRENCE'S PIN MOVED 2025 -> 2027 when RG 33's third omission trigger + was implemented. Its original comment reasoned only about the vigil's OWN + weekday -- "9 August 2025 is a Saturday", true, so RG 33's Sunday trigger + does not fire -- and missed that the rule also looks at the FEAST: 10 + August 2025 is a Sunday, the II-class Sunday reduces St Lawrence to a + commemoration, and "vel si festum cui praemittitur... ad commemorationem + reduci contingat" omits the vigil entirely. The day now keeps the BVM + Saturday Office instead, pinned separately just below. 9 August 2027 is a + Monday and 10 August 2027 a Tuesday (both `date -d`-checked), so St + Lawrence keeps his own day there and the vigil genuinely is observed -- + which is what this pin has always been trying to assert about RG 128's + colour rule. *) +let test_vigil_of_st_lawrence_is_violet_2027 () = + check ~msg:"RG 128: III-class vigil outside Paschaltide is violet, not red" 2027 8 9 + "2027-08-09 monday season=time-after-pentecost week=12 slug=vigil-of-st-lawrence \ rank=class-3 colour=violet subject=saint name_la=- comms=[romanus:ordinary] in=- out=[]" +(* RG 33's THIRD omission trigger -- "vel si festum cui praemittitur in alium + diem transferri aut ad commemorationem reduci contingat" -- on the shipped + universal calendar, no overlay involved. 10 August 2025 is a Sunday; the + II-class Sunday outranks St Lawrence's II-class feast (RG 15), so the feast + is "ad commemorationem reducta" on its own day and the vigil of 9 August is + "penitus omissa". + + This is the clause's most common live shape by a wide margin: 1 199 of the + 1 744 days it fires on across 1583-9999 are St Lawrence's vigil dropped + because 10 August fell on a Sunday. The day does not become a bare feria -- + 9 August 2025 is a Saturday, so RG 78's votive Office of the BVM takes the + freed IV-class Saturday, which is why this pin reads white and subject=bvm. + St Romanus keeps his commemoration either way (RG 111). + + Independently witnessed. The published Ordo -- the project's only witness + outside the Divinum Officium -> missalemeum -> lectio lineage -- omits the + vigil on this exact date, and lectio (and so missalemeum) keeps it; both + allow-lists gained cited entries for that disagreement. *) +let test_rg33_vigil_omitted_when_its_feast_is_commemorated_2025 () = + check + ~msg:"RG 33: 10 Aug 2025 is a Sunday, so St Lawrence is only commemorated and his vigil is \ + omitted entirely -- the BVM Saturday Office (RG 78) takes the freed day" + 2025 8 9 + "2025-08-09 saturday season=time-after-pentecost week=8 \ + slug=ef-time-after-pentecost-8-saturday rank=class-4 colour=white subject=bvm \ + name_la=Officium sanctae Mariae in sabbato comms=[romanus:ordinary] in=- out=[]" + +(* The same clause's OTHER half, "in alium diem transferri". 24 June 2038 is + impeded by the Sacred Heart (I class, movable), so the Nativity of St John + the Baptist is TRANSFERRED under RG 96 and its vigil of 23 June is omitted. + 478 days across the domain take this shape (a further 67 via Corpus + Christi). Pinned in 2038 because that year already carries an oracle + fixture, so the divergence this creates is visible to layer 4 as well. *) +let test_rg33_vigil_omitted_when_its_feast_transfers_2038 () = + check + ~msg:"RG 33: the Sacred Heart takes 24 June 2038, St John the Baptist transfers, \ + and his vigil of 23 June is omitted entirely" + 2038 6 23 + "2038-06-23 wednesday season=time-after-pentecost week=1 \ + slug=ef-time-after-pentecost-1-wednesday rank=class-4 colour=green subject=temporal \ + name_la=- comms=[] in=- out=[]" + let test_vigil_of_the_assumption_is_violet_2025 () = check ~msg:"RG 128: II-class vigil outside Paschaltide is violet, not white" 2025 8 14 "2025-08-14 thursday season=time-after-pentecost week=9 slug=vigil-of-the-assumption \ @@ -1342,8 +1398,16 @@ let test_vigil_of_the_assumption_is_violet_2025 () = let suite = ( "golden pins (known-tricky years)", - [ Alcotest.test_case "RG128: St Lawrence's vigil is violet (2025)" `Quick - test_vigil_of_st_lawrence_is_violet_2025; + [ Alcotest.test_case "RG128: St Lawrence's vigil is violet (2027)" `Quick + test_vigil_of_st_lawrence_is_violet_2027; + Alcotest.test_case + "RG33 third trigger: the vigil is omitted when its feast is reduced to a commemoration \ + (2025-08-09, St Lawrence)" + `Quick test_rg33_vigil_omitted_when_its_feast_is_commemorated_2025; + Alcotest.test_case + "RG33 third trigger: the vigil is omitted when its feast is transferred away \ + (2038-06-23, St John the Baptist)" + `Quick test_rg33_vigil_omitted_when_its_feast_transfers_2038; Alcotest.test_case "RG128: the Assumption's vigil is violet (2025)" `Quick test_vigil_of_the_assumption_is_violet_2025; Alcotest.test_case "Easter extreme: 1598 earliest (22 Mar, Gauss-verified)" `Quick diff --git a/test/test_oracle.ml b/test/test_oracle.ml index 7ee3848..f1d1a89 100644 --- a/test/test_oracle.ml +++ b/test/test_oracle.ml @@ -1878,6 +1878,21 @@ let classify_2038 (c : colitur_row) (o : oracle_row) diffs = else if contains title "Mass of the B. V. M." then Some "BVM-SATURDAY-MASS (M26 shape 1)" else if first_is "Col 1:12-20" then Some "CHRIST-THE-KING-WEEK (M26 shape 2b)" else if diffs = [ Colour_f ] && List.mem c.c_observed_slug m21_2038_slugs then Some "COLOUR (M21)" + (* RG 33's third omission trigger (register 6.31, implemented + 2026-08-21). The Sacred Heart takes 24 June 2038, so the Nativity of St + John the Baptist is transferred under RG 96 and the vigil of 23 June is + "penitus omissa". missalemeum keeps it -- as does lectio, whose own C39 + covers this same date on the differential side, which is exactly what + one expects of two points on one lineage. Gated on the ORACLE's own + title rather than on the date, so a different divergence arriving on 23 + June could not be silently absorbed here. Cited to the register rather + than to an allow-list id because data/ef/expected-divergences-missalemeum.sexp + is scoped to the 2026-2027 window, which contains no instance of this + clause -- the same convention PERPETUA-COMMON and MISSAL-TWO-PART-CITATION + already follow. The tracked, full account is C39's own note in + data/ef/expected-divergences.sexp. *) + else if contains title "Vigil of the Nativity of St. John the Baptist" then + Some "RG33-THIRD-TRIGGER (colitur; register 6.31, see C39)" else if first_is "Judith" then Some "JUDITH (M28, register 6.6)" else if c.c_observed_slug = "sts-felicitas-perpetua" then Some "PERPETUA-COMMON (register 6.8)" else if c.c_observed_slug = "ef-nativity" then Some "CHRISTMAS-MULTI-MASS (M27)" @@ -2108,6 +2123,9 @@ let test_2038_class_counts () = ("COMM-ONLY-TIE-BREAK (RG-under-determined convention -- register 6.16)", 1); ("JOSEPH-ANNUNCIATION-TRANSFER-ORDER (C14's own collision)", 2); ("JUDITH (M28, register 6.6)", 2); + (* RG 33's third trigger: 23 June only -- the Sacred Heart takes 24 + June 2038 and St John the Baptist transfers. See M31. *) + ("RG33-THIRD-TRIGGER (colitur; register 6.31, see C39)", 1); ("MISSAL-TWO-PART-CITATION (register 6.9)", 1); ("COMM-IDENTITY-UNRESOLVED (temporal-origin, no English name -- M15's limit)", 2); ("PERPETUA-COMMON (register 6.8)", 1); diff --git a/test/test_precedence.ml b/test/test_precedence.ml index ce75ab6..acc89fc 100644 --- a/test/test_precedence.ml +++ b/test/test_precedence.ml @@ -17,7 +17,7 @@ let ctx = (* Band: Hi beats Lo. Temporal breaks a tie in its own favour. *) let rules = - { P.band = (fun _ c -> (match c.P.cel.Cel.rank with Hi -> 10 | Lo -> 20) + { P.vigil_feast = (fun _ -> None); band = (fun _ c -> (match c.P.cel.Cel.rank with Hi -> 10 | Lo -> 20) - (match c.P.origin with P.Temporal -> 1 | P.Sanctoral -> 0)); disposition = (fun ~winner:_ ~loser -> diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 3f3e070..6bd7bfd 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -370,7 +370,7 @@ let test_all_souls_yields_to_sunday () = in let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in let rules = - { P.band = (fun c cd -> PE.band c cd); + { P.vigil_feast = (fun _ -> None); band = (fun c cd -> PE.band c cd); disposition = (fun ~winner:_ ~loser:_ -> P.Omit); admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } in @@ -1443,7 +1443,11 @@ let test_admit_uses_precedence_not_slug_when_they_disagree () = [rules] deliberately reused, not rebuilt per test, since it is always the same three real functions. *) -let real_rules = { P.band = PE.band; disposition = PE.disposition; admit = PE.admit } +(* The real EF ruleset, including RG 33's third-trigger hook -- a stub here + would make any test built on [real_rules] blind to it. *) +let real_rules = + { P.vigil_feast = PE.vigil_feast; band = PE.band; disposition = PE.disposition; + admit = PE.admit } (* I-class day, zero admitted: the strongest form of "does not vanish" -- EVERY commemoration due is dropped (RG 111: "none save one privileged", @@ -1929,7 +1933,7 @@ let test_all_souls_transfers_end_to_end () = in let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in let rules = - { P.band = PE.band; disposition = PE.disposition; + { P.vigil_feast = (fun _ -> None); band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } in let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in diff --git a/test/test_sanctoral_ef.ml b/test/test_sanctoral_ef.ml index 5c46edf..b237f25 100644 --- a/test/test_sanctoral_ef.ml +++ b/test/test_sanctoral_ef.ml @@ -165,6 +165,72 @@ let test_commemoration_only_has_no_readings () = Alcotest.(check int) "a commemoration contributes an oration, not a reading" 0 (List.length bad) +(* RG 33's third omission trigger reads a hand-written table in + [Precedence_ef.vigil_feast_table] pairing each vigil with the feast it + precedes. That table is the one part of the rule no type can check: a slug + that matches nothing in the data makes the rule silently inert for that + entry, and a vigil ADDED to the data but not to the table is never omitted + at all. Both directions are asserted here against the shipped file. + + The Ascension's vigil is excluded from the data-side checks because it is + TEMPORAL (Temporal_ef builds it off an Easter offset) and correctly absent + from the sanctoral layer -- asserted explicitly below rather than skipped, + so that the exclusion cannot quietly grow to cover a real miss. *) +let ascension_vigil = "ef-ascension-vigil" + +let test_vigil_feast_table_slugs_all_exist () = + let l = load () in + let mem slug = + match Colitur_kernel.Slug.of_string slug with + | Error e -> Alcotest.failf "%s: not a well-formed slug: %s" slug e + | Ok s -> L.mem l s + in + List.iter + (fun (vigil, feast) -> + let feast = Colitur_kernel.Slug.to_string feast in + if String.equal vigil ascension_vigil then begin + (* BOTH halves of this pair are temporal -- Temporal_ef builds the + vigil at Easter+38 and the feast at Easter+39 -- so neither belongs + in the sanctoral layer. Asserted in the negative rather than + skipped, so the exclusion cannot quietly widen to hide a real miss; + the pair's positive witness is test_temporal_ef's own Ascension + cases and the 2026-05-13 golden pin. *) + Alcotest.(check bool) "ef-ascension-vigil is temporal, not sanctoral" false (mem vigil); + Alcotest.(check bool) "ef-ascension is temporal, not sanctoral" false (mem feast) + end + else begin + Alcotest.(check bool) + (Printf.sprintf "%s is present in the shipped sanctoral" vigil) + true (mem vigil); + Alcotest.(check bool) + (Printf.sprintf "%s (the feast %s precedes) is present in the shipped sanctoral" + feast vigil) + true (mem feast) + end) + PE.vigil_feast_table + +let test_vigil_feast_table_covers_every_shipped_vigil () = + let l = load () in + let tabled = List.map fst PE.vigil_feast_table in + let shipped_vigils = + l.L.entries + |> List.filter (fun (e : V.rank L.entry) -> + let slug = Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug in + PE.is_vigil slug && PE.is_omissible_vigil e.L.cel.Colitur_kernel.Celebration.rank) + |> List.map (fun (e : V.rank L.entry) -> + Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug) + |> List.sort compare + in + let missing = List.filter (fun s -> not (List.mem s tabled)) shipped_vigils in + Alcotest.(check (list string)) + "every II/III-class vigil in the shipped sanctoral is in vigil_feast_table -- a new one \ + added to the data without a table row would never be omitted under RG 33" + [] missing; + (* Pinned so that a vigil VANISHING from the data is caught too: an empty + [shipped_vigils] would satisfy the subset check above vacuously. *) + Alcotest.(check int) "four sanctoral vigils are subject to RG 33" 4 + (List.length shipped_vigils) + let suite = ( "Sanctoral_ef (data/ef/sanctoral.sexp)", [ Alcotest.test_case "load succeeds and counts match the source INI" `Quick @@ -176,4 +242,8 @@ let suite = Alcotest.test_case "sanctoral entries carry their proper readings" `Quick test_sanctoral_carries_propers; Alcotest.test_case "Commemoration_only entries carry no readings" `Quick - test_commemoration_only_has_no_readings ] ) + test_commemoration_only_has_no_readings; + Alcotest.test_case "RG33: every vigil_feast_table slug exists in the shipped data" `Quick + test_vigil_feast_table_slugs_all_exist; + Alcotest.test_case "RG33: vigil_feast_table covers every shipped II/III-class vigil" `Quick + test_vigil_feast_table_covers_every_shipped_vigil ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index 4d5458f..8b3014a 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -247,7 +247,7 @@ module Synthetic = struct fixtures further down override [rules] (and supply a non-empty [layer]), each with its own small, deliberately-shaped ruleset. *) let rules : (season, rank) P.rules = - { P.band = (fun _ _ -> 0); + { P.vigil_feast = (fun _ -> None); band = (fun _ _ -> 0); disposition = (fun ~winner:_ ~loser:_ -> P.Omit); admit = (fun ~observed:_ ~temporal:_ _ -> []) } @@ -425,7 +425,7 @@ module Synthetic = struct let dup_layer = Layer.of_entries ~id:"dup" ~name:"dup" [ dup_entry ] let dup_rules : (season, rank) P.rules = - { P.band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); + { P.vigil_feast = (fun _ -> None); band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); disposition = (fun ~winner:_ ~loser:_ -> P.Commemorate P.Ordinary); admit = (fun ~observed:_ ~temporal:_ cs -> @@ -464,7 +464,7 @@ module Synthetic = struct let guard_layer = Layer.of_entries ~id:"guard" ~name:"guard" [ guard_winner_entry; guard_loser_entry ] let guard_rules : (season, rank) P.rules = - { P.band = (fun _ c -> match c.P.origin with P.Temporal -> 50 | P.Sanctoral -> 10); + { P.vigil_feast = (fun _ -> None); band = (fun _ c -> match c.P.origin with P.Temporal -> 50 | P.Sanctoral -> 10); disposition = (fun ~winner:_ ~loser -> match loser.P.cel.Cel.rank with R1 -> P.Transfer | R2 -> P.Commemorate P.Ordinary); @@ -493,7 +493,7 @@ module Synthetic = struct | x :: xs -> if n <= 0 then [] else x :: adm_take (n - 1) xs let adm_rules : (season, rank) P.rules = - { P.band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); + { P.vigil_feast = (fun _ -> None); band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); disposition = (fun ~winner:_ ~loser:_ -> P.Commemorate P.Ordinary); admit = (fun ~observed:_ ~temporal:_ cs -> @@ -520,7 +520,7 @@ module Synthetic = struct let collide_layer = Layer.of_entries ~id:"collide" ~name:"collide" [ collide_a_entry; collide_b_entry ] let collide_rules : (season, rank) P.rules = - { P.band = + { P.vigil_feast = (fun _ -> None); band = (fun ctx c -> match c.P.origin with | P.Temporal -> if D.compare ctx.P.date collide_d1 = 0 then 5 else 50 @@ -543,7 +543,7 @@ module Synthetic = struct let clean_sanctoral_layer = Layer.of_entries ~id:"clean" ~name:"clean" [ clean_sanctoral_entry ] let clean_sanctoral_rules : (season, rank) P.rules = - { P.band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); + { P.vigil_feast = (fun _ -> None); band = (fun _ c -> match c.P.origin with P.Temporal -> 0 | P.Sanctoral -> 10); disposition = (fun ~winner:_ ~loser:_ -> P.Commemorate P.Ordinary); admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } end -- cgit v1.3 From 651eb993240164123067001c053c9e0b8d970459 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 14:42:36 +0200 Subject: chore: gitignore .superpowers working artifacts Agent research reports, SDD ledgers and review packages, same local-only reasoning as docs/. Untracked but not ignored until now, so they showed up in every git status. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c8fb80b..f1c9899 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ _opam/ # (not installed as a package), so this is the only Python artefact the # repo ever produces. tools/__pycache__/ + +# Superpowers/agent working artifacts: research reports, SDD ledgers and +# review packages. Local-only, same reasoning as docs/. +.superpowers/ -- cgit v1.3 From 8072287da9d8e8a9e2de8b7845923f5d576dea55 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 14:46:34 +0200 Subject: docs(C39): correct the shape split, 7/3 -> 6/4 The note apportioned the entry's ten rows as seven commemoration-reduced and three transferred, counting 2038-06-23 in both halves to make the arithmetic work. It is six and four; the row count itself was always right, and is pinned by the comparator. --- data/ef/expected-divergences.sexp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index f99add0..bba21c1 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -92,7 +92,7 @@ Gated on the literal date window (month=12, day in {29,30,31}) AND colitur's own (verdict colitur) (note "colitur implements all three of RG 33's omission triggers; lectio implements the first two only. On these 10 days the vigil's own weekday is unremarkable -- it is the FEAST that fails to keep its day, either transferred away under RG 96 or reduced to a commemoration under RG 94 -- so lectio still emits the vigil and colitur omits it entirely. -Two shapes, both in the rubric's own words. SEVEN rows are 'ad commemorationem reduci': 10 August falls on a Sunday, the II-class Sunday outranks St Lawrence's II-class feast (RG 15), the feast is commemorated rather than celebrated, and the vigil of 9 August goes (2008, 2014, 2025, 2031, 2036, 2042 -- and the same shape reaches the 2038 row through the other half). THREE are 'in alium diem transferri': the Sacred Heart takes 24 June, the Nativity of St John the Baptist transfers under RG 96, and its vigil of 23 June goes (2022, 2033, 2038, 2044). +Two shapes, both in the rubric's own words. SIX rows are 'ad commemorationem reduci': 10 August falls on a Sunday, the II-class Sunday outranks St Lawrence's II-class feast (RG 15), the feast is commemorated rather than celebrated, and the vigil of 9 August goes (2008, 2014, 2025, 2031, 2036, 2042). FOUR are 'in alium diem transferri': the Sacred Heart takes 24 June, the Nativity of St John the Baptist transfers under RG 96, and its vigil of 23 June goes (2022, 2033, 2038, 2044). 6 + 4 = 10, each date checked against the comparator's own output rather than apportioned by hand. Domain-wide this clause fires on 1 744 of the 3 074 246 days in 1583-9999 -- 1 199 St Lawrence, 347 St John the Baptist, 198 Sts Peter and Paul -- measured by diffing a pre-change binary against HEAD over the whole domain and classifying every changed day: three shapes, zero unexplained, and every one independently re-checked to confirm the feast really is absent from its own day. -- cgit v1.3 From afd82f97ff440add51ea3e87c7ab53938443d5a0 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 15:00:03 +0200 Subject: docs(C39): withdraw the Ordo corroboration claim C39 and the 2025 golden pin both claimed the published Ordo independently confirms RG 33's third trigger, because it omits St Lawrence's vigil on 2025-08-09 where lectio and missalemeum keep it. It does not. The Ordo omits that vigil on 2027-08-09 as well, where the clause says to keep it (10 August is a Tuesday, the feast holds its day), and 2026-08-09 is a Sunday where the rule's first trigger omits it anyway. The 2024 date has no row. So the Ordo never shows St Lawrence's vigil on any date it covers, and the 2025 agreement is a blanket gap coinciding with the right answer, not evidence. The rule itself is unaffected: it rests on RG 33's text, which admits no latitude, and on lectio not evaluating the clause at all -- "RG 33" appears nowhere in its production code, and the third trigger cannot emerge from per-day rank comparison because it turns on what is observed the following day. The divergence is architectural, not a disagreement about the rubric. Recorded rather than deleted: the withdrawn claim is why this entry was first written up as unusually well corroborated. --- data/ef/expected-divergences.sexp | 4 +++- test/test_golden.ml | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index bba21c1..8078611 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -96,7 +96,9 @@ Two shapes, both in the rubric's own words. SIX rows are 'ad commemorationem red Domain-wide this clause fires on 1 744 of the 3 074 246 days in 1583-9999 -- 1 199 St Lawrence, 347 St John the Baptist, 198 Sts Peter and Paul -- measured by diffing a pre-change binary against HEAD over the whole domain and classifying every changed day: three shapes, zero unexplained, and every one independently re-checked to confirm the feast really is absent from its own day. -INDEPENDENTLY WITNESSED, which is rare in this file and is the reason this entry is verdict colitur rather than open. The published Ordo -- the project's only witness outside the Divinum Officium -> missalemeum -> lectio lineage (see CLAUDE.md's own account of why layers 3 and 4 are one lineage, not two) -- omits the vigil on 2025-08-09, agreeing with colitur against both engines. It had been examined earlier in the same session that implemented this rule and misread as an Ordo GAP, on the reasoning that the Missal's calendarium lists the vigil; the calendarium lists it as a fixed entry, which is not a claim about whether it survives a given year's occurrence. The Ordo was right. Two dates were compared and they part company for the correct reason: on 2027-08-09 (10 August a Tuesday, the feast keeps its day) the Ordo omits a vigil colitur correctly keeps, and that one IS an Ordo gap. +CORRECTED 2026-08-21, BEFORE THIS ENTRY WAS EVER REVIEWED: an earlier version of this note claimed the published Ordo -- the project's one witness outside the Divinum Officium -> missalemeum -> lectio lineage -- INDEPENDENTLY CONFIRMS colitur here, because it omits St Lawrence's vigil on 2025-08-09 where both software engines keep it. That claim does not survive its own check and is withdrawn. The Ordo covers 2024-2027 and omits that vigil on BOTH 2025-08-09 (where this clause says omit) and 2027-08-09 (where it says keep, 10 August being a Tuesday); 2026-08-09 is a Sunday, omitted by the rule's FIRST trigger anyway, and the fixture has no row for 2024-08-09. So the Ordo never shows St Lawrence's vigil on any date it covers, and its agreement on 2025-08-09 is a coincidence of a blanket gap, not evidence of the third trigger. The Ordo has nothing to say here in either direction. Recorded rather than quietly deleted because the withdrawn claim was the reason this entry was first written up as unusually well corroborated, and a reader who saw that framing should be able to see it retracted. + +WHAT THIS ENTRY ACTUALLY RESTS ON, then. Two things, neither of them a second witness. FIRST, the primary text, which admits no interpretive latitude: RG 33 joins three triggers with 'vel'/'aut' and the third is a plain conditional on the feast's own fate. SECOND -- and this is the substantive point -- lectio does not DISAGREE with the clause, it never evaluates it. 'RG 33' appears nowhere in lectio's production code, only in one test file's comments; its Sunday behaviour is emergent from ordinary rank comparison (a II-class Sunday outranks a III-class vigil), not from a transcribed rule. The third trigger cannot emerge that way at all, because it turns on what is observed the day AFTER, and a per-day precedence contest has no access to it. That is exactly why implementing it in colitur required a whole-year pass and a new field on Precedence.rules rather than a branch in disposition. So the divergence is architectural, not exegetical: on these 10 days lectio is not asserting that the vigil survives, it is producing the only answer its shape can produce. missalemeum and Divinum Officium inherit the same limit by lineage. Gated on lectio's own slug being one of the three vigils this clause can reach and colitur's not being a vigil -- identity on both sides, not a diff-set shape. The Assumption's and the Ascension's vigils are absent from that list because their I-class feasts always keep their own day: 0 occurrences across the whole domain, measured rather than assumed.") (expected_rows 10)) diff --git a/test/test_golden.ml b/test/test_golden.ml index b882596..14fbec4 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -1363,10 +1363,14 @@ let test_vigil_of_st_lawrence_is_violet_2027 () = freed IV-class Saturday, which is why this pin reads white and subject=bvm. St Romanus keeps his commemoration either way (RG 111). - Independently witnessed. The published Ordo -- the project's only witness - outside the Divinum Officium -> missalemeum -> lectio lineage -- omits the - vigil on this exact date, and lectio (and so missalemeum) keeps it; both - allow-lists gained cited entries for that disagreement. *) + NOT independently witnessed, and an earlier version of this comment wrongly + said it was. The published Ordo does omit the vigil on this date -- but it + also omits it on 2027-08-09, where this clause says to KEEP it, so it never + shows St Lawrence's vigil at all in the years it covers and cannot + corroborate anything here. This pin rests on RG 33's own text and on the + fact that lectio never evaluates the clause (it appears nowhere in its + production code); see C39's own note in + data/ef/expected-divergences.sexp. *) let test_rg33_vigil_omitted_when_its_feast_is_commemorated_2025 () = check ~msg:"RG 33: 10 Aug 2025 is a Sunday, so St Lawrence is only commemorated and his vigil is \ -- cgit v1.3 From f3ac69d0a679200a48716be065a0fa2e70a2c6da Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 15:04:52 +0200 Subject: docs(C39): O'Connell 1964 confirms the rule and the vigil inventory Replaces the withdrawn Ordo claim with a real one of a different kind. O'Connell, The Celebration of Mass (4th ed., 1964) -- a rubrics commentary contemporary with the 1960 Code, outside the Divinum Officium -> missalemeum -> lectio lineage, already cited here for RG 128 -- states all three of RG 33's triggers in English, the third included, verbatim in substance with the Latin. The same passage confirms vigil_feast_table's membership independently: Christmas Eve and Pentecost I class and so outside the rule; Ascension, Assumption, John the Baptist and Peter & Paul II class; St Lawrence the sole III-class vigil. Five entries, the same five, same class split. It confirms the RULE and the ENTITY LIST, not the 10 days -- O'Connell publishes no day-by-day calendar. The distinction is stated in the note rather than blurred, since blurring it is what the withdrawn Ordo paragraph did. --- data/ef/expected-divergences.sexp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index 8078611..60c4886 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -98,7 +98,13 @@ Domain-wide this clause fires on 1 744 of the 3 074 246 days in 1583-9999 -- 1 1 CORRECTED 2026-08-21, BEFORE THIS ENTRY WAS EVER REVIEWED: an earlier version of this note claimed the published Ordo -- the project's one witness outside the Divinum Officium -> missalemeum -> lectio lineage -- INDEPENDENTLY CONFIRMS colitur here, because it omits St Lawrence's vigil on 2025-08-09 where both software engines keep it. That claim does not survive its own check and is withdrawn. The Ordo covers 2024-2027 and omits that vigil on BOTH 2025-08-09 (where this clause says omit) and 2027-08-09 (where it says keep, 10 August being a Tuesday); 2026-08-09 is a Sunday, omitted by the rule's FIRST trigger anyway, and the fixture has no row for 2024-08-09. So the Ordo never shows St Lawrence's vigil on any date it covers, and its agreement on 2025-08-09 is a coincidence of a blanket gap, not evidence of the third trigger. The Ordo has nothing to say here in either direction. Recorded rather than quietly deleted because the withdrawn claim was the reason this entry was first written up as unusually well corroborated, and a reader who saw that framing should be able to see it retracted. -WHAT THIS ENTRY ACTUALLY RESTS ON, then. Two things, neither of them a second witness. FIRST, the primary text, which admits no interpretive latitude: RG 33 joins three triggers with 'vel'/'aut' and the third is a plain conditional on the feast's own fate. SECOND -- and this is the substantive point -- lectio does not DISAGREE with the clause, it never evaluates it. 'RG 33' appears nowhere in lectio's production code, only in one test file's comments; its Sunday behaviour is emergent from ordinary rank comparison (a II-class Sunday outranks a III-class vigil), not from a transcribed rule. The third trigger cannot emerge that way at all, because it turns on what is observed the day AFTER, and a per-day precedence contest has no access to it. That is exactly why implementing it in colitur required a whole-year pass and a new field on Precedence.rules rather than a branch in disposition. So the divergence is architectural, not exegetical: on these 10 days lectio is not asserting that the vigil survives, it is producing the only answer its shape can produce. missalemeum and Divinum Officium inherit the same limit by lineage. +INDEPENDENTLY CONFIRMED AT THE RULE LEVEL (2026-08-21), after the Ordo claim above was withdrawn and the question 'can these be checked against an outside source' was put again. O'Connell, The Celebration of Mass (4th ed., 1964), docs/research/oconnell/oconnell-1964.txt:2626-2629 -- a published rubrics commentary contemporary with the 1960 Code, outside the Divinum Officium -> missalemeum -> lectio lineage and independent of the Missal scans, already cited in this project for RG 128: 'A vigil of II or III class is entirely omitted if it falls on any Sunday, or on a feast of I class, OR IF THE FEAST WHICH IT PRECEDES IS TRANSFERRED TO ANOTHER DATE OR HAPPENS TO BE REDUCED TO A COMMEMORATION.' That is RG 33's three triggers in English, the third included, from a source that is neither the primary text this engine transcribes nor any engine derived from it. + +The same passage independently confirms vigil_feast_table's exact membership, which is the other thing no type can check: I class are Christmas Eve and Pentecost ('these take precedence of all feasts and admit no commemoration' -- RG 30, and why they are excluded from the table); II class are 'the eves of the feasts of the Ascension, the Assumption, the birthday of St. John Baptist (June 24), and SS. Peter and Paul (June 29)'; and 'there is only one vigil of III class, ie., the eve of the feast of St. Laurence (August 10)'. Five vigils, the same five, with the same class split the table encodes. + +WHAT IT DOES NOT DO is witness the 10 DAYS. O'Connell states the rule; he publishes no day-by-day calendar, so the individual dates rest on the rule plus colitur's own occurrence machinery. The distinction is kept deliberately, because collapsing it is exactly the error the withdrawn Ordo paragraph above made. + +WHAT THIS ENTRY ALSO RESTS ON. Two things, neither of them a day-level witness. FIRST, the primary text, which admits no interpretive latitude: RG 33 joins three triggers with 'vel'/'aut' and the third is a plain conditional on the feast's own fate. SECOND -- and this is the substantive point -- lectio does not DISAGREE with the clause, it never evaluates it. 'RG 33' appears nowhere in lectio's production code, only in one test file's comments; its Sunday behaviour is emergent from ordinary rank comparison (a II-class Sunday outranks a III-class vigil), not from a transcribed rule. The third trigger cannot emerge that way at all, because it turns on what is observed the day AFTER, and a per-day precedence contest has no access to it. That is exactly why implementing it in colitur required a whole-year pass and a new field on Precedence.rules rather than a branch in disposition. So the divergence is architectural, not exegetical: on these 10 days lectio is not asserting that the vigil survives, it is producing the only answer its shape can produce. missalemeum and Divinum Officium inherit the same limit by lineage. Gated on lectio's own slug being one of the three vigils this clause can reach and colitur's not being a vigil -- identity on both sides, not a diff-set shape. The Assumption's and the Ascension's vigils are absent from that list because their I-class feasts always keep their own day: 0 occurrences across the whole domain, measured rather than assumed.") (expected_rows 10)) -- cgit v1.3