aboutsummaryrefslogtreecommitdiff
path: root/test/test_precedence_ef.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
commite19adb9c7ea369cafe76e7eff50e9248a4a954d0 (patch)
tree57824e121606286ebdf8fff68439986dd3300eae /test/test_precedence_ef.ml
parent0506388da160a15ceddb0cea1a697d737103d5c4 (diff)
parent36df2bd0af9a555a09334e14b0d89118be1dbbc0 (diff)
downloadcolitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.tar.gz
colitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.zip
Merge branch 'ef-plan3': Plan 3, the EF resolution engine
Adds the rite-parameterised Precedence resolver, Liturgical_day, Rite, and Calendar (year as the primitive, because transfers need whole-year knowledge), the full EF precedence ruleset (RG 91's 28-entry table, occurrence RG 92-95, commemorations RG 108-111, transfers RG 96-98), 322 bootstrapped sanctoral entries, colitur day <year>, and validation layers 3-5. Layer 3 diffs 16801 days against lectio; layer 4 diffs 730 days against missalemeum; layer 5 pins ~30 dates on the known-tricky years. Both comparison layers carry cited allow-lists that name the governing RG paragraph and which engine is right. The oracle layer earned its place immediately: Holy Thursday was violet in colitur and lectio alike, because colitur's data was bootstrapped from lectio and both carried the same error. Only an independent source could see it. RG 128(b) and RG 122 name it white.
Diffstat (limited to 'test/test_precedence_ef.ml')
-rw-r--r--test/test_precedence_ef.ml1183
1 files changed, 1183 insertions, 0 deletions
diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml
new file mode 100644
index 0000000..a0e1db1
--- /dev/null
+++ b/test/test_precedence_ef.ml
@@ -0,0 +1,1183 @@
+(* RG 91's Table of Precedence, transcribed by Rite_ef.Precedence_ef.band.
+ Table-driven, one row (hence one Alcotest.test_case) per RG 91 entry, so a
+ misplaced or missing entry names itself in the failure output instead of
+ failing anonymously (docs/research/rules-register.md §4). Each row's date
+ is checked against the register to make sure it is not ALSO an instance of
+ some other entry at the same band (the vacuous-test trap this project has
+ caught before -- see the Advent-Ember-day note on entry 18 below). *)
+
+module P = Colitur_kernel.Precedence
+module Cel = Colitur_kernel.Celebration
+module S = Colitur_kernel.Slug
+module Col = Colitur_kernel.Colour
+module D = Colitur_kernel.Date
+module Sub = Colitur_kernel.Subject
+module Comp = Colitur_kernel.Computus
+module T = Rite_ef.Temporal_ef
+module V = Rite_ef.Vocab_ef
+module PE = Rite_ef.Precedence_ef
+
+let mk y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -> failwith e
+
+(* [T.season] is the same function Calendar itself would use to build a
+ context, so a row's [season]/[weekday] are exactly what the real engine
+ would compute for that date, not a hand-picked value that might not
+ actually occur together with it. *)
+let ctx date = { P.date; season = T.season date; weekday = D.weekday date }
+
+let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(status = Cel.Feast) ?(subject = Sub.Temporal)
+ ?(layer = "temporal") slug =
+ { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~status ~colour:Col.White ~subject ~layer ();
+ origin }
+
+(* A candidate built from [Temporal_ef.temporal]'s own real output, not a
+ hand-typed slug -- review finding 3: [band]'s Ember/vigil detection reads
+ temporal_ef.ml's slug conventions, and a row that also hand-types the same
+ literal proves nothing if that convention ever drifts (both sides would
+ drift together, silently). Rows built with this instead fail loudly on
+ such a drift, because they source the slug from the same place [band]
+ itself is implicitly trusting. *)
+let of_temporal date =
+ let day = T.temporal date in
+ { P.cel = day.Colitur_kernel.Temporal.office; origin = P.Temporal }
+
+(* Every Easter-relative date below is anchored to this single computed
+ Easter rather than a hand-typed calendar date, so an arithmetic slip in a
+ test date cannot silently pass by accident. *)
+let easter = Comp.gregorian_easter 2026
+let off n = D.add_days easter n
+
+(* (description, date, candidate, expected RG 91 entry). *)
+let cases =
+ [ (* Entry 1 -- RG 91 entry 1 (§4): Nativity, Easter Sunday, Pentecost Sunday. *)
+ ("1 Nativity", mk 2026 12 25, cand "ef-nativity", 1);
+ ("1 Easter Sunday", off 0, cand "ef-easter-sunday", 1);
+ ("1 Pentecost Sunday", off 49, cand "ef-pentecost", 1);
+ (* Entry 2 -- RG 91 entry 2 (§4): Sacred Triduum. Thu-Sat of Holy Week,
+ NOT entry 7 (which stops at Wednesday -- see entry 7 below). *)
+ ("2 Holy Thursday", off (-3), cand "ef-holy-thursday", 2);
+ ("2 Good Friday", off (-2), cand "ef-good-friday", 2);
+ ("2 Holy Saturday", off (-1), cand "ef-holy-saturday", 2);
+ (* Entry 3 -- RG 91 entry 3 (§4). *)
+ ("3 Epiphany", mk 2026 1 6, cand "ef-epiphany", 3);
+ ("3 Ascension", off 39, cand "ef-ascension", 3);
+ ("3 Trinity", off 56, cand "ef-trinity", 3);
+ ("3 Corpus Christi", off 60, cand "ef-corpus-christi", 3);
+ ("3 Sacred Heart", off 68, cand "ef-sacred-heart", 3);
+ ("3 Christ the King", T.christ_the_king 2026, cand "ef-christ-the-king", 3);
+ (* Entry 4 -- RG 91 entry 4 (§4). Sanctoral-origin: neither feast is part
+ of temporal_ef's movable cycle. *)
+ ( "4 Immaculate Conception", mk 2026 12 8,
+ cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer
+ "ef-immaculate-conception",
+ 4 );
+ ("4 Assumption", mk 2026 8 15, cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer "ef-assumption", 4);
+ (* Entry 5 -- RG 91 entry 5 (§4). *)
+ ("5 Nativity Vigil", mk 2026 12 24, cand "ef-nativity-vigil", 5);
+ ("5 Octave day (Circumcision)", mk 2026 1 1, cand "ef-circumcision", 5);
+ (* Entry 6 -- RG 91 entry 6 (§4). *)
+ ("6 Advent Sunday", T.advent_start 2026, cand "ef-advent-sunday-1", 6);
+ ("6 Lent Sunday", off (-42), cand "ef-lent-sunday-1", 6);
+ ("6 Passion Sunday (I Passiontide)", off (-14), cand "ef-passion-sunday", 6);
+ ("6 Palm Sunday (II Passiontide)", off (-7), cand "ef-palm-sunday", 6);
+ ("6 Low Sunday", off 7, cand "ef-low-sunday", 6);
+ (* Entry 7 -- RG 91 entry 7 (§4): Ash Wednesday and Mon/Tue/Wed of Holy
+ Week ONLY -- Thu-Sat are entry 2 above, not this entry. *)
+ ("7 Ash Wednesday", off (-46), cand "ef-ash-wednesday", 7);
+ ("7 Monday of Holy Week", off (-6), cand "ef-holy-monday", 7);
+ ("7 Tuesday of Holy Week", off (-5), cand "ef-holy-tuesday", 7);
+ ("7 Wednesday of Holy Week", off (-4), cand "ef-holy-wednesday", 7);
+ (* Entry 8 -- RG 91 entry 8 (§4). 2 Nov 2026 is a Monday (verified
+ independently below the table), so this row is the plain case. The
+ register's own qualifying case -- "yields to an occurring Sunday" --
+ gets its own row and its own end-to-end test after this table (2 Nov
+ 2025 is a real Sunday). *)
+ ("8 All Souls (non-Sunday)", mk 2026 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 8);
+ (* Entry 8's qualifier: "(yields to an occurring Sunday)". 2 Nov 2025 is
+ a Sunday, so this must NOT be 8 -- it must lose to entry 15 (16 =
+ entry 15's own value + 1, the exact value precedence_ef.ml documents
+ and justifies at entry 8's branch). The end-to-end resolve-level
+ proof that the Sunday actually wins the day is
+ [test_all_souls_yields_to_sunday] below; this row pins the specific
+ integer [band] returns. *)
+ ("8 All Souls (yields to a Sunday, 2 Nov 2025)", mk 2025 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 16);
+ (* Entry 9 -- RG 91 entry 9 (§4). *)
+ ("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9);
+ (* Entry 10 -- RG 91 entry 10 (§4): both range boundaries, to guard the
+ off-by-one an inclusive Easter-offset window invites. *)
+ ("10 Easter octave, day+1", off 1, cand "ef-easter-1-mon", 10);
+ ("10 Easter octave, day+6", off 6, cand "ef-easter-1-sat", 10);
+ ("10 Pentecost octave, day+50", off 50, cand "ef-pentecost-1-mon", 10);
+ ("10 Pentecost octave, day+55", off 55, cand "ef-pentecost-1-sat", 10);
+ (* Entry 11 -- RG 91 entry 11 (§4). *)
+ ( "11 Universal I-class feast", mk 2026 6 29,
+ cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:PE.universal_layer "ef-ss-peter-paul",
+ 11 );
+ (* Entry 12 -- RG 91 entry 12 (§4). The one non-base-layer case the brief
+ asks for explicitly: same date/rank/subject as 11, only the layer
+ differs, so this row isolates the layer test as the deciding factor. *)
+ ( "12 Proper I-class feast (non-base layer)", mk 2026 6 29,
+ cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:"diocese-warsaw" "ef-local-patron",
+ 12 );
+ (* Entry 13 -- RG 91 entry 13 (§4). *)
+ ( "13 Indult I-class feast", mk 2026 6 29,
+ cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:(PE.indult_prefix ^ "local-grant")
+ "ef-indult-feast-1",
+ 13 );
+ (* Entry 14 -- RG 91 entry 14 (§4), deliberately UNQUALIFIED (contrast
+ entry 16, which explicitly says "not of the Lord"). *)
+ ( "14 Feast of the Lord, II class", mk 2026 7 1,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
+ "ef-precious-blood",
+ 14 );
+ (* Entry 14, non-base layer: unlike 11-13/16/19/20/23/24, entry 14 draws
+ no universal/proper/indult line at all, so this must STILL be 14, not
+ 19 -- the exact restriction review finding 2 flagged and this row
+ exists to keep from silently coming back. *)
+ ( "14 Feast of the Lord, II class (non-base layer)", mk 2026 7 2,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:"diocese-warsaw"
+ "ef-local-feast-of-the-lord",
+ 14 );
+ (* Entry 15 -- RG 91 entry 15 (§4): an ordinary Sunday not named at entry 6
+ -- Septuagesima is II class (RG 11-12 names only Advent/Lent/
+ Passiontide/Easter/Low/Pentecost as I class). *)
+ ("15 II-class Sunday (Septuagesima)", off (-63), cand ~rank:V.Class2 "ef-septuagesima-sunday", 15);
+ (* Entry 16 -- RG 91 entry 16 (§4). *)
+ ( "16 Universal II-class feast, not of the Lord", mk 2026 1 20,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:PE.universal_layer
+ "ef-some-saint",
+ 16 );
+ (* Entry 17 -- RG 91 entry 17 (§4): days WITHIN the Nativity octave (26-28
+ Dec are Stephen/John/Innocents -- sanctoral, not this entry; 1 Jan is
+ entry 5's Octave DAY, not this entry either). *)
+ ("17 Nativity octave, 29 Dec", mk 2026 12 29, cand ~rank:V.Class2 "ef-nativity-octave-day-5", 17);
+ ("17 Nativity octave, 31 Dec", mk 2026 12 31, cand ~rank:V.Class2 "ef-nativity-octave-day-7", 17);
+ (* Entry 18 -- RG 91 entry 18 (§4): Advent 17-23 Dec ferias AND the
+ Ember days of Advent/Lent/September share this one entry. The second
+ row is deliberately a Lent date (season Lent, NOT Advent) to prove the
+ Ember-slug path fires on its own, not merely because it also happens
+ to fall in the Dec 17-23 window -- the exact trap the brief warns
+ about, worked the other way round: this Ember day must NOT be
+ mistaken for an ordinary entry-22 Lent feria either. *)
+ ("18 Advent 17-23 Dec feria", mk 2026 12 21, cand ~rank:V.Class2 "ef-advent-4-mon", 18);
+ (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
+ rather than a hand-typed "ef-lent-ember-wed" -- closes review finding
+ 3's coupling concern for the Ember prefixes specifically. *)
+ ("18 Lent Ember Wednesday (from Temporal_ef.temporal)", off (-39), of_temporal (off (-39)), 18);
+ (* Entry 19 -- RG 91 entry 19 (§4). *)
+ ( "19 Proper II-class feast", mk 2026 1 20,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:"diocese-warsaw"
+ "ef-local-saint-2",
+ 19 );
+ (* Entry 20 -- RG 91 entry 20 (§4). *)
+ ( "20 Indult II-class feast", mk 2026 1 20,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint
+ ~layer:(PE.indult_prefix ^ "local-grant-2") "ef-indult-feast-2",
+ 20 );
+ (* Entry 21 -- RG 91 entry 21 (§4, RG 28-34). Two rows: the Ascension
+ Vigil is the one II-class vigil temporal_ef already produces today
+ (temporal-origin); the Assumption Vigil stands in for the
+ sanctoral-origin case no task has loaded data for yet -- proving
+ [band] does not gate this entry on [origin] (see precedence_ef.ml's
+ file comment). *)
+ (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
+ rather than a hand-typed "ef-ascension-vigil" -- closes review finding
+ 3's coupling concern for [vigil_suffix]. *)
+ ("21 Ascension Vigil (from Temporal_ef.temporal)", off 38, of_temporal (off 38), 21);
+ ( "21 Assumption Vigil (sanctoral-origin)", mk 2026 8 14,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-assumption-vigil",
+ 21 );
+ (* Also review finding 3 / "worth doing": a UNIVERSAL-layer Class2 vigil
+ whose subject is the Lord must still be 21, not 14 -- pins entry 14's
+ [not is_vigil] guard even after finding 2 dropped its layer test. *)
+ ( "21 Universal II-class vigil of the Lord", mk 2026 6 23,
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
+ "ef-precious-blood-vigil",
+ 21 );
+ (* Entry 22 -- RG 91 entry 22 (§4) (corrected: ends at Palm Sunday, not
+ Passion Sunday). Both a Lent and a Passiontide feria, clear of Ash
+ Wednesday, Holy Week and the Ember days. *)
+ ("22 Lent feria", off (-41), cand ~rank:V.Class3 "ef-lent-1-mon", 22);
+ ("22 Passiontide feria", off (-12), cand ~rank:V.Class3 "ef-passiontide-1-tue", 22);
+ (* Entry 23 -- RG 91 entry 23 (§4). NOTE the table's own order here is the
+ REVERSE of 11/12 and 14/16/19/20 above: entry 23 (particular
+ calendars) is numbered BELOW entry 24 (universal), so a proper
+ III-class feast outranks a universal one -- transcribed as the
+ register states it, not "corrected" to match the other classes. *)
+ ( "23 Proper III-class feast (non-base layer)", mk 2026 6 30,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-saint-3",
+ 23 );
+ (* Entry 24 -- RG 91 entry 24 (§4). *)
+ ( "24 Universal III-class feast", mk 2026 6 30,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
+ 24 );
+ (* Entry 25 -- RG 91 entry 25 (§4). *)
+ ("25 Advent feria to 16 Dec", mk 2026 12 1, cand ~rank:V.Class3 "ef-advent-1-tue", 25);
+ (* Entry 26 -- RG 91 entry 26 (§4). *)
+ ( "26 III-class vigil", mk 2026 8 9,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
+ 26 );
+ (* Also worth doing: a NON-universal-layer Class3 vigil must still be 26,
+ not 23 -- pins entry 23's [not is_vigil] guard. *)
+ ( "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 -- RG 91 entry 27 (§4): an otherwise-unoccupied IV-class
+ Saturday. *)
+ ( "27 Office of the BVM on Saturday", off 62,
+ cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
+ 27 );
+ (* Entry 28 -- RG 91 entry 28 (§4): the unqualified IV-class catch-all. *)
+ ("28 IV-class feria", off 65, cand ~rank:V.Class4 "ef-time-after-pentecost-1-tue", 28);
+ (* Not an RG 91 row at all: a I-class candidate marked as a vigil, which
+ is not the Nativity or Pentecost (entries 5/9, the only I-class
+ vigils the table names) and so has no entry to fall into. Proves the
+ documented fallback -- not entry 11/12/13, which the [not is_vigil]
+ guard exists specifically to keep this out of. *)
+ ( "unclassified: I-class vigil outside Nativity/Pentecost", mk 2026 3 10,
+ cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-mystery-vigil",
+ PE.unclassified );
+ (* Also worth doing: a temporal-origin Class1 candidate on a date none of
+ entries 1/2/3/5/6/7/9/10 name. 15 Jul 2026 is a Wednesday, off=101
+ from Easter -- clear of every Easter-relative window this module
+ checks, and not one of the fixed dates either. Without the
+ [not is_temporal] guard on entries 11-13, this would wrongly reach 12
+ (its default layer, "temporal", is not [universal_layer] and does not
+ carry [indult_prefix], so it reads as "proper" by the layer test
+ alone -- precisely the bug the guard exists to prevent; see the
+ [not is_temporal] guard's role in the entry-25 mutation test recorded
+ in the task report). *)
+ ("unclassified: I-class temporal candidate on an unnamed date", mk 2026 7 15, cand "ef-unnamed-day", PE.unclassified);
+ (* RG 91's own vigil list (§4, "Vigils / octaves / Rogations / Sunday classes") stops at III class --
+ there is no IV-class vigil for entry 28's ferial catch-all to absorb. *)
+ ( "unclassified: IV-class candidate marked as a vigil", mk 2026 6 20,
+ cand ~rank:V.Class4 "ef-second-mystery-vigil", PE.unclassified )
+ ]
+
+(* Review finding 1's end-to-end proof: on a real Sunday landing on 2
+ November, [Precedence.resolve] -- not just [band] in isolation -- observes
+ the Sunday, not All Souls. This exercises the exact mechanism the finding
+ named ("resolve observes the lowest band, so whenever 2 November falls on
+ a Sunday, All Souls wins and the Sunday loses"), rather than only the
+ integer [band] returns for the standalone row above. [disposition] and
+ [admit] are stubs -- only [observed] is under test here. *)
+let test_all_souls_yields_to_sunday () =
+ let date = mk 2025 11 2 in
+ let day_ctx = ctx date in
+ let sunday =
+ { P.cel =
+ Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
+ ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
+ origin = P.Temporal }
+ in
+ let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
+ let rules =
+ { P.band = (fun c cd -> PE.band c cd);
+ disposition = (fun ~winner:_ ~loser:_ -> P.Omit);
+ admit = (fun ~observed:_ cs -> cs) }
+ in
+ let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
+ Alcotest.(check string) "the Sunday is observed, not All Souls"
+ "ef-time-after-pentecost-sunday-x"
+ (S.to_string resolution.P.observed.P.cel.Cel.slug)
+
+(* Task 8: [disposition] -- what happens to the day's LOSING candidate (RG
+ 92-95, 33, 94; §4). Table-driven like [band]'s
+ own [cases] above, one row per rule, each checked against a description of
+ which register clause it pins. [disposition] takes no context (see
+ precedence.mli's [rules.disposition]), so "is the winner a Sunday" is read
+ off the winner's own slug the same way [band] itself reads "is this a
+ vigil" off the loser's -- see precedence_ef.ml's [sunday_marker]. *)
+
+let string_of_disposition = function
+ | P.Omit -> "Omit"
+ | P.Commemorate P.Privileged -> "Commemorate(Privileged)"
+ | P.Commemorate P.Ordinary -> "Commemorate(Ordinary)"
+ | P.Transfer -> "Transfer"
+ | P.Repose -> "Repose"
+
+(* A II-class ordinary Sunday, built the same way [test_all_souls_yields_to_sunday]
+ builds its Sunday -- a hand-typed slug matching temporal_ef.ml's own
+ "ef-<season>-sunday-<n>" convention, since [disposition] only ever reads
+ this string, never the real computed date. *)
+let an_ordinary_sunday =
+ cand ~rank:V.Class2 "ef-time-after-pentecost-sunday-11"
+
+let disposition_cases =
+ [ (* RG 95 -- §4, "Occurrence": only I-class feasts transfer; a
+ II-class feast loses to a I-class day and is COMMEMORATED, not
+ transferred. Paired with the next row (a I-class loser, same shape of
+ winner) so the discriminating factor is provably the LOSER's own
+ rank, not the winner's -- the brief's explicit "one without the other
+ proves nothing" pairing. *)
+ ( "RG95 II-class feast loses to I-class day -> Commemorate",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint",
+ "Commemorate(Ordinary)" );
+ ( "RG95 I-class feast loses to a higher I-class day -> Transfer",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-local-i-class-feast",
+ "Transfer" );
+ (* Fix round 1 (post-Task-9 review): RG 95 (§4, "Occurrence" and "Transfer/translation")
+ restricts the right of translation to I-class FEASTS -- RG 91's own
+ table lists Sundays as a separate row (entry 6)
+ from feasts (entries 11-13) -- so an impeded I-class
+ Sunday must NOT transfer, unlike the plain I-class feast row above:
+ same [Class1] rank, same kind of winner, the ONLY difference is that
+ this loser's slug carries [PE.sunday_marker]. RG 109(a) (§4)
+ confirms this from the other direction: "of a Sunday" is a
+ privileged commemoration category, which presupposes an impeded
+ Sunday stays put rather than moving to another day the way a feast
+ does. Sourced from [Temporal_ef.temporal]'s own real output (Advent I
+ Sunday 2026, Class1, "ef-advent-sunday-1"), the same coupling-safety
+ reason [of_temporal]'s other callers use it -- this is also a
+ realistic shape: 8 December falls on an Advent Sunday in 2024, 2030
+ and 2041 (Immaculate Conception, RG 91 entry 4, outranking entry 6),
+ and 24 December falls on Advent IV in 2023, 2028, 2034 and 2045 (the
+ Nativity Vigil, also entry 5 outranking entry 6). *)
+ ( "RG95/RG109(a): an impeded I-class SUNDAY does NOT transfer -- it is \
+ Commemorated and Privileged",
+ cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-immaculate-conception",
+ of_temporal (T.advent_start 2026),
+ "Commemorate(Privileged)" );
+ (* RG 33 -- §4, "Vigils / octaves / Rogations / Sunday classes": a II- or
+ III-class vigil impeded by any Sunday or a I-class feast is entirely
+ OMITTED, not commemorated. The
+ vigil is sourced from [Temporal_ef.temporal]'s own real output (as
+ [of_temporal]'s existing callers above do), not a hand-typed
+ "ef-ascension-vigil", so a drift in temporal_ef's vigil-slug
+ convention cannot silently defeat this row the way a duplicated
+ literal could. This is the row the brief singles out as most likely
+ to pass vacuously if the fallback below happened to already be
+ [Omit] -- it is not: the fallback is [Commemorate] (see the next two
+ rows), so this genuinely exercises RG 33's own branch. *)
+ ( "RG33 II-class vigil loses to an ordinary Sunday -> Omit",
+ an_ordinary_sunday,
+ of_temporal (off 38) (* Ascension Vigil *),
+ "Omit" );
+ ( "RG33 II-class vigil loses to a I-class feast (non-Sunday) -> Omit",
+ cand "ef-immaculate-conception",
+ of_temporal (off 38),
+ "Omit" );
+ (* RG 33's own boundary, proved from both sides so the rule is shown to
+ gate on the WINNER too, not "any vigil is always omitted": winner is
+ neither a Sunday nor I class, so RG 33's omission does not fire; a
+ vigil, per RG 31's own text ("si impediuntur, commemorantur"), is
+ ALWAYS commemorated once RG 33 does not omit it outright, regardless
+ of RG 109's closed list.
+
+ CORRECTED comment (final fix wave, item 2b): this row's own comment
+ used to claim it "proves [the RG26 Class4-Omit branch's] own [not
+ (is_vigil ...)] guard" -- that claim does NOT survive under the
+ current [rank = Class4] gate (it may have been true under an earlier
+ predicate). The vigil sourced here ({!of_temporal}, the real
+ Ascension Vigil) is [Class2], not [Class4], so the RG26 branch's own
+ [rank = Class4] test already excludes it on rank alone, with or
+ without the [not (is_vigil ...)] guard -- verified: deleting that
+ guard entirely leaves 257/257 green. This row still genuinely proves
+ RG 31's own "if impeded, commemorated" vigil mandate (a vigil that
+ RG 33 does not omit outright is commemorated, not omitted by some
+ OTHER mechanism), which is real and worth keeping; it just does not
+ discriminate the [not (is_vigil ...)] guard specifically. *)
+ ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \
+ II-class day -> Commemorate, NOT Omit (RG31's own vigil mandate)",
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast",
+ of_temporal (off 38),
+ "Commemorate(Ordinary)" );
+ (* The GENUINE witness for the RG26 branch's [not (is_vigil ...)] guard
+ (final fix wave, item 2b): no REAL vigil this engine constructs can
+ exercise it, since RG 91 has no IV-class vigil at all (this file's own
+ entry-27/28 comments) -- every real vigil is Class2 or Class3, already
+ excluded by [rank = Class4] alone, the exact shape the row above is.
+ This row is therefore deliberately SYNTHETIC: a temporal-origin loser,
+ [Class4], whose slug ALSO carries {!PE.vigil_suffix} -- a shape RG 91
+ itself does not describe, but one {!Precedence.resolve} can still be
+ asked to construct (see precedence_ef.ml's own comment on this guard).
+ With the guard present, [is_vigil] excludes it from the RG26 branch,
+ so it falls through to the ordinary [Commemorate] catch-all (RG 31's
+ "if impeded, commemorated" mandate, the same rule the row above
+ exercises for a realistic Class2 vigil). Deleting [not (is_vigil ...)]
+ from that branch turns this row -- and only this row -- red: the
+ branch would then fire on [rank = Class4] alone and return [Omit]. *)
+ ( "SYNTHETIC (final fix wave, item 2b): a temporal-origin Class4 loser \
+ ALSO vigil-shaped is Commemorate (RG31's vigil mandate via the \
+ not-is_vigil guard), not Omit",
+ an_ordinary_sunday,
+ cand ~rank:V.Class4 "ef-synthetic-class4-vigil",
+ "Commemorate(Ordinary)" );
+ (* CORRECTED 2026-08-12 (Task 16, primary-source-verified): the register
+ previously (mis-)transcribed RG 33 as covering only I/II-class
+ vigils, so this row's own title used to read "outside RG33's
+ I/II-class scope" and expect Commemorate. The primary text ("Vigilia
+ II AUT III classis penitus omittitur...") covers II OR III class --
+ St Lawrence's vigil (III class, RG 32) falling on ANY Sunday ("in
+ dominica quavis") is entirely omitted, exactly like a II-class vigil.
+ See {!PE.is_omissible_vigil}'s own comment for the full primary text
+ and the register correction. *)
+ ( "RG33 (corrected): a III-class vigil loses to a Sunday -> Omit, not \
+ Commemorate",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
+ "Omit" );
+ (* 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
+ Commemoration_only check is checked BEFORE both RG 33's omission and
+ RG 95's transfer, not after. Its expected privilege is [Privileged],
+ not [Ordinary]: this loser's [rank] is [Class1] (the default [cand]
+ leaves unless overridden, deliberately kept here for the
+ branch-order proof above), and RG 109(b) (§4, "of
+ a I-class day") makes any [Class1] commemoration privileged
+ regardless of how it reached [Commemorate] -- Task 8's placeholder
+ [interim_privilege] used to hide this (always [Ordinary]); Task 9's
+ real [privilege_of] does not. This row is also this suite's ONLY
+ witness for RG 109(b): a plain [Feast]-status [Class1] loser never
+ reaches [Commemorate] at all (RG 95 sends it to [Transfer] instead,
+ see the row above), so [Commemoration_only] is the only shape that
+ can exercise it here (see the task report). *)
+ ( "Commemoration_only loser is always Commemorate, even if I-class and \
+ vigil-shaped, even losing to a Sunday -- and RG109(b) makes it \
+ privileged",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
+ "ef-suppressed-vigil",
+ "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
+ 111(c)/(d) admit an "ordinary" SAINT commemoration freely, with none
+ of RG 109's closed-list restriction the temporal branch below has. *)
+ ( "III-class feast loses to a I-class day -> Commemorate",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
+ "Commemorate(Ordinary)" );
+ (* Task 16 (primary-source-verified: RG 95 + RG 109's closed list + RG
+ 113 -- see this branch's own comment in precedence_ef.ml for the full
+ three-text argument): an ORDINARY, non-privileged TEMPORAL-cycle
+ loser -- a bog-standard green-season feria of Time after Pentecost,
+ none of RG 109(a)-(f) -- has NO standing to be commemorated at all
+ when impeded; it is entirely omitted, not the "ordinary"
+ commemoration a losing SAINT would get (contrast the SANCTORAL row
+ immediately above, same rank, same kind of winner, opposite
+ [Commemorate]/[Omit] outcome -- the discriminating factor is
+ [origin], nothing else). Before this fix the engine wrongly
+ commemorated the losing feria itself here; confirmed wrong against
+ the missalemeum oracle (Task 16 report): every one of ~190
+ structurally identical days (an ordinary sanctoral feast impeding an
+ ordinary temporal feria, 2026-2027) shows zero commemorations in an
+ independent published EF calendar. *)
+ ( "TASK16: an ORDINARY temporal feria loses to a II-class Sunday -> \
+ Omit, not Commemorate (RG109's closed list; contrast the sanctoral \
+ row above)",
+ an_ordinary_sunday,
+ cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
+ "Omit" );
+ (* Totality's other half: a SANCTORAL loser of the exact same rank as
+ the row above still reaches [Commemorate], proving the branch above
+ is gated on [origin] and not merely on rank -- without this row nothing
+ here would distinguish "temporal losers are omitted" from "IV-class
+ losers are omitted", which would be a much bigger (and wrong) claim. *)
+ ( "TASK16 contrast: a SANCTORAL IV-class loser still reaches \
+ Commemorate(Ordinary)",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~rank:V.Class4 ~layer:PE.universal_layer "ef-some-minor-saint",
+ "Commemorate(Ordinary)" );
+ (* Final fix wave, item 2(a): the RG 26 gate above is [rank = Class4],
+ and until this row nothing in the suite discriminated that from the
+ REFUTED predicate it replaced, [privilege_of loser = Ordinary]
+ (precedence_ef.ml's own comment on this branch names that exact
+ predicate as the wrong reading it corrects). The two predicates are
+ extensionally identical over every candidate this engine actually
+ constructs -- confirmed by enumerating the whole candidate space over
+ 1583-9999: substituting the old predicate back leaves 257/257 green
+ and produces byte-identical `colitur day` output for every sampled
+ year. No REALISTIC row can tell them apart, so this one is
+ deliberately SYNTHETIC: a temporal-origin loser, [Class3] (not
+ [Class4]), whose slug matches none of RG 109(a)-(f) -- [privilege_of]
+ therefore returns [Ordinary] for it, the SAME privilege value as the
+ Class4 row above. The two gates now diverge: RG 26 ([rank = Class4])
+ says this loser is NOT a IV-class feria, so it falls through to the
+ ordinary [Commemorate] branch (RG 24/25's mandate for a II/III-class
+ feria); the refuted gate ([privilege_of loser = Ordinary]) tests only
+ the privilege value, which is [Ordinary] here exactly as it is for
+ the Class4 row, so it would wrongly return [Omit]. Reverting the
+ branch's condition to [privilege_of loser = Ordinary] turns this row
+ (and only this row, of the two) red -- see the final-fix-report for
+ the exact Alcotest failure message this produces and its reversion. *)
+ ( "SYNTHETIC (final fix wave, item 2a): a temporal-origin ORDINARY \
+ Class3 loser is Commemorate under RG26 (rank=Class4 gate), would be \
+ Omit under the refuted privilege_of=Ordinary gate",
+ an_ordinary_sunday,
+ cand ~rank:V.Class3 "ef-synthetic-ordinary-class3-feria",
+ "Commemorate(Ordinary)" )
+ ]
+
+(* Task 9: [privilege_of]'s RG 109 categories (§4, "Commemorations"),
+ exercised through [PE.disposition]'s [Commemorate] payload -- [privilege_of]
+ itself is private, so this is the only vantage point a test outside
+ precedence_ef.ml has on it. Each row below is built to match ONLY the one
+ category it names (see each row's own comment for why), closing the
+ hazard flagged in the task brief ("a test day that is both a Sunday and a
+ I-class day proves nothing about either"). Category (b), "of a I-class
+ day", already has its sole witness above (the Commemoration_only row):
+ 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. *)
+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
+ feria of Advent/Lent/Passiontide -- matches (a) alone. *)
+ ( "(a) an ordinary Sunday commemoration is privileged",
+ cand "ef-nativity",
+ an_ordinary_sunday,
+ "Commemorate(Privileged)" );
+ (* (c) RG 109(c) (§4): "of days within the Octave of the Nativity" --
+ sourced from [Temporal_ef.temporal]'s own output (29 Dec 2026, Class2,
+ "ef-nativity-octave-day-5"), not a hand-typed slug, for the same
+ coupling-safety reason the file's own [of_temporal] rows use it
+ elsewhere. Not a Sunday, not Class1, not an Ember day, not an
+ Advent/Lent/Passiontide feria slug. *)
+ ( "(c) a day within the Nativity octave is privileged",
+ cand "ef-nativity",
+ of_temporal (mk 2026 12 29),
+ "Commemorate(Privileged)" );
+ (* (d) RG 109(d) (§4): "of September Ember days" -- 23 Sep 2026 is
+ the September Ember Wednesday (independently derived from
+ [Temporal_ef]'s own third-Sunday-of-September rule: first Sunday of
+ September 2026 is the 6th, +14 days = 20th, +3 = 23rd), sourced from
+ [Temporal_ef.temporal] itself, Class2. Not a Sunday, not Class1, not
+ within the Nativity octave, not a plain Advent/Lent/Passiontide feria
+ slug either -- and, unlike the Advent/Lent Ember rows below, its own
+ slug ("ef-september-ember-wed") never starts with any of (e)'s own
+ [alp_feria_prefixes] ("ef-advent-"/"ef-lent-"/"ef-passiontide-"), so
+ (d) is this candidate's ONLY route to [Privileged] -- a genuine,
+ still-necessary distinction from (e), unlike the Advent/Lent Ember
+ case below (fix round 1). *)
+ ( "(d) a September Ember day is privileged",
+ cand "ef-nativity",
+ of_temporal (mk 2026 9 23),
+ "Commemorate(Privileged)" );
+ (* (e) RG 109(e) (§4): "of ferias of Advent, Lent and Passiontide" --
+ two rows, one per season named, both from [Temporal_ef.temporal]'s
+ own generic ferial fallback, neither a Sunday, Ember day, or within
+ the Nativity octave. *)
+ ( "(e) an Advent feria is privileged",
+ cand "ef-nativity",
+ of_temporal (mk 2026 12 1),
+ "Commemorate(Privileged)" );
+ ( "(e) a Lent feria is privileged",
+ cand "ef-nativity",
+ of_temporal (off (-41)),
+ "Commemorate(Privileged)" );
+ (* CHANGED, fix round 1 (F1/F2/F6): these two rows used to be titled
+ "boundary: ... is NOT privileged (only September is, RG109(d))" and
+ expected [Omit] (a Task-16-pass reading that treated RG 109(e)'s bare
+ "feriis Adventus, Quadragesimae" as tacitly excluding the Advent and
+ Lent Ember sub-days, by analogy with (d)'s own separate, explicit
+ September carve-out). Review round 1 (F1) reproduced the real
+ consequence directly -- 1900-12-21, an Advent Ember Friday, lost its
+ own commemoration entirely, while an ordinary (lower-solemnity,
+ non-Ember) Advent feria the same week kept its commemoration --
+ backwards on any reading, and traced it to this exact
+ misclassification (F2).
+
+ Corrected reading (precedence_ef.ml's own [privilege_of], (e)
+ branch, carries the full argument): RG 91's TABLE needs an explicit
+ "exceptis feriis Quatuor Temporum" at its own entries 22 and 25 to
+ keep Ember days from being double-listed against their own entry 18
+ -- an exception that would be unnecessary drafting if "feriae
+ Adventus"/"feriae Quadragesimae" did not ALREADY include their Ember
+ sub-days by default. RG 109(e) carries no such "exceptis" clause, so
+ its bare text is read at that same default, INCLUSIVE scope: the
+ Advent and Lent Ember ferias ARE privileged under (e), not merely
+ commemorable-but-ordinary. (d)'s own separate existence survives
+ this reading intact -- September Ember days sit outside Advent/Lent/
+ Passiontide under ANY reading, so (d) remains the ONLY way they
+ reach [Privileged], the point the row immediately above this one
+ makes explicit.
+
+ 16 Dec 2026 is the Advent Ember Wednesday (independently derived:
+ Advent I 2026 is 29 Nov, +14 days = 13 Dec, +3 = 16 Dec); the Lent
+ Ember Wednesday is the same date [off (-39)] already used by the
+ entry-18 [band] row above. Both sourced from [Temporal_ef.temporal],
+ not hand-typed, for the same coupling-safety reason every
+ [of_temporal] row in this file uses it. *)
+ ( "(e), corrected fix round 1: an Advent Ember day is ALSO privileged, \
+ not excluded from (e)",
+ cand "ef-nativity",
+ of_temporal (mk 2026 12 16),
+ "Commemorate(Privileged)" );
+ ( "(e), corrected fix round 1: a Lent Ember day is ALSO privileged, \
+ not excluded from (e)",
+ cand "ef-nativity",
+ of_temporal (off (-39)),
+ "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
+ names, see [privilege_of]'s own comment) must NOT be mistaken for the
+ Major Rogations RG 109(f) privileges: RG 88 says the Minor Rogations
+ change nothing in the Office at all, so nothing about them is
+ privileged either -- and (Task 16) being temporal+ordinary, a Minor
+ Rogation day impeded by a saint is now omitted outright, matching the
+ missalemeum oracle exactly (Task 16 report: 11 May 2026 and 12 May
+ 2026, both Minor Rogation days impeded by a saint, show zero
+ commemoration of the Rogation in the independent oracle). *)
+ ( "boundary: a Minor Rogation day is NOT privileged (RG109(f) names \
+ the Major Litanies, not these) -- TASK16 omits it entirely",
+ cand "ef-nativity",
+ of_temporal (off 36),
+ "Omit" )
+ ]
+
+(* Task 9: [PE.admit] -- RG 111's admission counts (§4, "Commemorations"),
+ given commemorations ALREADY tagged with their real privilege (as
+ [PE.disposition] now tags them -- see [privilege_cases] above). Every
+ candidate/privilege pair here is built directly, not routed through
+ [PE.disposition], so these rows isolate [admit]'s own selection logic
+ from [privilege_of]'s classification -- the two are proved separately by
+ design (unlike a test that only proves [admit] admits SOME correct-looking
+ set without knowing whether it or [privilege_of] supplied the "correct"
+ part). Checked on slug IDENTITY, not count (the brief: "'two admitted'
+ proves nothing about *which* two"). *)
+
+(* Class2 dignity, tagged [Ordinary] explicitly (not via [privilege_of]) --
+ used as the higher-dignity, non-privileged half of every asymmetry pair
+ below. *)
+let ordinary_hi = cand ~rank:V.Class2 "ef-ordinary-hi"
+
+(* Class3 dignity (LOWER than [ordinary_hi]), tagged [Privileged] explicitly
+ -- pairing a lower-dignity privileged candidate against a higher-dignity
+ ordinary one is what makes the II-class-Sunday-vs-other-II-class
+ asymmetry observable: pure dignity and "privilege wins the slot" pick
+ DIFFERENT winners from this exact pair. *)
+let privileged_lo = cand ~rank:V.Class3 "ef-privileged-lo"
+
+(* Class2 dignity (tied with [ordinary_hi], distinguishing rank from
+ privilege alone), tagged [Privileged] -- the higher-dignity privileged
+ candidate for the "two privileged due" row. *)
+let privileged_hi = cand ~rank:V.Class2 "ef-privileged-hi"
+
+(* Class4, the lowest dignity in play -- the third candidate for the
+ III/IV-class "at most two" row, so which TWO of three survive is the
+ thing under test, not merely how many. *)
+let ordinary_lowest = cand ~rank:V.Class4 "ef-ordinary-lowest"
+
+(* Class3, tagged [Ordinary] -- fix round 1, F7: the RG 111(b) rank-floor
+ witness [admit_cases] was missing. [ordinary_hi] above is already Class2,
+ so every existing II-class-Sunday row here passes whether or not
+ [admit]'s "de festo II classis" filter is even present -- reverting that
+ filter would only redden [test_oracle.ml], not this file, which is
+ exactly the coverage gap the review round found. This candidate is the
+ ONLY thing due on the Sunday row below, so a version of [admit] without
+ the rank floor would (wrongly) admit it on pure "best available"
+ dignity, same as it would have admitted [ordinary_hi]. *)
+let ordinary_class3 = cand ~rank:V.Class3 "ef-ordinary-class3"
+
+let observed_class1 = cand "ef-nativity" (* Class1 by [cand]'s own default. *)
+let observed_class2_sunday = an_ordinary_sunday (* Class2, slug carries "-sunday". *)
+let observed_class2_other = cand ~rank:V.Class2 "ef-other-class2-day" (* Class2, no "-sunday". *)
+let observed_class3 = cand ~rank:V.Class3 "ef-some-class3-day"
+
+let slugs_of admitted =
+ List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) admitted
+
+let admit_cases =
+ [ (* RG 111 (§4): "I class: none save one privileged." *)
+ ( "I-class day, only an ordinary commemoration due -> none admitted",
+ observed_class1,
+ [ (ordinary_hi, P.Ordinary) ],
+ [] );
+ ( "I-class day, ordinary + privileged both due -> only the privileged \
+ one, regardless of the ordinary one's higher dignity",
+ observed_class1,
+ [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
+ [ "ef-privileged-lo" ] );
+ ( "I-class day, two privileged due -> only the higher-dignity one (still \
+ just \"one\")",
+ observed_class1,
+ [ (privileged_lo, P.Privileged); (privileged_hi, P.Privileged) ],
+ [ "ef-privileged-hi" ] );
+ (* RG 111: "II-class Sundays: one (dropped if a privileged one is due)." *)
+ ( "II-class Sunday, only an ordinary commemoration due -> it is admitted",
+ observed_class2_sunday,
+ [ (ordinary_hi, P.Ordinary) ],
+ [ "ef-ordinary-hi" ] );
+ ( "II-class Sunday, ordinary (higher dignity) + privileged (lower \
+ dignity) both due -> the PRIVILEGED one is admitted, the ordinary \
+ one dropped despite outranking it",
+ observed_class2_sunday,
+ [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
+ [ "ef-privileged-lo" ] );
+ (* RG 111(b)'s own rank floor ("scilicet DE FESTO II CLASSIS"), fix
+ round 1 F7: a Class3 ORDINARY candidate -- no privileged rival due,
+ so the pre-fix-round code's "no privileged? take the best of what's
+ left" fallback would (wrongly) admit it -- is admitted NOTHING. The
+ slot is reserved for a II-class candidate specifically; a III-class
+ ordinary one has no standing for it at all, unlike "other II class"
+ below, which has no such restriction. *)
+ ( "II-class Sunday, sole candidate is an ORDINARY Class3 (not \
+ \"de festo II classis\") -> admitted nothing, not the best available",
+ observed_class2_sunday,
+ [ (ordinary_class3, P.Ordinary) ],
+ [] );
+ (* RG 111: "other II class: one" -- no privilege override, the exact
+ asymmetry the brief and precedence_ef.ml's own [admit] comment flag:
+ same candidate pair as the II-class-Sunday row above, OPPOSITE
+ observed day, OPPOSITE winner. *)
+ ( "other II-class day, only an ordinary commemoration due -> it is \
+ admitted",
+ observed_class2_other,
+ [ (ordinary_hi, P.Ordinary) ],
+ [ "ef-ordinary-hi" ] );
+ ( "other II-class day, same ordinary+privileged pair as the Sunday row \
+ above -> the ORDINARY one wins on pure dignity this time, the \
+ privileged one dropped",
+ observed_class2_other,
+ [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
+ [ "ef-ordinary-hi" ] );
+ (* RG 111: "III-IV class: at most two" -- three candidates due, top two
+ by dignity admitted, the third (lowest dignity) dropped. *)
+ ( "III-class day, three commemorations due -> the top two by dignity, \
+ not merely \"two of them\"",
+ observed_class3,
+ [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged);
+ (ordinary_lowest, P.Ordinary) ],
+ [ "ef-ordinary-hi"; "ef-privileged-lo" ] )
+ ]
+
+(* Order independence (brief: "the admitted set must not depend on input
+ order"): the SAME three candidates as the III-class row above, passed in
+ the reverse order, must still admit the same top two -- exercised on this
+ row specifically because it is the one where the sort actually has work
+ to do (three distinct dignities, a real top-2 cut), unlike a
+ two-candidate row where either order already happens to be sorted. *)
+let test_admit_order_independent () =
+ let comms =
+ [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); (ordinary_lowest, P.Ordinary) ]
+ in
+ let forward = slugs_of (PE.admit ~observed:observed_class3 comms) in
+ let reversed = slugs_of (PE.admit ~observed:observed_class3 (List.rev comms)) in
+ Alcotest.(check (list string)) "reversed input admits the same candidates"
+ forward reversed
+
+(* The brief: "a case proving that what the limit drops is reported in
+ omitted rather than vanishing" -- three end-to-end proofs, wired with the
+ REAL [PE.band], [PE.disposition] and [PE.admit] together (not a stub, so
+ [privilege_of]'s real classification is exercised too, not just [admit]'s
+ selection logic in isolation as above).
+
+ [rules] deliberately reused, not rebuilt per test, since it is always the
+ same three real functions. *)
+let real_rules = { P.band = PE.band; disposition = PE.disposition; admit = PE.admit }
+
+(* I-class day, zero admitted: the strongest form of "does not vanish" --
+ EVERY commemoration due is dropped (RG 111: "none save one privileged",
+ and the one loser here is ordinary), yet it must still appear in
+ [omitted], not merely be absent from [commemorations]. *)
+let test_i_class_day_drops_into_omitted () =
+ let date = mk 2026 12 25 in
+ let day_ctx = ctx date in
+ let nativity = of_temporal date in
+ let saint = cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3" in
+ let resolution = P.resolve real_rules day_ctx ~temporal:nativity ~sanctoral:[ saint ] in
+ Alcotest.(check (list string)) "nothing admitted on a I-class day with only an ordinary loser due"
+ [] (List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) resolution.P.commemorations);
+ Alcotest.(check (list (pair string string))) "the ordinary loser is reported omitted, not vanished"
+ [ ("ef-some-saint-3", "omitted: admission limit reached") ]
+ (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)
+
+(* II-class Sunday, two ordinary losers due, RG 111's "one" admits the
+ higher-dignity one and drops the other into [omitted]. *)
+let test_ii_class_sunday_drops_second_loser_into_omitted () =
+ let date = mk 2025 11 9 (* an ordinary Time-after-Pentecost Sunday, not All Souls-adjacent. *) in
+ let day_ctx = ctx date in
+ let sunday =
+ { P.cel =
+ Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
+ ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
+ origin = P.Temporal }
+ in
+ let saint_a = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
+ let saint_b = cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3" in
+ let resolution = P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ saint_a; saint_b ] in
+ Alcotest.(check (list string)) "only the higher-dignity (Class2) loser is admitted"
+ [ "ef-some-saint" ]
+ (List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) resolution.P.commemorations);
+ Alcotest.(check (list (pair string string))) "the lower-dignity loser is reported omitted, not vanished"
+ [ ("ef-some-saint-3", "omitted: admission limit reached") ]
+ (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)
+
+(* A genuinely privileged commemoration reaching [admit] through the REAL
+ pipeline (register RG 109(e)): a Lent feria (Class3, temporal-origin)
+ loses to a universal Class2 sanctoral feast on the same date, and
+ [PE.disposition] tags it [Privileged] via [privilege_of] -- proving
+ [privilege_of] and [admit] cooperate correctly end-to-end, not merely in
+ the hand-tagged unit tests above. *)
+let test_privileged_lent_feria_admitted_end_to_end () =
+ let date = off (-41) (* Lent I Monday, the same date the entry-22 [band] row uses. *) in
+ let day_ctx = ctx date in
+ let lent_feria = of_temporal date in
+ let saint = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
+ let resolution = P.resolve real_rules day_ctx ~temporal:lent_feria ~sanctoral:[ saint ] in
+ Alcotest.(check string) "the Lent feast wins the day, not the sanctoral feast's own commemoration"
+ "ef-some-saint" (S.to_string resolution.P.observed.P.cel.Cel.slug);
+ Alcotest.(check (list (pair string string))) "the Lent feria is admitted, tagged Privileged"
+ [ ("ef-lent-1-monday", "Privileged") ]
+ (List.map
+ (fun (c, p) -> (S.to_string c.P.cel.Cel.slug, match p with P.Privileged -> "Privileged" | P.Ordinary -> "Ordinary"))
+ resolution.P.commemorations);
+ Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)
+
+(* Fix round 1 (post-Task-9 review): the II-class-Sunday override (RG 111
+ "one, dropped if a privileged one is due") reached through the REAL
+ pipeline, with both a privileged AND an ordinary commemoration due on the
+ same day -- previously only proven at the isolated [admit] level
+ ([admit_cases]'s hand-tagged rows above). RG 109(a)-(f)'s five reachable
+ categories are all properties of a TEMPORAL-origin office, and only one
+ temporal candidate exists per date, so a privileged AND an ordinary
+ commemoration cannot both be due from the temporal side alone -- but
+ category (b) is the exception: it is sanctoral and rank-based (a
+ [Commemoration_only] entry carrying [Class1], the exact shape
+ [disposition_cases]'s own (b) witness row uses), and a [Commemoration_only]
+ entry is held out of the band contest entirely ({!Precedence.resolve}),
+ so it is a loser regardless of what [observed] turns out to be. Paired
+ with an ordinary sanctoral saint, both lose to an ordinary Class2 Sunday,
+ giving [observed_is_sunday = true] with one [Privileged] and one
+ [Ordinary] loser due at once -- no synthetic fixture needed.
+
+ NOTE on what this test does and does not prove: the only reachable
+ witness for RG 109(b) is [Class1] (the highest dignity), and any
+ sanctoral loser that could ALSO beat this same Sunday by pure dignity
+ would win the day outright instead of losing to it (any [Feast]-status
+ [Class1] sanctoral candidate bands at entry 11-13, ahead of an ordinary
+ Sunday's entry 15) -- so within this specific pipeline shape the
+ privileged loser is unavoidably also the higher-dignity one, and this
+ test cannot by itself distinguish "privilege overrides dignity" from
+ "dignity alone happened to pick the same winner". That distinction is
+ what [admit_cases]'s hand-tagged rows above prove (a LOWER-dignity
+ privileged candidate still beats a HIGHER-dignity ordinary one on a
+ II-class Sunday, the opposite of "other II class"'s own row). This test's
+ job is narrower and complementary: proving the real pipeline
+ ([PE.band], [PE.disposition], [PE.privilege_of] via [disposition],
+ [PE.admit] together) actually reaches and exercises the override branch
+ end-to-end, not merely in isolation. *)
+let test_ii_class_sunday_privileged_witness_admitted_end_to_end () =
+ let date = mk 2026 7 5 (* an ordinary Time-after-Pentecost Sunday. *) in
+ let day_ctx = ctx date in
+ let sunday = of_temporal date in
+ let privileged_witness =
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
+ "ef-commemoration-only-b-witness"
+ (* Class1 by [cand]'s own default -- RG 109(b). *)
+ in
+ let ordinary_saint = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
+ let resolution =
+ P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ privileged_witness; ordinary_saint ]
+ in
+ (* Checked against [sunday]'s own slug, not a hand-typed/guessed literal
+ (its exact week number is not worth independently re-deriving here):
+ this asserts identity with the real [Temporal_ef.temporal] candidate,
+ proving the SUNDAY -- not either sanctoral loser -- is what wins the
+ day, which the privilege assertions below presuppose. *)
+ Alcotest.(check string) "the Sunday wins the day, not either sanctoral loser"
+ (S.to_string sunday.P.cel.Cel.slug) (S.to_string resolution.P.observed.P.cel.Cel.slug);
+ Alcotest.(check (list (pair string string)))
+ "only the privileged (Commemoration_only, Class1) witness is admitted"
+ [ ("ef-commemoration-only-b-witness", "Privileged") ]
+ (List.map
+ (fun (c, p) -> (S.to_string c.P.cel.Cel.slug, match p with P.Privileged -> "Privileged" | P.Ordinary -> "Ordinary"))
+ resolution.P.commemorations);
+ Alcotest.(check (list (pair string string))) "the ordinary saint is dropped into omitted, not vanished"
+ [ ("ef-some-saint", "omitted: admission limit reached") ]
+ (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)
+
+(* Completes Task 7's carried fix (RG 91 entry 8, §4): on a real Sunday
+ landing on 2 November, All Souls does not merely lose (that was Task 7's
+ [band] fix, proved by [test_all_souls_yields_to_sunday] above) -- it must
+ be TRANSFERRED, not commemorated and not omitted. All Souls is I class
+ (RG 91 entry 8's own [rank] field, untouched by the Sunday-exception band
+ bump -- see precedence_ef.ml's comment on entry 8), so RG 95's rank
+ condition alone should route it to [Transfer]. *)
+let test_all_souls_disposition_is_transfer () =
+ let sunday = an_ordinary_sunday in
+ let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
+ Alcotest.(check string) "All Souls loses to a Sunday and transfers"
+ "Transfer"
+ (string_of_disposition (PE.disposition ~winner:sunday ~loser:all_souls))
+
+(* The same fact, proved end-to-end through [Precedence.resolve] with the
+ REAL [PE.band] and REAL [PE.disposition] wired together (Task 7's own
+ integration test above still stubs [disposition] to a constant [Omit],
+ which is exactly what this task must not leave true) -- All Souls must
+ land in [deferred], not [commemorations] or [omitted]. WHERE it is placed
+ (3 November, RG 96) is [Rite.transfer_target]'s job, out of this task's
+ scope; this only proves [resolve] hands it to the transfer path at all. *)
+let test_all_souls_transfers_end_to_end () =
+ let date = mk 2025 11 2 in
+ let day_ctx = ctx date in
+ let sunday =
+ { P.cel =
+ Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
+ ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
+ origin = P.Temporal }
+ in
+ let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
+ let rules = { P.band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ cs -> cs) } in
+ let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
+ Alcotest.(check (list string)) "All Souls is deferred (transferred), not omitted or commemorated"
+ [ "ef-all-souls" ]
+ (List.map (fun c -> S.to_string c.P.cel.Cel.slug) resolution.P.deferred);
+ 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)
+
+(* Coordinator review (fix round 1): RG 96 Attamen (a) (register-transcribed,
+ primary-source-verified) makes the Annunciation exception CONDITIONAL on
+ the general RG 96 walk carrying the feast past Easter Sunday -- NOT
+ unconditional as the first transcription had it. The occupant here blocks
+ every day from [origin + 1] through the day after Easter (26 March - 6
+ April 2026 inclusive), so the GENERAL target itself would land at 7
+ April -- after Easter (5 April) -- which is exactly the trigger
+ condition, not merely "the Annunciation is impeded at all". *)
+let test_transfer_target_annunciation_starts_at_monday_after_low_sunday () =
+ let origin = mk 2026 3 25 in
+ let easter_2026 = Comp.gregorian_easter 2026 in
+ let blocked_through_day_after_easter =
+ let rec range a b = if D.compare a b > 0 then [] else a :: range (D.add_days a 1) b in
+ range (D.add_days origin 1) (D.add_days easter_2026 1)
+ in
+ let occupant = occupant_blocking_on blocked_through_day_after_easter 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 easter_2026 8 in
+ Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8), the general \
+ walk having crossed Easter itself"
+ (D.to_iso8601 monday_after_low_sunday) (D.to_iso8601 target);
+ Alcotest.(check bool) "NOT the general target (2 days after Easter, discriminates the branch)"
+ true
+ (D.compare target (D.add_days easter_2026 2) <> 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: same blocked run as above
+ (forcing the general target past Easter, so the exception fires), PLUS
+ the Monday after Low Sunday itself blocked, confirming the search
+ continues exactly one more day from there. *)
+let test_transfer_target_annunciation_searches_onward_if_blocked () =
+ let origin = mk 2026 3 25 in
+ let easter_2026 = Comp.gregorian_easter 2026 in
+ let monday_after_low_sunday = D.add_days easter_2026 8 in
+ let blocked =
+ let rec range a b = if D.compare a b > 0 then [] else a :: range (D.add_days a 1) b in
+ range (D.add_days origin 1) (D.add_days easter_2026 1) @ [ monday_after_low_sunday ]
+ in
+ let occupant = occupant_blocking_on blocked 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)
+
+(* THE REGRESSION PIN (coordinator review): the bug an unconditional
+ exception produced. 25 March 2057 is Lent III Sunday (I class, RG 91
+ entry 6), impeding the Annunciation; 26 March 2057 is an ordinary Lent
+ feria (III class, well before Easter, 22 April 2057) -- the general RG
+ 96 target. The general target does NOT fall after Easter, so the
+ exception must NOT fire: the Annunciation lands on 26 March, not 13
+ April (Easter + 8), which is what the unconditional reading produced
+ (verified by reverting the fix and re-running this exact test -- see the
+ task report's mutation record). Uses the REAL [Temporal_ef.temporal] as
+ [occupant] (not a synthetic stand-in), the same coupling-safety
+ convention [of_temporal]'s callers use elsewhere in this file, so this
+ is also effectively an end-to-end check of the real 2057 calendar
+ shape, not just the search's own logic in isolation. *)
+let test_transfer_target_annunciation_not_overridden_when_general_target_precedes_easter () =
+ let origin = mk 2057 3 25 in
+ Alcotest.(check string) "25 March 2057 is a Sunday (Lent III)" "sunday"
+ (D.weekday_to_string (D.weekday origin));
+ let occupant d = (T.temporal d).Colitur_kernel.Temporal.office 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) "lands on 26 March 2057 (the general RG96 target), NOT the \
+ Annunciation exception's Monday after Low Sunday"
+ "2057-03-26" (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)
+
+(* Coordinator review: [search_from] must not probe [occupant] past
+ {!Date}'s own domain ceiling (31 December 9999). A SYNTHETIC occupant
+ (like [occupant_always_blocking] above) can never actually discriminate
+ this: it never calls [Computus.gregorian_easter] itself, so it cannot
+ raise regardless of whether the domain guard exists -- a test built on
+ one would only prove [search_from]'s unrelated step bound, not this fix.
+ [occupant] here is instead the REAL [Temporal_ef.temporal] (no sanctoral
+ layer needed: 29-31 Dec are ALREADY II class via [named]'s own Nativity-
+ octave-day entries, so three real, unbroken blocking days already sit at
+ the very end of the domain) -- exactly the shape that raises without the
+ fix: 1 January of civil year 10000 is next, and [Computus.gregorian_easter
+ 10000] does [Date.make ~year:10000 ...] and [failwith]s (the .ml's own
+ [domain_max_date] comment; also how the reviewer reproduced the bug
+ through the project's own overlay mechanism -- see the task report for
+ that end-to-end reproduction). Mutation-verified: reverting the domain
+ guard makes this test error with exactly that uncaught [Failure], not
+ merely fail an assertion (see the task report). *)
+let test_transfer_target_does_not_raise_at_domain_ceiling () =
+ let origin = mk 9999 12 28 in
+ let occupant d = (T.temporal d).Colitur_kernel.Temporal.office in
+ let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-domain-ceiling-case" in
+ let target = PE.transfer_target c origin occupant in
+ Alcotest.(check bool) "past 31 December 9999 (the guard engaged; nothing admissible remained \
+ in-domain, so the search gave up at the ceiling rather than crashing)"
+ true
+ (D.compare target (mk 9999 12 31) > 0)
+
+
+let suite =
+ ( "Precedence_ef",
+ List.map
+ (fun (desc, date, c, expect) ->
+ Alcotest.test_case desc `Quick (fun () ->
+ Alcotest.(check int) desc expect (PE.band (ctx date) c)))
+ cases
+ @ [ Alcotest.test_case "8 All Souls yields to a Sunday (resolve-level)" `Quick
+ test_all_souls_yields_to_sunday ]
+ @ List.map
+ (fun (desc, winner, loser, expect) ->
+ Alcotest.test_case desc `Quick (fun () ->
+ Alcotest.(check string) desc expect
+ (string_of_disposition (PE.disposition ~winner ~loser))))
+ disposition_cases
+ @ List.map
+ (fun (desc, winner, loser, expect) ->
+ Alcotest.test_case desc `Quick (fun () ->
+ Alcotest.(check string) desc expect
+ (string_of_disposition (PE.disposition ~winner ~loser))))
+ privilege_cases
+ @ [ Alcotest.test_case "All Souls disposition is Transfer" `Quick
+ test_all_souls_disposition_is_transfer;
+ Alcotest.test_case "All Souls transfers end-to-end (resolve, real rules)" `Quick
+ test_all_souls_transfers_end_to_end ]
+ @ List.map
+ (fun (desc, observed, comms, expect) ->
+ Alcotest.test_case desc `Quick (fun () ->
+ Alcotest.(check (list string)) desc expect
+ (slugs_of (PE.admit ~observed comms))))
+ admit_cases
+ @ [ Alcotest.test_case "admit is order-independent (III-class, 3 candidates)" `Quick
+ test_admit_order_independent;
+ Alcotest.test_case "I-class day: full drop reported in omitted, not vanished" `Quick
+ test_i_class_day_drops_into_omitted;
+ Alcotest.test_case "II-class Sunday: second loser dropped into omitted" `Quick
+ test_ii_class_sunday_drops_second_loser_into_omitted;
+ Alcotest.test_case "RG109(e) Lent feria privileged end-to-end" `Quick
+ 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;
+ 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: Annunciation NOT overridden when the general target precedes Easter \
+ (2057 regression)"
+ `Quick test_transfer_target_annunciation_not_overridden_when_general_target_precedes_easter;
+ 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 ] )