From 7d3b5ec831a60e8b63466251d63b2bd564acba2b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 13:32:13 +0200 Subject: fix(rite-ef,kernel): follow the oracle on the Purification; admit reads the day's own Sunday-ness, not observed Fix round 1 review, two findings landed together because each golden pin's correctness depends on both. CRITICAL (item 1): reverts this branch's own first-pass retag of the Purification (2 February) from Lord back to Bvm -> Lord. The calendarium titles it 'B. Mariae Virg.', but missalemeum -- this project's designated EF oracle -- treats it as taking an occurring II-class Sunday's place outright, commemorations empty, exactly RG16(a)'s own festum Domini treatment (2020-02-02, 2014-02-02, independently fetched). Control, 2019-09-08 (the Nativity of the BVM, an ordinary Marian feast, on a Sunday): the Sunday observed, the feast merely commemorated -- the opposite pattern, proving the Purification's own oracle treatment is deliberate, not a gap. RG16(a) alone, on the untouched data, would have matched the oracle character for character; retagging Bvm is what moved colitur away from it on 1200 domain-wide days. The user has ruled: follow the oracle. Genuine primary-text counter-evidence (RG120(b), the colour rule grouping 2 February under 'B. Mariae Virg.', a category separate from 'Domini') is recorded in the register, not discarded -- a different project could reasonably rule the other way on the same evidence. most-holy-name-of-mary stays retagged Bvm; that one was never disputed. Removes the now-unneeded C12 lectio allow-list entry (data/ef/ expected-divergences.sexp, test_differential.ml): with the Purification Lord again, colitur matches lectio's own class = lord on every affected date, no divergence to allow-list. IMPORTANT (item 2): Precedence.rules.admit gains a ~temporal parameter -- a genuine kernel signature change, unlike RG16(a) itself. RG111(b)'s own Sunday rank floor ('one commemoration, namely of a II-class feast') used to be detected by reading observed's own slug for a Sunday marker, which silently assumed observed IS the day's temporal-cycle office. RG16(a) breaks that assumption on purpose: a Feast of the Lord standing in for an impeded Sunday 'cum omnibus iuribus et privilegiis' keeps the day a dominica II classis for RG111(b) too, even though the FEAST, not the Sunday, is observed. Before this fix an unrelated saint (Pope Sixtus II et al., 6 August, Class3) was wrongly admitted into the freed slot on every one of 1178 domain-wide days. Oracle-confirmed directly: 2023-08-06 (a Sunday) shows him displaced, commemorations empty; 2026-08-06 (an ordinary Thursday) shows him admitted -- being a Sunday is exactly what excludes him. admit now reads Sunday-ness off ~temporal (the day's own temporal-cycle candidate, passed through from Precedence.resolve unchanged) instead of observed. Validate's own admission fixed-point invariant updated to pass the same real temporal candidate through. Both golden pins in test_golden.ml were promoted from actual colitur day output without independently consulting the rubric or the oracle first -- a real defect the review caught. Re-derived from both before re-pinning: 2028-08-06 and 2025-02-02 now pin comms=[], no commemoration at all, matching missalemeum exactly in both cases. --- lib/kernel/precedence.ml | 3 ++- lib/kernel/precedence.mli | 16 ++++++++++++++++ lib/kernel/validate.ml | 17 ++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) (limited to 'lib/kernel') diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml index 05ad69f..d48203e 100644 --- a/lib/kernel/precedence.ml +++ b/lib/kernel/precedence.ml @@ -17,6 +17,7 @@ type ('s, 'r) rules = { disposition : winner:'r candidate -> loser:'r candidate -> disposition; admit : observed:'r candidate -> + temporal:'r candidate -> ('r candidate * privilege) list -> ('r candidate * privilege) list; } @@ -55,7 +56,7 @@ let resolve rules ctx ~temporal ~sanctoral = ([], [], []) losers in let comms = List.rev comms and deferred = List.rev deferred in - let admitted = rules.admit ~observed comms in + let admitted = rules.admit ~observed ~temporal comms in let dropped = List.filter (fun c -> not (List.exists (fun a -> fst a == fst c) admitted)) comms in diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli index ae054dd..d394cb1 100644 --- a/lib/kernel/precedence.mli +++ b/lib/kernel/precedence.mli @@ -34,12 +34,28 @@ type ('s, 'r) rules = { (** RG 92-95: the loser's fate, which depends on the loser's own rank. *) admit : observed:'r candidate -> + temporal:'r candidate -> ('r candidate * privilege) list -> ('r candidate * privilege) list; (** RG 108-111: how many commemorations are admitted, and in what order; anything filtered out here is recorded in {!resolution.omitted}, not dropped. + [temporal] is {!resolve}'s own [~temporal] argument, passed through + unchanged -- the day's temporal-cycle candidate, regardless of + whether it won. Fix round 1 (RG16(a) task): before this, a rite's + [admit] could only infer properties of the CIVIL DAY (chiefly "is + this a Sunday", RG 111(b)'s own two-tier admission rule) from + [observed]'s own fields -- a proxy that breaks the moment something + OTHER than the day's own temporal candidate can be [observed], the + exact shape RG 16(a) introduces (a Feast of the Lord standing in + the impeded Sunday's place "cum omnibus iuribus et privilegiis", + RG 91 entry 14). This is NOT a kernel definition of "Sunday" -- + the kernel does not gain any rite-specific knowledge by this + parameter, it only threads through a value {!resolve} already + holds; a rite's own [admit] is free to ignore it entirely, the + same as [observed]. + OBLIGATION ON THE IMPLEMENTATION, not enforced by this type: every candidate this function returns must be a value taken UNCHANGED from its input list, never rebuilt (e.g. via a [{ c with ... }] diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index cc8bdce..a3208dd 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -303,11 +303,26 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = let observed_candidate : 'r Precedence.candidate = { Precedence.cel = d.Liturgical_day.observed; origin = Precedence.Sanctoral } in + (* [~temporal] (fix round 1, RG16(a) task): {!Precedence.rules.admit} + now also takes the day's own temporal-cycle candidate, reused + here from {!Liturgical_day.t}'s own embedded [Temporal.t] -- + the exact same value {!Calendar} passed to {!Precedence.resolve} + in the first place, so re-offering is against the SAME inputs, + not a reconstruction that could itself introduce a false + negative. [origin] is [Precedence.Temporal], genuinely (this IS + the temporal candidate, not a reconstructed sanctoral one). *) + let temporal_candidate : 'r Precedence.candidate = + { Precedence.cel = d.Liturgical_day.temporal.Temporal.office; + origin = Precedence.Temporal } + in let as_candidates comms = List.map (fun (c, p) -> ({ Precedence.cel = c; origin = Precedence.Sanctoral }, p)) comms in let offered = as_candidates d.Liturgical_day.commemorations in - let readmitted = rite.Rite.rules.Precedence.admit ~observed:observed_candidate offered in + let readmitted = + rite.Rite.rules.Precedence.admit ~observed:observed_candidate + ~temporal:temporal_candidate offered + in let norm l = List.map (fun (c, p) -> (Slug.to_string c.Precedence.cel.Celebration.slug, p)) l |> List.sort compare -- cgit v1.3 From ea22ad2bde211998e6719fd5fb76233571a48383 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 15:16:00 +0200 Subject: fix(kernel,rite-ef): admit orders commemorations by RG 113's table of precedence, not slug Precedence_ef.admit broke a same-rank tie among commemoration candidates alphabetically by slug -- a deterministic engineering convention with no rubrical citation. RG 113's own second sentence, primary-source-verified against two independent scans and previously uncited in the register (only "commemoratio de Tempore fit primo loco" was quoted before), gives the real rule: "in admittendis et ordinandis aliis commemorationibus, servetur ordo tabellae praecedentiae" -- admitting and ordering commemorations both run on the rite's own table of precedence (band's 28-entry table), not RG 8's coarse four-class rank. Precedence.resolve now computes each commemoration candidate's own band value once, generically, and hands it to rules.admit as a third tuple element (Precedence.rules.admit's signature changed accordingly, ditto Precedence_ef.admit; every rule-record stub in the test suite updated to match). Precedence_ef.admit's own compare_dignity is replaced by compare_precedence, ordering by band then slug; a residual tie within one identical band value still falls back to slug, since RG 113 gives no further instruction there -- documented as a still-uncited engineering convention, not dressed up as a rubric. RG 98 ("in paritate autem Officium prius impeditum praecedit") was considered as a candidate authority for that residual and rejected: it governs the transfer queue order among several simultaneously-impeded I-class feasts (Caput XIII), a different operation in a different chapter from RG 113's commemoration admission (Caput XVI); nothing in the primary text connects the two. Blast radius measured against the pre-change binary across the entire 1583-9999 domain (not only 2005-2050): the admitted-commemoration-slug set is byte-identical, day for day, before and after this change. The fix corrects the citation and mechanism, not the answer, on this codebase's current data -- both of the task brief's named examples (22 Feb Chair-of-Peter/Lent-vs-Paul, 22 Sept Maurice-vs-Thomas-of- Villanova) are confirmed present and unchanged in both streams. A new test (RG113: admit picks by precedence order, not slug, when they disagree) proves admit actually consults the passed-in precedence value with a synthetic pair whose slug order and precedence order disagree -- teeth a same-band-only regression test could not have caught, since every real collision found in the domain happens to agree on both axes. 271 -> 272 tests, all green; COLITUR_EXHAUSTIVE_SWEEP=1 unaffected. --- lib/kernel/precedence.ml | 21 ++++++- lib/kernel/precedence.mli | 15 ++++- lib/kernel/validate.ml | 26 +++++++- lib/rites/rite_ef/precedence_ef.ml | 116 ++++++++++++++++++++---------------- lib/rites/rite_ef/precedence_ef.mli | 58 +++++++++++------- test/test_calendar.ml | 5 +- test/test_precedence.ml | 4 +- test/test_precedence_ef.ml | 85 +++++++++++++++++++++----- test/test_validate.ml | 15 +++-- 9 files changed, 245 insertions(+), 100 deletions(-) (limited to 'lib/kernel') diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml index d48203e..2c55817 100644 --- a/lib/kernel/precedence.ml +++ b/lib/kernel/precedence.ml @@ -18,8 +18,13 @@ type ('s, 'r) rules = { admit : observed:'r candidate -> temporal:'r candidate -> - ('r candidate * privilege) list -> + ('r candidate * privilege * int) list -> ('r candidate * privilege) list; + (** the trailing [int] on each input triple is that candidate's own + [band] value, computed once by {!resolve} below (a rite's [admit] + 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. *) } type 'r resolution = { @@ -56,7 +61,19 @@ let resolve rules ctx ~temporal ~sanctoral = ([], [], []) losers in let comms = List.rev comms and deferred = List.rev deferred in - let admitted = rules.admit ~observed ~temporal comms in + (* RG 113 (EF; docs/research/rules-register.md §4 "Commemorations"): "in + admittendis et ordinandis aliis commemorationibus, servetur ordo + tabellae praecedentiae" -- admitting AND ordering commemorations both + run on the rite's own table of precedence, the same [band] already + used above to pick [observed]. Computed here, once, generically (a + rite's own [admit] has no [ctx] of its own to call [band] with) rather + than inside every rite's [admit] separately -- a kernel-level POLICY + ("commemorations are ordered by the rite's own band"), not a + rite-specific RULE baked into the kernel: the actual [band] function, + and whether a rite's [admit] even uses the value it is handed, both + stay entirely rite-supplied. *) + let comms_by_precedence = List.map (fun (c, p) -> (c, p, rules.band ctx c)) comms in + let admitted = rules.admit ~observed ~temporal comms_by_precedence in let dropped = List.filter (fun c -> not (List.exists (fun a -> fst a == fst c) admitted)) comms in diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli index d394cb1..09376d7 100644 --- a/lib/kernel/precedence.mli +++ b/lib/kernel/precedence.mli @@ -35,12 +35,25 @@ type ('s, 'r) rules = { admit : observed:'r candidate -> temporal:'r candidate -> - ('r candidate * privilege) list -> + ('r candidate * privilege * int) list -> ('r candidate * privilege) list; (** RG 108-111: how many commemorations are admitted, and in what order; anything filtered out here is recorded in {!resolution.omitted}, not dropped. + Each input triple's trailing [int] is that candidate's own {!band} + value, computed once by {!resolve} (RG 113: "in admittendis et + ordinandis aliis commemorationibus, servetur ordo tabellae + praecedentiae" -- ADMITTING and ORDERING commemorations is governed + by the same table-of-precedence order {!band} already supplies for + picking the day's own winner; docs/research/rules-register.md §4 + "Commemorations"). Supplied rather than left for [admit] to compute + itself because [admit] has no [context] (date/season/weekday) of + its own -- {!resolve} already holds one and calls {!band} with it + for every candidate regardless. A rite's [admit] is free to ignore + the value entirely (e.g. fall back to [Vocab.rank] alone), the same + as it may ignore [temporal] below. + [temporal] is {!resolve}'s own [~temporal] argument, passed through unchanged -- the day's temporal-cycle candidate, regardless of whether it won. Fix round 1 (RG16(a) task): before this, a rite's diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index a3208dd..433dc99 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -315,8 +315,29 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = { Precedence.cel = d.Liturgical_day.temporal.Temporal.office; origin = Precedence.Temporal } in + (* [band]'s own [context] (RG 113, docs/research/rules-register.md + §4 "Commemorations" -- {!Precedence.rules.admit}'s own new + [int] parameter, added alongside RG 113's fix: [admit] now + orders/selects by the rite's table-of-precedence value + {!resolve} attaches to each candidate, not by [Vocab.rank] + alone). Reconstructed from {!Liturgical_day.t}'s own embedded + [Temporal.t], the same source [temporal_candidate] above + already draws its [cel] from, so this is the exact [ctx] + {!Calendar} passed to {!Precedence.resolve} for this date in + the first place, not a re-derivation that could itself drift. *) + let day_ctx : 's Precedence.context = + { Precedence.date; + season = d.Liturgical_day.temporal.Temporal.season; + weekday = d.Liturgical_day.temporal.Temporal.weekday } + in let as_candidates comms = - List.map (fun (c, p) -> ({ Precedence.cel = c; origin = Precedence.Sanctoral }, p)) comms + List.map + (fun (c, p) -> + let cand : 'r Precedence.candidate = + { Precedence.cel = c; origin = Precedence.Sanctoral } + in + (cand, p, rite.Rite.rules.Precedence.band day_ctx cand)) + comms in let offered = as_candidates d.Liturgical_day.commemorations in let readmitted = @@ -327,7 +348,8 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = List.map (fun (c, p) -> (Slug.to_string c.Precedence.cel.Celebration.slug, p)) l |> List.sort compare in - if norm readmitted <> norm offered then + let offered_pairs = List.map (fun (c, p, _) -> (c, p)) offered in + if norm readmitted <> norm offered_pairs then fail date "admission" (Printf.sprintf "admit is not a fixed point on this day's own commemorations: re-offering %d \ diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 5688640..861bb1a 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -758,38 +758,47 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) carries everything this split needs, the same way [observed]'s used to before a competing office could occupy the Sunday's place. *) -(* RG 8's four-class dignity order, Class1 highest. Deliberately NOT [band] - (RG 91's much finer 28-entry table): [band] needs a [context] [admit] - does not have (see above), and Celebration.mli's own comment on [status] - -- "RG 111 orders admitted commemorations by dignity" -- names [rank] - itself as that dignity, not the finer occurrence-table entry. *) -let dignity = function - | Vocab_ef.Class1 -> 1 - | Vocab_ef.Class2 -> 2 - | Vocab_ef.Class3 -> 3 - | Vocab_ef.Class4 -> 4 - -(* Deterministic selection order for RG 111: dignity first, then slug -- - the same tie-break {!Precedence.compare_by} uses for [band] itself (the - brief: "break ties on slug"), so which candidate wins a shared rank never - depends on the order [comms] arrives in. *) -let compare_dignity (a, _) (b, _) = - let da = dignity a.Precedence.cel.Celebration.rank - and db = dignity b.Precedence.cel.Celebration.rank in - if da <> db then Int.compare da db +(* RG 113 -- CORRECTED, Task B (branch ef-rg16a): docs/research/rules- + register.md §4's RG 113 entry previously quoted only its FIRST sentence + ("commemoratio de Tempore fit primo loco"); its SECOND, load-bearing + sentence, primary-source-verified against two independent scans, is the + real rule for this function: *"In admittendis et ordinandis aliis + commemorationibus, servetur ordo tabellae praecedentiae"* -- in ADMITTING + and ORDERING the other commemorations, the order of the table of + precedence (RG 91, {!band}'s own 28-entry table) is to be kept. + + This REPLACES a previous [compare_dignity], which sorted by RG 8's coarse + four-class [rank] ("dignity") and broke same-rank ties alphabetically by + slug -- an engineering convention with no rubrical warrant, silently + deciding 66 days over 2005-2050 (e.g. 22 Sept 2027, an Ember Wednesday: + the alphabetical rule admitted "maurice-and-companions-martyrs" over + "thomas-of-villanova", both Class3/{!band} entry 24 -- missalemeum shows + Thomas). {!band} needs a [context] this function itself does not have + (date/season/weekday) -- unlike [dignity], which read [rank] alone -- + so {!Precedence.resolve} now computes each candidate's own [band] value + once, generically, and hands it to [admit] as the trailing [int] on each + input triple (see {!Precedence.rules.admit}'s own doc). [comms] below is + [(candidate * privilege * int) list], not the pair it used to be. *) +let compare_precedence (a, _, ba) (b, _, bb) = + if ba <> bb then Int.compare (ba : int) bb else Slug.compare a.Precedence.cel.Celebration.slug b.Precedence.cel.Celebration.slug let rec take n = function | [] -> [] | x :: xs -> if n <= 0 then [] else x :: take (n - 1) xs +(* [comms]'s own candidate/privilege pair, its [band] value dropped once a + selection has been made -- {!Precedence.rules.admit}'s return type is + still the pair, not the triple; only the INPUT carries [band]. *) +let drop_band (c, p, (_ : int)) = (c, p) + let admit ~(observed : Vocab_ef.rank Precedence.candidate) ~(temporal : Vocab_ef.rank Precedence.candidate) - (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) : + (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege * int) list) : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list = - (* Sorted once, by dignity then slug (see [compare_dignity]); every branch - below either takes a prefix of this list or filters it, so the RESULT - is always a sub-list of [comms] with its elements untouched -- never + (* Sorted once, by {!band} then slug (see [compare_precedence]); every + branch below either takes a prefix of this list or filters it, so the + RESULT is always built from [comms]'s own elements, untouched -- never rebuilt -- which matters beyond determinism: {!Precedence.resolve}'s own [dropped] computation tells an admitted candidate from a dropped one by physical equality (==) on the candidate value (Task 2's own @@ -801,9 +810,11 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) celebration would surface TWICE in the same day -- once in [commemorations] (the rebuilt copy) and once in [omitted] (the original, which nothing admitted matches). One admission, double-reported, and no - crash to announce it, which is exactly why this comment exists. *) - let sorted = List.stable_sort compare_dignity comms in - let is_privileged (_, p) = p = Precedence.Privileged in + crash to announce it, which is exactly why this comment exists. + [drop_band] only unwraps the pair back out of the triple -- it does not + rebuild [c] or [p] themselves, so this obligation still holds. *) + let sorted = List.stable_sort compare_precedence comms in + let is_privileged (_, p, _) = p = Precedence.Privileged in let observed_rank = observed.Precedence.cel.Celebration.rank in (* CORRECTED (fix round 1, RG16(a) task): read off [temporal], not [observed] -- see this function's own doc comment above for the full @@ -819,9 +830,9 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) | Class1, _ -> (* RG 111: "I class: none save one privileged." Ordinary commemorations never get a slot at all on a I-class day, no matter how many are - due; at most one privileged one does, the highest-dignity one if - several are. *) - (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ best ]) + due; at most one privileged one does, the highest-precedence one + (RG 113: {!band}'s own table order) if several are. *) + (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ drop_band best ]) | Class2, true -> (* RG 111(b), primary text, RE-VERIFIED word for word against the scan (final fix wave; this sentence is the sole textual basis for the @@ -835,17 +846,18 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) dropped if a privileged commemoration is due." Two clauses, not one: (i) a privileged commemoration, whenever due, categorically takes the day's one slot - -- not by comparing its dignity against the ordinary contender's, + -- not by comparing its precedence against the ordinary contender's, so an ordinary commemoration that would otherwise win on raw - dignity is still dropped once any privileged one is also due (the - asymmetric clause the brief and task report flag as deliberate, not - present at "other II class" below); (ii) failing that, the slot is - reserved SPECIFICALLY for a [Class2] candidate -- "de festo II - classis" is a RANK restriction, not merely "whichever ordinary - candidate has the best dignity": a III- or IV-class ordinary loser - (a plain commemoration-only saint with no privilege of its own) has - NO standing for this slot at all and must be entirely omitted, even - when it is the only candidate present. + table order is still dropped once any privileged one is also due + (the asymmetric clause the brief and task report flag as + deliberate, not present at "other II class" below); (ii) failing + that, the slot is reserved SPECIFICALLY for a [Class2] candidate -- + "de festo II classis" is a RANK restriction, not merely "whichever + ordinary candidate has the best table position": a III- or + IV-class ordinary loser (a plain commemoration-only saint with no + privilege of its own) has NO standing for this slot at all and + must be entirely omitted, even when it is the only candidate + present. Fix, Task 16 (primary-source-verified + missalemeum-confirmed): previously this fell back to "the best of [sorted], whatever its @@ -857,21 +869,21 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) shows him "displaced" (omitted), never commemorated; the pre-fix code admitted him regardless. *) (match List.filter is_privileged sorted with - | best :: _ -> [ best ] + | best :: _ -> [ drop_band best ] | [] -> ( - match List.filter (fun (c, _) -> c.Precedence.cel.Celebration.rank = Class2) sorted with + match List.filter (fun (c, _, _) -> c.Precedence.cel.Celebration.rank = Class2) sorted with | [] -> [] - | best :: _ -> [ best ])) + | best :: _ -> [ drop_band best ])) | Class2, false -> (* RG 111: "other II class: one" -- no privilege-override clause here, unlike the Sunday case immediately above, so the day's one slot - goes to whichever candidate outranks the rest by dignity alone, - privileged or not. *) - (match sorted with [] -> [] | best :: _ -> [ best ]) + goes to whichever candidate outranks the rest by RG 113's own + table-of-precedence order ({!band}), privileged or not. *) + (match sorted with [] -> [] | best :: _ -> [ drop_band best ]) | (Class3 | Class4), _ -> - (* RG 111: "III-IV class: at most two" -- by dignity, same as the - non-Sunday II-class case, just with room for two. *) - take 2 sorted + (* RG 111: "III-IV class: at most two" -- by RG 113's table order, same + as the non-Sunday II-class case, just with room for two. *) + List.map drop_band (take 2 sorted) (* Task 11: RG 96 -- where an impeded I-class feast lands (docs/research/ rules-register.md §4, "Transfer/translation"). [band] decides who is @@ -884,9 +896,11 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) RG 96's own text, register-transcribed: "the next following day that is not I or II class." [is_blocking] reads that off [Vocab_ef.rank] -- - RG 96 speaks of the day's CLASS (RG 8's four-way dignity), not [band]'s - finer 28-entry occurrence-table row, the same distinction {!admit} above - already draws for RG 111 ({!dignity}, not [band]). *) + RG 96 speaks of the day's CLASS (RG 8's four-way "dignity"), not [band]'s + finer 28-entry occurrence-table row -- unlike {!admit} above, which (RG + 113, Task B/ef-rg16a) now DOES use [band] itself for its own selection + order; RG 96's own text has no such finer-table reading, so [is_blocking] + stays on [Vocab_ef.rank] alone. *) let is_blocking (rank : Vocab_ef.rank) = rank = Vocab_ef.Class1 || rank = Vocab_ef.Class2 (* RG 96's own named exception (docs/research/rules-register.md §4, diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index ee7667d..49195f9 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -166,33 +166,49 @@ val september_ember_prefix : string (** [admit ~observed ~temporal comms]: RG 108-111 (docs/research/rules-register.md §4, "Commemorations"). How many of [comms] -- each already tagged with - its real RG 109 privilege by {!disposition} -- RG 111 admits, and which, - given the day actually observed: + its real RG 109 privilege by {!disposition}, and its own {!band} value + (RG 91's table-of-precedence entry, computed once by + {!Precedence.resolve} -- see {!Precedence.rules.admit}'s own doc) -- RG + 111 admits, and which, given the day actually observed: - [observed] a [Class1] day: none, except at most one privileged - commemoration (the highest-dignity one, if several are due) -- an - ordinary one is never admitted here, no matter how many are due; + commemoration (the highest-{!band}-precedence one, if several are + due) -- an ordinary one is never admitted here, no matter how many + are due; - the CIVIL DAY is a [Class2] Sunday ([temporal]'s slug carries {!sunday_marker} -- CORRECTED, fix round 1, RG16(a) task: read off [temporal], not [observed]; see below): one, subject to TWO conditions, not one -- (i) a privileged commemoration, whenever due, categorically takes the day's one slot over any ordinary one, not by - comparing dignity, so an ordinary commemoration that would otherwise - win on dignity is still dropped; (ii) failing that, the slot is - reserved for a [Class2] candidate SPECIFICALLY ("de festo II classis", - RG 111(b)'s own wording -- a RANK FLOOR, not "whichever ordinary - candidate has the best dignity"): a III- or IV-class ordinary loser - has no standing for this slot at all and is admitted nothing, even - when it is the only candidate due; - - the civil day is any other [Class2] day: one, by dignity alone -- no - privilege override and no rank floor, unlike the Sunday case - immediately above; - - [observed] a [Class3] or [Class4] day: at most two, by dignity alone. + comparing table position, so an ordinary commemoration that would + otherwise win on table order is still dropped; (ii) failing that, the + slot is reserved for a [Class2] candidate SPECIFICALLY ("de festo II + classis", RG 111(b)'s own wording -- a RANK FLOOR, not "whichever + ordinary candidate has the best table position"): a III- or IV-class + ordinary loser has no standing for this slot at all and is admitted + nothing, even when it is the only candidate due; + - the civil day is any other [Class2] day: one, by {!band}'s table order + alone -- no privilege override and no rank floor, unlike the Sunday + case immediately above; + - [observed] a [Class3] or [Class4] day: at most two, by {!band}'s table + order alone. - "Dignity" here is [Vocab_ef.rank] (RG 8's four classes), NOT {!band}'s - finer RG 91 entry number -- {!band} needs a [context] (date/season/ - weekday) this function does not receive (see {!Precedence.rules.admit}). - Ties break on slug, matching {!Precedence.compare_by}, so the result - never depends on the order [comms] arrives in. + CORRECTED, Task B (branch ef-rg16a): this used to sort by + [Vocab_ef.rank] (RG 8's coarse four-class "dignity") and break same-rank + ties alphabetically by slug -- an engineering convention with no + rubrical warrant, cited nowhere, silently deciding 66 days over + 2005-2050. RG 113's own second sentence, previously uncited + (docs/research/rules-register.md §4's RG 113 entry), is the real rule: + *"in admittendis et ordinandis aliis commemorationibus, servetur ordo + tabellae praecedentiae"* -- admitting and ordering commemorations both + run on {!band}'s own finer RG 91 table, not RG 8's four classes. + {!band} needs a [context] (date/season/weekday) this function does not + receive on its own, so {!Precedence.resolve} computes it once, + generically, and hands each candidate its own value as the trailing + [int] on [comms]'s triples (see {!Precedence.rules.admit}). Ties break + on slug ONLY within one identical [band] value (RG 113 gives no further + instruction there; still an uncited engineering convention, register §6 + "RG 113 tie-break"), matching {!Precedence.compare_by}'s own fallback, + so the result never depends on the order [comms] arrives in. [temporal] -- CORRECTED, fix round 1 (RG16(a) task): this function used to read the Sunday/non-Sunday split off [observed]'s own slug, which @@ -219,7 +235,7 @@ val september_ember_prefix : string val admit : observed:Vocab_ef.rank Precedence.candidate -> temporal:Vocab_ef.rank Precedence.candidate -> - (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list -> + (Vocab_ef.rank Precedence.candidate * Precedence.privilege * int) list -> (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list (** The Annunciation's own bootstrapped slug (data/ef/sanctoral.sexp, Task diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 8770017..93cd0bb 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -82,7 +82,10 @@ 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; admit = (fun ~observed:_ ~temporal:_ cs -> List.filteri (fun i _ -> i < 1) cs) } + { P.band; disposition; + admit = + (fun ~observed:_ ~temporal:_ cs -> + List.filteri (fun i _ -> i < 1) cs |> List.map (fun (c, p, (_ : int)) -> (c, p))) } (* RG 96, generic form: search forward from the day after [origin] for the first day whose occupant is not "blocking" -- in this synthetic diff --git a/test/test_precedence.ml b/test/test_precedence.ml index ff75baa..ce75ab6 100644 --- a/test/test_precedence.ml +++ b/test/test_precedence.ml @@ -26,7 +26,9 @@ let rules = | Cel.Feast -> (match loser.P.cel.Cel.rank with | Hi -> P.Transfer | Lo -> P.Commemorate P.Ordinary)); - admit = (fun ~observed:_ ~temporal:_ cs -> List.filteri (fun i _ -> i < 2) cs) } + admit = + (fun ~observed:_ ~temporal:_ cs -> + List.filteri (fun i _ -> i < 2) cs |> List.map (fun (c, p, (_ : int)) -> (c, p))) } let slug_of c = S.to_string c.P.cel.Cel.slug diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 8465870..ca623d2 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -290,7 +290,7 @@ let test_all_souls_yields_to_sunday () = let rules = { P.band = (fun c cd -> PE.band c cd); disposition = (fun ~winner:_ ~loser:_ -> P.Omit); - admit = (fun ~observed:_ ~temporal:_ cs -> cs) } + 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 Alcotest.(check string) "the Sunday is observed, not All Souls" @@ -822,6 +822,22 @@ let observed_class3 = cand ~rank:V.Class3 "ef-some-class3-day" let slugs_of admitted = List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) admitted +(* [admit_cases] below (Task B, ef-rg16a) needs a precedence-order [int] on + every comms triple -- {!Precedence.rules.admit}'s new parameter, RG 113 + (docs/research/rules-register.md §4, "RG 113"). This table's own header + comment states its purpose: isolate [admit]'s own selection/count logic + from {!PE.band}'s occurrence-table classification, so every candidate + here is built from a synthetic slug ("ef-ordinary-hi" etc.) that matches + none of [band]'s 28 real branches -- calling the REAL [PE.band] on them + would collapse every one to {!PE.unclassified} (the same tied value), + destroying the table's ability to distinguish rows at all. [order_of_rank] + is a TEST-ONLY stand-in, reproducing exactly the relative order the + previous [dignity] function gave (RG 8's rank cardinal, lower first) -- + so every existing expectation below still holds unchanged; only the + tuple shape gained this third field. *) +let order_of_rank = function V.Class1 -> 10 | V.Class2 -> 20 | V.Class3 -> 30 | V.Class4 -> 40 +let po c p = (c, p, order_of_rank c.P.cel.Cel.rank) + (* Real-data shapes for the fix round's own RG16(a)/[admit] interaction (below): a Feast of the Lord (subject Lord, Class2, sanctoral) as [observed], oracle-confirmed real slugs/ranks rather than hand-typed @@ -853,28 +869,28 @@ let admit_cases = [ (* RG 111 (§4): "I class: none save one privileged." *) ( "I-class day, only an ordinary commemoration due -> none admitted", observed_class1, observed_class1, - [ (ordinary_hi, P.Ordinary) ], + [ po ordinary_hi P.Ordinary ], [] ); ( "I-class day, ordinary + privileged both due -> only the privileged \ one, regardless of the ordinary one's higher dignity", observed_class1, observed_class1, - [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ], + [ po ordinary_hi P.Ordinary; po privileged_lo P.Privileged ], [ "ef-privileged-lo" ] ); ( "I-class day, two privileged due -> only the higher-dignity one (still \ just \"one\")", observed_class1, observed_class1, - [ (privileged_lo, P.Privileged); (privileged_hi, P.Privileged) ], + [ po privileged_lo P.Privileged; po privileged_hi P.Privileged ], [ "ef-privileged-hi" ] ); (* RG 111: "II-class Sundays: one (dropped if a privileged one is due)." *) ( "II-class Sunday, only an ordinary commemoration due -> it is admitted", observed_class2_sunday, observed_class2_sunday, - [ (ordinary_hi, P.Ordinary) ], + [ po ordinary_hi P.Ordinary ], [ "ef-ordinary-hi" ] ); ( "II-class Sunday, ordinary (higher dignity) + privileged (lower \ dignity) both due -> the PRIVILEGED one is admitted, the ordinary \ one dropped despite outranking it", observed_class2_sunday, observed_class2_sunday, - [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ], + [ po ordinary_hi P.Ordinary; po privileged_lo P.Privileged ], [ "ef-privileged-lo" ] ); (* RG 111(b)'s own rank floor ("scilicet DE FESTO II CLASSIS"), fix round 1 F7: a Class3 ORDINARY candidate -- no privileged rival due, @@ -886,7 +902,7 @@ let admit_cases = ( "II-class Sunday, sole candidate is an ORDINARY Class3 (not \ \"de festo II classis\") -> admitted nothing, not the best available", observed_class2_sunday, observed_class2_sunday, - [ (ordinary_class3, P.Ordinary) ], + [ po ordinary_class3 P.Ordinary ], [] ); (* RG 111: "other II class: one" -- no privilege override, the exact asymmetry the brief and precedence_ef.ml's own [admit] comment flag: @@ -895,21 +911,21 @@ let admit_cases = ( "other II-class day, only an ordinary commemoration due -> it is \ admitted", observed_class2_other, observed_class2_other, - [ (ordinary_hi, P.Ordinary) ], + [ po ordinary_hi P.Ordinary ], [ "ef-ordinary-hi" ] ); ( "other II-class day, same ordinary+privileged pair as the Sunday row \ above -> the ORDINARY one wins on pure dignity this time, the \ privileged one dropped", observed_class2_other, observed_class2_other, - [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ], + [ po ordinary_hi P.Ordinary; po privileged_lo P.Privileged ], [ "ef-ordinary-hi" ] ); (* RG 111: "III-IV class: at most two" -- three candidates due, top two by dignity admitted, the third (lowest dignity) dropped. *) ( "III-class day, three commemorations due -> the top two by dignity, \ not merely \"two of them\"", observed_class3, observed_class3, - [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); - (ordinary_lowest, P.Ordinary) ], + [ po ordinary_hi P.Ordinary; po privileged_lo P.Privileged; + po ordinary_lowest P.Ordinary ], [ "ef-ordinary-hi"; "ef-privileged-lo" ] ); (* Fix round 1, item 2 (RG16(a) task review): the CRITICAL witness for [~temporal]. RG 16(a)'s own text -- the winning Feast of the Lord @@ -928,7 +944,7 @@ let admit_cases = TEMPORAL candidate is a II-class Sunday -> the RG111(b) rank floor \ still applies, admits nothing (Sixtus, Class3, has no standing)", lord_winner, an_ordinary_sunday, - [ (sixtus, P.Ordinary) ], + [ po sixtus P.Ordinary ], [] ); (* The control, same pair as the row above with [observed] = [temporal] (no displacement -- an ordinary WEEKDAY, not a Sunday): "other II @@ -941,7 +957,7 @@ let admit_cases = = observed, not a Sunday) -> \"other II class: one\", Sixtus IS \ admitted", lord_winner, lord_winner, - [ (sixtus, P.Ordinary) ], + [ po sixtus P.Ordinary ], [ "pope-sixtus-ii-felicissimus-and-agapitus-martyrs" ] ) ] @@ -953,7 +969,7 @@ let admit_cases = two-candidate row where either order already happens to be sorted. *) let test_admit_order_independent () = let comms = - [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); (ordinary_lowest, P.Ordinary) ] + [ po ordinary_hi P.Ordinary; po privileged_lo P.Privileged; po ordinary_lowest P.Ordinary ] in let forward = slugs_of (PE.admit ~observed:observed_class3 ~temporal:observed_class3 comms) in let reversed = @@ -962,6 +978,39 @@ let test_admit_order_independent () = Alcotest.(check (list string)) "reversed input admits the same candidates" forward reversed +(* RG 113 teeth (Task B, ef-rg16a): two SAME-rank candidates whose slug order + DISAGREES with their band/precedence order -- "aa-worse-precedence" sorts + first alphabetically but is given the WORSE (higher) precedence int, + "zz-better-precedence" sorts last alphabetically but the BETTER (lower) + one. Both are [Class3] (same RG 8 "dignity", so a version of [admit] that + silently reverted to sorting by [rank] then slug -- the exact pre-fix + shape this task replaced -- would pick "aa-worse-precedence" here, the + OPPOSITE of what this asserts. A real RG 91 [band] value never actually + produces this exact pairing against these two synthetic slugs (see + [admit_cases]'s own header on why this table uses synthetic, non-band- + classifiable slugs) -- the precedence ints are supplied directly, the + same [po]/explicit-order convention this file already uses, standing in + for whatever real [band] would compute. This is what proves [admit] + actually consults the passed-in precedence value rather than merely + accepting one as a matter of new type-checking. *) +let order_tiebreak_alpha_first_worse = cand ~rank:V.Class2 "aa-worse-precedence" +let order_tiebreak_alpha_last_better = cand ~rank:V.Class2 "zz-better-precedence" + +(* [observed_class2_other]: "other II class: one" admits exactly ONE, by + precedence order alone (no privilege override, no rank floor) -- unlike + the III-class row [test_admit_order_independent] reuses, which admits + TWO out of two offered here and so cannot distinguish "picked by + precedence" from "picked both anyway". *) +let test_admit_uses_precedence_not_slug_when_they_disagree () = + let comms = + [ (order_tiebreak_alpha_first_worse, P.Ordinary, 90); + (order_tiebreak_alpha_last_better, P.Ordinary, 10) ] + in + Alcotest.(check (list string)) + "the BETTER-precedence candidate is admitted even though it sorts LAST alphabetically" + [ "zz-better-precedence" ] + (slugs_of (PE.admit ~observed:observed_class2_other ~temporal:observed_class2_other comms)) + (* The brief: "a case proving that what the limit drops is reported in omitted rather than vanishing" -- three end-to-end proofs, wired with the REAL [PE.band], [PE.disposition] and [PE.admit] together (not a stub, so @@ -1181,7 +1230,10 @@ let test_all_souls_transfers_end_to_end () = origin = P.Temporal } 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; admit = (fun ~observed:_ ~temporal:_ cs -> cs) } in + let rules = + { P.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 Alcotest.(check (list string)) "All Souls is deferred (transferred), not omitted or commemorated" [ "ef-all-souls" ] @@ -1378,6 +1430,9 @@ let suite = admit_cases @ [ Alcotest.test_case "admit is order-independent (III-class, 3 candidates)" `Quick test_admit_order_independent; + Alcotest.test_case + "RG113: admit picks by precedence order, not slug, when they disagree" `Quick + test_admit_uses_precedence_not_slug_when_they_disagree; Alcotest.test_case "I-class day: full drop reported in omitted, not vanished" `Quick test_i_class_day_drops_into_omitted; Alcotest.test_case "II-class Sunday: second loser dropped into omitted" `Quick diff --git a/test/test_validate.ml b/test/test_validate.ml index 42c58f5..501dd2e 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -377,7 +377,9 @@ module Synthetic = struct let dup_rules : (season, rank) P.rules = { P.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) -> ({ c with P.origin = c.P.origin }, p)) cs) } + admit = + (fun ~observed:_ ~temporal:_ cs -> + List.map (fun (c, p, (_ : int)) -> ({ c with P.origin = c.P.origin }, p)) cs) } (* "unconverged": two entries collide on one date (6 June), both beating the temporal office and tied with each other, so slug decides: @@ -416,7 +418,7 @@ module Synthetic = struct disposition = (fun ~winner:_ ~loser -> match loser.P.cel.Cel.rank with R1 -> P.Transfer | R2 -> P.Commemorate P.Ordinary); - admit = (fun ~observed:_ ~temporal:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } let guard_transfer_target (_ : rank P.candidate) (origin : D.t) (_ : D.t -> rank Cel.t) = origin @@ -434,7 +436,7 @@ module Synthetic = struct let adm_c_entry = mk_entry ~month:9 ~day:9 ~slug:"adm-c" ~rank:R2 let adm_layer = Layer.of_entries ~id:"adm" ~name:"adm" [ adm_a_entry; adm_b_entry; adm_c_entry ] - let adm_compare_slug (c1, _) (c2, _) = Slug.compare c1.P.cel.Cel.slug c2.P.cel.Cel.slug + let adm_compare_slug (c1, _, _) (c2, _, _) = Slug.compare c1.P.cel.Cel.slug c2.P.cel.Cel.slug let rec adm_take n = function | [] -> [] @@ -446,7 +448,8 @@ module Synthetic = struct admit = (fun ~observed:_ ~temporal:_ cs -> let sorted = List.stable_sort adm_compare_slug cs in - if List.length sorted mod 2 = 1 then adm_take 2 sorted else adm_take 1 sorted) } + let taken = if List.length sorted mod 2 = 1 then adm_take 2 sorted else adm_take 1 sorted in + List.map (fun (c, p, (_ : int)) -> (c, p)) taken) } (* "observed": two DIFFERENT layer entries sharing one slug -- a realistic data mistake (a renamed or duplicated entry), not prevented by @@ -475,7 +478,7 @@ module Synthetic = struct disposition = (fun ~winner:_ ~loser -> match loser.P.cel.Cel.rank with R1 -> P.Transfer | R2 -> P.Commemorate P.Ordinary); - admit = (fun ~observed:_ ~temporal:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } let collide_d2 = match D.make ~year:2026 ~month:2 ~day:10 with Ok d -> d | Error e -> failwith e let collide_transfer_target (_ : rank P.candidate) (_ : D.t) (_ : D.t -> rank Cel.t) = collide_d2 @@ -492,7 +495,7 @@ module Synthetic = struct let clean_sanctoral_rules : (season, rank) P.rules = { P.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 -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> List.map (fun (c, p, (_ : int)) -> (c, p)) cs) } end open Synthetic -- cgit v1.3 From 5d5454208464892e96310efd99ec9db09171f982 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 16:45:58 +0200 Subject: fix(rite-ef): band has no table entry for a bare commemoration, ever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 1 on the commemoration-identity/tie-break work: the "genuine same-band tie" M17 adjudicated as open was not a tie at all. RG 91's table enumerates only "dies liturgici" (real feasts) -- entry 24's own text is "Festa III classis, in calendario Ecclesiae universae inscripta" -- so a Celebration.t.status = Commemoration_only candidate (a saint reduced to a bare commemoration) has no row in the table to begin with. Precedence_ef.band used to read rank alone, so it silently lent such a candidate the same table entry as a genuine Feast of its own rank; the calendarium's own 22 September row confirms the distinction in its own notation ("S. Thomae de Villanova ... III classis. / Commemoratio Ss. Mauritii et Soc. Mm." -- Thomas carries a class number, Maurice carries none). Fixed at the source, not by adding a status tier to compare_precedence: band now checks status first, ahead of every rank-keyed branch, and returns unclassified for any Commemoration_only candidate. M17 deleted (not re-adjudicated) -- 22 September now resolves identically on both sides, with no divergence left to name. Blast radius measured independently (a temporary git worktree at the pre-fix commit): 4451 days change across the whole 1583-9999 domain, exactly 4 verified shapes -- 3576 x maurice-and-companions-martyrs -> thomas-of-villanova and 282 x eleutherius -> philip-neri (admitted set changes), 502 x appollonia/cyril-of-alexandria and 91 x dorothy/titus (order only, independently confirmed against the calendarium's own listing convention). Zero days change who is observed. Further swept the whole domain for a genuine "two different candidates on the identical real table entry" residual -- the shape RG 113 truly gives no instruction for -- and found it empty: of 109201 same-rank ties domain-wide, 83950 now resolve by band alone and the remaining 25251 are exclusively two Commemoration_only candidates tied at unclassified, never two real feasts. The residual admit still breaks alphabetically is real, but narrower than previously documented. The "66 days" figure is reconciled, not merely disputed: 599 is the tie population, 65 (whole-comparator: 67) is the real admitted-set decision count within it (independently reproduced against the pre-Task-B binary), 149 is order-only. Every one of the 65 real decisions traces to the same Commemoration_only/Feast root cause as Maurice/Thomas. Also: validate.ml's admission fixed-point check recovers a commemoration's real origin (by matching its slug against the day's own temporal office) instead of reconstructing every candidate as Sanctoral, since band -- unlike EF's own admit -- does read origin; precedence_ef.ml/.mli's motivating example and blast-radius claims corrected to no longer rest on the fixed Maurice/Thomas case; test_identity_rank_corroboration's own population count corrected now that 22 September (and 21 other Feast-status matches) became reachable; its rank-agreement pin documents exactly what it does and does not prove. Register (§4, §6.1, §6 checklist) and CLAUDE.md corrected in place, including the RG 113 first-sentence gap this exercise surfaced but did not close, recorded as a new open item. 275 -> 276 tests (with the exhaustive sweep), all green. --- CLAUDE.md | 120 ++++++++++++--------- data/ef/expected-divergences-missalemeum.sexp | 34 +++--- lib/kernel/validate.ml | 33 +++++- lib/kernel/validate.mli | 26 +++-- lib/rites/rite_ef/precedence_ef.ml | 86 ++++++++++++--- lib/rites/rite_ef/precedence_ef.mli | 44 ++++++-- test/test_oracle.ml | 146 ++++++++++++++++---------- test/test_precedence_ef.ml | 21 +++- test/test_rite_ef.ml | 44 +++++++- 9 files changed, 402 insertions(+), 152 deletions(-) (limited to 'lib/kernel') diff --git a/CLAUDE.md b/CLAUDE.md index 0f18537..4fb93f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,38 +114,53 @@ against missalemeum; layer 5 pins ~30 dates. bootstrapped from lectio, so an error both inherited is invisible here. Proven: Holy Thursday was violet in both because both were wrong. - **Layer 4's commemoration-identity gap is CLOSED** (2026-08-12, Task B, branch - `ef-rg16a`): it used to compare presence/count only, never *which* commemoration - won — reversing `admit`'s dignity sort (the engine admitting the *worst* - commemoration, an outright RG 111 violation) left all eight differential and - oracle assertions green across 17 531 days. Layer 4 now also compares **identity** - — colitur's own resolved English name (`Celebration.t.names`, `en`) against - missalemeum's title text, for every day both streams admit the same count. The - mapping resolves **every SANCTORAL-origin commemoration** (colitur's own name - field, bootstrapped from lectio, verified to match missalemeum's titles - character-for-character); it **cannot resolve a TEMPORAL-origin one** (an - impeded feria/Ember/Rogation day — `Rite_ef.Temporal_ef` never sets a celebration - name) — that case is **never silently skipped**: it is a separate, counted, - allow-listed outcome (`Comm_identity_unresolved`, 19 of 227 non-empty-commemoration - days over 2026–2027), not a silent pass. Two genuine identity mismatches surfaced - and were adjudicated (register §4/§6.1, `data/ef/expected-divergences-missalemeum - .sexp` M16/M17): a known unimplemented office (the Seven Sorrows of Passion - Friday) and the RG 113 same-band tie-break residual (below), the latter left - explicitly OPEN, not defaulted to either side. Proof of teeth: reversing - `admit`'s *dignity*-based sort (the same historical defect shape) now turns - layer 4 red — `test_no_unexplained_differences` reports an unexplained - `commemoration-identity-mismatch` day (colitur admitting "St. Thecla" where both - the rubric and missalemeum require "St. Linus") — reverted after confirming. + `ef-rg16a`, two fix rounds): it used to compare presence/count only, never + *which* commemoration won — reversing `admit`'s dignity sort (the engine + admitting the *worst* commemoration, an outright RG 111 violation) left all + eight differential and oracle assertions green across 17 531 days. Layer 4 now + also compares **identity** — colitur's own resolved English name + (`Celebration.t.names`, `en`) against missalemeum's title text, for every day + both streams admit the same count. The mapping resolves **every + SANCTORAL-origin commemoration** (colitur's own name field, bootstrapped from + lectio, verified to match missalemeum's titles character-for-character); it + **cannot resolve a TEMPORAL-origin one** (an impeded feria/Ember/Rogation day — + `Rite_ef.Temporal_ef` never sets a celebration name) — that case is **never + silently skipped**: it is a separate, counted, allow-listed outcome + (`Comm_identity_unresolved`, 19 of 227 non-empty-commemoration days over + 2026–2027), not a silent pass. ONE genuine identity mismatch remains adjudicated + open (register §4/§6.1, `data/ef/expected-divergences-missalemeum.sexp` M16): a + known unimplemented office (the Seven Sorrows of Passion Friday). Proof of + teeth, reproduced twice: reversing `admit`'s *dignity*-based sort (the + historical defect shape) turns layer 4 red — an unexplained + `commemoration-identity-mismatch` day (colitur admitting "St. Thecla" where + both the rubric and missalemeum require "St. Linus"); separately, disabling + `band`'s own `Commemoration_only` guard (below) also turns it red, on the same + date this whole gap was originally found through — both reverted after + confirming. - **The `admit` same-rank tie-break is RG 113, not an uncited convention** (same - task): RG 113's own second sentence ("in admittendis et ordinandis aliis - commemorationibus, servetur ordo tabellae praecedentiae"), previously quoted only - in its first half, is the real rule — `admit` now orders/selects by + task, fix round 1): RG 113's own second sentence ("in admittendis et ordinandis + aliis commemorationibus, servetur ordo tabellae praecedentiae"), previously + quoted only in its first half, is the real rule — `admit` now orders/selects by `Precedence_ef.band` (RG 91's own table), not RG 8's coarse four-class rank. - Measured (not assumed): the fix's blast radius against the pre-change binary is - **zero admitted-commemoration changes across the entire 1583–9999 domain** — a - correctness-of-citation fix, not an answer-changing one, on this codebase's - current data. A narrower residual remains open where two candidates land on the - *identical* RG 91 table entry (RG 113 gives no further instruction there); - `admit` still breaks that alphabetically, still uncited, register §6.1. + **`band` itself had a fidelity bug this exercise surfaced**: RG 91's table + enumerates only "dies liturgici" (real feasts), so a `Celebration.t.status = + Commemoration_only` candidate has NO row in it at all — `band` used to read + `rank` alone and silently lent such a candidate the same table entry as a + genuine `Feast` of its own rank, manufacturing ties RG 113 never created (the + original "Maurice vs Thomas of Villanova, both entry 24" example was this bug, + not a real RG 91 tie). Fixed at the source: `band` now returns `unclassified` + for any `Commemoration_only` candidate, checked first. Measured, independently, + twice (`compare_precedence`'s own ordering-criterion change, then `band`'s + fidelity fix): the ORDERING-CRITERION change alone is zero-blast-radius + (byte-identical across the whole 1583–9999 domain — a correctness-of-citation + fix, not an answer-changing one); the `band`-FIDELITY fix has a real, large, + fully-classified effect, **4 451 days across the whole domain, exactly 4 + verified shapes, no surprises** (register §6.1). A genuine "two different + candidates on the identical REAL table entry" residual was searched for + exhaustively across the whole domain and found EMPTY — the tie-break `admit` + still breaks alphabetically is real but narrower than first thought: it is only + ever exercised between two `Commemoration_only` candidates, neither of which has + any RG 91 table position to compare in the first place. ## Current state (Plans 1–3 DONE — verify with `git log`) @@ -214,8 +229,10 @@ dune exec colitur -- day 2026 | head - **Plan 4 — OF rite module** (proves `RITE` generalizes) → the lectionary bootstrap and citations → full output/rendering → hardening and a first tag. - **Open Plan 4 with the two behaviour items below**, before OF: they are the - places the five layers, taken together, currently sleep through a real error. + **Both behaviour items below are now RESOLVED** (RG 16(a) and commemoration + identity, closed on branch `ef-rg16a`) — kept here as the record of what the + five layers, taken together, used to sleep through, and as the shape a + future gap of the same kind would need to be caught by. ### Carried into Plan 4 (read before starting) @@ -259,24 +276,27 @@ nowhere in git (`docs/` is gitignored). — a feast/mystery of one Divine Person excludes a commemoration of another of the SAME Person — entirely unimplemented, confirmed live on 7 real years). -2. **Commemoration identity is unasserted** outside ~3 test rows (see the layer - caveats above) — **the exact gap the RG 16(a) fix round exploited**: the - lectio differential compares season/slug/rank/colour only, never - commemorations, so of the 5 996 days RG 16(a) changes, the differential - sees at most the handful where the OBSERVED slug itself also changes (the - Purification's own 1 200, and only because it happens to change who is - observed) — RG 16(a) proper moved ZERO differential rows either round. - The missalemeum oracle window (2026–2027) contains zero live RG 16(a) - instances at all. Every finding in the RG 16(a) fix round, both the - original bug and the fix-round-1 review's own catch (Pope Sixtus II - wrongly admitted, both golden pins promoted from output rather than - independently derived), rested entirely on unit tests and two golden - pins — this is precisely how a defect of this shape gets through - undetected for as long as it did, and precisely how the review's own - correction had to be caught by the same narrow means. Related: - `compare_dignity` breaks same-rank ties - **alphabetically**, with no RG warrant, and over 2005–2050 the admission cut - falls between two same-rank candidates on **66 days**. +2. **Commemoration identity — RESOLVED (Task B, branch `ef-rg16a`, 2026-08-12; + ONE FIX ROUND of review after the first pass — see register §6.1 for the + full, corrected account).** Was unasserted outside ~3 test rows — **the + exact gap the RG 16(a) fix round above had exploited**: the lectio + differential (layer 3) compares season/slug/rank/colour only, never + commemorations, BY DESIGN (lectio has no RG 111 admission logic of its + own) and still does not — that part of this item is unchanged and remains + the reason layer 4, not layer 3, had to close this gap. Layer 4 + (missalemeum, 2026–2027) now compares commemoration IDENTITY, not only + presence/count (see the "know what each layer cannot see" section above + for the mapping and its limits). While building it, found and fixed a + SECOND, independent bug the exercise surfaced: `Precedence_ef.band` gave a + `Commemoration_only` candidate the same RG 91 table entry as a genuine + `Feast` of its own rank (RG 91's table has no row for a bare commemoration + at all) — **4 451 days wrong across the whole 1583–9999 domain**, exactly + 4 verified shapes, fixed at the source. The `admit` same-rank tie-break + itself is RG 113 (previously uncited), not the alphabetical convention + this item used to describe — reconciled against the Plan-3-era "66 days" + figure: 599 is the tie POPULATION, 65 (or 67) the real ADMITTED-SET + decisions within it, 149 order-only — all now independently reproduced + (register §6.1), not merely asserted. **Structural items, cheapest to fix before OF exists:** diff --git a/data/ef/expected-divergences-missalemeum.sexp b/data/ef/expected-divergences-missalemeum.sexp index 2488cb8..53e93d4 100644 --- a/data/ef/expected-divergences-missalemeum.sexp +++ b/data/ef/expected-divergences-missalemeum.sexp @@ -16,17 +16,22 @@ ; did not build. Those are honestly verdicted [missalemeum] -- colitur is ; short a feature or a row, not correct -- and each is cross-referenced ; into docs/research/rules-register.md §6 as an open item, not silently -; absorbed as if colitur were right. THREE entries (M11, M13, and, since -; Task B/branch ef-rg16a, M17) are [verdict open] -- corrected, final fix -; wave, item 7: this note previously said "one entry (M13)", missing M11 -; (whose own verdict changed from [colitur] to [open] in fix round 1, see -; M11's own entry below); M17 (the RG 113 same-band tie-break residual) is -; a genuinely new open item from the commemoration-identity task, not a -; stale-comment fix. ONE entry (M15) carries a fourth verdict, -; [unresolvable] -- a LIMIT of this comparator (no English name exists on -; colitur's side for a temporal-origin commemoration to compare), not a -; rubric dispute or a data gap either engine is wrong about; see M15's own -; note. All are adjudicated as UNRESOLVED/UNRESOLVABLE after real +; absorbed as if colitur were right. TWO entries (M11 and M13) are +; [verdict open] -- corrected, final fix wave, item 7: this note previously +; said "one entry (M13)", missing M11 (whose own verdict changed from +; [colitur] to [open] in fix round 1, see M11's own entry below). Task B +; (branch ef-rg16a) briefly added a THIRD, M17, adjudicated [open] as a +; "genuine same-band tie RG 113 does not resolve" -- CORRECTED, Task B fix +; round 1: M17 was wrong. The tie was manufactured by Precedence_ef.band +; lending a Commemoration_only candidate the same table entry as a genuine +; Feast of its own rank (RG 91's table has no row for a bare commemoration +; at all); fixed in band itself, and the divergence disappears entirely -- +; M17 was REMOVED, not re-adjudicated to a different verdict. ONE entry +; (M15) carries a fourth verdict, [unresolvable] -- a LIMIT of this +; comparator (no English name exists on colitur's side for a temporal- +; origin commemoration to compare), not a rubric dispute or a data gap +; either engine is wrong about; see M15's own note. All remaining OPEN +; entries are adjudicated as UNRESOLVED/UNRESOLVABLE after real ; primary-source effort, not defaulted past -- see each entry's own note ; and the task report for the full search. ; @@ -117,10 +122,5 @@ ((id M16) (citation "1962 calendarium's March table (same primary text as M13's own citation): \"Feria VI post dominicam I Passionis: Commemoratio septem Dolorum B. Mariae Virg.\" -- a fixed commemoration of Our Lady's Seven Sorrows for the Friday of Passion Week, every year, confirmed real") (verdict missalemeum) - (note "27 March 2026 (Friday of Passion Week): colitur admits \"St. John Damascene\" (a real Class3 universal feast that genuinely falls that day, data/ef/sanctoral.sexp's own john-damascene entry, 27 March); missalemeum shows \"For Our Lady of the Seven Sorrows\" instead. The SAME standing gap M13 already names: the Seven-Sorrows-of-Passion-Friday commemoration is confirmed real in the primary text but not implemented anywhere in temporal_ef.ml, so colitur has no candidate for it and falls back to whichever ordinary Class3 saint actually wins that Friday. Verdict missalemeum: colitur is missing a real, primary-cited office, not differently opinioned. Register §6 open item (the same one M13's own note already tracks). Only 2026 shows here as an IDENTITY mismatch -- 2027's Friday of Passion Week IS 19 March, M13's own date, where identity comparison cannot even be reached (M13's own rank/colour mismatch already excludes that day from count-matched identity comparison, folded into M13's own widened subset above).") - (expected_rows 1)) - ((id M17) - (citation "RG 113 (docs/research/rules-register.md §4's own RG 113 entry, and §6.1's \"RG 113 same-band residual\" record, Task B/ef-rg16a): \"in admittendis et ordinandis aliis commemorationibus, servetur ordo tabellae praecedentiae\" reaches only the TABLE order (Precedence_ef.band); it supplies no further instruction for two candidates landing on the identical band value") - (verdict open) - (note "22 September 2027 (September Ember Wednesday): colitur admits \"St. Maurice and Companions, Martyrs\" (Commemoration_only, Class3); missalemeum shows \"St. Thomas of Villanova\" (Feast, Class3) instead -- both land on Precedence_ef.band entry 24 (III-class universal feasts), a genuine tie RG 113 does not resolve. colitur's own residual tie-break (alphabetical by slug, register §6.1's own uncited-convention record) picks \"maurice...\" over \"thomas...\" purely because 'm' < 't' in ASCII -- no rubrical warrant either way. NOT adjudicated colitur or missalemeum: verdict OPEN, the same explicit permission the brief gives M11/M13 (\"say so as an open item rather than absorbing it\"). Only 2027 shows this exact collision in this window -- 2026's 22 September is an ordinary (non-Ember) Tuesday, where Thomas of Villanova simply wins the day outright on both sides (colitur: observed; missalemeum: title) with Maurice commemorated alongside him identically on both, no tie to observe that year.") + (note "27 March 2026 (Friday of Passion Week): colitur admits \"St. John Damascene\" (a real Class3 universal feast that genuinely falls that day, data/ef/sanctoral.sexp's own john-damascene entry, 27 March); missalemeum shows \"For Our Lady of the Seven Sorrows\" instead. The SAME standing gap M13 already names: the Seven-Sorrows-of-Passion-Friday commemoration is confirmed real in the primary text but not implemented anywhere in temporal_ef.ml, so colitur has no candidate for it and falls back to whichever ordinary Class3 saint actually wins that Friday. Verdict missalemeum: colitur is missing a real, primary-cited office, not differently opinioned. Register §6 open item (the same one M13's own note already tracks). Only 2026 shows here as an IDENTITY mismatch -- 2027's Friday of Passion Week IS 19 March, M13's own date, where identity comparison cannot even be reached (M13's own rank/colour mismatch already excludes that day from count-matched identity comparison, folded into M13's own widened subset above). NOTE for whoever builds the office (fix round 1, coordinator finding 7): 27 March 2026 is a III-class day, where RG 111(d) admits TWO commemorations -- yet missalemeum admits only the Seven Sorrows and DISPLACES John Damascene entirely, not merely drops him to second place. A naive implementation (one more ordinary III-class commemoration competing for the day's two slots) would give colitur two commemorations where missalemeum shows one; whatever privilege the Seven Sorrows carries must itself explain the exclusion, not just win admission.") (expected_rows 1)) diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index 433dc99..24adcd8 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -330,12 +330,41 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = season = d.Liturgical_day.temporal.Temporal.season; weekday = d.Liturgical_day.temporal.Temporal.weekday } in + (* CORRECTED (Task B fix round 1, coordinator finding 5): [origin] + used to be reconstructed as [Sanctoral] UNCONDITIONALLY, which + was harmless while nothing here called [band] on the result + (the comment this replaces was correct about [PE.admit] itself: + it never reads [origin]) -- but this function ALSO now calls + [rite.rules.Precedence.band day_ctx cand] on every one of these + reconstructed candidates (the line just below), and [band] DOES + read [origin] (its own [is_temporal] test) to choose between its + temporal- and sanctoral-keyed branches. A genuinely + TEMPORAL-origin commemoration (a privileged Advent/Lent/ + Passiontide feria, an Ember day, an impeded Sunday) mislabelled + [Sanctoral] would score the WRONG band entry (e.g. a Lent feria + scoring entry 23, "III-class feasts in particular calendars", + instead of its real entry 22) -- inert today only because + nothing currently asserts on the SCORE [band] returns here, only + on whether re-offering [admit] the same values round-trips + (which happens not to depend on getting [origin] right for any + case this codebase's data reaches -- unverified in general). + [Liturgical_day.t] itself still does not retain a + commemoration's original origin, so it is recovered the only + way available: a commemoration whose SLUG matches the day's own + temporal office is temporal-origin; every other commemoration + is sanctoral-origin. This is exact whenever slugs cannot + collide across the two streams (Task 12's own "observed" check + already assumes this for a different purpose), which is the + same assumption the rest of this codebase already leans on. *) + let temporal_office_slug = d.Liturgical_day.temporal.Temporal.office.Celebration.slug in let as_candidates comms = List.map (fun (c, p) -> - let cand : 'r Precedence.candidate = - { Precedence.cel = c; origin = Precedence.Sanctoral } + let origin = + if Slug.equal c.Celebration.slug temporal_office_slug then Precedence.Temporal + else Precedence.Sanctoral in + let cand : 'r Precedence.candidate = { Precedence.cel = c; origin } in (cand, p, rite.Rite.rules.Precedence.band day_ctx cand)) comms in diff --git a/lib/kernel/validate.mli b/lib/kernel/validate.mli index 5e55fc5..b46ca30 100644 --- a/lib/kernel/validate.mli +++ b/lib/kernel/validate.mli @@ -59,14 +59,24 @@ val failure_to_string : failure -> string fixed point. - ["admission"]: the rite's own [rules.admit] is a fixed point on what it already admitted -- re-offering a day's [commemorations] back to - [admit] (reconstructed with {!Precedence.Sanctoral} origin; the real EF - admit reads only rank and slug, never origin, so this reconstruction is - exact for it) must return exactly that same set. A cap-enforcing - selector that is not idempotent on its own output has, by definition, - admitted something its own rule would not admit if asked again -- the - rite-agnostic form of "the admission limit was not exceeded" available - without embedding a rite's specific numeric caps (RG 111's, for EF) - into kernel code. + [admit] (with [observed]'s own origin reconstructed as + {!Precedence.Sanctoral}; the real EF admit reads only rank and slug + from [observed], never origin, so this reconstruction is exact for + it) must return exactly that same set. Each offered commemoration's + own origin -- CORRECTED, Task B fix round 1 -- is recovered by + comparing its slug against the day's own temporal office, not + reconstructed as [Sanctoral] uniformly: {!Precedence.rules.admit} + (since Task B) is handed each candidate's own {!Precedence.rules.band} + value, computed here exactly as {!Precedence.resolve} computes it, and + [band] DOES read a candidate's origin (temporal- vs sanctoral-keyed + branches) even though EF's own [admit] itself still does not -- a + mislabelled origin would silently score the wrong table entry for a + genuinely temporal-origin commemoration (e.g. a Lent feria) before + this fix. A cap-enforcing selector that is not idempotent on its own + output has, by definition, admitted something its own rule would not + admit if asked again -- the rite-agnostic form of "the admission limit + was not exceeded" available without embedding a rite's specific + numeric caps (RG 111's, for EF) into kernel code. Total over the whole 1583..9999 domain, including [year] = 9999: the liturgical year opening there continues into out-of-domain civil year diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 861bb1a..8452e69 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -110,6 +110,7 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc int = let cel = c.Precedence.cel in let rank = cel.Celebration.rank in + let status = cel.Celebration.status in let subject = cel.Celebration.subject in let layer = cel.Celebration.layer in let slug = Slug.to_string cel.Celebration.slug in @@ -129,8 +130,41 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc binding, not a second literal, so the two can never drift apart. *) let entry_15_band = 15 in let open Vocab_ef in - (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *) - if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1 + (* CORRECTED (Task B fix round 1, ef-rg16a): a [Commemoration_only] + celebration has NO row in RG 91's table at all, checked FIRST, ahead of + every rank-keyed branch below -- RG 91's own text enumerates only "dies + liturgici" (entry 24's own wording, e.g., "Festa III classis, in + calendario Ecclesiae universae inscripta" -- FEASTS, inscribed in the + calendar), and the calendarium itself marks the difference in its own + notation: 22 September's row reads "S. Thomae de Villanova Ep. et + Conf., III classis. / Commemoratio Ss. Mauritii et Soc. Mm." -- Thomas + gets a class number (a "festum"); Maurice gets "Commemoratio" and NO + class number at all, because he never had a row in the table for that + occasion to begin with. Before this fix, [band] read [rank] alone, so + a [Commemoration_only] entry silently borrowed the SAME entry number + as a genuine [Feast] of its own rank (a Class3 Commemoration_only + entry banded to 24, indistinguishable from a real Class3 universal + feast) -- manufacturing a "tie" at {!compare_precedence}/RG 113's own + admission ordering that the primary text never creates: RG 113 does + not run out of instruction between two same-rank commemorations; + [band] ran out of fidelity, handing out a table row that does not + exist for one of them. [unclassified] (worse than every real entry) is + the same value already used for a candidate this table's 28 branches + otherwise fail to describe, which is exactly the right answer here + too: "not in this table" for a different reason, same table-position + consequence. Confirmed by the oracle: missalemeum's own commemoration + id embeds a rank that AGREES with the demoted status this represents + (e.g. sancti:09-22o:4:r for Maurice, rank 4, against Thomas's own + sancti:09-22:3:w, rank 3, on the day Thomas is observed -- test_oracle + .ml's own [test_identity_rank_corroboration]). Restricted to [status]; + [rank] itself is untouched, since {!Celebration.status}'s own doc + comment is explicit that a demoted feast "retains a rank" for RG 111's + admission-COUNT purposes (I/II/III/IV class thresholds), which this + guard does not touch -- only ORDERING among candidates already offered + to {!admit} changes. *) + if status = Celebration.Commemoration_only then unclassified + (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *) + else if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1 (* 2: Sacred Triduum (Thu-Sat of Holy Week). *) else if is_temporal && rank = Class1 && off >= -3 && off <= -1 then 2 (* 3: Epiphany, Ascension, Holy Trinity, Corpus Christi, Sacred Heart, @@ -769,16 +803,44 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) This REPLACES a previous [compare_dignity], which sorted by RG 8's coarse four-class [rank] ("dignity") and broke same-rank ties alphabetically by - slug -- an engineering convention with no rubrical warrant, silently - deciding 66 days over 2005-2050 (e.g. 22 Sept 2027, an Ember Wednesday: - the alphabetical rule admitted "maurice-and-companions-martyrs" over - "thomas-of-villanova", both Class3/{!band} entry 24 -- missalemeum shows - Thomas). {!band} needs a [context] this function itself does not have - (date/season/weekday) -- unlike [dignity], which read [rank] alone -- - so {!Precedence.resolve} now computes each candidate's own [band] value - once, generically, and hands it to [admit] as the trailing [int] on each - input triple (see {!Precedence.rules.admit}'s own doc). [comms] below is - [(candidate * privilege * int) list], not the pair it used to be. *) + slug -- an engineering convention with no rubrical warrant. Measured, + fix round 1 (2005-2050, a temporary pre-fix [git worktree]): a + same-[rank] tie existed on 599 days, most never reaching a real + decision; reversing the slug tie-break alone changed the ADMITTED SET on + 65 of them and the printed ORDER of an already-admitted pair on a + further 149 (docs/research/rules-register.md §6.1's full account, + correcting this comment's own earlier, unreproduced "66 days" claim). + + CORRECTED, fix round 1 (coordinator finding 1): checking every one of + those 65+149 real decisions against data/ef/sanctoral.sexp found the + SAME underlying shape in all of them, no exceptions -- one candidate is + always [Cel.Commemoration_only] (e.g. "maurice-and-companions-martyrs", + 22 September) and the other always a genuine [Cel.Feast] or a temporal + office of the SAME [rank] (e.g. "thomas-of-villanova", same day; or a + Lent feria, {!band} entry 22, against "paul", 22 February). That is + {!band}'s OWN fidelity bug, fixed separately at its source (see [band]'s + own top-of-branch guard, RG 91's table has no row for a bare + commemoration at all) -- NOT a case RG 113's table-order alone resolves, + since a [Commemoration_only] candidate never had a real table entry to + compare in the first place. With that fixed, none of these 599 days' + real decisions any longer depend on THIS function's own slug fallback: + {!band} alone (a real entry vs {!unclassified}) already decides every + one. What remains genuinely open -- two DIFFERENT candidates landing on + the IDENTICAL real table entry, e.g. two different Class3 universal + feasts both at entry 24 -- was checked and found EMPTY across + 2005-2050 (register §6.1): RG 113's own table-order, once {!band} is + accurate, already decides every real case this codebase's current data + produces; the slug fallback below is exercised only between two + [Commemoration_only] candidates tied at {!unclassified} (138 of the 599, + all order/count-invisible -- RG 91 has no table position for either of + them to compare, so there is nothing more specific RG 113 could supply + here either). {!band} needs a [context] this function itself does not + have (date/season/weekday) -- unlike [dignity], which read [rank] alone + -- so {!Precedence.resolve} now computes each candidate's own [band] + value once, generically, and hands it to [admit] as the trailing [int] + on each input triple (see {!Precedence.rules.admit}'s own doc). [comms] + below is [(candidate * privilege * int) list], not the pair it used to + be. *) let compare_precedence (a, _, ba) (b, _, bb) = if ba <> bb then Int.compare (ba : int) bb else Slug.compare a.Precedence.cel.Celebration.slug b.Precedence.cel.Celebration.slug diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 49195f9..b283a17 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -68,10 +68,21 @@ val unclassified : int text states an exception: entry 8 (All Souls) reads "yields to an occurring Sunday", so on a Sunday this returns a value that loses to entry 15 rather than the literal integer 8 (see the comment on - entry 8 in precedence_ef.ml for the exact value and why). Total over - every candidate {!Precedence.resolve} or {!Calendar} can construct, - including shapes the 1962 table itself does not describe (see - {!unclassified}). *) + entry 8 in precedence_ef.ml for the exact value and why). ALSO + {!unclassified} for any [Cel.Commemoration_only] candidate, checked + first, ahead of every rank-keyed branch -- CORRECTED, Task B fix round + 1 (coordinator finding 1): RG 91's own table enumerates only "dies + liturgici" (entry 24's own text, "Festa III classis..." -- FEASTS), so + a celebration reduced to a bare commemoration was never a row in the + table to begin with, confirmed by the calendarium's own notation (a + genuine feast carries a class number on its occurrence line; a bare + "Commemoratio" carries none). Before this fix, a [Commemoration_only] + candidate silently borrowed the SAME entry number as a genuine [Feast] + of its own [rank] -- manufacturing ties {!Precedence.compare_by} and + {!compare_precedence} both then had to break some other way, that RG 91 + itself never created. Total over every candidate {!Precedence.resolve} + or {!Calendar} can construct, including shapes the 1962 table itself + does not describe (see {!unclassified}). *) val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candidate -> int (** RG 33's marker: every Sunday slug this rite's temporal cycle produces @@ -195,8 +206,16 @@ val september_ember_prefix : string CORRECTED, Task B (branch ef-rg16a): this used to sort by [Vocab_ef.rank] (RG 8's coarse four-class "dignity") and break same-rank ties alphabetically by slug -- an engineering convention with no - rubrical warrant, cited nowhere, silently deciding 66 days over - 2005-2050. RG 113's own second sentence, previously uncited + rubrical warrant, cited nowhere. Measured, fix round 1 (2005-2050, + reproducible via a temporary [git worktree] at the pre-fix commit): + a same-[rank] tie existed in the candidate pool offered to [admit] on + 599 days, most never reaching a real decision (RG 111's own admission + count often has room for both, e.g. a III-class day admits two); of + those, reversing the slug tie-break ALONE actually changed the ADMITTED + SET on 65 days and only the printed ORDER of an already-admitted pair + on a further 149 (docs/research/rules-register.md §6.1's own full + account, superseding this comment's earlier, unreproduced "66 days" + claim). RG 113's own second sentence, previously uncited (docs/research/rules-register.md §4's RG 113 entry), is the real rule: *"in admittendis et ordinandis aliis commemorationibus, servetur ordo tabellae praecedentiae"* -- admitting and ordering commemorations both @@ -205,8 +224,17 @@ val september_ember_prefix : string receive on its own, so {!Precedence.resolve} computes it once, generically, and hands each candidate its own value as the trailing [int] on [comms]'s triples (see {!Precedence.rules.admit}). Ties break - on slug ONLY within one identical [band] value (RG 113 gives no further - instruction there; still an uncited engineering convention, register §6 + on slug ONLY within one identical [band] value -- CORRECTED, fix round + 1 (coordinator finding 1): [band] itself used to hand a + [Cel.Commemoration_only] candidate the same table entry as a genuine + [Cel.Feast] of its own rank (RG 91's table has no row for a bare + commemoration at all), manufacturing most of the "identical [band] + value" ties this comment used to describe as RG 113's own residual. + Fixed in [band] itself; checked afterward (2005-2050 and the full + 1583-9999 domain, register §6.1) and found EMPTY of any remaining + "two different real table entries tied" case -- the slug fallback below + is exercised only between two [Commemoration_only] candidates both at + {!unclassified}, still an uncited engineering convention (register §6 "RG 113 tie-break"), matching {!Precedence.compare_by}'s own fallback, so the result never depends on the order [comms] arrives in. diff --git a/test/test_oracle.ml b/test/test_oracle.ml index 07a5634..2624664 100644 --- a/test/test_oracle.ml +++ b/test/test_oracle.ml @@ -107,18 +107,25 @@ entries; RG 110's inseparable-Peter/Paul commemoration is unimplemented code, a real feature this task did not build) -- honestly verdicted [missalemeum] (colitur is short a feature or a row, not right), never - silently absorbed as if colitur were correct. THREE entries (M11, M13 - and, since Task B/ef-rg16a, M17) are [verdict open] -- CORRECTED, final - fix wave, item 7: this comment previously said "one entry (M13)", - missing M11, whose own verdict was changed from [colitur] to [open] in - fix round 1 (see M11's own entry below for why) but this summary was - never updated to match; M17 (the RG 113 same-band tie-break residual, - register §6.1) is a genuinely NEW open item, not a stale-comment fix. - ONE entry (M15) carries its own fourth verdict, [unresolvable] -- not a - rubric dispute or a data gap either engine is wrong about, but a LIMIT - of this comparator itself (see M15's own entry). All are adjudicated as - unresolved/unresolvable, not resolved either way -- the brief's own - explicit permission ("say so as an open item") used for real, not + silently absorbed as if colitur were correct. TWO entries (M11 and M13) + are [verdict open] -- CORRECTED, final fix wave, item 7: this comment + previously said "one entry (M13)", missing M11, whose own verdict was + changed from [colitur] to [open] in fix round 1 (see M11's own entry + below for why) but this summary was never updated to match. Task B + (branch ef-rg16a) briefly added a THIRD, M17 (the same-band tie-break + between Maurice and Thomas of Villanova, 22 September) -- CORRECTED, + fix round 1 of that same task: M17 was itself wrong. The "tie" was + manufactured by {!Precedence_ef.band} lending a [Commemoration_only] + candidate the same table entry as a genuine [Feast] of its own rank + (RG 91's table has no row for a bare commemoration at all -- fixed in + [band] itself, not here); once fixed, 22 September resolves cleanly on + both sides and M17 was deleted, not merely re-adjudicated. ONE entry + (M15) carries its own fourth verdict, [unresolvable] -- not a rubric + dispute or a data gap either engine is wrong about, but a LIMIT of this + comparator itself (see M15's own entry). All remaining OPEN entries are + adjudicated as unresolved/unresolvable, not resolved either way -- the + brief's own explicit permission ("say so as an open item") used for real, + not defaulted past. See the task report for every entry's full reasoning and primary-source citation. *) @@ -655,30 +662,43 @@ let m15_dates = already tracks). Only 2026 shows here -- 2027's Friday of Passion Week IS 19 March, M13's own date, where the identity axis cannot even be reached (M13's own rank/colour mismatch already excludes that day from - count-matched identity comparison). *) + count-matched identity comparison). + + NOTE for whoever builds the office (fix round 1, coordinator finding 7): + 27 March 2026 is a III-class day, where RG 111(d) admits TWO + commemorations, not one -- yet missalemeum admits only the Seven Sorrows + and DISPLACES John Damascene entirely (its own "displaced" list carries + his title that day), not merely drops him to second place. Implementing + the Seven Sorrows candidate naively (as one more ordinary III-class + commemoration competing for the day's two slots) will not reproduce + this: John Damascene would still win one of the two admitted slots by + dignity/band, giving colitur TWO commemorations where missalemeum shows + one. Whatever privilege or precedence the Seven Sorrows commemoration + carries must itself explain the exclusion, not just the admission -- + register §6's own open item for this office should carry this caveat + forward. *) let m16_dates = [ "2026-03-27" ] -(* M17 -- Task B: the SAME-band residual tie-break (docs/research/rules- - register.md §4's own "RG 113" entry, and §6.1's "RG 113 same-band - residual" record) made visible for the first time by identity - comparison. 22 September 2027 (September Ember Wednesday): colitur - admits "St. Maurice and Companions, Martyrs" (Commemoration_only, - Class3); missalemeum shows "St. Thomas of Villanova" (Feast, Class3) - instead -- both land on {!Precedence_ef.band} entry 24 (III-class - universal feasts), a genuine tie RG 113 gives no further instruction for - (its own text only reaches "servetur ordo tabellae praecedentiae", the - TABLE order; nothing in the primary text breaks a tie WITHIN one table - entry). colitur's own residual tie-break (alphabetical by slug, §6.1's - own uncited-convention record) picks "maurice..." over "thomas..." purely - because 'm' < 't' -- no rubrical warrant either way, so this is NOT - adjudicated colitur or missalemeum: verdict OPEN, the same explicit - permission the brief gives M11/M13 ("say so as an open item rather than - absorbing it"), register §6.1. Only 2027 falls in this window with this - EXACT collision -- 2026's 22 September is an ordinary (non-Ember) - Tuesday, so Thomas of Villanova simply wins the day outright on both - sides (colitur: observed; missalemeum: title) with Maurice commemorated - alongside him identically on both -- no tie to observe that year. *) -let m17_dates = [ "2027-09-22" ] +(* M17 was DELETED, fix round 1 (Task B): the "genuine tie" it adjudicated + as [open] was itself wrong. 22 September (any year the September Ember + Wednesday falls on the 22nd -- 2027 in this window): colitur used to + admit "St. Maurice and Companions, Martyrs" (Commemoration_only, Class3) + where missalemeum shows "St. Thomas of Villanova" (Feast, Class3) -- + NOT because RG 113 runs out of instruction between two same-rank + candidates (the framing this entry used to carry), but because + {!Precedence_ef.band} used to lend a [Commemoration_only] candidate the + SAME table entry (24) as a genuine [Feast] of its own rank, manufacturing + a tie the primary text never creates: RG 91's own table enumerates only + "dies liturgici" (entry 24: "Festa III classis..." -- FEASTS), and the + calendarium's own 22 September row confirms it in its own notation -- + "S. Thomae de Villanova Ep. et Conf., III classis. / Commemoratio Ss. + Mauritii et Soc. Mm." -- Thomas carries a class number, Maurice carries + none. Fixed at the source ([band] itself now returns [Precedence_ef + .unclassified] for any [Commemoration_only] candidate, docs/research/ + rules-register.md §6.1's own corrected account) rather than here: 22 + September now resolves identically on both sides with no allow-list + entry needed at all -- removed, not re-adjudicated to a different + verdict, since there is no longer a divergence to name. *) let layer_m_reason (c : colitur_row) (o : oracle_row) diffs = if diffs = [] then None @@ -704,7 +724,6 @@ let layer_m_reason (c : colitur_row) (o : oracle_row) diffs = Some "M13" else if List.mem c.c_date m15_dates && diffs = [ Comm_identity_unresolved ] then Some "M15" else if List.mem c.c_date m16_dates && diffs = [ Comm_identity_mismatch ] then Some "M16" - else if List.mem c.c_date m17_dates && diffs = [ Comm_identity_mismatch ] then Some "M17" else None (* ---------------------------------------------------------------------- *) @@ -862,23 +881,36 @@ let test_layer_m_counts_match_citations () = combination would break it) that stays honest about what remains unverified. + WHAT THIS DOES NOT PIN (coordinator finding 8, fix round 1, honestly + named rather than left implicit): the [oracle_rank = colitur_rank] + branch accepts ANY genuine agreement, including one this check cannot + independently verify is the CORRECT rank -- a regression that silently + flipped some entry's [rank] from [Class3] to [Class4] would land in the + agreement branch and pass cleanly if missalemeum's own id happened to + read 4 for that entry too (data drift on one side coinciding with data + drift on the other is not ruled out by this check, only coincidence + independent of any real cause is). This test pins "no third pattern + appears", not "every individual rank is correct" -- a narrower, still + genuinely useful claim (see the [> 50] population guard below, which + confirms the pinned shape is actually exercised at scale, not vacuously + true over an empty or trivial set), and this comment says so rather than + letting the assertion's own name imply more than it checks. + [Feast]-status candidates get the ORIGINAL, unrestricted check (any - disagreement at all is unexpected) -- but this window's own data never - actually offers a [Feast]-status LOSING candidate whose identity is - independently clean (checked: 0 in the 2026-2027 fixture -- a [Feast] - candidate here either wins its own day outright, in which case it is - never a commemoration at all, or the one day where it does lose, - 22 September 2027/Thomas of Villanova, is ITSELF the M17 tie-break - mismatch and so is excluded by the [identity_diff = None] guard before - ever reaching this check). Kept anyway, not deleted: a real disagreement - would still be reported the moment one becomes reachable (a wider oracle - window, or a data change), and this project's own vacuity catalogue - flags "an assertion true by construction" as a defect shape to avoid, - not "an assertion whose population happens to be empty in the one - fixture available" -- the CHECK still does real work when its input is - non-empty; it is the DATA, not the code, that is currently silent here. *) + disagreement at all is unexpected). CORRECTED (fix round 1, coordinator + finding 1): this comment previously claimed this branch was unreachable + in the 2026-2027 window (checked: 0) because its one candidate, Thomas + of Villanova on 22 September, was M17's own mismatch -- WRONG, once + traced further: M17 itself was wrong (see {!Precedence_ef.band}'s own + fidelity fix, register §6.1), and fixing it made 22 September resolve + cleanly on both sides, reachable here after all. Measured, not assumed: + 22 Feast-status commemorations are now examined by this branch (not + only Thomas of Villanova -- every OTHER genuinely clean Feast-status + match in the window reaches it too, which the earlier version of this + comment did not check for before asserting "0"). All 22 agree. *) let test_identity_rank_corroboration () = let oracle, colitur = compare_streams () in + let feast_checked = ref 0 in let feast_mismatches = ref [] in let commemoration_only_checked = ref 0 in let commemoration_only_surprises = ref [] in @@ -896,6 +928,7 @@ let test_identity_rank_corroboration () = | Some oracle_rank -> ( match List.find_opt (fun (_, _, _, n) -> n = Some title) c.c_commemorations with | Some (slug, colitur_rank, Cel.Feast, _) -> + incr feast_checked; if colitur_rank <> oracle_rank then feast_mismatches := Printf.sprintf "%s: %s oracle-id-rank=%d colitur-rank=%d" c.c_date slug @@ -921,14 +954,21 @@ let test_identity_rank_corroboration () = o.o_commemorations o.o_commemoration_ids) oracle colitur; Alcotest.(check (list string)) - "Feast-status matches: oracle id-rank agrees with colitur's own rank (none reachable in this window, \ - see this test's own comment; the check still runs)" - [] (List.rev !feast_mismatches); + "Feast-status matches: oracle id-rank agrees with colitur's own rank" [] (List.rev !feast_mismatches); Alcotest.(check (list string)) "Commemoration_only-status matches: every one fits the KNOWN oracle=4/colitur=3 convention gap -- any \ other combination would be a genuine, new surprise" [] (List.rev !commemoration_only_surprises); - (* Vacuity guard for the branch that IS populated in this window. *) + (* Vacuity guard: BOTH branches must actually run now. CORRECTED (fix + round 1, coordinator finding 1): the [Feast] branch used to be + unreachable in this window (checked: 0) because its one candidate, + Thomas of Villanova on 22 September, was M17's own mismatch (excluded + by the [identity_diff = None] guard above). Fixing {!Precedence_ef + .band}'s Commemoration_only fidelity (register §6.1) made that day + resolve cleanly, so it is reachable here too -- this guard now expects + at least 1, not merely documents the branch as dormant (measured: 22 + Feast-status commemorations now examined, up from 0). *) + Alcotest.(check bool) "the Feast-status population actually examined is non-trivial" true (!feast_checked > 0); Alcotest.(check bool) "the Commemoration_only population actually examined is non-trivial" true (!commemoration_only_checked > 50) diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index ca623d2..2935e40 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -267,7 +267,26 @@ let cases = (* RG 91's own vigil list (§4, "Vigils / octaves / Rogations / Sunday classes") stops at III class -- there is no IV-class vigil for entry 28's ferial catch-all to absorb. *) ( "unclassified: IV-class candidate marked as a vigil", mk 2026 6 20, - cand ~rank:V.Class4 "ef-second-mystery-vigil", PE.unclassified ) + cand ~rank:V.Class4 "ef-second-mystery-vigil", PE.unclassified ); + (* Task B fix round 1 (coordinator finding 1): RG 91's table enumerates + only "dies liturgici" -- a [Cel.Commemoration_only] candidate is + never a row in the table at all, regardless of what rank it would + otherwise band to. [Class1]/universal-layer is deliberate: absent + the status guard this would band to entry 11, the FIRST, highest- + priority real entry any sanctoral candidate can reach, so this + proves the guard is checked BEFORE and overrides every rank-keyed + branch, not only the low-priority ones an accidental placement + might happen to catch. Real-world witness: "maurice-and-companions- + martyrs" (data/ef/sanctoral.sexp, 22 September, Class3, + Commemoration_only) used to band to entry 24 alongside the genuine + Class3 feast "thomas-of-villanova" -- test_oracle.ml's own former + M17 allow-list entry, deleted once this fix made the divergence it + named disappear. *) + ( "unclassified: a Commemoration_only candidate has no row in RG91's \ + table at all, even at Class1", mk 2026 5 20, + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer + "ef-some-commemoration-only", + PE.unclassified ) ] (* Review finding 1's end-to-end proof: on a real Sunday landing on 2 diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml index 6fcb25d..2e5d2fd 100644 --- a/test/test_rite_ef.ml +++ b/test/test_rite_ef.ml @@ -298,6 +298,45 @@ let test_search_genuinely_enters_the_window () = "the walked span is at least 8 days -- the octave alone (Easter..Easter+7), not a one-day hop" true (List.length walked >= 8) +(* Task B fix round 1 (coordinator finding 1), end to end with REAL data: + 22 September 2027 is a September Ember Wednesday -- the OBSERVED day is + the Ember office itself ([band] entry 18, RG 91, beats both sanctoral + candidates), and RG 111(c) ("other II class: one") admits exactly ONE + of the two competing losers, "maurice-and-companions-martyrs" + ([Commemoration_only], Class3) and "thomas-of-villanova" ([Feast], + Class3). Before the fix, [Precedence_ef.band] gave Maurice the SAME + table entry (24) as Thomas, so the residual slug tie-break picked + Maurice ('m' < 't') and OMITTED Thomas -- contrary to the calendarium's + own 22 September row ("S. Thomae de Villanova Ep. et Conf., III + classis. / Commemoratio Ss. Mauritii et Soc. Mm.": Thomas is the one + WITH a table entry) and to missalemeum (test/test_oracle.ml's own + former M17 allow-list entry -- oracle "commemorations": Thomas, + "displaced": Maurice -- deleted once this fix made the divergence + disappear). This is the SAME date the oracle harness itself checks, + pinned here too so a regression shows up at the OCaml level, not only + via the fixture comparison. *) +let test_maurice_thomas_band_fidelity_end_to_end () = + let layer = real_layer () in + (* [Calendar.year]'s own liturgical-year convention (Advent-anchored, see + calendar.ml's [year_bounds]): the label [y] spans December of civil + year [y] through November of civil year [y+1], so 22 September 2027 + is reached via [Cal.year ... 2026], not 2027 -- the same + [y-1]/straddling indexing test_oracle.ml's own header comment already + documents for exactly this reason. *) + let days = Cal.year Rite_ef.context layer 2026 in + let day = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (mk 2027 9 22) = 0) in + Alcotest.(check string) "the September Ember Wednesday office itself is observed (band entry 18)" + "ef-september-ember-wed" (slug_of day.LD.observed); + Alcotest.(check (list string)) + "St Thomas of Villanova (band entry 24, a real table row) is the sole commemoration" + [ "thomas-of-villanova" ] + (List.map (fun (c, _) -> slug_of c) day.LD.commemorations); + Alcotest.(check (list string)) + "St Maurice and Companions (Commemoration_only, unclassified -- no table row) is omitted, not \ + commemorated" + [ "maurice-and-companions-martyrs" ] + (List.map (fun (c, _) -> slug_of c) day.LD.omitted) + let suite = ( "Rite_ef (real data: overlay-in-effect, domain-ceiling)", [ Alcotest.test_case "the overlay suppression is observably in effect" `Quick @@ -311,4 +350,7 @@ let suite = Alcotest.test_case "transfer_target skips the whole Easter octave from inside Holy Week" `Quick test_transfer_target_skips_the_whole_easter_octave; Alcotest.test_case "the search genuinely enters the window (not vacuous)" `Quick - test_search_genuinely_enters_the_window ] ) + test_search_genuinely_enters_the_window; + Alcotest.test_case + "band fidelity end-to-end: Thomas of Villanova observed, Maurice commemorated (2027-09-22)" `Quick + test_maurice_thomas_band_fidelity_end_to_end ] ) -- cgit v1.3 From e5b368dec8fdc9ab983ee0f1dee69c37883cbe12 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 17:02:48 +0200 Subject: docs: three comments that outlived the rules they described All three are the project's own "correct code, wrong explanation" shape, found by the final review of the RG 113 work. No behaviour change. test_rite_ef.ml's registered Alcotest label said "Thomas of Villanova observed, Maurice commemorated" while the test asserts the Ember Wednesday is observed, Thomas is the sole commemoration and Maurice is omitted -- the opposite of two of its three assertions. The body's own doc comment was right; only the name a test run prints was wrong. validate.ml's lead-in still said origin is reconstructed as Sanctoral uniformly because the EF admit "reads only rank and slug, never origin". That stopped being true in ea22ad2: admit now orders by band, and band reads origin via is_temporal, so a temporal-origin commemoration relabelled Sanctoral would be scored on the wrong table entry. The code below already recovers the origin exactly; only this comment lagged. precedence_ef.ml's band guard justified itself by citing celebration.mli as saying a demoted feast retains its rank for RG 111's admission-COUNT purposes. celebration.mli says the opposite -- it names the ORDERING use the guard removes. The guard is right for a reason it did not give: rank stays load-bearing through RG 111(b)'s "de festo II classis" floor, which is what excludes these entries from a II-class Sunday's slot. Both the citation and celebration.mli's own now-stale line are corrected, each recording what it previously claimed. --- lib/kernel/celebration.mli | 13 ++++++++++--- lib/kernel/validate.ml | 18 +++++++++++------- lib/rites/rite_ef/precedence_ef.ml | 17 ++++++++++++----- test/test_rite_ef.ml | 3 ++- 4 files changed, 35 insertions(+), 16 deletions(-) (limited to 'lib/kernel') diff --git a/lib/kernel/celebration.mli b/lib/kernel/celebration.mli index a3c2960..85f7af4 100644 --- a/lib/kernel/celebration.mli +++ b/lib/kernel/celebration.mli @@ -1,7 +1,14 @@ (** Whether this celebration can be the observed day at all. The 1960 reform - reduced many feasts to a bare commemoration; they retain a rank (RG 111 - orders admitted commemorations by dignity) but can never be observed. NOT - a fifth rank: RG 8 fixes the classes at four. *) + reduced many feasts to a bare commemoration; they retain a rank but can + never be observed. NOT a fifth rank: RG 8 fixes the classes at four. + + What the retained rank is FOR (corrected 2026-08-12): this line formerly + read "RG 111 orders admitted commemorations by dignity". Ordering is no + longer done by rank at all -- it moved to the rite's own table of + precedence (RG 113) in commit ea22ad2, and a bare commemoration has no + row in that table. The rank remains load-bearing for ADMISSION: RG 111(b) + reserves a II-class Sunday's single slot "de festo II classis", a floor + read off this rank directly. *) type status = Feast | Commemoration_only [@@deriving sexp] (** A celebration. Parameterised by the rite's rank type only. *) diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index 24adcd8..ffeb89e 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -293,13 +293,17 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = "transfer placement did not reach a fixed point within the round guard (RG 96-98)"; (* "admission": re-offer this day's own admitted commemorations back to [rite.rules.admit] and require the exact same set back. - [origin] is reconstructed as [Sanctoral] uniformly: - {!Liturgical_day.t} does not retain a commemoration's original - origin, and the real EF [admit] (precedence_ef.ml) reads only - rank and slug from a candidate, never [origin], so this - reconstruction is exact for it; documented in validate.mli as - the one place a rite whose [admit] DOES consult [origin] could - see a false negative from this check. *) + [origin] is RECOVERED, not fabricated -- see the fuller note + below on [as_candidates]. It was formerly reconstructed as + [Sanctoral] uniformly, justified by the claim that the real EF + [admit] reads only rank and slug and never [origin]. That claim + is now FALSE: since ea22ad2 the EF [admit] orders by [band] + (RG 113), and [band] does read [origin] via [is_temporal], so a + temporal-origin commemoration relabelled [Sanctoral] would be + scored on the wrong table entry. The recovery below is exact, + not a heuristic: [resolve] builds exactly one temporal + candidate per day, so a slug match against the day's own + temporal office identifies it unambiguously. *) let observed_candidate : 'r Precedence.candidate = { Precedence.cel = d.Liturgical_day.observed; origin = Precedence.Sanctoral } in diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 8452e69..750da8b 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -157,11 +157,18 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc (e.g. sancti:09-22o:4:r for Maurice, rank 4, against Thomas's own sancti:09-22:3:w, rank 3, on the day Thomas is observed -- test_oracle .ml's own [test_identity_rank_corroboration]). Restricted to [status]; - [rank] itself is untouched, since {!Celebration.status}'s own doc - comment is explicit that a demoted feast "retains a rank" for RG 111's - admission-COUNT purposes (I/II/III/IV class thresholds), which this - guard does not touch -- only ORDERING among candidates already offered - to {!admit} changes. *) + [rank] itself is untouched, and stays load-bearing for a demoted feast: + RG 111(b)'s "scilicet de festo II classis" floor ({!admit} below) reads + [rank] directly, and it is exactly that floor which excludes these + Class3 entries from a II-class Sunday's single slot. Only ORDERING among + candidates already offered to {!admit} changes here. + NOTE, corrected: this comment previously cited {!Celebration.status}'s + own doc comment as saying a demoted feast retains its rank for RG 111's + admission-COUNT purposes. That misquotes it -- celebration.mli says RG + 111 "orders admitted commemorations by dignity", i.e. it names the + ORDERING use this guard removes, not a counting one. celebration.mli's + line is itself now stale: ordering moved from dignity to [band] in + ea22ad2. The guard is right; its former justification was not. *) if status = Celebration.Commemoration_only then unclassified (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *) else if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1 diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml index 2e5d2fd..84dc060 100644 --- a/test/test_rite_ef.ml +++ b/test/test_rite_ef.ml @@ -352,5 +352,6 @@ let suite = Alcotest.test_case "the search genuinely enters the window (not vacuous)" `Quick test_search_genuinely_enters_the_window; Alcotest.test_case - "band fidelity end-to-end: Thomas of Villanova observed, Maurice commemorated (2027-09-22)" `Quick + "band fidelity end-to-end: Ember Wednesday observed, Thomas of Villanova the sole \ + commemoration, Maurice omitted (2027-09-22)" `Quick test_maurice_thomas_band_fidelity_end_to_end ] ) -- cgit v1.3