aboutsummaryrefslogtreecommitdiff
path: root/test/test_precedence_ef.ml
diff options
context:
space:
mode:
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 ] )