(* 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 -- register line 327: 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 -- register line 328: 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 -- register line 329. *) ("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 -- register line 330. 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 -- register line 331. *) ("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 -- register line 332. *) ("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 -- register line 333: 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 -- register line 334. 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 -- register line 335. *) ("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9); (* Entry 10 -- register line 336: 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 -- register line 337. *) ( "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 -- register line 338. 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 -- register line 339. *) ( "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 -- register line 341, deliberately UNQUALIFIED (contrast entry 16, line 342, 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 -- register line 342: 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 -- register line 342. *) ( "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 -- register line 343: 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 -- register line 343-344: 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 -- register line 344. *) ( "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 -- register line 345. *) ( "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 -- register line 345 (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 -- register line 347-348 (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 -- register line 349. 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 -- register line 349. *) ( "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 -- register line 350. *) ("25 Advent feria to 16 Dec", mk 2026 12 1, cand ~rank:V.Class3 "ef-advent-1-tue", 25); (* Entry 26 -- register line 350. *) ( "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 -- register line 352: 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 -- register line 352: 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 (register lines 381-384) 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; register lines 316-325, 381-384). 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--sunday-" 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 -- register line 323-325: 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 (register lines 323, 363) restricts the right of translation to I-class FEASTS -- RG 91's own table lists Sundays as a separate row (entry 6, register line 332) from feasts (entries 11-13, lines 337-339) -- 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) (register line 374) 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 -- register line 383-384: a I/II-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": *) ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \ II-class day -> Commemorate, NOT Omit", cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast", of_temporal (off 38), "Commemorate(Ordinary)" ); ( "RG33 boundary: a III-class vigil (outside RG33's I/II-class scope) \ loses to a Sunday -> Commemorate, NOT Omit", an_ordinary_sunday, cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil", "Commemorate(Ordinary)" ); (* Task 11, issue (a): [disposition]'s own [is_vigil] check (the RG 33 omission test) is a SEPARATE call site from [band]'s -- both read the same private [is_vigil], but each needed its own witness, since a fix to one call site could in principle miss the other. Real bootstrapped slug (data/ef/sanctoral.sexp's "vigil-of-the-assumption", 14 Aug), not a hand-typed one, for the same coupling-safety reason [of_temporal] rows use real data elsewhere in this file. Before the fix this vigil was invisible to [is_vigil] entirely, so it would have fallen through to the ordinary Commemorate branch below instead of Omit -- the exact failure the task brief describes. *) ( "RG33 (prefix convention): a \"vigil-of-X\"-named II-class vigil loses \ to an ordinary Sunday -> Omit", an_ordinary_sunday, cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-the-assumption", "Omit" ); (* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked here with a loser that ALSO carries a Class1 rank and a vigil-suffixed slug losing to a Sunday, so this row only passes if the 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) (register line 374-375, "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: the lower ranks the RG 33/RG 95 branches never touch still reach the RG 95 "commemorated or omitted" branch, not an unhandled/exceptional case. *) ( "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)" ); ( "IV-class feria loses to a II-class Sunday -> Commemorate", an_ordinary_sunday, cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat", "Commemorate(Ordinary)" ) ] (* Task 9: [privilege_of]'s RG 109 categories (register lines 374-377), 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) register line 374: "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) register line 375: "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) register line 375-376: "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 an Advent/Lent Ember day (a DIFFERENT Ember set, deliberately excluded by (d) -- see the negative row below), not a plain Advent/Lent/Passiontide feria slug either. *) ( "(d) a September Ember day is privileged", cand "ef-nativity", of_temporal (mk 2026 9 23), "Commemorate(Privileged)" ); (* (e) register line 376: "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)" ); (* Negative, RG 109(d) vs (e)'s own boundary: the Advent and Lent Ember sets are ALSO II-class ferias of Advent/Lent by RG 91 (entry 18), and their slugs ("ef-advent-ember-*", "ef-lent-ember-*") share (e)'s own season prefixes -- but RG 109 privileges ONLY the September set (d), leaving these two ordinary. 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]. If [privilege_of] relied on the season prefix alone without excluding Ember slugs, both would wrongly come back [Privileged] -- the exact trap this pair of rows guards against. *) ( "boundary: an Advent Ember day is NOT privileged (only September is, \ RG109(d))", cand "ef-nativity", of_temporal (mk 2026 12 16), "Commemorate(Ordinary)" ); ( "boundary: a Lent Ember day is NOT privileged (only September is, \ RG109(d))", cand "ef-nativity", of_temporal (off (-39)), "Commemorate(Ordinary)" ); (* 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. *) ( "boundary: a Minor Rogation day is NOT privileged (RG109(f) names \ the Major Litanies, not these)", cand "ef-nativity", of_temporal (off 36), "Commemorate(Ordinary)" ) ] (* Task 9: [PE.admit] -- RG 111's admission counts (register line 378), 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" 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 (register line 378): "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: "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 (register line 334): 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) (* RG 96's Annunciation exception: starts the search at the Monday after Low Sunday, NOT [origin + 1] -- occupant is unconditionally free, so a general-path implementation would return [origin + 1] (26 March), a date this test explicitly rules out as well as pinning the real expected one, so the assertion genuinely discriminates the two starting points rather than merely checking "some date after origin". *) let test_transfer_target_annunciation_starts_at_monday_after_low_sunday () = let origin = mk 2026 3 25 in let occupant = occupant_blocking_on [] in let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in let target = PE.transfer_target c origin occupant in let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8)" (D.to_iso8601 monday_after_low_sunday) (D.to_iso8601 target); Alcotest.(check bool) "NOT the general path's origin + 1 (discriminates the branch)" true (D.compare target (D.add_days origin 1) <> 0) (* RG 96's own qualifier on the exception -- "searching onward from there only if that day is itself blocked" (rite.mli) -- is [search_from]'s ORDINARY behaviour, not a second mechanism: block the Monday after Low Sunday itself and confirm the search continues exactly one more day. *) let test_transfer_target_annunciation_searches_onward_if_blocked () = let origin = mk 2026 3 25 in let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in let occupant = occupant_blocking_on [ monday_after_low_sunday ] in let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in let target = PE.transfer_target c origin occupant in Alcotest.(check string) "searches onward one more day when that Monday is itself blocked" (D.to_iso8601 (D.add_days monday_after_low_sunday 1)) (D.to_iso8601 target) (* rite.mli's own obligations on [transfer_target] (Task 11 brief): the call must TERMINATE and its result must be STRICTLY AFTER [origin], even for a rite/data shape this function cannot have anticipated -- an occupant that reports every single day as blocking, forever. Calendar's own round guard (max_transfer_rounds) does not cover this: it bounds ROUNDS across a whole year, not the internal walk one call to this function makes (see precedence_ef.ml's own comment on [search_from] and [max_search_days]). Deliberately NOT pinning the exact returned date against the private [max_search_days] constant -- that would coalesce a behavioural contract (terminates, makes forward progress) with an internal tuning value this function is free to change; a generous, test-owned ceiling (1000 days, comfortably past any realistic bound) is enough to prove termination is genuine and not merely "didn't hang during this particular run". *) let test_transfer_target_terminates_under_pathological_occupant () = let origin = mk 2026 1 1 in let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-pathological-case" in let target = PE.transfer_target c origin occupant_always_blocking in Alcotest.(check bool) "strictly after origin" true (D.compare target origin > 0); Alcotest.(check bool) "terminates within a generous bound (proves the internal search is bounded, not merely lucky)" true (D.compare target (D.add_days origin 1000) <= 0) (* 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: 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 ] )