diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 21:19:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 21:19:12 +0200 |
| commit | 2af482c7293d821f10c3bbc96b7913d658c5839c (patch) | |
| tree | 608eaf6bcc46d9dbda0a6dd3a830a5967b201bfa /test | |
| parent | a3afb19dc6390c9e33ed5fa0ecbd6e5895e60e8d (diff) | |
| parent | 72b19098905cd609d87783f6e68311de4f1b61a0 (diff) | |
| download | colitur-2af482c7293d821f10c3bbc96b7913d658c5839c.tar.gz colitur-2af482c7293d821f10c3bbc96b7913d658c5839c.zip | |
Merge branch 'ef-major-litanies': the Major Litanies, and a kernel bug
Builds the Major Litanies (RG 80, 81, 109(f)) -- an item deferred three
times, each time on a reason that did not survive contact. Two recorded
blockers were false (a kernel signature said to be missing already
carried what was needed; Easter Monday, said to be unmarkable, occurs
exactly 8417 times in 8417 years) and a displacement estimate of ~97.7%
of years measured at 4 years in 46.
8611 days change in four shapes, zero unclassified: the commemoration
appearing on ordinary 25 Aprils, displacing St Mark's own on the four
II-class Sundays RG 111(b) governs, and relocating to the following
Tuesday in the 194 years RG 80's transfer clause fires. No day outside
25/26/27 April differs at all, and observed, rank, colour, season, week
and transferred_in are byte-identical across all 3074246 days.
The entity is a Commemoration_only sanctoral entry, which honours RG 81
structurally rather than by special case: Precedence.resolve partitions
such candidates out of the band contest, so 'nihil fit in Officio' holds
by construction, and being sanctoral-origin it can never claim RG 113's
de-Tempore first place.
And it surfaced a latent kernel bug. Calendar.build_day decided whether
a transferred candidate had settled by testing only whether it became
that day's observed celebration -- correct for every prior use, a losing
feast that wins its unblocked target, and wrong for a Commemoration_only
candidate, which can never be observed by the same status that lets it
transfer. The first fix accepted two settlement channels and the review
found a third: a candidate capped out by admit at its target. That third
case was already visible inside the task's own mutation proof, as an
undiagnosed ninth failure it had set aside.
The fix is a provable no-op in isolation: a3afb19 plus the new
calendar.ml alone, swept over every day of the domain, is byte-identical
to base.
Diffstat (limited to 'test')
| -rw-r--r-- | test/test_calendar.ml | 90 | ||||
| -rw-r--r-- | test/test_golden.ml | 138 | ||||
| -rw-r--r-- | test/test_oracle.ml | 125 | ||||
| -rw-r--r-- | test/test_precedence_ef.ml | 124 | ||||
| -rw-r--r-- | test/test_rite_ef.ml | 53 |
5 files changed, 497 insertions, 33 deletions
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 ] ) 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_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 (* ---------------------------------------------------------------------- *) 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 |
