summaryrefslogtreecommitdiff
path: root/test/test_precedence_ef.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
commit7f183c847f8b67c88ad6ea3bf2d635c5c0534651 (patch)
tree0c4a7adb94e5374b61d979c2b4c54637da677cda /test/test_precedence_ef.ml
parent4624441d0bc9c1f28dea54606ab7999041d4b323 (diff)
downloadcolitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.tar.gz
colitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.zip
cli: colitur day <year> dumps resolved liturgical days
Assembles Rite_ef.context (lib/rites/rite_ef/rite_ef.ml[i]): temporal, anchors and vocab from Temporal_ef; rules from Precedence_ef's band, disposition and admit; season_runs = Vocab_ef.seasons; transfer_target newly implemented here. transfer_target (RG 96): the next following day that is not I or II class, with the Annunciation's own exception (Monday after Low Sunday). Terminates by a structural step bound on its internal search, independent of Calendar's own round guard, which bounds rounds across a year, not one call's walk; documented as an obligation on rite.mli's transfer_target field, which did not previously state it. Fixes the vigil-naming mismatch Task 7's review predicted: the sanctoral bootstrap names its vigils with a vigil-of-X prefix (lectio's own convention), while Precedence_ef's is_vigil only recognised the temporal cycle's own X-vigil suffix. Both are now recognised, fixing RG 91 entries 21/26 and RG 33's vigil omission for the four affected celebrations. Verified by unit test and by mutation-testing the fix (reverting it fails exactly the new rows) and against real output across several years. Suppresses data/ef/sanctoral.sexp's vigil-of-christmas via a new overlay, data/ef/adjustments.sexp: it is the same celebration as the temporal cycle's own ef-nativity-vigil, both dated 24 December. colitur day <year>: one line per civil-year day, temporal and sanctoral fully resolved through Layer, Overlay, Precedence_ef and Calendar -- the first CLI path exercising the whole Plan 3 pipeline against real data. Verified the All Souls transfer chain (Tasks 7-8-11) end to end against real output for both a Sunday year (2025, lands on 3 Nov) and a non-Sunday year (2026, observed directly on 2 Nov).
Diffstat (limited to 'test/test_precedence_ef.ml')
-rw-r--r--test/test_precedence_ef.ml130
1 files changed, 129 insertions, 1 deletions
diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml
index a95b321..084d708 100644
--- a/test/test_precedence_ef.ml
+++ b/test/test_precedence_ef.ml
@@ -222,6 +222,22 @@ let cases =
( "26 III-class vigil (non-base layer)", mk 2026 8 10,
cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-patron-vigil",
26 );
+ (* Task 11, issue (a): the sanctoral bootstrap (data/ef/sanctoral.sexp)
+ names its vigils with lectio's OWN "vigil-of-X" PREFIX convention, not
+ [PE.vigil_suffix]'s "-vigil" SUFFIX every row above uses -- exactly
+ the mismatch Task 7's review predicted. These two rows use the real
+ bootstrapped slugs verbatim (data/ef/sanctoral.sexp: 28 Jun, 9 Aug),
+ proving [band] recognises the prefix convention too: without it, both
+ would misfile at 16/24 (an ordinary feast of the same rank) instead
+ of 21/26. *)
+ ( "21 II-class vigil via the sanctoral data's own \"vigil-of-X\" prefix",
+ mk 2026 6 28,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-sts-peter-paul",
+ 21 );
+ ( "26 III-class vigil via the sanctoral data's own \"vigil-of-X\" prefix",
+ mk 2026 8 9,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "vigil-of-st-lawrence",
+ 26 );
(* Entry 27 -- register line 352: an otherwise-unoccupied IV-class
Saturday. *)
( "27 Office of the BVM on Saturday", off 62,
@@ -370,6 +386,21 @@ let disposition_cases =
an_ordinary_sunday,
cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
"Commemorate(Ordinary)" );
+ (* Task 11, issue (a): [disposition]'s own [is_vigil] check (the RG 33
+ omission test) is a SEPARATE call site from [band]'s -- both read the
+ same private [is_vigil], but each needed its own witness, since a fix
+ to one call site could in principle miss the other. Real bootstrapped
+ slug (data/ef/sanctoral.sexp's "vigil-of-the-assumption", 14 Aug),
+ not a hand-typed one, for the same coupling-safety reason [of_temporal]
+ rows use real data elsewhere in this file. Before the fix this vigil
+ was invisible to [is_vigil] entirely, so it would have fallen through
+ to the ordinary Commemorate branch below instead of Omit -- the exact
+ failure the task brief describes. *)
+ ( "RG33 (prefix convention): a \"vigil-of-X\"-named II-class vigil loses \
+ to an ordinary Sunday -> Omit",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-the-assumption",
+ "Omit" );
(* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked
here with a loser that ALSO carries a Class1 rank and a vigil-suffixed
slug losing to a Sunday, so this row only passes if the
@@ -778,6 +809,94 @@ let test_all_souls_transfers_end_to_end () =
Alcotest.(check int) "nothing commemorated" 0 (List.length resolution.P.commemorations);
Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)
+(* Task 11: [PE.transfer_target] -- RG 96 ("the next following day that is
+ not I or II class") plus its Annunciation exception. [occupant] is a
+ synthetic callback ({!Colitur_kernel.Rite.t.transfer_target}'s own
+ [occupant] parameter), not a real [Calendar]-driven one -- the CLI's own
+ end-to-end proof (colitur day, All Souls landing on 3 Nov 2025 and the
+ Annunciation landing on 5 Apr 2027, see test/cli.t and the task report)
+ is what wires this against real data; these rows isolate the search
+ function itself. *)
+
+(* [blocked] returns Class1 (blocking) for exactly the listed dates, Class4
+ (not blocking) everywhere else -- enough to exercise [is_blocking]'s own
+ two-way test (RG 96 speaks of I OR II class; Class1 alone is enough to
+ prove the blocking side, [test_transfer_target_terminates...] below adds
+ nothing by varying it further). *)
+let occupant_blocking_on blocked_dates (d : D.t) : V.rank Cel.t =
+ let blocking = List.exists (fun bd -> D.compare bd d = 0) blocked_dates in
+ Cel.make ~slug:(S.of_string_exn "occupant") ~rank:(if blocking then V.Class1 else V.Class4)
+ ~colour:Col.Green ~layer:"synthetic" ()
+
+let occupant_always_blocking (_ : D.t) : V.rank Cel.t =
+ Cel.make ~slug:(S.of_string_exn "occupant") ~rank:V.Class1 ~colour:Col.Green ~layer:"synthetic" ()
+
+(* General RG 96 search: two consecutive blocked days past [origin], proving
+ the search walks past MORE than one ineligible day rather than only
+ trying [origin + 1] and stopping (the same shape Calendar's own
+ synthetic fixture pins for the abstraction -- this pins it for the real
+ EF search function). *)
+let test_transfer_target_general_multi_step_search () =
+ let origin = mk 2026 1 10 in
+ let occupant = occupant_blocking_on [ mk 2026 1 11; mk 2026 1 12 ] in
+ let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-some-i-class-feast" in
+ let target = PE.transfer_target c origin occupant in
+ Alcotest.(check string) "lands on the first day past the blocked run"
+ "2026-01-13" (D.to_iso8601 target)
+
+(* RG 96's Annunciation exception: starts the search at the Monday after Low
+ Sunday, NOT [origin + 1] -- occupant is unconditionally free, so a
+ general-path implementation would return [origin + 1] (26 March), a date
+ this test explicitly rules out as well as pinning the real expected one,
+ so the assertion genuinely discriminates the two starting points rather
+ than merely checking "some date after origin". *)
+let test_transfer_target_annunciation_starts_at_monday_after_low_sunday () =
+ let origin = mk 2026 3 25 in
+ let occupant = occupant_blocking_on [] in
+ let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in
+ let target = PE.transfer_target c origin occupant in
+ let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in
+ Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8)"
+ (D.to_iso8601 monday_after_low_sunday) (D.to_iso8601 target);
+ Alcotest.(check bool) "NOT the general path's origin + 1 (discriminates the branch)" true
+ (D.compare target (D.add_days origin 1) <> 0)
+
+(* RG 96's own qualifier on the exception -- "searching onward from there
+ only if that day is itself blocked" (rite.mli) -- is [search_from]'s
+ ORDINARY behaviour, not a second mechanism: block the Monday after Low
+ Sunday itself and confirm the search continues exactly one more day. *)
+let test_transfer_target_annunciation_searches_onward_if_blocked () =
+ let origin = mk 2026 3 25 in
+ let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in
+ let occupant = occupant_blocking_on [ monday_after_low_sunday ] in
+ let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in
+ let target = PE.transfer_target c origin occupant in
+ Alcotest.(check string) "searches onward one more day when that Monday is itself blocked"
+ (D.to_iso8601 (D.add_days monday_after_low_sunday 1)) (D.to_iso8601 target)
+
+(* rite.mli's own obligations on [transfer_target] (Task 11 brief): the call
+ must TERMINATE and its result must be STRICTLY AFTER [origin], even for a
+ rite/data shape this function cannot have anticipated -- an occupant that
+ reports every single day as blocking, forever. Calendar's own round guard
+ (max_transfer_rounds) does not cover this: it bounds ROUNDS across a
+ whole year, not the internal walk one call to this function makes (see
+ precedence_ef.ml's own comment on [search_from] and [max_search_days]).
+ Deliberately NOT pinning the exact returned date against the private
+ [max_search_days] constant -- that would coalesce a behavioural contract
+ (terminates, makes forward progress) with an internal tuning value this
+ function is free to change; a generous, test-owned ceiling (1000 days,
+ comfortably past any realistic bound) is enough to prove termination is
+ genuine and not merely "didn't hang during this particular run". *)
+let test_transfer_target_terminates_under_pathological_occupant () =
+ let origin = mk 2026 1 1 in
+ let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-pathological-case" in
+ let target = PE.transfer_target c origin occupant_always_blocking in
+ Alcotest.(check bool) "strictly after origin" true (D.compare target origin > 0);
+ Alcotest.(check bool)
+ "terminates within a generous bound (proves the internal search is bounded, not merely lucky)"
+ true
+ (D.compare target (D.add_days origin 1000) <= 0)
+
let suite =
( "Precedence_ef",
List.map
@@ -819,4 +938,13 @@ let suite =
test_privileged_lent_feria_admitted_end_to_end;
Alcotest.test_case
"II-class Sunday override: RG109(b) witness admitted over an ordinary saint, end-to-end"
- `Quick test_ii_class_sunday_privileged_witness_admitted_end_to_end ] )
+ `Quick test_ii_class_sunday_privileged_witness_admitted_end_to_end;
+ Alcotest.test_case "transfer_target: general RG96 search walks past more than one blocked day"
+ `Quick test_transfer_target_general_multi_step_search;
+ Alcotest.test_case "transfer_target: Annunciation exception starts at Monday after Low Sunday"
+ `Quick test_transfer_target_annunciation_starts_at_monday_after_low_sunday;
+ Alcotest.test_case
+ "transfer_target: Annunciation exception searches onward if that Monday is blocked" `Quick
+ test_transfer_target_annunciation_searches_onward_if_blocked;
+ Alcotest.test_case "transfer_target: terminates and stays forward under a pathological occupant"
+ `Quick test_transfer_target_terminates_under_pathological_occupant ] )