From 84333a8c74ddb3988ad88398632ca5e1c7788e56 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 20:15:42 +0200 Subject: test(ef): commemoration, transfer, and privilege coverage for the Major Litanies test_precedence_ef.ml: a privilege_cases row for RG109(f), deliberately rank Class4 so it cannot pass via the pre-existing "of a I-class day" branch by accident. Three disposition_cases rows: the two RG80 trigger shapes (Transfer, not Commemorate) and a synthetic third row proving the winner-slug guard is specific to the two named trigger days, not "any I-class winner". Two transfer_target unit tests, both using a pathological always-blocking occupant to prove no RG96 search runs at all for this candidate -- a much stronger witness than a cooperative occupant, which could pass by accident. test_golden.ml: three new pinned tests through the real data pipeline -- the ordinary case (2026-04-25, matches the oracle's own M5 date), the four Sunday-displacement years in 2005-2050 (2010, 2021, 2027, 2032, RG111(b)), and the Easter-Monday transfer shape (2011, complementing the Easter-Sunday shape already pinned by the existing 1666/2038 Easter-extreme tests, now widened to include the transfer and its target). test_rite_ef.ml: the existing "no transfer lands inside the Easter octave" property gets a named, cited exemption for the Major Litanies (RG80's own deliberate exception), plus a positive companion test asserting the exemption is not vacuous (exactly two landings in 2005-2050, both Easter+2). That companion test was written but never registered in its own suite list -- caught while mutation-testing the transfer (a mutation that should have failed it passed silently); fixed by registering it. Mutation-tested, each of the three parts separately (recorded in the task's own report): moving the data entity off 25 April fails 7 tests (golden + oracle only -- zero unit tests, since they build synthetic candidates); corrupting the transfer's winner-slug guard fails 6 (the two disposition_cases rows, 3 golden pins, and the newly-registered transfer-count test); flipping the privilege to Ordinary fails 9 (privilege_cases, the disposition SYNTHETIC row, the oracle's count pin, 5 golden pins, and the exhaustive Validate property sweep). --- test/test_golden.ml | 138 +++++++++++++++++++++++++++++++++++++++++++-- test/test_precedence_ef.ml | 124 +++++++++++++++++++++++++++++++++++++--- test/test_rite_ef.ml | 53 ++++++++++++++++- 3 files changed, 301 insertions(+), 14 deletions(-) (limited to 'test') diff --git a/test/test_golden.ml b/test/test_golden.ml index fdfc01d..7b461bf 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -173,7 +173,18 @@ let test_easter_extreme_1598 () = (* 1666: latest Gregorian Easter possible, 25 April -- hand-verified via Gauss's algorithm (a = 13, b = 16, c = 66, ..., h = 29, l = 5, m = 0 -> - 25 April). Same citations as 1598 above. *) + 25 April). Same citations as 1598 above. + + ef-major-litanies task: 25 April = Easter Sunday is RG 80's own FIRST + trigger condition ("si vero eo die occurrit dominica Paschatis... + transferuntur in sequentem feriam III") -- this pin, already the + project's own hand-verified latest-Easter witness, now ALSO carries the + transfer this rule requires: [major-litanies] departs 25 April for + 27 April (Easter+2, "the following Tuesday" -- Precedence_ef.transfer_ + target's own Litanies branch has the full citation). Nothing else about + this day changes: [observed]/[comms]/[in] are exactly as before, + proving the Litanies departure is additive, not disruptive, to the + day Easter itself owns outright. *) let test_easter_extreme_1666 () = check ~msg:"1666-04-24 Holy Saturday: still Passiontide, violet (RG 128)" 1666 4 24 "1666-04-24 saturday season=passiontide week=2 slug=ef-passiontide-2-saturday rank=class-1 colour=violet subject=lord \ @@ -181,7 +192,21 @@ let test_easter_extreme_1666 () = check ~msg:"1666-04-25 latest possible Easter (Gauss-verified): Paschaltide begins, white, class-1" 1666 4 25 "1666-04-25 sunday season=paschaltide week=1 slug=ef-easter-sunday rank=class-1 colour=white subject=temporal name_la=- comms=[] in=- \ - out=[]" + out=[major-litanies->1666-04-27]"; + (* The transfer's own target, positively pinned (not merely inferred from + [out] above): an ordinary Easter-octave Tuesday, I class, white (RG + 119 -- white "a Missa Vigiliae paschalis usque ad Missam vigiliae + Pentecostis"), with the Litanies as its own sole, privileged + commemoration (RG 109(f); RG 111(a), "I class: none save one + privileged" -- {!PE.admit}'s own [Class1] case). No [transferred_in] + printed: [Liturgical_day.transferred_in] only ever names a candidate + that went on to WIN the day (calendar.ml's own [build_day]), and the + Litanies structurally never can (RG 81, Commemoration_only) -- this is + the SAME asymmetry {!Colitur_kernel.Calendar}'s own [settled_at] fix + (this task) exists to get right on the OTHER side of the ledger. *) + check ~msg:"1666-04-27 Easter+2: the Litanies' own transfer target, commemorated and privileged" 1666 4 27 + "1666-04-27 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white subject=temporal \ + name_la=- comms=[major-litanies:privileged] in=- out=[]" (* 2038: the brief's "late modern case" -- Easter also 25 April (Gauss- verified: a = 5, b = 20, c = 38, ..., h = 29, l = 5, m = 0 -> 25 April, @@ -203,13 +228,24 @@ let test_easter_extreme_1666 () = temporal-cycle-only test regardless (the two concerns -- Easter-extreme arithmetic and a sanctoral occurrence rule -- stay separate pins even though nothing forces that any more). *) +(* ef-major-litanies task: 2038 is this file's own worked example for the + OTHER RG 80 trigger shape checked elsewhere in this task (Easter Monday + = 25 April, e.g. 2011) landing on the identical target (Easter+2) -- + here Easter SUNDAY itself is 25 April, so the "following Tuesday" is + two days later, 27 April, not one; both shapes converge on Easter+2, + never on Easter+1 or Easter+3 (precedence_ef.ml's own [transfer_target] + Litanies branch has the full RG 80 citation and the arithmetic for + both shapes). *) let test_easter_extreme_2038_late_modern () = check ~msg:"2038-04-24 Holy Saturday: still Passiontide, violet (RG 128)" 2038 4 24 "2038-04-24 saturday season=passiontide week=2 slug=ef-passiontide-2-saturday rank=class-1 colour=violet subject=lord \ name_la=Sabbato sancto comms=[] in=- out=[]"; check ~msg:"2038-04-25 late-modern instance of the latest possible Easter (Gauss-verified)" 2038 4 25 "2038-04-25 sunday season=paschaltide week=1 slug=ef-easter-sunday rank=class-1 colour=white subject=temporal name_la=- comms=[] in=- \ - out=[]" + out=[major-litanies->2038-04-27]"; + check ~msg:"2038-04-27 Easter+2: the Litanies' own transfer target, commemorated and privileged" 2038 4 27 + "2038-04-27 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white subject=temporal \ + name_la=- comms=[major-litanies:privileged] in=- out=[]" (* ------------------------------------------------------------------ *) (* Annunciation transfer, 25 March inside Holy Week (register §4, RG 96 @@ -958,6 +994,91 @@ let test_bvm_saturday_excludes_mt_carmel_2033 () = rank=class-4 colour=white subject=bvm name_la=Officium sanctae Mariae in sabbato comms=[] in=- out=[]" (describe d) +(* ------------------------------------------------------------------ *) +(* RG 80/81/109(f), the Major Litanies (ef-major-litanies task). Every + date below independently derived from the primary text, not read off a + `colitur day` run first and rationalised (this file's own header, rule + 3): Easter's own date for each civil year comes from Computus (already + independently Gauss-cross-checked by this file's own extreme-year + pins), 25 April's Easter-offset and weekday follow arithmetically from + that, and the RULE applied at each offset (RG 80's transfer condition; + RG 91 entries 15/16 for the Sunday-vs-Mark band comparison; RG 111(b)'s + privilege-overrides-ordinary clause) is quoted at + Rite_ef.Precedence_ef's own [disposition]/[admit]/[transfer_target] and + at data/ef/expected-divergences-missalemeum.sexp's own M5/M20 entries, + which independently adjudicate the one substantive rubric question here + (WHO wins the Sunday's single slot) against the missalemeum oracle. *) +(* ------------------------------------------------------------------ *) + +(* The ORDINARY case (~97.7% of years, register's own measurement): 25 + April 2026 is a Saturday (independently checked via `date -d + 2026-04-25 +%A`, this file's own header rule 2), Easter+20 (Easter 2026 + = 5 April, itself independently checked the same way against a + published 2026 Easter date). St Mark (II class, RG 91 entry 16) wins the day outright + over the ordinary Class4 Paschaltide feria (entry 28); the Litanies, + Commemoration_only, is the day's ONLY other candidate and is admitted + uncontested ({!Rite_ef.Precedence_ef.admit}'s [Class2, false] case: one + slot, table order alone, no rival). This is the SAME date and shape + data/ef/expected-divergences-missalemeum.sexp's own [M5] entry + adjudicates against the real missalemeum oracle (verdict colitur, a + naming-convention difference only -- "The Major Litanies" vs + missalemeum's own "Pro rogationibus"). *) +let test_major_litanies_ordinary_2026 () = + check ~msg:"2026-04-25: St Mark observed outright (RG91 e16 > e28); the Major Litanies (RG80/109(f)) ride \ + along uncontested" 2026 4 25 + "2026-04-25 saturday season=paschaltide week=3 slug=mark rank=class-2 colour=red subject=saint name_la=- \ + comms=[major-litanies:privileged] in=- out=[]" + +(* The FOUR Sunday-displacement years in 2005-2050 (this task's own + measurement, register): 25 April is a Sunday, so St Mark (entry 16, II + class) loses to it exactly as any other II-class feast would (entry 15 + < entry 16), becoming an ordinary commemoration CANDIDATE rather than + the observed day -- and then loses the day's own single slot to the + Litanies (RG 111(b): "quæ tamen omittitur si commemoratio privilegiata + facienda sit" -- the ordinary de-festo-II-classis commemoration is + DROPPED once a privileged one is due). [omitted_has] on each date below + confirms Mark is genuinely reported dropped, not silently absent -- + "omitted: admission limit reached", {!Precedence.resolve}'s own generic + reason for a candidate [disposition] admitted to the contest but + [admit] then cut. Two different generic Sunday slugs appear (week 4 in + 2010/2021, week 5 in 2027/2032) because Easter falls on a different + date in each pair -- neither is hand-picked, both are what + [Temporal_ef.sunday_slug] actually computes. *) +let test_major_litanies_displaces_mark_on_ii_class_sunday () = + List.iter + (fun (y, week) -> + let d = fetch y 4 25 in + Alcotest.(check bool) + (Printf.sprintf "%d-04-25: St Mark is in [omitted] (RG111(b)'s privileged-commemoration override), not \ + silently dropped" y) + true (omitted_has d "mark"); + Alcotest.(check string) + (Printf.sprintf "%d-04-25: the Sunday observed (II class); ONLY the Litanies commemorated, Mark \ + displaced (RG111(b))" y) + (Printf.sprintf + "%d-04-25 sunday season=paschaltide week=%d slug=ef-easter-sunday-%d rank=class-2 colour=white \ + subject=temporal name_la=- comms=[major-litanies:privileged] in=- out=[]" + y week week) + (describe d)) + [ (2010, 4); (2021, 4); (2027, 5); (2032, 5) ] + +(* The OTHER RG 80 trigger shape, complementing 1666/2038 above (Easter + Sunday itself = 25 April): here Easter MONDAY = 25 April (Easter = 24 + April 2011, independently Gauss-derivable and already the domain's own + register-cited "Easter Monday" marker, [ef-easter-1-monday]). RG 80's + "following Tuesday" is one day later this time (26 April, not 27) -- + both shapes converge on the SAME offset, Easter+2, never on a fixed + civil-date difference. *) +let test_major_litanies_transfer_2011_easter_monday () = + check ~msg:"2011-04-25 Easter Monday (RG80's SECOND trigger): Litanies depart for the following Tuesday, \ + 26 April" 2011 4 25 + "2011-04-25 monday season=paschaltide week=1 slug=ef-easter-1-monday rank=class-1 colour=white \ + subject=temporal name_la=- comms=[] in=- out=[major-litanies->2011-04-26]"; + check ~msg:"2011-04-26 Easter+2: the Litanies land here, commemorated and privileged, exactly one day after \ + origin (not two, unlike the Easter-Sunday shape)" 2011 4 26 + "2011-04-26 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white \ + subject=temporal name_la=- comms=[major-litanies:privileged] in=- out=[]" + let suite = ( "golden pins (known-tricky years)", [ Alcotest.test_case "Easter extreme: 1598 earliest (22 Mar, Gauss-verified)" `Quick @@ -1026,5 +1147,14 @@ let suite = Alcotest.test_case "RG112(d), fix round 1 (F1): the BVM Saturday office excludes Mt Carmel's own commemoration \ (2033-07-16)" - `Quick test_bvm_saturday_excludes_mt_carmel_2033 + `Quick test_bvm_saturday_excludes_mt_carmel_2033; + Alcotest.test_case "RG80/109(f): the Major Litanies, ordinary year, St Mark wins outright (2026-04-25)" + `Quick test_major_litanies_ordinary_2026; + Alcotest.test_case + "RG111(b): the Major Litanies displace St Mark's own commemoration on a II-class Sunday (2010, 2021, \ + 2027, 2032)" + `Quick test_major_litanies_displaces_mark_on_ii_class_sunday; + Alcotest.test_case + "RG80's second trigger: Easter Monday = 25 April, the Litanies transfer to Easter+2 (2011)" `Quick + test_major_litanies_transfer_2011_easter_monday ] ) diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 6f8b5b5..790dab6 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -555,6 +555,45 @@ let disposition_cases = cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer "ef-suppressed-vigil", "Commemorate(Privileged)" ); + (* RG 80 (ef-major-litanies task, NEW) -- {!PE.major_litanies_slug}'s + own citation (precedence_ef.ml, above [privilege_of]) has the full + text. The row immediately above proves "Commemoration_only is + ALWAYS Commemorate" as a GENERAL rule; these three rows prove + [PE.major_litanies_slug] is the ONE deliberate, cited EXCEPTION to + it, and prove the exception is gated on the WINNER's own slug, not + merely on the loser being this particular Commemoration_only + candidate. *) + ( "RG80 first trigger: the Litanies lose to Easter Sunday itself -> \ + Transfer, not Commemorate (25 April IS Easter Sunday this year)", + cand "ef-easter-sunday", + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug, + "Transfer" ); + ( "RG80 second trigger: the Litanies lose to Easter Monday -> \ + Transfer (25 April IS Easter Monday this year, i.e. Easter itself \ + is 24 April)", + cand "ef-easter-1-monday", + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug, + "Transfer" ); + (* MUTATION-PROOFING the WINNER-slug guard specifically (not merely + "the Litanies can transfer at all"): losing to a THIRD I-class + temporal winner that is neither of RG 80's two named trigger days + must NOT transfer -- RG 80's own condition is exactly "Easter + Sunday or Easter Monday", not "any I-class day", and 25 April can + structurally never actually coincide with, say, the Nativity in + real data (this row is a deliberately synthetic collision, the + same "proves the guard, not merely the absence of its own bug" + shape {!band}'s own Holy-Family-window row uses) -- if + {!PE.disposition}'s own winner-slug test were ever loosened to + "any Class1 winner", this row would wrongly turn [Transfer] too. *) + ( "SYNTHETIC: the Litanies losing to an UNRELATED I-class day (not \ + Easter Sunday or Monday) is an ordinary Commemoration_only loser \ + -- Commemorate(Privileged) via RG109(f), NOT Transfer", + cand "ef-nativity", + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug, + "Commemorate(Privileged)" ); (* Totality (SANCTORAL side): the lower ranks the RG 33/RG 95/Task-16 branches never touch still reach the RG 95 "commemorated or omitted" branch's [Commemorate] side, not an unhandled/exceptional case -- RG @@ -865,12 +904,13 @@ let disposition_cases = a plain [Feast]-status [Class1] loser can never reach [Commemorate] at all in this ruleset (RG 95 routes it to [Transfer] instead), so no further row for (b) is added here -- see the task report. Category (f), - "of the Major Rogations, in Mass", has no row at all: no candidate this - codebase can currently construct represents one (see [privilege_of]'s own - comment on (f)) -- the negative row below proves the one slug this engine - DOES compute that could be mistaken for it (the Minor Rogations) is - correctly NOT conflated with it, which is the strongest claim available - without inventing an unfounded slug convention. *) + "of the Major Litanies, in Mass" -- NOW LIVE (ef-major-litanies task): + [PE.major_litanies_slug]'s own data/ef/adjustments.sexp entry is the + real candidate; the positive row below is added alongside the existing + negative one, which still proves the one slug this engine ALSO computes + that could be mistaken for it (the Minor Rogations, RG 87 -- a + different observance RG 109(f) does not name) is correctly NOT + conflated with it. *) let privilege_cases = [ (* (a) RG 109(a) (§4): "of a Sunday". [an_ordinary_sunday] is Class2, not Class1, not within the Nativity octave, not an Ember day, not a @@ -960,6 +1000,26 @@ let privilege_cases = cand "ef-nativity", of_temporal (off (-39)), "Commemorate(Privileged)" ); + (* (f) RG 109(f) (§4; Caput XVI "De Commemorationibus"): "de Litaniis + maioribus, in Missa" -- ef-major-litanies task, NEW. [rank] is + DELIBERATELY [Class4], not [cand]'s own [Class1] default: category + (b) above ("of a I-class day") fires on [rank = Class1] alone and + is checked BEFORE (f) in [privilege_of]'s own branch order, so a + [Class1] row here would reach [Privileged] via (b) regardless of + whether (f) itself is even wired -- proving nothing about (f) + specifically (the exact "test day that is both [X] and [Y] proves + nothing about either" hazard this table's own header warns about, + worked for the FIRST time on this axis rather than Sunday-vs- + I-class). [Class4] (data/ef/adjustments.sexp's own honestly-flagged + placeholder rank, chosen for exactly this reason) rules out (b), + and the slug is neither a Sunday, within the Nativity octave, an + Ember day, nor an Advent/Lent/Passiontide feria -- (f) is this + row's only route to [Privileged], the real proof. *) + ( "(f) the Major Litanies commemoration is privileged (RG109(f))", + cand "ef-nativity", + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug, + "Commemorate(Privileged)" ); (* Negative, RG 109(f)'s own boundary: the Minor Litanies/Rogations (Monday/Tuesday before Ascension, RG 87 -- [Temporal_ef.temporal] DOES compute these, unlike the Major Litanies RG 109(f) actually @@ -2006,6 +2066,49 @@ let test_transfer_target_does_not_raise_at_domain_ceiling () = true (D.compare target (mk 9999 12 31) > 0) +(* RG 80 (ef-major-litanies task, NEW) -- {!PE.major_litanies_slug}'s own + citation has the full text. [occupant_always_blocking] is deliberately + PATHOLOGICAL (every day reports I-class, forever) -- for every OTHER + candidate this would send [search_from] to its own 400-day structural + ceiling ({!test_transfer_target_terminates_under_pathological_occupant} + above proves exactly that shape). Using it here is the mutation proof + this branch's own comment promises ("running search_from here would be + ACTIVELY WRONG"): if the Litanies' own early-return branch were ever + deleted or bypassed, this test would immediately land somewhere near + [origin + 1000], not Easter+2, and fail loudly -- a MUCH stronger + witness than a cooperative occupant could give, which would pass either + way by accident (Easter+2 is itself never blocking under a realistic + occupant, so a real [Temporal_ef.temporal] could not distinguish "no + search happened" from "a search happened and immediately succeeded"). *) +let test_transfer_target_major_litanies_easter_sunday_shape () = + let easter_2038 = Comp.gregorian_easter 2038 in + Alcotest.(check string) "2038: Easter Sunday IS 25 April (this file's own worked example)" "2038-04-25" + (D.to_iso8601 easter_2038); + let origin = easter_2038 in + let c = + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug + in + let target = PE.transfer_target c origin occupant_always_blocking in + Alcotest.(check string) "lands on Easter+2 regardless of the pathological occupant (no RG96 search at all)" + (D.to_iso8601 (D.add_days easter_2038 2)) (D.to_iso8601 target); + Alcotest.(check bool) "strictly after origin (rite.mli's own obligation)" true (D.compare target origin > 0) + +let test_transfer_target_major_litanies_easter_monday_shape () = + let easter_2011 = Comp.gregorian_easter 2011 in + Alcotest.(check string) "2011: Easter Sunday is 24 April, so 25 April is Easter Monday" "2011-04-24" + (D.to_iso8601 easter_2011); + let origin = D.add_days easter_2011 1 (* 25 April 2011, Easter Monday *) in + let c = + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer + PE.major_litanies_slug + in + let target = PE.transfer_target c origin occupant_always_blocking in + Alcotest.(check string) "ALSO lands on Easter+2 (not Easter+3 -- both RG80 trigger shapes converge on the \ + SAME offset), regardless of the pathological occupant" + (D.to_iso8601 (D.add_days easter_2011 2)) (D.to_iso8601 target); + Alcotest.(check bool) "strictly after origin (rite.mli's own obligation)" true (D.compare target origin > 0) + let suite = ( "Precedence_ef", @@ -2098,4 +2201,11 @@ let suite = Alcotest.test_case "transfer_target: terminates and stays forward under a pathological occupant" `Quick test_transfer_target_terminates_under_pathological_occupant; Alcotest.test_case "transfer_target: does not raise probing past the domain ceiling" `Quick - test_transfer_target_does_not_raise_at_domain_ceiling ] ) + test_transfer_target_does_not_raise_at_domain_ceiling; + Alcotest.test_case + "transfer_target: RG80, Easter-Sunday shape -- lands on Easter+2 with NO search, even under a \ + pathological occupant" + `Quick test_transfer_target_major_litanies_easter_sunday_shape; + Alcotest.test_case + "transfer_target: RG80, Easter-Monday shape -- ALSO lands on Easter+2, not Easter+3" `Quick + test_transfer_target_major_litanies_easter_monday_shape ] ) diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml index 9a1e6d5..6cd8709 100644 --- a/test/test_rite_ef.ml +++ b/test/test_rite_ef.ml @@ -233,6 +233,21 @@ let sample_years = let rec range a b = if a > b then [] else a :: range (a + 1) b in range 2005 2050 +(* ef-major-litanies task: [major-litanies] (RG 80, Precedence_ef's own + [major_litanies_slug]) is a DELIBERATE, cited exception to this + property, not a bug to catch -- RG 80's own text sends the Major + Litanies to "the following Tuesday" unconditionally in a transfer year, + which is always Easter+2, squarely inside [Easter, Easter+7] + (precedence_ef.ml's own [transfer_target] Litanies branch has the full + citation and the "why not the general RG96 search" argument). Every + OTHER slug this rite ever transfers is still held to the original + property below: RG 96's general walk (and its own named Annunciation + exception) is what this property actually protects, and neither of + those two mechanisms is exempted here -- only this one rite-cited, + hand-verified exception, matched by NAME so a future regression in some + OTHER slug cannot silently hide behind this exemption. *) +let is_major_litanies_slug slug = String.equal slug PE.major_litanies_slug + (* Property 1: no day in the resolved output, across the whole sample, is EVER a transfer's landing point inside [Easter, Easter+7] -- checked two ways. [transferred_out]'s own recorded target is what [transfer_target] @@ -248,7 +263,7 @@ let test_no_transfer_lands_in_easter_octave () = Array.iter (fun (d : (V.season, V.rank) LD.t) -> (match d.LD.transferred_in with - | Some c when in_easter_octave d.LD.date -> + | Some c when in_easter_octave d.LD.date && not (is_major_litanies_slug (slug_of c)) -> violations := (Printf.sprintf "%s transferred_in on %s (Easter+%d)" (slug_of c) (Date.to_iso8601 d.LD.date) (easter_offset d.LD.date)) @@ -256,7 +271,7 @@ let test_no_transfer_lands_in_easter_octave () = | _ -> ()); List.iter (fun (c, target) -> - if in_easter_octave target then + if in_easter_octave target && not (is_major_litanies_slug (slug_of c)) then violations := Printf.sprintf "%s transferred_out from %s to %s (Easter+%d)" (slug_of c) (Date.to_iso8601 d.LD.date) (Date.to_iso8601 target) (easter_offset target) @@ -265,7 +280,37 @@ let test_no_transfer_lands_in_easter_octave () = days) sample_years; Alcotest.(check (list string)) - "no day in [Easter, Easter+7] is ever a transfer's target, 2005-2050" [] (List.rev !violations) + "no day (other than the RG80-cited Major Litanies exception) in [Easter, Easter+7] is ever a transfer's \ + target, 2005-2050" [] + (List.rev !violations) + +(* The positive contrast to the property above: RG 80's own exception DOES + fire, exactly twice in the 2005-2050 sample (register: 194/8417 domain- + wide trigger years; this task's own report has the full count), both + landing on Easter+2 as RG 80's text requires -- proving the exemption + above is not merely masking an absent case. 2011 (Easter Monday = 25 + April, Easter = 24 April) and 2038 (Easter Sunday = 25 April) are this + file's own worked examples (precedence_ef.ml's [major_litanies_slug] + citation). *) +let test_major_litanies_transfers_inside_easter_octave_exactly_when_rg80_requires () = + let layer = real_layer () in + let landings = ref [] in + List.iter + (fun y -> + let days = Cal.year Rite_ef.context layer y in + Array.iter + (fun (d : (V.season, V.rank) LD.t) -> + List.iter + (fun (c, target) -> + if is_major_litanies_slug (slug_of c) then + landings := (Date.to_iso8601 d.LD.date, Date.to_iso8601 target) :: !landings) + d.LD.transferred_out) + days) + sample_years; + Alcotest.(check (list (pair string string))) + "RG80 fires exactly twice in 2005-2050, both landing on Easter+2" + [ ("2011-04-25", "2011-04-26"); ("2038-04-25", "2038-04-27") ] + (List.rev !landings) (* Property 2, and the LIVE case: [PE.transfer_target] called directly, with an origin that genuinely starts the search INSIDE Holy Week -- Holy @@ -381,6 +426,8 @@ let suite = test_transfer_search_does_not_raise_at_domain_ceiling; Alcotest.test_case "no transfer ever lands inside [Easter, Easter+7], 2005-2050" `Quick test_no_transfer_lands_in_easter_octave; + Alcotest.test_case "RG80: the Major Litanies DO transfer inside the octave, exactly twice in 2005-2050" + `Quick test_major_litanies_transfers_inside_easter_octave_exactly_when_rg80_requires; 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 -- cgit v1.3 From 81c72ce9fc4b769be19e6373ce66852acd5b3425 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 20:15:56 +0200 Subject: test(ef): oracle allow-list for the Major Litanies, and a source-fidelity correction M5 (the pre-existing register-cited gap this task closes) had a genuine backwards claim in its own note: it said missalemeum's single admitted commemoration on 25 April 2027 (the Sunday-conflict year) was "the Major Litanies". Checked directly against the raw fixture row while adjudicating this task's own new divergence -- missalemeum's own commemorations field there is "St. Mark"; "Pro rogationibus" (the Litanies) is the one listed as displaced. Corrected in place, both the .sexp entry and this file's own header note. M5 itself now covers only the ordinary case (25 April 2026): presence agrees now that the entity is built, identity does not (colitur's own English name vs missalemeum's Latin-ish descriptive string), the same declared comparator limit M15/M18 already document, hit here for a different reason. The real, substantive divergence the correction exposes: colitur now admits the Litanies on 2027-04-25 where missalemeum admits Mark. New entry M20, adjudicated colitur -- RG 109(f) places the Litanies in the same closed privileged list as (a)-(e), and RG 111(b)'s own text is unconditional ("the II-class commemoration is dropped if a privileged one is due", no table-order qualifier). The same mechanism already primary-source-verified and used for other RG 109 categories elsewhere in this codebase; honestly flagged as the first real, non-synthetic test of this specific admit clause, consistent with this oracle's own already-documented pattern of RG 108-111 gaps (M1, M8, M10). M18's own count moves 395 -> 394 (2027-04-25 no longer falls into "observed- identity-unresolved alone"; it moved into M20), traced to the specific date and mechanism, not merely re-baselined. data/ef/expected-divergences.sexp (the lectio allow-list) needed no change: lectio computes no Major Litanies at all, and its own row type carries no commemorations field -- layer 3 is entirely blind to this change, confirmed rather than assumed. --- data/ef/expected-divergences-missalemeum.sexp | 17 ++-- test/test_oracle.ml | 125 ++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 23 deletions(-) (limited to 'test') diff --git a/data/ef/expected-divergences-missalemeum.sexp b/data/ef/expected-divergences-missalemeum.sexp index 1fce257..afc793e 100644 --- a/data/ef/expected-divergences-missalemeum.sexp +++ b/data/ef/expected-divergences-missalemeum.sexp @@ -168,9 +168,9 @@ (note "missalemeum, like lectio, computes no Rogation day at all and shows the plain paschaltide-week feria instead. Only 3 May 2027 (Rogation Monday) shows it in this window -- every other Rogation day here is won outright by a saint on both sides, so only the underlying temporal identity differs, which this comparator's axes do not expose.") (expected_rows 1)) ((id M5) - (citation "RG 80 (Major Litanies, 25 April) -- register §6's own pre-existing open item, \"Major Litanies (25 April, RG 80) are not computed\"") - (verdict missalemeum) - (note "Independently confirmed by the oracle, not a new finding: missalemeum's \"Pro rogationibus\" commemoration on St Mark's day (25 April, the Major Litanies' fixed date) is exactly the missing office register §6 already tracks. Only 2026 shows as a comparator MISMATCH: 25 April 2026 is a Saturday with Mark winning outright, so colitur's commemoration list is empty where the oracle's carries \"Pro rogationibus\" -- a real presence gap. 25 April 2027 is a Sunday: the Sunday wins on BOTH sides, and each side's own single commemoration slot goes to a DIFFERENT candidate (colitur: St Mark himself, an ordinary II-class sanctoral loser admitted under RG111(b)'s \"de festo II classis\" slot; missalemeum: the Major Litanies) -- both non-empty, same count, so this comparator's count-only axis (deliberately not slug-identity, see test_oracle.ml's own header) cannot see that mismatch; the underlying gap is the same, just invisible to this year's row.") + (citation "RG 80/81 (Major Litanies, Caput X \"De Litaniis maioribus et minoribus\") + RG 109(f) (Caput XVI \"De Commemorationibus\") -- now BUILT (ef-major-litanies task): Rite_ef.Precedence_ef's own [major_litanies_slug]/[disposition]/[privilege_of]/[transfer_target], data/ef/adjustments.sexp's matching `Add` entry") + (verdict colitur) + (note "CORRECTED (ef-major-litanies task): this entry's own PRIOR text claimed missalemeum's single admitted commemoration on 25 April 2027 was \"the Major Litanies\" -- checked directly against the raw fixture row while adjudicating a new divergence this task found (test/fixtures/missalemeum-ef-2026-2027.txt, the 2027-04-25 line: \"...|IV Sunday after Easter|-|St. Mark|Pro rogationibus|1|sancti:04-25:2:r\") and found BACKWARDS: missalemeum's own [commemorations] field there is \"St. Mark\", and \"Pro rogationibus\" (the Litanies) is the one listed in [displaced]. A genuine source-fidelity slip in this entry's own prior authorship, corrected here; see [M20] below for the divergence this correction actually motivates (2027's own now-real colitur-vs-missalemeum disagreement, adjudicated separately with its own RG citation). This entry (M5) now covers only 25 April 2026 (the ordinary, non-Sunday shape -- Mark wins the day outright on both sides, uncontested): both engines now admit a commemoration there (colitur: major-litanies; missalemeum: \"Pro rogationibus\"), so PRESENCE agrees (the register §6 gap this entry used to track is closed), but the two engines name the same real-world observance in different registers -- colitur's own English descriptive name (data/ef/adjustments.sexp's own honestly-flagged, not-primary-sourced \"The Major Litanies\") against missalemeum's Latin-ish \"Pro rogationibus\" -- never going to match as literal strings, and not a rubric dispute: RG 80/81 prescribe no English title for this observance at all. The SAME declared comparator limit M15/M18 already document for a temporal-origin candidate's identity, hit here for a different structural reason (a real candidate on BOTH sides now, just two different vocabularies, not an unresolvable name). Identity-gated (test_oracle.ml's own [m5_commemoration_matches]): pins that colitur's own sole admitted commemoration really is [major-litanies], not merely that some [Comm_identity_mismatch] diff exists on this date, the same discipline M19's own identity guard already established.") (expected_rows 1)) ((id M8) (citation "RG 109(a) (\"of a Sunday\" is always privileged) + RG 111(a) (\"I class: none save one privileged\")") @@ -205,10 +205,17 @@ ((id M18) (citation "test_oracle.ml's own header, \"COMMEMORATION IDENTITY\" section, restated for the OBSERVED axis (ef-rg112-rg110 task) -- not an RG citation, a comparator LIMIT: Rite_ef.Temporal_ef never sets an English [names] field on any candidate it builds, the SAME limit M15 already names for a commemoration, now visible for the OBSERVED day itself now that the observed-identity axis exists") (verdict unresolvable) - (note "REVISED, ef-bvm-saturday task: 395 of the 730 days in this window now carry this shape alone (M1/M3/M16 absorb a further 4 where it fires ALONGSIDE their own pre-existing citation -- see each entry's own widened note; 395+4=399 is the axis's own full unresolved population, UNCHANGED from before this task -- only the SPLIT moved, since M2 (closed above, this task) used to separately absorb 22 of the 26 that used to be \"elsewhere\"). Before this revision the split was 373 alone / 26 elsewhere (373+26=399) -- CORRECTED, fix round 1 (coordinator finding 8) -- the full breakdown, precisely: of 730 days, 331 are RESOLVED (colitur's own observed celebration carries a name) -- 330 resolved-and-MATCHING, 1 resolved-and-MISMATCHED (M13, Joseph vs the Seven Sorrows, already its own entry above) -- and 399 are UNRESOLVED (colitur's own name is [None]), split between this entry's own 395 and the 4 absorbed elsewhere. The BLIND SPOT this axis exists to close, stated precisely: a TEMPORAL-origin observed day silently replaced by a DIFFERENT temporal-origin observed day of the SAME RANK AND COLOUR -- exactly the shape that hid Holy Family from every layer before this task (rank 2/white on both sides, purely coincidental). This count PIN is what stands guard against that population growing silently in either direction: an implementation change that made MORE days temporal-origin-and-unresolvable, or fewer, changes 373 and fails the pin, even though the axis itself cannot say WHICH specific day moved or why. The overwhelming majority of days whose observed celebration is TEMPORAL-origin (an ordinary Sunday, a feria, a movable named feast including Holy Family itself) never carry an English name on colitur's side -- not a rubric dispute and not a data gap either engine is wrong about, a LIMIT of this comparator, honestly counted rather than silently passed, per the brief's own explicit instruction (\"a day whose observed identity cannot be resolved must be a counted, allow-listed outcome, never a silent skip\"). Gated on the diff SHAPE alone (exactly, and only, observed-identity-unresolved), not a literal date list the way every other entry in this file is -- at this population size a date list would itself be the \"pattern that could silently widen\" this file's own header warns against, for the opposite reason a date RANGE is normally risky: the predicate (colitur's own resolved name is [None]) is the precise, falsifiable evidence, the same shape M2's own title-substring predicate already uses instead of enumerating dates, just keyed on presence-of-a-name rather than a title string. Building an English name onto every temporal-cycle candidate (a data/lectionary-bootstrap task, Plan 4, out of this task's scope -- register §6, the same open item M15's own note already tracks) would resolve this entry to Matched for every one of these days it does not instead expose as a REAL divergence (Holy Family's own two dates in this window, 2026-01-11 and 2027-01-10, are counted here, not separately -- the name comparison genuinely cannot distinguish \"Holy Family\" from any other unnamed Sunday, so proving Holy Family's own correctness rests on the golden pins and the differential's own C15, not this axis). Derived directly from the comparator's own failure output (test_layer_m_counts_match_citations), not hand-counted first and cross-checked after: 373.") - (expected_rows 395)) + (note "REVISED AGAIN, ef-major-litanies task: 394, down by exactly 1 from the ef-bvm-saturday task's own 395 (breakdown below unchanged in kind, only this one row moved). 25 April 2027 used to carry [Observed_identity_unresolved] ALONE (the Sunday itself is temporal-origin, unresolved; commemoration identity agreed by coincidence -- Mark on both sides, pre-Litanies) and so fell into this entry's own bucket; now that the Major Litanies are built, that same date ALSO carries [Comm_identity_mismatch] (colitur admits the Litanies, missalemeum still admits Mark -- see [M20]'s own new entry), so it moves OUT of this bucket and into M20's own single-row citation instead. Verified directly from the comparator's own failure output, not merely arithmetic (395-1=394 is necessary but not sufficient: the mechanism above is why it left, not merely that some count decreased). + +REVISED, ef-bvm-saturday task: 395 of the 730 days in this window used to carry this shape alone (M1/M3/M16 absorb a further 4 where it fires ALONGSIDE their own pre-existing citation -- see each entry's own widened note; 395+4=399 is the axis's own full unresolved population, UNCHANGED from before that task -- only the SPLIT moved, since M2 (closed above) used to separately absorb 22 of the 26 that used to be \"elsewhere\"). Before that revision the split was 373 alone / 26 elsewhere (373+26=399) -- CORRECTED, fix round 1 (coordinator finding 8) -- the full breakdown, precisely: of 730 days, 331 are RESOLVED (colitur's own observed celebration carries a name) -- 330 resolved-and-MATCHING, 1 resolved-and-MISMATCHED (M13, Joseph vs the Seven Sorrows, already its own entry above) -- and 399 are UNRESOLVED (colitur's own name is [None]), split between this entry's own (then 395, now 394) and the 4 absorbed elsewhere. The BLIND SPOT this axis exists to close, stated precisely: a TEMPORAL-origin observed day silently replaced by a DIFFERENT temporal-origin observed day of the SAME RANK AND COLOUR -- exactly the shape that hid Holy Family from every layer before the ef-rg112-rg110 task (rank 2/white on both sides, purely coincidental). This count PIN is what stands guard against that population growing silently in either direction: an implementation change that made MORE days temporal-origin-and-unresolvable, or fewer, changes this number and fails the pin, even though the axis itself cannot say WHICH specific day moved or why -- exactly the guard that caught this task's own -1 shift, which HAD to be traced to a specific date and mechanism (above) rather than merely re-baselined. The overwhelming majority of days whose observed celebration is TEMPORAL-origin (an ordinary Sunday, a feria, a movable named feast including Holy Family itself) never carry an English name on colitur's side -- not a rubric dispute and not a data gap either engine is wrong about, a LIMIT of this comparator, honestly counted rather than silently passed, per the brief's own explicit instruction (\"a day whose observed identity cannot be resolved must be a counted, allow-listed outcome, never a silent skip\"). Gated on the diff SHAPE alone (exactly, and only, observed-identity-unresolved), not a literal date list the way every other entry in this file is -- at this population size a date list would itself be the \"pattern that could silently widen\" this file's own header warns against, for the opposite reason a date RANGE is normally risky: the predicate (colitur's own resolved name is [None]) is the precise, falsifiable evidence, the same shape M2's own title-substring predicate already uses instead of enumerating dates, just keyed on presence-of-a-name rather than a title string. Building an English name onto every temporal-cycle candidate (a data/lectionary-bootstrap task, Plan 4, out of this task's scope -- register §6, the same open item M15's own note already tracks) would resolve this entry to Matched for every one of these days it does not instead expose as a REAL divergence (Holy Family's own two dates in this window, 2026-01-11 and 2027-01-10, are counted here, not separately -- the name comparison genuinely cannot distinguish \"Holy Family\" from any other unnamed Sunday, so proving Holy Family's own correctness rests on the golden pins and the differential's own C15, not this axis).") + (expected_rows 394)) ((id M19) (citation "RG 110's OTHER direction: \"In Officio et Missa S. Petri semper fit commemoratio S. Pauli, ET VICISSIM\" -- the SAME rule M12 used to cite, applied here to Paul's own office commemorating Peter, not Peter's own office commemorating Paul; confirmed a third time by the calendarium's own June table, both photographic scans, word for word: \"In Commemoratione S. Pauli Ap., III classis. / Commemoratio S. Petri Ap.\"") (verdict colitur) (note "30 June, both years in this window (2026-06-30, 2027-06-30): colitur now shows +commemoration-of-st-peter (data/ef/adjustments.sexp's own `Add` directive) where missalemeum shows nothing at all -- not even Peter listed under \"displaced\", meaning missalemeum's own engine never constructs a candidate for this commemoration in the first place, the SAME shape this task found in lectio's own source data (`~/git/projects/lectio/internal/caldata/tridentine-calendar.ini` has no 06-30 companion entry either -- an upstream data gap both reference engines share, not a colitur bootstrap miss). Verdict colitur, the same shape as M1/M3/M8/M10 above (each already \"missalemeum does not implement X\"): the calendarium's own text is unconditional and confirmed on both photographic scans, and colitur's own machinery for the other two RG 110 pairs (25 January, 22 February) already produces the identical shape correctly. Diff shape [Comm_presence] alone: rank/colour/observed-identity all already agreed before this fix (the OBSERVED celebration on both dates is unchanged, `in-commemoratione-sancti-pauli-apostoli`, sanctoral-origin with a resolvable name matching missalemeum's own title exactly on both rows) -- only the new commemoration's PRESENCE is new, so identity comparison is never even reached (this file's own header: identity is only attempted once presence and count already agree). 2, one per year, independently re-derived against the fixture's own two 06-30 rows before being set here, not assumed from the citation's own two-pair symmetry.") (expected_rows 2)) + ((id M20) + (citation "RG 109(f) + RG 111(b), Caput XVI \"De Commemorationibus\" (docs/research/rules-register.md §4, both primary-source-verified word for word, all three documents): RG 109(f) lists \"de Litaniis maioribus, in Missa\" in the SAME closed privileged list as (a)-(e); RG 111(b): \"in dominicis II classis, una tantum admittitur commemoratio, scilicet de festo II classis, QUÆ TAMEN OMITTITUR SI COMMEMORATIO PRIVILEGIATA FACIENDA SIT\" -- the II-class-feast commemoration is dropped if a privileged one is due") + (verdict colitur) + (note "NEW (ef-major-litanies task). 25 April 2027: RG 80's own transfer condition (25 April = Easter Sunday or Monday) does NOT fire this year, so the Litanies stay on 25 April itself -- which this year happens to be an ordinary II-class Sunday (RG 91 entry 15). St Mark (II class, entry 16) also loses to the Sunday. Two losing candidates, ONE slot (RG 111(b)): colitur admits the Litanies, not Mark; missalemeum's raw fixture row shows the reverse (verified directly against test/fixtures/missalemeum-ef-2026-2027.txt's own 2027-04-25 line -- see [M5]'s own corrected note above for the exact text, which previously mis-transcribed this same row backwards). ADJUDICATED, not merely asserted: {!Precedence_ef.admit}'s [Class2, true] branch already implements RG 111(b)'s privilege-overrides-ordinary clause exactly as quoted above, and this project's own prior work (precedence_ef.ml's \"Fix, Task 16\" comment) already primary-source-verified that same clause independently of this task -- the Litanies winning here is the SAME mechanism, not a special case written for it; St Mark meets the fate any ordinary Class2 commemoration meets when a privileged one is also due (RG 16(a)'s Transfiguration/Sixtus shape is the nearest existing witness, though there the privileged side wins the whole DAY, not merely the commemoration slot). HONESTLY FLAGGED: this is the first real (non-synthetic) data point this codebase has for \"an ordinary Class2 feast and a privileged non-feast commemoration both losing to the identical Sunday\" -- every other witness for this admit branch in test_precedence_ef.ml is hand-built. Read plainly, RG 111(b)'s text supports colitur's outcome here; missalemeum's divergence is consistent with this project's already-documented pattern of RG 108-111 gaps in that oracle (M1, M8, M10 above each already \"missalemeum does not implement X\") -- plausibly one more instance of the same generator not modelling RG 109(f)'s privilege for this rare, single-date observance, not evidence the RG 111(b) mechanism itself is mis-read. Recorded as an adjudicated verdict, not a certainty -- revisit first if a future primary-source pass finds textual grounds narrowing RG 109(f)'s privilege specifically. Identity-gated (test_oracle.ml's own [m20_commemoration_matches]): pins that colitur's own sole admitted commemoration really is [major-litanies].") + (expected_rows 1)) diff --git a/test/test_oracle.ml b/test/test_oracle.ml index c0badfe..3a9e499 100644 --- a/test/test_oracle.ml +++ b/test/test_oracle.ml @@ -517,25 +517,108 @@ let m3_dates = [ "2027-05-03" ] sourced from lectio's independently-fixed generator; the divergence no longer occurs on either 2026-01-28 or 2027-01-28). *) -(* M5 -- register §6's OWN already-open item, independently confirmed by - the oracle: "Major Litanies (25 April, RG 80) are not yet computed" -- - missalemeum's commemoration "Pro rogationibus" ("for the Rogations") on - St Mark's day (25 April, the Major Litanies' fixed date) is exactly the - missing office. Not a new finding; recorded here so the automated - comparator does not report it as an unexplained mismatch, and cross- - referenced from the register so a reader sees both. Verdict missalemeum - (colitur is missing a real, cited office; register §6 already tracks - it). Only ONE date below, not two: 25 April 2026 is a Saturday (Mark - wins outright, colitur's commemoration list is empty where the oracle's - is not -- a visible presence gap); 25 April 2027 is a Sunday, where the - Sunday wins on BOTH sides and each side's own single slot goes to a - DIFFERENT candidate (colitur: Mark himself, admitted under RG 111(b)'s - "de festo II classis"; missalemeum: the Major Litanies) -- both - non-empty, same count, so this comparator's deliberately-not-slug- - identity axes cannot see that year's instance at all (see the task - report). *) +(* M5 -- CORRECTED (ef-major-litanies task): the Major Litanies (RG 80, + Caput X "De Litaniis maioribus et minoribus") are now built + (Rite_ef.Precedence_ef's own [major_litanies_slug]/[disposition]/ + [privilege_of] RG 109(f) branch/[transfer_target] RG 80 branch, + data/ef/adjustments.sexp's matching [Add] entry). This note's own + PRIOR text (struck below, kept for the record of what was corrected) + claimed missalemeum's single admitted commemoration on 25 April 2027 + (the Sunday-conflict shape) was "the Major Litanies" -- CHECKED AGAINST + THE RAW FIXTURE ROW DIRECTLY while adjudicating this task's own new + divergence (test/fixtures/missalemeum-ef-2026-2027.txt, the 2027-04-25 + line: "...|IV Sunday after Easter|-|St. Mark|Pro rogationibus|1| + sancti:04-25:2:r") and found BACKWARDS: missalemeum's own + [commemorations] field for that row is "St. Mark", and "Pro + rogationibus" (the Litanies) is the one listed in [displaced]. The + note had the two swapped -- a genuine source-fidelity slip in this + file's own prior authorship, not a re-reading of a primary document, + but corrected on the same "read positively, quote it" discipline this + project applies to the primary scans. See [M20] below for the fix + this correction actually motivates (2027's own now-real divergence, + colitur=Litanies vs missalemeum=Mark, adjudicated separately). + + What THIS entry (M5) still covers, unchanged in kind: 25 April 2026 (an + ordinary, non-Sunday year -- Mark wins the day outright on both sides, + uncontested). Both engines now admit a commemoration there (colitur: + [major-litanies]; missalemeum: "Pro rogationibus") -- PRESENCE now + agrees (closing the register §6 gap this entry used to track), but the + two engines name the SAME real-world observance in different registers: + colitur's own English descriptive name (data/ef/adjustments.sexp's own + honestly-flagged, not-primary-sourced "The Major Litanies") against + missalemeum's Latin-ish "Pro rogationibus" ("for the Rogations") -- + never going to match as literal strings, and not a rubric dispute at + all (RG 80/81 do not prescribe an English title for this observance; + this comparator's own [identity_diff] can only ever compare exact + strings, the same declared limit [M15]/[M18] already document for + temporal-origin candidates, now hit here for a different structural + reason -- a real candidate on both sides, just two different + vocabularies). Verdict colitur (the identity axis's own declared limit, + not an error): {!m5_commemoration_matches} pins WHICH candidate colitur + actually admits here, not merely that some [Comm_identity_mismatch] + diff exists, the same discipline [M19]'s own identity guard already + established. *) let m5_dates = [ "2026-04-25" ] +let m5_commemoration_matches (c : colitur_row) = + match c.c_commemorations with [ (slug, _, _, _) ] -> String.equal slug "major-litanies" | _ -> false + +(* M20 -- ef-major-litanies task, NEW. 25 April 2027: the Sunday-conflict + shape (RG 80's own condition -- 25 April is Easter Sunday or Monday -- + does NOT fire this year, so the Litanies stay put on 25 April itself, + which this year happens to be an ordinary II-class Sunday, RG 91 entry + 15). St Mark (II class, RG 91 entry 16) also loses to the Sunday. Two + losing candidates, ONE slot (RG 111(b)): colitur admits the Litanies, + NOT Mark; missalemeum's raw fixture row shows the reverse (verified + directly, not inferred -- see [M5]'s own corrected note above for the + exact line). + + ADJUDICATED: verdict colitur, RG 109(f) + RG 111(b) (docs/research/ + rules-register.md §4 "Commemorations", both primary-source-verified + word for word, all three documents): RG 109(f), Caput XVI "De + Commemorationibus", places "de Litaniis maioribus, in Missa" in the + SAME closed list as (a)-(e), with equal grammatical standing -- no + textual qualifier narrows its privilege relative to theirs. RG 111(b), + same chapter: "in dominicis II classis, una tantum admittitur + commemoratio, scilicet de festo II classis, QUÆ TAMEN OMITTITUR SI + COMMEMORATIO PRIVILEGIATA FACIENDA SIT" -- the II-class-feast + commemoration is DROPPED if a privileged commemoration is due, full + stop; the clause names no table-order qualifier ("whichever ranks + higher"), only the categorical fact of a privileged commemoration being + due. This is the SAME mechanism {!Precedence_ef.admit}'s [Class2, true] + branch already implements and this project's OWN prior work already + primary-source-verified for this exact clause (precedence_ef.ml's own + "Fix, Task 16" comment) -- not new code, not a special case written for + the Litanies; St Mark simply meets the same fate any other ordinary + Class2 commemoration meets when a privileged one is also due that + Sunday (RG 16(a)'s Transfiguration/Sixtus shape is the nearest existing + witness, though there the privileged side wins the DAY, not merely the + commemoration slot). + + HONESTLY FLAGGED, not overclaimed: this is the FIRST real, independent + (non-synthetic) data point this codebase has for "an ordinary Class2 + feast candidate and a privileged non-feast commemoration candidate, + both losing to the identical Sunday" -- every other witness for + [admit]'s Class2-Sunday privilege-override branch in this suite's own + test_precedence_ef.ml is hand-built (real slugs/ranks, but a + constructed collision, not one the calendar itself produces). Read + plainly, RG 111(b)'s text supports colitur's outcome; missalemeum's own + divergence here is consistent with this project's ALREADY-DOCUMENTED + pattern of RG 108-111 gaps in that oracle (M1: RG 33's Sunday omission + not implemented; M8: RG 109(a)+RG 111(a) not implemented for an impeded + I-class Sunday; M10: RG 109(e) inconsistently applied) -- plausibly one + more instance of the same generator not modelling RG 109(f)'s privilege + for this one rare, single-date observance, not evidence the general + RG 111(b) mechanism itself is mis-read. Recorded as an adjudicated + verdict, not a certainty: if a future primary-source pass finds + textual grounds narrowing RG 109(f)'s privilege specifically (e.g. a + clause this task's own scan reading did not surface), this entry is + the one to revisit first. *) +let m20_dates = [ "2027-04-25" ] + +let m20_commemoration_matches (c : colitur_row) = + match c.c_commemorations with [ (slug, _, _, _) ] -> String.equal slug "major-litanies" | _ -> false + (* M6 -- REMOVED, ef-rebootstrap (2026-08-12): see data/ef/expected- divergences-missalemeum.sexp's own "FIVE MORE REMOVED" note. Eusebius Confessor (14 Aug) is now present in data/ef/sanctoral.sexp @@ -852,7 +935,8 @@ let layer_m_reason (c : colitur_row) (_o : oracle_row) diffs = observes is temporal-origin, same root cause as M1/M2 above. *) else if List.mem c.c_date m3_dates && subset diffs [ Colour_f; Observed_identity_unresolved ] then Some "M3" - else if List.mem c.c_date m5_dates && diffs = [ Comm_presence ] then Some "M5" + else if List.mem c.c_date m5_dates && diffs = [ Comm_identity_mismatch ] && m5_commemoration_matches c then + Some "M5" else if List.mem c.c_date m8_dates && diffs = [ Comm_presence ] then Some "M8" else if List.mem c.c_date m10_dates && diffs = [ Comm_presence ] then Some "M10" else if List.mem c.c_date m11_dates && diffs = [ Comm_presence ] then Some "M11" @@ -880,6 +964,11 @@ let layer_m_reason (c : colitur_row) (_o : oracle_row) diffs = else if diffs = [ Observed_identity_unresolved ] then Some "M18" else if List.mem c.c_date m19_dates && diffs = [ Comm_presence ] && m19_commemoration_matches c then Some "M19" + else if + List.mem c.c_date m20_dates + && subset diffs [ Comm_identity_mismatch; Observed_identity_unresolved ] + && m20_commemoration_matches c + then Some "M20" else None (* ---------------------------------------------------------------------- *) -- cgit v1.3 From 10f0e964ceb2999b330ca4b3c6545e24eeb71f6c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 20:55:28 +0200 Subject: fix(kernel): a transferred candidate can settle by being capped out, too (fix round 1, F1) The prior fix (settled_at) recognised two settlement channels for a transferred candidate at its target -- winning outright (observed) or surviving as a commemoration -- but missed a third: reaching the target and then being CAPPED OUT there, by admit's own RG-111-style admission count limit or by disposition's own Omit. That candidate lands in the target's own omitted list, genuinely settled and accurately labelled, but settled_at did not check that list, so the origin reported it as unresolved under the same wrong, hardcoded unconverged_reason -- the exact original bug, one level further out. Unreachable on shipped EF data (the Major Litanies are the only privileged Commemoration_only candidate real data carries, and no second one can ever share Easter+2), but reachable by construction: a second privileged Commemoration_only entry on the Litanies' own transfer target that outranks it in admit's Class1 selection, or -- without any synthetic data -- forcing the Litanies' own RG 109(f) privilege to Ordinary, which makes the transferred candidate lose that same cap against its own real target. Fixed by adding target-omitted membership as a third disjunct in settled_at. New regression test in test_calendar.ml, built the same way: the real EF layer plus one synthetic privileged Commemoration_only entry on the real 2011 transfer target, sorting ahead of the Litanies so it wins the Class1 slot. Mutation-verified to fail specifically when the third disjunct is removed. COLITUR_EXHAUSTIVE_SWEEP=1 dune test --force stays clean after the fix, confirming it changes no shipped day's output. --- lib/kernel/calendar.ml | 104 +++++++++++++++++++++++++++++++++++++------------ test/test_calendar.ml | 90 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 26 deletions(-) (limited to 'test') diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index 27e564f..f2a03a7 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -305,33 +305,86 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date) double-booking it in both would fail Task 12's "appears exactly once" reading of this day alone. *) (* Whether a transferred/injected candidate is genuinely accounted for at - its assigned target -- true if it won the day outright there (every - [Transfer]-disposed candidate this kernel produced before a rite could - transfer a [Celebration.status = Commemoration_only] one: a losing - FEAST, which RG 96's own "next day not I or II class" guarantees an - unblocked day to win once it arrives -- [occupant_of] alone answered - this), OR if it survives at the target as one of the day's own admitted + its assigned target -- true under any of THREE conditions, not two + (fix round 1, ef-major-litanies task -- the first version of this + function, and this comment, claimed two, missing exactly the same + shape one channel further out than the bug it had just fixed; see + below for how that was found). + + (1) It won the day outright there (every [Transfer]-disposed candidate + this kernel produced before a rite could transfer a + [Celebration.status = Commemoration_only] one: a losing FEAST, which + RG 96's own "next day not I or II class" guarantees an unblocked day + to win once it arrives -- [occupant_of] alone used to answer this, and + still would). + + (2) It survives at the target as one of the day's own admitted COMMEMORATIONS instead (a shape [place_transfers] itself never used to produce, because nothing before could dispose a [Commemoration_only] - candidate as [Transfer] -- {!Precedence.resolve} holds such a candidate - out of the band contest entirely, so it can never win a day outright, - only ever be commemorated on one; a rite is nonetheless free to - [Transfer] one to a named date, e.g. RG 80's Major Litanies, RG 81's - own "nihil fit in Officio" making [observed] structurally impossible - for it anywhere). [occupant_of] alone under-reports this second shape - as unsettled, which previously had no live witness to catch it: a - transferred candidate that only ever becomes a commemoration, never the - day's own office, was mislabelled here with [unconverged_reason] (a - hardcoded string, not a true read of the placement pass's own - convergence -- {!place_transfers} itself had already reached a fixed - point) and double-counted by {!Validate}'s own "duplicated" check - (sighted once in [omitted] here under that wrong label, and correctly - again in [commemorations] at its target) -- found by this kernel's own - exhaustive property sweep once a rite (Rite_ef, RG 80) first produced - this shape, not guessed at in advance. Kept rite-agnostic: nothing here - reads anything EF-specific, only {!Precedence.resolution}'s own - [observed]/[commemorations] fields, the same two channels - {!Liturgical_day.t} already promises never to lose. *) + candidate as [Transfer] -- {!Precedence.resolve} holds such a + candidate out of the band contest entirely, so it can never win a day + outright, only ever be commemorated on one; a rite is nonetheless free + to [Transfer] one to a named date, e.g. RG 80's Major Litanies, RG + 81's own "nihil fit in Officio" making [observed] structurally + impossible for it anywhere). The FIRST version of this function + stopped here, at (1) and (2) -- {!Liturgical_day.t}'s own doc comment + promises [observed] and [commemorations] are never silently lost, and + this read as "the same two channels", which is where the "two, not + three" miscount came from: that promise is about {!Liturgical_day.t}'s + OWN five fields, not an exhaustive account of every way + {!Precedence.resolve} can dispose of a candidate at one date. + + (3) It reaches the target and is disposed there as [Omit] by + {!Precedence.rules.disposition} itself (RG 33's vigil omission, RG + 16(a)'s Sunday suppression, RG 26's IV-class-feria omission, the + Lord-vs-Lord exclusion, ...) OR is offered to + {!Precedence.rules.admit} there but capped out by an admission-count + limit RG 111 itself imposes (e.g. a Class1 day admits only ONE + privileged commemoration; a second one due the same day, or the SAME + Litanies candidate no longer privileged, loses that slot) -- both + land in the target's own [omitted], with their own accurate, + already-diagnostic reason ("omitted: yielded to a higher day" / + "omitted: admission limit reached"), and both are just as genuinely + "delivered and considered" as (1)/(2), not stuck anywhere. Missing + this third channel reproduces the EXACT ORIGINAL BUG this function was + written to fix, one level further out: a candidate settled (via (3)) + at its target was still reported [unresolved] at its ORIGIN, under + the same wrong, hardcoded [unconverged_reason] label, and + double-counted by {!Validate}'s own "duplicated" check the same way. + + Found, not merely reasoned to: fix round 1's review reproduced it two + ways. Constructively, a second privileged [Commemoration_only] entry + placed on the Litanies' own transfer target (Easter+2) that sorts + ahead of it forces the Litanies to lose {!Precedence.rules.admit}'s + own Class1 "one privileged commemoration only" cap there. And, + already present in this branch's own mutation-testing record without + being run to ground at the time: mutation 3 (this task's own report, + `privilege_of`'s RG 109(f) branch forced to [Ordinary]) makes the + transferred Litanies itself lose that SAME Class1 cap at its OWN + target -- no second candidate needed, since an [Ordinary] commemoration + has no standing at all against a [Class1] day's privileged-only + admission rule ({!Precedence_ef.admit}'s own Class1 case). That + mutation's 9th failure, the exhaustive `Validate` property sweep on a + random year, was this bug; the report noted the failure and declined + to diagnose it before reverting the mutation, which is precisely how + it survived one fix round. + + Unreachable on shipped EF data today (every `Commemoration_only` + candidate this rite's own real data carries is at most [Class3] + except the Litanies themselves, and no second privileged + commemoration can ever fall on Easter+2 -- {!Precedence_ef + .privilege_of}'s own (a)-(e) categories are all Sunday/Ember/Advent- + Lent-Passiontide/Nativity-octave shaped, none of which Easter+2 is or + can be), which is exactly why it survived this far: nothing in the + shipped calendar has ever exercised it. Fails LOUDLY (a wrong, + misleading label) rather than silently, and is fixed here rather than + left as a documented residual, since the fix is a one-line + generalisation of the same check, not new machinery. Kept + rite-agnostic: nothing here reads anything EF-specific, only + {!Precedence.resolution}'s own [observed]/[commemorations]/[omitted] + fields -- THREE of {!Precedence.resolution}'s four fields (the fourth, + [deferred], denotes a candidate that has NOT yet settled at this date, + by definition, so it is correctly never consulted here). *) let settled_at target slug = let _, _, target_resolution = resolve_with_injected rite idx injected target in let matches (c : 'r Precedence.candidate) = @@ -339,6 +392,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date) in matches target_resolution.Precedence.observed || List.exists (fun (c, _) -> matches c) target_resolution.Precedence.commemorations + || List.exists (fun (c, _) -> matches c) target_resolution.Precedence.omitted in let unresolved c = let slug = c.Precedence.cel.Celebration.slug in diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 93cd0bb..22f5f04 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -426,6 +426,90 @@ let test_transfer_guard_records_failure_instead_of_looping () = in Alcotest.(check bool) "non-convergence is recorded rather than silently dropped or hung" true stuck +(* ef-major-litanies task, fix round 1 (F1) -- a regression test for a + THIRD settlement channel `build_day`'s own `settled_at` had to learn to + recognise: a transferred candidate that reaches its target and is then + CAPPED OUT there by the target day's own RG 111 admission-count limit + (not `observed`, not surviving as a `commemoration` -- the two channels + the first version of this fix checked), rather than settling cleanly. + Missing it reproduces the exact original bug ONE LEVEL FURTHER OUT: the + origin wrongly reports the transferred candidate as + `unconverged_reason`, even though placement genuinely converged. + + Unreachable on real EF data ALONE (the Major Litanies, RG 80, are the + only privileged `Commemoration_only` candidate real data carries, and + no second one can ever coincide with Easter+2) -- reproduced here the + same way the fix-round review did: one synthetic privileged + `Commemoration_only` candidate, added directly to the REAL EF layer + (not a hand-built synthetic rite -- this bug is about the real Litanies + candidate's own real transfer, so the real rite is the honest fixture), + on the real Litanies' own real 2011 transfer target (26 April -- Easter + 2011 = 24 April, so 25 April is Easter Monday, RG 80's second trigger, + landing on Easter+2 = 26 April), with a slug ("aaa-probe") sorting + ahead of "major-litanies" so it wins {!Rite_ef.Precedence_ef.admit}'s + Class1 "one privileged commemoration only" selection there, capping the + Litanies out. *) +let real_ef_layer_for_transfer_probes = + match Colitur_kernel.Layer.load Rite_ef.Vocab_ef.rank_of_sexp "../data/ef/sanctoral.sexp" with + | Error e -> failwith ("../data/ef/sanctoral.sexp: " ^ e) + | Ok layer -> ( + match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp "../data/ef/adjustments.sexp" with + | Error e -> failwith ("../data/ef/adjustments.sexp: " ^ e) + | Ok overlay -> + let layer, diagnostics = Colitur_kernel.Overlay.apply layer overlay in + if diagnostics <> [] then failwith "unexpected overlay diagnostics loading the real EF layer"; + layer) + +let test_transferred_commemoration_only_capped_out_at_target_settles_cleanly () = + let probe_date = + match Colitur_kernel.Date_spec.fixed ~month:4 ~day:26 with Ok d -> d | Error e -> failwith e + in + let probe = + { Colitur_kernel.Layer.date = probe_date; + cel = + Cel.make ~slug:(Sl.of_string_exn "aaa-probe") ~rank:Rite_ef.Vocab_ef.Class1 + ~status:Cel.Commemoration_only ~colour:Colitur_kernel.Colour.White + ~subject:Colitur_kernel.Subject.Saint ~layer:"synthetic-probe" () + } + in + let augmented_layer = Colitur_kernel.Layer.set real_ef_layer_for_transfer_probes probe in + (* Liturgical year "2010" (Advent 2010 -- eve of Advent 2011) covers both + 25 and 26 April 2011. *) + let year = C.year Rite_ef.context augmented_layer 2010 in + let find_date target = + match Array.to_list year |> List.find_opt (fun d -> D.compare d.LD.date target = 0) with + | Some d -> d + | None -> failwith "date not found in resolved year" + in + let origin = find_date (mk 2011 4 25) and target = find_date (mk 2011 4 26) in + let slug_s (c : Rite_ef.Vocab_ef.rank Cel.t) = Sl.to_string c.Cel.slug in + Alcotest.(check string) "2011-04-25 is Easter Monday, RG80's second trigger" "ef-easter-1-monday" + (slug_s origin.LD.observed); + let omitted_s d = List.map (fun (c, r) -> (slug_s c, r)) d.LD.omitted in + Alcotest.(check (list (pair string string))) "origin: major-litanies is NOT in [omitted] at all -- it \ + genuinely, cleanly transferred away, no false 'did not converge'" [] + (List.filter (fun (s, _) -> s = "major-litanies") (omitted_s origin)); + let contains_substring s ~needle = + let ls = String.length s and ln = String.length needle in + let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in + ln = 0 || at 0 + in + Alcotest.(check (list (pair string string))) "origin: no [omitted] entry anywhere claims non-convergence \ + (the exact regression this test guards against, stated directly rather than only via the slug check \ + above)" [] + (List.filter (fun (_, r) -> contains_substring r ~needle:"did not converge") (omitted_s origin)); + Alcotest.(check (list string)) "origin: [transferred_out] still correctly names major-litanies -> target" + [ "major-litanies->2011-04-26" ] + (List.map + (fun (c, d) -> Printf.sprintf "%s->%s" (slug_s c) (D.to_iso8601 d)) + origin.LD.transferred_out); + Alcotest.(check (list string)) "target: aaa-probe wins the Class1 privileged slot (sorts ahead of \ + major-litanies at the tied [unclassified] band)" [ "aaa-probe" ] + (List.map (fun (c, _) -> slug_s c) target.LD.commemorations); + Alcotest.(check bool) "target: major-litanies is capped out into [omitted] there, with the REAL \ + admission-limit reason, not lost and not mislabelled" true + (List.mem ("major-litanies", "omitted: admission limit reached") (List.map (fun (c,r) -> (slug_s c, r)) target.LD.omitted)) + let suite = ( "Calendar", [ Alcotest.test_case "year covers every day" `Quick test_year_covers_every_day; @@ -447,4 +531,8 @@ let suite = Alcotest.test_case "transfer target outside year is recorded not lost" `Quick test_transfer_target_outside_year_is_recorded_not_lost; Alcotest.test_case "transfer guard records failure instead of looping" `Quick - test_transfer_guard_records_failure_instead_of_looping ] ) + test_transfer_guard_records_failure_instead_of_looping; + Alcotest.test_case + "ef-major-litanies fix round 1 (F1): a transferred candidate capped out at its own target settles \ + cleanly, no false 'did not converge'" + `Quick test_transferred_commemoration_only_capped_out_at_target_settles_cleanly ] ) -- cgit v1.3