summaryrefslogtreecommitdiff
path: root/test/test_calendar.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 21:19:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 21:19:12 +0200
commit2af482c7293d821f10c3bbc96b7913d658c5839c (patch)
tree608eaf6bcc46d9dbda0a6dd3a830a5967b201bfa /test/test_calendar.ml
parenta3afb19dc6390c9e33ed5fa0ecbd6e5895e60e8d (diff)
parent72b19098905cd609d87783f6e68311de4f1b61a0 (diff)
downloadcolitur-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/test_calendar.ml')
-rw-r--r--test/test_calendar.ml90
1 files changed, 89 insertions, 1 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 ] )