From 04f7521a9143d8888d1e29a3e5c2d60eaae56951 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 12:16:53 +0200 Subject: kernel(rite-ef): apply RG16(a) -- a Feast of the Lord leaves the impeded Sunday uncommemorated Caput III "De Dominicis", RG 16(a): a Feast of the Lord, I or II class, occurring on a II-class Sunday takes the Sunday's own place with all its rights and privileges -- of the Sunday, therefore, no commemoration is made. band's entry 14 already ranked such a feast above the Sunday correctly; disposition had no exception for the collision, so the impeded Sunday fell through to the ordinary RG 95/109(a) commemorate-or-omit catch-all and was wrongly admitted as a privileged commemoration. Adds a branch to Rite_ef.Precedence_ef.disposition, gated on all four of: loser is temporal-origin, loser rank Class2, loser carries the Sunday slug marker, winner rank Class1-or-2 with subject Lord. No signature change -- disposition already receives the winner (RG 33's own vigil-omission branch already reads it); each conjunct is proved independently load-bearing by a dedicated disposition_cases row, including one real-data case each for the winner's subject and rank conjuncts, and a defensive synthetic case for the winner rank conjunct's currently-unreachable Class3 shape. A new end-to-end resolve-level test (2028-08-06, the Transfiguration) proves the real band/disposition pipeline together, checking the Omit reason string distinguishes this from admit's own admission-limit cut. --- lib/rites/rite_ef/precedence_ef.ml | 79 ++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) (limited to 'lib/rites/rite_ef/precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index db7e708..179fa13 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -244,13 +244,14 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc (* Task 8: what happens to the day's LOSING candidate (docs/research/ rules-register.md §4, "Occurrence" RG 92-95 and "Vigils" RG 33, plus RG - 94). [band] above decides who wins; this decides the loser's fate, which - turns on the LOSER's own rank and status (RG 95), except RG 33's vigil - omission, which also has to read the winner. Nothing here ever returns - [Precedence.Repose]: that disposition denotes RG 100-102's *repositio* - (perpetual impediment from a proper/diocesan calendar), out of this - plan's scope -- see calendar.mli's own note that nothing in the EF - ruleset currently emits it. *) + 94; also §6.0/Caput III "De Dominicis" RG 16(a), below). [band] above + decides who wins; this decides the loser's fate, which turns on the + LOSER's own rank and status (RG 95), except RG 33's vigil omission and RG + 16(a)'s Sunday-suppression, which also have to read the winner. Nothing + here ever returns [Precedence.Repose]: that disposition denotes RG + 100-102's *repositio* (perpetual impediment from a proper/diocesan + calendar), out of this plan's scope -- see calendar.mli's own note that + nothing in the EF ruleset currently emits it. *) (* RG 33 -- CORRECTED 2026-08-12 (Task 16, primary-source-verified against docs/research/1962-06-23,_SS_Ioannes_XXIII,_Missale_Romanum,_LT.pdf, the @@ -605,6 +606,70 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) register's own §4 "Vigils" entry states RG 32 only as "same pattern [as RG 31]", not verbatim -- now closed here). *) Precedence.Omit + else if + is_temporal + && cel.Celebration.rank = Class2 + && is_sunday_slug (Slug.to_string cel.Celebration.slug) + && (let wcel = winner.Precedence.cel in + (wcel.Celebration.rank = Class1 || wcel.Celebration.rank = Class2) + && wcel.Celebration.subject = Subject.Lord) + then + (* RG 16(a) (docs/research/rules-register.md §6.0, Caput III "De + Dominicis", primary text): "Dominica II classis, in occurrentia, + festis II classis praefertur. Attamen: a) festum Domini I aut II + classis, in dominica II classis occurrens, locum tenet ipsius + dominicae cum omnibus iuribus et privilegiis: de dominica, proinde, + NULLA FIT COMMEMORATIO" -- a Feast of the Lord, I or II class, + occurring on a II-class Sunday, takes the Sunday's own place with all + its rights and privileges: OF THE SUNDAY, THEREFORE, NO + COMMEMORATION IS MADE. {!band} entry 14 already ranks such a feast + above the Sunday (RG 91 entry 14 < entry 15), so the feast is + correctly [observed]; this is [disposition]'s own answer for what + becomes of the SUNDAY once it has lost -- [Omit], not the [Commemorate] + every other impeded II-class Sunday gets via RG 109(a)/RG 111(b) + below. This is the ONE place in this function that needs the WINNER's + [subject], not only the loser's own fields -- like RG 33's vigil + omission above, not a new kind of signature: [Precedence.rules.disposition] + already takes [~winner], this is simply its first other reader. + + Both winner-side conjuncts are load-bearing, proved by + [test_precedence_ef.ml]'s own paired rows (the brief's "one without + the other proves nothing"): + - [rank = Class1 || rank = Class2]: {!band} entries 11-13 admit ANY + I-class feast -- Lord or Saint alike, no subject test at all (entry + 14's own comment: unlike entry 16, entries 11-13 draw no such + line) -- ahead of a II-class Sunday. REACHABLE at Class1 on real + data: e.g. the Nativity of St John the Baptist (24 June, I class, + Saint) landing on a Time-after-Pentecost Sunday already wins the + day under {!band} alone; without this conjunct RG 16(a) would + wrongly fire for it too. (At Class2 this conjunct adds no further + cases beyond what the [subject = Lord] conjunct below already + requires -- entry 16's ordinary II-class feasts never outrank a + Sunday in {!band} in the first place, 16 > 15 -- so it is only + independently reachable at Class1.) + - [subject = Lord]: at Class2 this is what {!band} entry 14 already + requires of its own winners, so it adds no further restriction + there; at Class1 it is independently reachable and necessary, per + the St John Baptist example above -- dropping it would fire RG + 16(a) for any winning I-class feast at all, exactly the + over-wide branch the task brief warns against. + + The SUNDAY-side conjuncts: [rank = Class2] excludes a I-class Sunday + (Advent/Lent/Passiontide, Low Sunday, {!band} entry 6) -- RG 16(a)'s + own text says "Dominica II classis", not "any Sunday", and a I-class + Sunday can never actually reach here as a [loser] against a + Class1-or-2 Lord winner in the first place: entry 6's own band value + (6) is lower than every entry a Lord feast of Class1 or Class2 could + occupy (3, 11-14), so a I-class Sunday always wins outright -- this + is exactly why the rubric restricts itself to II class. [is_sunday_slug] + (the same marker RG 33's [impedes_vigil] and RG 109(a)'s + [privilege_of] already use, with no [is_temporal] guard there either + -- no sanctoral slug this codebase's data produces contains + {!sunday_marker}) is the "Dominica" half; [is_temporal] is kept + alongside it anyway, the same explicit-but-not-load-bearing defence + the RG26 branch above gives its own [not is_vigil] guard, rather than + relying on that absence silently. *) + Precedence.Omit else (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" -- commemorated or wholly omitted. Reached by every SANCTORAL loser -- cgit v1.3 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. --- data/ef/adjustments.sexp | 71 ++++++++-------- data/ef/expected-divergences.sexp | 5 -- lib/kernel/precedence.ml | 3 +- lib/kernel/precedence.mli | 16 ++++ lib/kernel/validate.ml | 17 +++- lib/rites/rite_ef/precedence_ef.ml | 46 +++++++++-- lib/rites/rite_ef/precedence_ef.mli | 45 ++++++---- test/test_calendar.ml | 2 +- test/test_differential.ml | 23 +++--- test/test_golden.ml | 95 +++++++++++++++------- test/test_precedence.ml | 2 +- test/test_precedence_ef.ml | 158 +++++++++++++++++++++++++++--------- test/test_rite_ef.ml | 45 ++++++---- test/test_validate.ml | 12 +-- 14 files changed, 370 insertions(+), 170 deletions(-) (limited to 'lib/rites/rite_ef/precedence_ef.ml') diff --git a/data/ef/adjustments.sexp b/data/ef/adjustments.sexp index 7d61170..05ee401 100644 --- a/data/ef/adjustments.sexp +++ b/data/ef/adjustments.sexp @@ -11,41 +11,46 @@ ; removal (Overlay.apply's own diagnostic fires if this slug is ever absent, ; e.g. after a re-bootstrap that renames it), not a silent drop. ; -; RG16(a) task (docs/research/rules-register.md §6.0): corrects two of -; data/ef/sanctoral.sexp's six `(subject Lord)` entries, both inherited -; unchecked from lectio's own `class = lord` field (tridentine-calendar.ini) -; -- confirmed WRONG against the calendarium itself, which names both a -; feast of the BLESSED VIRGIN, not of the Lord: -; - `purification-of-the-blessed-virgin-mary` (2 Feb): calendarium line -; "IN PURIFICATIONE B. MARIAE VIRG., II classis" -- "B. Mariae Virg." is -; the Missal's own Purification-of-Mary title, not "D. N. I. C." (the -; formula the genuinely Lord-tagged entries carry, e.g. 6 Aug -; "IN TRANSFIGURATIONE D. N. I. C." or 1 Jul "PRETIOSISSIMI SANGUINIS -; D. N. I. C."). -; - `most-holy-name-of-mary` (12 Sep): calendarium line "Sanctissimi -; Nominis Mariae, III classis" -- "Nominis Mariae", the Name of MARY, -; not of the Lord. -; Corrected as an Edit directive, not a hand-edit of data/ef/sanctoral.sexp -; itself: that file's own header says "do not hand-edit... re-run the -; generator and commit the diff instead", and its provenance SHA-256 covers -; a straight, byte-faithful conversion of lectio's INI -- an edit belongs in -; THIS file, the project's own existing mechanism for a correction over the -; bootstrap (the [Suppress] directive above sets exactly this precedent). -; The base sanctoral.sexp stays a faithful mirror of lectio (so a future -; re-bootstrap cannot silently re-introduce the error unnoticed: this Edit -; keeps applying regardless, and [Overlay.apply]'s own diagnostic fires -; loudly if the slug it targets is ever renamed or removed upstream). +; RG16(a) task (docs/research/rules-register.md §6.0): corrects one of +; data/ef/sanctoral.sexp's six `(subject Lord)` entries, inherited unchecked +; from lectio's own `class = lord` field (tridentine-calendar.ini) -- +; `most-holy-name-of-mary` (12 Sep): calendarium line "Sanctissimi Nominis +; Mariae, III classis" -- "Nominis Mariae", the Name of MARY, not of the +; Lord, with no counter-evidence found anywhere (the "D. N. I. C." formula +; the genuinely Lord-tagged entries carry, e.g. 6 Aug "IN TRANSFIGURATIONE +; D. N. I. C.", is simply absent here, and nothing else in the calendarium +; suggests otherwise). This tag has no behavioural effect either way today +; ({!Precedence_ef.band}'s entry 14, the only place [subject] is tested for +; a II-class candidate, requires [Class2]; this entry is [Class3]) -- kept +; for the data's own accuracy, not because anything currently reads it. ; -; The other four `(subject Lord)` entries were verified against the same -; calendarium and left untouched (all task-report-cited): 1 Jul "PRETIOSISSIMI -; SANGUINIS D. N. I. C., I classis"; 6 Aug "IN TRANSFIGURATIONE D. N. I. C., -; II classis"; 14 Sep "IN EXALTATIONE S. CRUCIS, II classis" (the Cross as -; the instrument of Christ's own Passion, not a canonised person -- the -; traditional "feast of the Lord" reading, {!Subject.t} having no separate -; category for it); 9 Nov "IN DEDICATIONE ARCHIBASILICAE SANCTISSIMI -; SALVATORIS, II classis" ("of the Most Holy SAVIOUR", i.e. Christ). +; The tag [Bvm] itself carries no further systematic meaning in this +; codebase beyond "not Lord" -- after this correction it exists on exactly +; this one entry, while every other Marian feast (the Assumption, the +; Immaculate Conception, the Nativity of the BVM, ...) stays `(subject +; Saint)`. Nothing reads [Bvm] specifically; {!Subject.t}'s four-way split +; is not fully exercised by this codebase's own logic, only [Lord] is. +; +; RG16(a) task, fix round 1 (CRITICAL finding, reverted): a companion Edit +; here previously also retagged `purification-of-the-blessed-virgin-mary` +; (2 Feb) to [Bvm], on the same calendarium-title argument ("B. Mariae +; Virg.", not "D. N. I. C."). REVERTED -- the user has ruled: follow the +; oracle. missalemeum (this project's designated EF oracle) treats the +; Purification as taking an occurring II-class Sunday's place OUTRIGHT, with +; NO commemoration of the Sunday (`2020-02-02`, `2014-02-02`: title +; "Purification of the Blessed Virgin Mary", `"commemorations": []`), +; exactly RG 16(a)'s own "festum Domini" treatment -- and NOT the treatment +; it gives an ordinary Marian feast on a Sunday (`2019-09-08`, the Nativity +; of the BVM: title "XIII Sunday after Pentecost", the FEAST demoted to a +; commemoration, the Sunday observed -- the opposite pattern). Genuine +; primary-text counter-evidence for the Marian reading remains on record +; (RG 120(b): the white-colour rule groups 2 February under "B. Mariae +; Virg.", a SEPARATE category from "Domini" -- register §6.0 quotes it in +; full), so the calendarium TITLE and the oracle's BEHAVIOUR disagree here; +; the user's ruling resolves that disagreement in the oracle's favour for +; this codebase's own purposes, not by declaring the calendarium argument +; wrong. See register §6.0 for the full account of both sides. ((id ef-adjustments) (directives ((Suppress vigil-of-christmas) - (Edit purification-of-the-blessed-virgin-mary ((Set_subject Bvm))) (Edit most-holy-name-of-mary ((Set_subject Bvm)))))) diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index d6161c2..c8d863d 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -72,8 +72,3 @@ (verdict colitur) (note "Holy Thursday (\"ef-passiontide-2-thursday\") is white in colitur, per RG 128(b)'s explicit exception -- independently confirmed by the missalemeum oracle (Task 16 report), whose colour set for that day is white alone, unlike the surrounding Triduum days which include violet as an option. lectio has no such exception and prints Passiontide's base violet straight through Holy Thursday. Found via Task 16's oracle comparison, not the original lectio differential (lectio's own colour did not previously disagree, because colitur's own bug matched it) -- fixed in colitur, so this row records a NEW divergence from lectio's still-uncorrected violet, not a pre-existing one.") (expected_rows 46)) - ((id C12) - (citation "Calendarium (docs/research/1962-06-23,_SS_Ioannes_XXIII,_Missale_Romanum,_LT.pdf), 2 February row: \"IN PURIFICATIONE B. MARIAE VIRG., II classis\" -- \"of the Purification of the Blessed VIRGIN MARY\", not \"D. N. I. C.\" (the formula the calendarium uses for the project's other five genuine Lord-subject entries, e.g. 6 Aug \"IN TRANSFIGURATIONE D. N. I. C.\"); combined with RG 16(a) (register §6.0, Caput III \"De Dominicis\": a Feast of the LORD, I or II class, takes an occurring II-class Sunday's own place -- a BVM feast does not)") - (verdict colitur) - (note "The Purification (2 February) is a feast of the Blessed Virgin Mary, not of the Lord -- data/ef/sanctoral.sexp inherited lectio's own unchecked `class = lord` field; data/ef/adjustments.sexp retags it `Bvm` (RG16(a) task, this task's own report), so it no longer bands at RG 91 entry 14 (Feasts of the Lord, II class) and no longer outranks an occurring II-class Sunday. On the six 2 Februaries in this fixture's 2005-2050 span that fall on a Sunday, colitur now observes the Sunday and commemorates the Purification (RG 109(a)/RG 111(b), an ordinary II-class-Sunday commemoration); lectio still tags the entry `lord` and so still observes the Purification outright, displacing the Sunday. expected_rows is not transcribed from a comparator run: 2 February falls on a Sunday in exactly six years of 2005-2050 -- 2014, 2020, 2025, 2031, 2042, 2048 -- independently verified with `date -d -02-02 +%A` (glibc, the same cross-check discipline test_golden.ml's own header requires), one row each.") - (expected_rows 6)) 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 diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 179fa13..2987312 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -707,10 +707,35 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) classis", "dominicis II classis", "aliis diebus II classis", "diebus III et IV classis") -- read here off [observed]'s own [rank] and, for the Sunday/non-Sunday II-class split, the same slug marker [privilege_of] and - RG 33's [impedes_vigil] already use ([is_sunday_slug]). No [context] - (date/season/weekday) is available to [admit] (see precedence.mli's - [rules.admit]) or needed: [observed] IS the day's own celebration, so its - rank and slug already carry everything RG 111's own four categories test. *) + RG 33's [impedes_vigil] already use ([is_sunday_slug]). + + CORRECTED (fix round 1, RG16(a) task): the previous version of this + comment claimed "[observed] IS the day's own celebration, so its rank and + slug already carry everything RG 111's own four categories test" -- + WRONG once RG 16(a) exists. RG 16(a)'s own text says the winning Feast of + the Lord holds the Sunday's place "cum omnibus iuribus et privilegiis" + (with ALL its rights and privileges) -- the day remains a "dominica II + classis" for RG 111(b)'s own purposes even though [observed] is now the + FEAST, not the Sunday, so [observed]'s own slug is no longer a reliable + signal of "is this a Sunday" once something can legitimately observe in + the Sunday's place. Confirmed wrong for real data by the oracle: 6 August + falling on a Sunday shows the Transfiguration observed and NO + commemoration (missalemeum: `commemorations: []`, Pope Sixtus II et al. + `displaced`); the pre-fix code, reading Sunday-ness off [observed]'s own + slug ("transfiguration-of-our-lord", no Sunday marker), wrongly took the + [Class2, false] "other II class: one" branch below and admitted Sixtus + (Class3) regardless of RG 111(b)'s own "de festo II classis" rank floor. + Control, also oracle-confirmed: 6 August on an ordinary WEEKDAY (no + Sunday collision) correctly admits Sixtus -- being a Sunday is exactly + what excludes him, and [observed]'s own identity cannot tell the two + cases apart on its own. + + Fixed by reading Sunday-ness off [temporal] instead -- {!Precedence.rules.admit}'s + own [~temporal] parameter, {!Precedence.resolve}'s [~temporal] argument + passed straight through, unaffected by whether it won the day. [context] + (date/season/weekday) is still not needed: [temporal]'s own slug already + 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] @@ -738,6 +763,7 @@ let rec take n = function | x :: xs -> if n <= 0 then [] else x :: take (n - 1) xs let admit ~(observed : Vocab_ef.rank Precedence.candidate) + ~(temporal : Vocab_ef.rank Precedence.candidate) (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list = (* Sorted once, by dignity then slug (see [compare_dignity]); every branch @@ -758,11 +784,17 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) let sorted = List.stable_sort compare_dignity comms in let is_privileged (_, p) = p = Precedence.Privileged in let observed_rank = observed.Precedence.cel.Celebration.rank in - let observed_is_sunday = - is_sunday_slug (Slug.to_string observed.Precedence.cel.Celebration.slug) + (* CORRECTED (fix round 1, RG16(a) task): read off [temporal], not + [observed] -- see this function's own doc comment above for the full + argument and the oracle evidence. [temporal] is the day's own + temporal-cycle candidate regardless of who is [observed]; RG 16(a) + makes that distinction live for the first time (a Feast of the Lord + can now be [observed] on a day whose [temporal] candidate is a Sunday). *) + let day_is_sunday = + is_sunday_slug (Slug.to_string temporal.Precedence.cel.Celebration.slug) in let open Vocab_ef in - match (observed_rank, observed_is_sunday) with + match (observed_rank, day_is_sunday) with | 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 diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 55f947e..ee7667d 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -164,24 +164,26 @@ val nativity_octave_prefix : string so the two cannot silently drift apart. *) val september_ember_prefix : string -(** [admit ~observed 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, +(** [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: - [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; - - [observed] a [Class2] Sunday (its slug carries {!sunday_marker}): 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; - - [observed] any other [Class2] day: one, by dignity alone -- no + - 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. @@ -192,6 +194,20 @@ val september_ember_prefix : string Ties break on slug, matching {!Precedence.compare_by}, 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 + silently assumed [observed] IS the day's temporal-cycle office. RG 16(a) + breaks that assumption on purpose: a Feast of the Lord holds an impeded + II-class Sunday's own place "cum omnibus iuribus et privilegiis" + (RG 16(a)'s own text), so the day still counts as a "dominica II + classis" for RG 111(b) even though [observed] is now the FEAST. + [temporal] -- {!Precedence.rules.admit}'s own parameter, the day's + temporal-cycle candidate regardless of who won -- is the reliable + signal; oracle-confirmed (6 August: Sunday excludes a Class3 saint + Sixtus II from the freed slot, an ordinary weekday admits him). Every + row of the previous behaviour where [observed] and [temporal] coincide + (no RG 16(a) displacement occurred) is unaffected. + Every candidate this returns is a value taken unchanged from [comms], never rebuilt: {!Precedence.resolve}'s own [dropped]/[omitted] accounting tells an admitted candidate from a dropped one by physical @@ -202,6 +218,7 @@ val september_ember_prefix : string itself total over an empty or arbitrarily long [comms]. *) 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) list diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 505994e..8770017 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -82,7 +82,7 @@ module Fixture = struct test below a genuine Precedence-native omission (distinct from a deferred one) to exercise. *) let rules : (season, rank) P.rules = - { P.band; disposition; admit = (fun ~observed:_ cs -> List.filteri (fun i _ -> i < 1) cs) } + { P.band; disposition; admit = (fun ~observed:_ ~temporal:_ cs -> List.filteri (fun i _ -> i < 1) cs) } (* 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_differential.ml b/test/test_differential.ml index aa75737..e12cd9a 100644 --- a/test/test_differential.ml +++ b/test/test_differential.ml @@ -450,19 +450,16 @@ let layer_c_reason (l : row) (c : row) diffs = && subset diffs [ Slug_f; Rank; Colour_f ] then Some "C10" else if String.equal c.slug "ef-passiontide-2-thursday" && diffs = [ Colour_f ] then Some "C11" - (* RG16(a) task (register §6.0 + the sanctoral subject audit, this task's - own report): 2 February on a Sunday. colitur now observes the Sunday - (data/ef/adjustments.sexp retags the Purification `Bvm`, so it no - longer bands at RG 91 entry 14 -- only a Feast of the LORD does); the - Purification survives as its own commemoration, matching the register's - ruling, just not as [c]'s OBSERVED slug here. lectio still tags it - `class = lord` (unretagged upstream) and so still observes it outright. - Matched on [l.slug] (lectio's side), the only side that can show this - literal now -- colitur's own [c.slug] is whichever Sunday family the - date falls in (time-after-epiphany or Septuagesima), not one fixed - literal, unlike [c7]/[c11]'s single-slug tests above. *) - else if String.equal l.slug "purification-of-the-blessed-virgin-mary" && subset diffs [ Slug_f; Colour_f ] - then Some "C12" + (* RG16(a) task, fix round 1 (CRITICAL finding): a C12 entry briefly lived + here, allow-listing 2 February on a Sunday against lectio, when + `data/ef/adjustments.sexp` retagged the Purification `Bvm`. REVERTED -- + the user has ruled: follow the oracle (register §6.0 has the full + account). The Purification is `Lord` again, colitur observes it + outright on those six 2-February Sundays, matching lectio's own + `class = lord` exactly -- no divergence to allow-list. Left + unimplemented here deliberately, not merely absent by oversight: this + comment is the record that C12 existed and was removed, should a + future reader wonder why the numbering skips it. *) else None (* ---------------------------------------------------------------------- *) diff --git a/test/test_golden.ml b/test/test_golden.ml index 85a129b..fb3cff4 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -501,16 +501,38 @@ let test_ii_class_sunday_admits_only_ii_class_commemoration () = of the bug this pin closes -- before this fix `colitur day` admitted "+ef-time-after-pentecost-sunday-9" here. + CORRECTED, fix round 1 (IMPORTANT finding, item 2): this pin used to + expect Pope Sixtus II et al. (6 August's own unrelated Class3 saint, + Commemoration_only) admitted as an ordinary commemoration in the freed + slot -- promoted from `colitur day`'s own actual output without + consulting either the primary text or the oracle first (the exact + vacuous-pin flavour the review caught, and this file's own header warns + against). Re-derived properly this time: + - PRIMARY TEXT: RG 16(a) itself says the winning Feast of the Lord holds + the Sunday's place cum omnibus iuribus et privilegiis -- WITH ALL the + Sunday's own rights and privileges. RG 111(b) is one of those: in + dominicis II classis, una tantum admittitur commemoratio, SCILICET DE + FESTO II CLASSIS -- a Sunday's own slot is reserved for a Class2 + candidate specifically. The day is still a dominica II classis for + this purpose even though the Transfiguration, not the Sunday, is + [observed] -- so Sixtus (Class3) has no standing for the slot at all, + the same as on any other II-class Sunday (contrast + [test_ii_class_sunday_admits_only_ii_class_commemoration] above, + romanus, Class3, excluded the identical way on an ORDINARY II-class + Sunday). + - ORACLE, independently fetched (missalemeum's own /api/v5/calendar + JSON, not the small-model webpage summary, which proved unreliable for + this exact question): 2023-08-06 (a Sunday) -- title Transfiguration of + Our Lord, commemorations empty, displaced Pope Sixtus II, Felicissimus + and Agapitus, Martyrs -- Sixtus displaced, not commemorated. Control, + 2026-08-06 (a Thursday, no Sunday collision) -- Sixtus present in + commemorations. Being a Sunday is exactly what excludes him. + Deliberately NOT a bare "comms=[]" pin (this project's own catalogued vacuity flavour: a count/emptiness assertion that would pass even if nothing had ever been offered): [omitted_has] first proves the Sunday really WAS a candidate that reached [disposition] and was actively - excluded, not merely never constructed. And the Sunday is not the day's - ONLY loser -- Pope Sixtus II, Felicissimus and Agapitus (Martyrs, - Commemoration_only, III class, also fixed 6 August, unrelated to the - Sunday) still receive their own ORDINARY commemoration exactly as RG - 111(c)/(d) require: proof that RG 16(a) suppresses the SUNDAY - specifically, not "every commemoration due that day". *) + excluded, not merely never constructed. *) let test_rg16a_lord_feast_no_sunday_commemoration_2028 () = let d = fetch 2028 8 6 in Alcotest.(check bool) @@ -518,35 +540,45 @@ let test_rg16a_lord_feast_no_sunday_commemoration_2028 () = true (omitted_has d "ef-time-after-pentecost-sunday-9"); Alcotest.(check string) - "2028-08-06: the Transfiguration observed; the Sunday has NO commemoration (RG16a); an UNRELATED III-class \ - saint the same day still gets its own ordinary one" + "2028-08-06: the Transfiguration observed; NO commemoration at all -- RG16(a)'s own \"cum omnibus iuribus \ + et privilegiis\" keeps the day a dominica II classis for RG111(b), so Sixtus (Class3) has no standing \ + either, oracle-confirmed" "2028-08-06 sunday season=time-after-pentecost week=9 slug=transfiguration-of-our-lord rank=class-2 \ - colour=white comms=[pope-sixtus-ii-felicissimus-and-agapitus-martyrs:ordinary] in=- out=[]" + colour=white comms=[] in=- out=[]" (describe d) -(* 2025-02-02: the other direction, and the change that made this task's - two halves inseparable (this task's own report). The Purification (2 - Feb, calendarium "IN PURIFICATIONE B. MARIAE VIRG.") is a feast of the - BLESSED VIRGIN, not of the Lord -- data/ef/adjustments.sexp retags it - [Bvm] (the sanctoral audit, this task's report), so it no longer bands - at RG 91 entry 14 and no longer outranks an occurring II-class Sunday - (entry 16 > entry 15). `date -d 2025-02-02 +%A` = Sunday, independently - confirmed. Register §6.0's own text: "Applying RG 16(a) to today's - [pre-fix] data would then also suppress the Sunday's commemoration - there" -- exactly the wrong outcome the data fix (not RG 16(a) itself) - prevents: with the Purification correctly Bvm, RG 16(a)'s own winner-side - [subject = Lord] conjunct never matches here, so the Sunday is simply the - observed day and the Purification survives as its ordinary commemoration - (RG 109/RG 111(b): a plain II-class candidate on a II-class Sunday, no - privileged rival due). *) +(* 2025-02-02, the other real instance of the SAME pattern -- not a + contrasting second direction, per this task's fix round 1 (CRITICAL + finding, item 1, reverted; see register §6.0 for the full account). + + The Purification (2 Feb, calendarium "IN PURIFICATIONE B. MARIAE VIRG.") + stays [subject = Lord] -- the user has ruled: follow the oracle. + missalemeum treats it exactly as RG 16(a)'s own "festum Domini" case + (independently fetched, `/api/v5/calendar` JSON): 2020-02-02 (a Sunday) + -- `{"title":"Purification of the Blessed Virgin Mary","tags":["IV + Sunday after Epiphany"],...,"commemorations":[],"displaced":[]}` -- the + Sunday named only as a TAG (the underlying temporal placement), not + commemorated at all; matches 2014-02-02 exactly. Contrast, the SAME + oracle on an ordinary Marian feast (2019-09-08, the Nativity of the + BVM): `{"title":"XIII Sunday after Pentecost",...,"commemorations": + [{"title":"Nativity of the Blessed Virgin Mary",...}],...}` -- the + Sunday observed, the feast merely commemorated, the OPPOSITE pattern -- + proving missalemeum does not treat every Marian feast this way, only the + Purification specifically. `date -d 2025-02-02 +%A` = Sunday, + independently confirmed. + + Unlike 2028-08-06, no second sanctoral candidate exists on 2 February + (data/ef/sanctoral.sexp has exactly one 2 February entry) -- a genuinely + clean "comms=[]" case, complementing the pair above rather than + duplicating it. *) let test_purification_on_a_sunday_2025 () = check ~msg: - "2025-02-02: the Sunday observed (Purification retagged Bvm, no longer a Feast of the Lord); the \ - Purification survives as an ordinary commemoration, not suppressed" + "2025-02-02: the Purification observed (subject Lord, unretagged -- the oracle's own treatment); NO \ + commemoration of the Sunday, oracle-confirmed" 2025 2 2 - "2025-02-02 sunday season=time-after-epiphany week=4 slug=ef-time-after-epiphany-sunday-4 rank=class-2 \ - colour=green comms=[purification-of-the-blessed-virgin-mary:ordinary] in=- out=[]" + "2025-02-02 sunday season=time-after-epiphany week=4 slug=purification-of-the-blessed-virgin-mary \ + rank=class-2 colour=white comms=[] in=- out=[]" let suite = ( "golden pins (known-tricky years)", @@ -574,10 +606,11 @@ let suite = Alcotest.test_case "IV-class ferias never commemorated (2026)" `Quick test_iv_class_ferias_never_commemorated; Alcotest.test_case "RG111(b): a II-class Sunday admits only a de-festo-II-classis commemoration (2009, 2026)" `Quick test_ii_class_sunday_admits_only_ii_class_commemoration; - Alcotest.test_case "RG16(a): a Feast of the Lord leaves the impeded Sunday with no commemoration (2028-08-06)" + Alcotest.test_case + "RG16(a): a Feast of the Lord leaves the impeded Sunday with NO commemoration at all, not even an \ + unrelated saint's own (2028-08-06, rubric + oracle)" `Quick test_rg16a_lord_feast_no_sunday_commemoration_2028; Alcotest.test_case - "RG16(a) does not fire for the (corrected) Purification: the Sunday observed, the Purification \ - commemorated (2025-02-02)" + "RG16(a), the Purification (oracle-ruled): observed outright, the Sunday not commemorated (2025-02-02)" `Quick test_purification_on_a_sunday_2025 ] ) diff --git a/test/test_precedence.ml b/test/test_precedence.ml index 369c490..ff75baa 100644 --- a/test/test_precedence.ml +++ b/test/test_precedence.ml @@ -26,7 +26,7 @@ let rules = | Cel.Feast -> (match loser.P.cel.Cel.rank with | Hi -> P.Transfer | Lo -> P.Commemorate P.Ordinary)); - admit = (fun ~observed:_ cs -> List.filteri (fun i _ -> i < 2) cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> List.filteri (fun i _ -> i < 2) cs) } 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 facf3eb..52de989 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:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> 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" @@ -812,31 +812,58 @@ 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 +(* 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 + ones, the same coupling-safety reason [of_temporal] rows elsewhere in + this file use real data. *) +let lord_winner = + cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer + "transfiguration-of-our-lord" + +(* Pope Sixtus II et al. (6 August, real slug/rank/status from + data/ef/sanctoral.sexp): Class3, Commemoration_only, tagged [Ordinary] + here directly (as every other [admit_cases] row does, isolating + [admit]'s own selection logic from [privilege_of], per this table's own + header comment). *) +let sixtus = + cand ~origin:P.Sanctoral ~rank:V.Class3 ~status:Cel.Commemoration_only + ~layer:PE.universal_layer "pope-sixtus-ii-felicissimus-and-agapitus-martyrs" + +(* (description, observed, temporal, comms, expected). [temporal] -- + {!Precedence.rules.admit}'s own new parameter, fix round 1 (RG16(a) + task): the day's temporal-cycle candidate, independent of who is + [observed]. Every row below except the last two repeats [observed] as + [temporal] -- no RG16(a) displacement occurs in those rows (the pattern + [PE.admit]'s pre-fix code implicitly, and wrongly, assumed held + UNIVERSALLY), so the two values coinciding is the faithful shape, not a + simplification that dodges the new parameter. The last two rows are + where they genuinely differ -- see their own comment. *) 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, observed_class1, [ (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, observed_class1, [ (ordinary_hi, P.Ordinary); (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, observed_class1, [ (privileged_lo, P.Privileged); (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, observed_class2_sunday, [ (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, observed_class2_sunday, [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ], [ "ef-privileged-lo" ] ); (* RG 111(b)'s own rank floor ("scilicet DE FESTO II CLASSIS"), fix @@ -848,7 +875,7 @@ let admit_cases = below, which has no such restriction. *) ( "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, observed_class2_sunday, [ (ordinary_class3, P.Ordinary) ], [] ); (* RG 111: "other II class: one" -- no privilege override, the exact @@ -857,23 +884,55 @@ let admit_cases = observed day, OPPOSITE winner. *) ( "other II-class day, only an ordinary commemoration due -> it is \ admitted", - observed_class2_other, + observed_class2_other, observed_class2_other, [ (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, observed_class2_other, [ (ordinary_hi, P.Ordinary); (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, observed_class3, [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); (ordinary_lowest, P.Ordinary) ], - [ "ef-ordinary-hi"; "ef-privileged-lo" ] ) + [ "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 + holds the Sunday's place "cum omnibus iuribus et privilegiis" -- means + the day is STILL a "dominica II classis" for RG 111(b)'s own rank + floor, even though [observed] is now the FEAST (Transfiguration, real + slug/rank/subject), not the Sunday. [temporal] (a real Sunday + candidate, {!an_ordinary_sunday}) is what reveals that; nothing in + [observed] does, since its own slug carries no Sunday marker at all. + Oracle-confirmed (missalemeum, 2023-08-06, a Sunday): + "commemorations": [], Sixtus "displaced". Before this fix + [observed_is_sunday] read [observed]'s own slug and got [false] here, + wrongly taking the "other II class: one" branch below and admitting + Sixtus (Class3) despite RG 111(b)'s rank floor. *) + ( "RG16(a) interaction: a Feast of the Lord observed on a day whose \ + 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) ], + [] ); + (* The control, same pair as the row above with [observed] = [temporal] + (no displacement -- an ordinary WEEKDAY, not a Sunday): "other II + class: one", no rank floor, Sixtus IS admitted. Oracle-confirmed + (missalemeum, 2026-08-06, a Thursday): Sixtus in "commemorations". + Proves the DISCRIMINATING factor is [temporal]'s own Sunday-ness, not + merely "was the winner a Feast of the Lord" -- that alone is true of + BOTH rows here, and only one of them excludes Sixtus. *) + ( "control: the same Feast of the Lord on an ORDINARY weekday (temporal \ + = observed, not a Sunday) -> \"other II class: one\", Sixtus IS \ + admitted", + lord_winner, lord_winner, + [ (sixtus, P.Ordinary) ], + [ "pope-sixtus-ii-felicissimus-and-agapitus-martyrs" ] ) ] (* Order independence (brief: "the admitted set must not depend on input @@ -886,8 +945,10 @@ let test_admit_order_independent () = let comms = [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); (ordinary_lowest, P.Ordinary) ] in - let forward = slugs_of (PE.admit ~observed:observed_class3 comms) in - let reversed = slugs_of (PE.admit ~observed:observed_class3 (List.rev comms)) in + let forward = slugs_of (PE.admit ~observed:observed_class3 ~temporal:observed_class3 comms) in + let reversed = + slugs_of (PE.admit ~observed:observed_class3 ~temporal:observed_class3 (List.rev comms)) + in Alcotest.(check (list string)) "reversed input admits the same candidates" forward reversed @@ -1023,25 +1084,34 @@ let test_ii_class_sunday_privileged_witness_admitted_end_to_end () = (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted) (* RG 16(a) (register §6.0) reached end-to-end through the REAL pipeline - ([PE.band] AND [PE.disposition] together, not [disposition] in - isolation as [disposition_cases]'s own rows above test it): a real civil - date this task's own blast-radius measurement names as a live instance - of the bug this branch fixes -- 6 August 2028 is a Sunday (independently - checked with `date -d 2028-08-06 +%A`, the same cross-check discipline - [test_golden.ml]'s own header requires), Time after Pentecost week 9, and - the Transfiguration (6 Aug, II class, "IN TRANSFIGURATIONE D. N. I. C.") - falls on it. Before this fix `colitur day` emitted this Sunday as an - admitted [+ef-time-after-pentecost-sunday-9] commemoration on that date - (register §6.0's own reproduction); this proves the full pipeline now - agrees with the isolated [disposition] rows above: [observed] is the + ([PE.band], [PE.disposition] AND [PE.admit] together, not any one of + them in isolation as this file's own hand-tagged rows test them + separately): a real civil date this task's own blast-radius measurement + names as a live instance of the bug this branch fixes -- 6 August 2028 + is a Sunday (independently checked with `date -d 2028-08-06 +%A`, the + same cross-check discipline [test_golden.ml]'s own header requires), + Time after Pentecost week 9, and the Transfiguration (6 Aug, II class, + "IN TRANSFIGURATIONE D. N. I. C.") falls on it. Before this fix + `colitur day` emitted this Sunday as an admitted + [+ef-time-after-pentecost-sunday-9] commemoration on that date (register + §6.0's own reproduction). + + Fix round 1 (item 2): a THIRD candidate, Pope Sixtus II et al. (real + slug/rank/status, 6 August, unrelated to the Sunday), is now also + offered, so this test exercises BOTH fixes at once, the same real shape + [test_golden.ml]'s own 2028-08-06 pin does: [observed] is the Transfiguration ({!band} entry 14 already did this correctly before this - task), [commemorations] is EMPTY (not merely missing the Sunday from - [admit]'s cut -- RG 16(a) is a [disposition]-level Omit, before [admit] - ever runs), and the Sunday appears in [omitted] with - ["omitted: yielded to a higher day"] ({!Precedence.resolve}'s own Omit - reason), never ["omitted: admission limit reached"] (that reason would - mean the Sunday was tagged [Commemorate] and then cut by [admit] -- a - different, wrong mechanism for the same wrong end state). *) + task); the Sunday is [disposition]-level [Omit] (RG 16(a)); Sixtus is + [disposition]-level [Commemorate], but [admit] then excludes him too -- + RG 111(b)'s rank floor, reached only because [admit] reads the day's + Sunday-ness off [~temporal] (the [sunday] candidate passed to + [P.resolve]), not off [observed] (the Transfiguration, whose own slug + carries no Sunday marker). [commemorations] is EMPTY, and BOTH losers + land in [omitted] with their own distinct reasons -- the Sunday via + [disposition]'s own Omit ("omitted: yielded to a higher day"), Sixtus via + [admit]'s cut ("omitted: admission limit reached") -- proving the two + fixes operate through genuinely different mechanisms, not the same one + coincidentally producing the same string. *) let test_rg16a_lord_feast_suppresses_sunday_end_to_end () = let date = mk 2028 8 6 in let day_ctx = ctx date in @@ -1050,15 +1120,25 @@ let test_rg16a_lord_feast_suppresses_sunday_end_to_end () = cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer "transfiguration-of-our-lord" in - let resolution = P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ transfiguration ] in + let sixtus = + cand ~origin:P.Sanctoral ~rank:V.Class3 ~status:Cel.Commemoration_only ~layer:PE.universal_layer + "pope-sixtus-ii-felicissimus-and-agapitus-martyrs" + in + let resolution = + P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ transfiguration; sixtus ] + in Alcotest.(check string) "the Transfiguration is observed, not the Sunday" "transfiguration-of-our-lord" (S.to_string resolution.P.observed.P.cel.Cel.slug); - Alcotest.(check int) "no commemorations at all -- RG16(a)'s own \"nulla fit commemoratio\"" + Alcotest.(check int) + "no commemorations at all -- RG16(a)'s own \"nulla fit commemoratio\" AND RG111(b)'s rank floor, both live" 0 (List.length resolution.P.commemorations); Alcotest.(check (list (pair string string))) - "the Sunday is omitted with RG16(a)'s own disposition-level reason, not admit's cut" - [ ("ef-time-after-pentecost-sunday-9", "omitted: yielded to a higher day") ] - (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted) + "both losers omitted, via two DIFFERENT mechanisms: the Sunday at disposition-level (RG16a), Sixtus at \ + admit's own cut (RG111b)" + [ ("ef-time-after-pentecost-sunday-9", "omitted: yielded to a higher day"); + ("pope-sixtus-ii-felicissimus-and-agapitus-martyrs", "omitted: admission limit reached") ] + (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted + |> List.sort compare) (* Completes Task 7's carried fix (RG 91 entry 8, §4): on a real Sunday landing on 2 November, All Souls does not merely lose (that was Task 7's @@ -1091,7 +1171,7 @@ 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:_ cs -> cs) } in + let rules = { P.band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ ~temporal:_ cs -> 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" ] @@ -1281,10 +1361,10 @@ let suite = Alcotest.test_case "All Souls transfers end-to-end (resolve, real rules)" `Quick test_all_souls_transfers_end_to_end ] @ List.map - (fun (desc, observed, comms, expect) -> + (fun (desc, observed, temporal, comms, expect) -> Alcotest.test_case desc `Quick (fun () -> Alcotest.(check (list string)) desc expect - (slugs_of (PE.admit ~observed comms)))) + (slugs_of (PE.admit ~observed ~temporal comms)))) admit_cases @ [ Alcotest.test_case "admit is order-independent (III-class, 3 candidates)" `Quick test_admit_order_independent; diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml index ad9057b..6fcb25d 100644 --- a/test/test_rite_ef.ml +++ b/test/test_rite_ef.ml @@ -88,22 +88,30 @@ let test_vigil_of_christmas_suppressed () = appears_anywhere (* RG16(a) task (register §6.0 + the sanctoral subject audit, this task's - own report): data/ef/sanctoral.sexp is left untouched (still a faithful - mirror of lectio, subject `Lord` for both entries below -- see - test_sanctoral_ef.ml's own "explicit class = lord" spot-check, which - loads that file WITHOUT applying this overlay and still passes); the - correction lives entirely in data/ef/adjustments.sexp's two [Edit] - directives, the same mechanism [test_vigil_of_christmas_suppressed] - above already proves for a sibling bootstrap defect. This is the ONE - test in the suite that observes the two retags actually take effect - through the real pipeline -- without it, a typo in either directive's - slug (silently a no-op diagnostic, not a hard failure -- Overlay.apply's - own contract) or a reverted adjustments.sexp would leave every other - test green (2 February isn't a Sunday in any golden-pinned year, and - [test_sanctoral_ef.ml] deliberately never applies the overlay). Also - re-verifies the four entries the audit confirmed correct and left - untouched, so a future accidental retag of one of THEM would be caught - here too, not just the two that changed. *) + own report). data/ef/sanctoral.sexp is left untouched (still a faithful + mirror of lectio, subject `Lord` for every entry below except + `most-holy-name-of-mary` -- see test_sanctoral_ef.ml's own "explicit + class = lord" spot-check, which loads that file WITHOUT applying this + overlay and still passes); the one real correction lives entirely in + data/ef/adjustments.sexp's single remaining [Edit] directive, the same + mechanism [test_vigil_of_christmas_suppressed] above already proves for + a sibling bootstrap defect. + + CORRECTED, fix round 1 (CRITICAL finding, item 1): this test used to + also assert `purification-of-the-blessed-virgin-mary` was retagged + `Bvm`. REVERTED -- the user has ruled: follow the oracle (register §6.0 + has the full account, both the calendarium argument for `Bvm` and the + oracle evidence against it). The Purification is `Lord` again, one of + FIVE untouched entries this test now re-verifies, not four. + + This is the ONE test in the suite that observes the one live retag + actually takes effect through the real pipeline -- without it, a typo in + the directive's slug (silently a no-op diagnostic, not a hard failure -- + Overlay.apply's own contract) or a reverted adjustments.sexp would leave + every other test green ([test_sanctoral_ef.ml] deliberately never + applies the overlay). Also re-verifies the five entries the audit + confirmed correct and left untouched, so a future accidental retag of + one of THEM would be caught here too, not just the one that changed. *) let test_rg16a_subject_retag_in_effect () = let layer = real_layer () in let subject_of slug = @@ -111,10 +119,11 @@ let test_rg16a_subject_retag_in_effect () = | Some e -> e.Layer.cel.Cel.subject | None -> Alcotest.failf "slug %s not found in the overlaid layer" slug in - Alcotest.(check bool) "Purification retagged Bvm (calendarium: \"B. MARIAE VIRG.\")" true - (subject_of "purification-of-the-blessed-virgin-mary" = Subject.Bvm); Alcotest.(check bool) "Most Holy Name of Mary retagged Bvm (calendarium: \"Nominis Mariae\")" true (subject_of "most-holy-name-of-mary" = Subject.Bvm); + Alcotest.(check bool) + "Purification stays Lord (reverted, fix round 1 -- the user's ruling: follow the oracle)" true + (subject_of "purification-of-the-blessed-virgin-mary" = Subject.Lord); Alcotest.(check bool) "Precious Blood stays Lord (calendarium: \"D. N. I. C.\") -- audit, not touched" true (subject_of "precious-blood-of-our-lord-jesus-christ" = Subject.Lord); Alcotest.(check bool) "Transfiguration stays Lord (calendarium: \"D. N. I. C.\") -- audit, not touched" true diff --git a/test/test_validate.ml b/test/test_validate.ml index 89053c1..42c58f5 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -225,7 +225,7 @@ module Synthetic = struct let rules : (season, rank) P.rules = { P.band = (fun _ _ -> 0); disposition = (fun ~winner:_ ~loser:_ -> P.Omit); - admit = (fun ~observed:_ _ -> []) } + admit = (fun ~observed:_ ~temporal:_ _ -> []) } let year_start y = match D.make ~year:y ~month:1 ~day:1 with Ok d -> d | Error e -> failwith e @@ -377,7 +377,7 @@ 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:_ 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) -> ({ 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 +416,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:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> cs) } let guard_transfer_target (_ : rank P.candidate) (origin : D.t) (_ : D.t -> rank Cel.t) = origin @@ -444,7 +444,7 @@ module Synthetic = struct { 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:_ cs -> + (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) } @@ -475,7 +475,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:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> 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 +492,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:_ cs -> cs) } + admit = (fun ~observed:_ ~temporal:_ cs -> cs) } end open Synthetic -- cgit v1.3 From 3d8bafa9c5b0f3ac2b16128413ea7ae977b1856a Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 13:33:03 +0200 Subject: docs(rite-ef): correct a false band-value comparison in the RG16(a) comment Fix round 1 review, MINOR finding (item 4). Both precedence_ef.ml and test_precedence_ef.ml claimed the loser-side Class2 conjunct held because 'entry 6's own band value (6) is lower than every entry [3, 11-14]' -- false on its face (6 is not lower than 3) and, worse, the claim proves the opposite of what it was cited for: if a band-3 candidate really did contest a Class1 Sunday, the lower number would win, meaning the Sunday would lose, not beat it as claimed. The conclusion itself was never wrong, only the justification. Against entries 11-14 (sanctoral-origin Lord feasts) the numeric argument holds (6 < 11-14). Against entry 3 (Epiphany, Ascension, Trinity, Corpus Christi, Sacred Heart, Christ the King) it is not numeric at all but structural: every band-3 celebration is temporal-origin, and Precedence.resolve takes exactly one temporal candidate per day, so a band-3 Lord feast IS that date's own single temporal candidate, never a second one contesting a separately-produced Sunday -- there is no band comparison to make in the first place. Comment-only; no behaviour change. --- lib/rites/rite_ef/precedence_ef.ml | 29 +++++++++++++++++++++++++---- test/test_precedence_ef.ml | 22 ++++++++++++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) (limited to 'lib/rites/rite_ef/precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 2987312..5688640 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -658,10 +658,31 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) (Advent/Lent/Passiontide, Low Sunday, {!band} entry 6) -- RG 16(a)'s own text says "Dominica II classis", not "any Sunday", and a I-class Sunday can never actually reach here as a [loser] against a - Class1-or-2 Lord winner in the first place: entry 6's own band value - (6) is lower than every entry a Lord feast of Class1 or Class2 could - occupy (3, 11-14), so a I-class Sunday always wins outright -- this - is exactly why the rubric restricts itself to II class. [is_sunday_slug] + Class1-or-2 Lord winner in the first place, for TWO DIFFERENT reasons + depending on the winner's shape (CORRECTED, fix round 1: the previous + version of this comment claimed "entry 6's own band value (6) is + lower than every entry [3, 11-14]" as a single numeric argument -- + WRONG on its face, 6 is not lower than 3, and the claim would in any + case prove the opposite of what it was cited for: if a band-3 + candidate really did contest a Class1 Sunday, the LOWER number (3) + would win, i.e. the Sunday would LOSE, not "always win outright" as + claimed): + - Against entries 11-14 (SANCTORAL-origin Lord feasts, e.g. the + Transfiguration): the numeric argument genuinely holds here -- + entry 6 (6) IS lower than 11-14, so a I-class Sunday always wins + outright against these. + - Against entry 3 (TEMPORAL-origin Lord feasts -- Epiphany, + Ascension, Trinity, Corpus Christi, Sacred Heart, Christ the King): + not a numeric argument at all, but a STRUCTURAL one -- every + band-3 celebration is [is_temporal], and {!Precedence.resolve} + takes exactly ONE temporal candidate per day ([~temporal], not a + list), so a band-3 Lord feast IS that date's own single temporal + candidate, never a SEPARATE candidate contesting an + independently-produced Sunday on the same date. There is no + collision to resolve by band comparison in the first place. + Either way, no I-class Sunday can reach here as a loser against a + Class1-or-2 Lord winner -- this is exactly why the rubric restricts + itself to II class. [is_sunday_slug] (the same marker RG 33's [impedes_vigil] and RG 109(a)'s [privilege_of] already use, with no [is_temporal] guard there either -- no sanctoral slug this codebase's data produces contains diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 52de989..8465870 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -587,12 +587,22 @@ let disposition_cases = (* Conjunct 2/4, LOSER rank: RG 16(a) names "Dominica II classis", not any Sunday. A I-class Sunday (Advent/Lent/Passiontide, Low Sunday, {!band} entry 6) can never actually reach here as a loser against a - Class1-or-2 Lord winner in real banding (entry 6's own band value, 6, - beats every entry such a winner could occupy, 3/11-14), which is - exactly why the primary text restricts itself to II class -- but - [disposition] takes no [context] and cannot itself re-derive that - band fact, so the rank check is what actually enforces it here. - Sourced from [Temporal_ef.temporal]'s own real output (Advent I + Class1-or-2 Lord winner in real banding -- CORRECTED (fix round 1): + not because "entry 6's own band value (6) beats every entry such a + winner could occupy, 3/11-14" (WRONG: 6 is not lower than 3, and the + claim proves the opposite of what it was cited for anyway -- see + precedence_ef.ml's own corrected comment on this same conjunct for + the full argument). Against entries 11-14 the numeric claim genuinely + holds (6 < 11-14); against entry 3 (Epiphany/Ascension/Trinity/ + Corpus Christi/Sacred Heart/Christ the King) it is a STRUCTURAL fact + instead -- every band-3 celebration is temporal-origin, and + [Precedence.resolve] takes exactly one temporal candidate per day, so + a band-3 Lord feast IS that date's own single temporal candidate, + never a second one contesting a separately-produced Sunday. Either + way, this is exactly why the primary text restricts itself to II + class -- but [disposition] takes no [context] and cannot itself + re-derive that fact, so the rank check is what actually enforces it + here. Sourced from [Temporal_ef.temporal]'s own real output (Advent I Sunday 2026, Class1, "ef-advent-sunday-1"), the same row [disposition_cases]'s own RG95/RG109(a) entry above already uses. Dropping the [rank = Class2] conjunct on the loser would wrongly Omit -- 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/rites/rite_ef/precedence_ef.ml') 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/rites/rite_ef/precedence_ef.ml') 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/rites/rite_ef/precedence_ef.ml') 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