diff options
Diffstat (limited to 'test/test_precedence_ef.ml')
| -rw-r--r-- | test/test_precedence_ef.ml | 1183 |
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 ] ) |
