From 436ba75e27d2aa61b1c6035a22157b40f1a9834b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 22:01:24 +0200 Subject: rite(ef): RG 91 Table of Precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precedence_ef.band transcribes RG 91's 28-entry Table of Precedence (rules-register.md §4) for the EF rite: given a day's context and a candidate celebration, returns the table's own entry number, 1-28 (I class 1-13, II class 14-21, III class 22-26, IV class 27-28); lower wins. Every branch carries its entry number and register citation in a comment, checked in the table's own numeric order. Two entries are transcribed as the register states them even though they invert the pattern the rest of the table follows: at III class, 23 (particular calendars) outranks 24 (universal), the reverse of how 11/12 and 14/16/19/20 rank a universal feast ahead of a proper one at I and II class. Sanctoral-origin, layer-decided entries (11-13, 14/16/19/20, 23/24) follow the brief's structural insight: a celebration whose layer is not the universal base is an overlay -- proper, or indult if its layer id also carries the indult prefix. Neither the universal-layer id nor the indult prefix is an RG citation; both are colitur's own data-modelling convention, exposed from the module so whichever task loads the real EF sanctoral overlays can align to them. Vigils (21, 26) are read off the temporal cycle's own -vigil slug suffix rather than gated on origin, since a II/III-class vigil can be either temporal-origin (Ascension, already produced by temporal_ef) or sanctoral-origin (a saint's vigil, no task has loaded yet); Ember days (part of entry 18) are read off temporal_ef's own ember slug prefixes rather than re-derived, since the September anchor is independently flagged there as one of the more contested dates in the calendar. A candidate shape the table has no row for (e.g. a Class1 vigil that is not Nativity or Pentecost, or a Class4 candidate marked as a vigil -- RG 91 has no IV-class vigil either) returns a dedicated unclassified sentinel (max_int) rather than being folded into a same-rank entry it does not belong to. test_precedence_ef.ml is table-driven: one Alcotest.test_case per RG 91 entry (55 rows total, several entries covered by more than one named day so a single missed offset cannot hide behind a passing sibling), each date computed from Computus.gregorian_easter rather than hand-typed, so an arithmetic slip cannot pass by accident. --- test/test_precedence_ef.ml | 202 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 test/test_precedence_ef.ml (limited to 'test/test_precedence_ef.ml') diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml new file mode 100644 index 0000000..fb05582 --- /dev/null +++ b/test/test_precedence_ef.ml @@ -0,0 +1,202 @@ +(* 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) ?(subject = Sub.Temporal) ?(layer = "temporal") + slug = + { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~colour:Col.White ~subject ~layer (); + origin } + +(* 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. *) + ("8 All Souls", mk 2026 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 8); + (* 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. *) + ( "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 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); + ("18 Lent Ember Wednesday", off (-39), cand ~rank:V.Class2 "ef-lent-ember-wed", 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). *) + ("21 Ascension Vigil (temporal-origin)", off 38, cand ~rank:V.Class2 "ef-ascension-vigil", 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 ); + (* 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 ); + (* 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 ); + (* 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 ) + ] + +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 ) -- cgit v1.3 From 553dc44d2ba0e131e7f2ac79dc755641afcd6a1c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 22:26:17 +0200 Subject: rite(ef): fix entry 8's Sunday exception and entry 14's over-restriction Review of 436ba75 found two calendar defects and one coupling risk. Entry 8 (All Souls) dropped register line 334's own qualifier, "(yields to an occurring Sunday)" -- it returned 8 unconditionally, so on 2 November falling on a Sunday (2025, 2031, 2036, 2042 in the 2005-2050 differential window), All Souls incorrectly outranked and observed over the Sunday. 2 November is always Time_after_pentecost and never coincides with any other entry's own date, so the only rival this exception ever has is an ordinary entry-15 II-class Sunday; on such a Sunday, band now returns one worse than entry 15's own value rather than the literal 8 -- strictly worse, not merely different, since an exact tie would fall to Precedence.resolve's slug tie-break, which for "ef-all-souls" against a Sunday slug would make All Souls win the tie anyway. Entry 8's own rank is untouched, so Task 8's disposition still sees a true I-class candidate to transfer. Entry 14 (Feasts of the Lord, II class) added a universal-layer restriction the register's line 341 does not carry -- contrast entry 16 at line 342, which explicitly says "not of the Lord"; RG 37c (register line 393) also speaks of "II-class feasts of the Lord" with no universal qualifier. Dropped the layer test: a proper or indult feast of the Lord now bands 14, not 19/20. Also exposes vigil_suffix and ember_prefixes from precedence_ef.mli, matching universal_layer/indult_prefix's existing "colitur convention, not an RG citation" treatment -- both were previously private literals duplicated in the test file, so a rename of temporal_ef.ml's slug format could have drifted silently past both sides agreeing with each other. Two test rows now build their candidate from Temporal_ef.temporal's own real output (entry 18's Lent Ember day, entry 21's Ascension Vigil) instead of a hand-typed slug, closing that specific coupling. Adds the three cheap rows review named as closing the remaining unpinned guards (a temporal-origin Class1 candidate on an unnamed date; a universal Class2 vigil of the Lord; a non-universal Class3 vigil), plus a resolve()-level test proving the Sunday is actually observed over All Souls, not just that band returns the right integer in isolation. --- lib/rites/rite_ef/precedence_ef.ml | 109 +++++++++++++++++++++++++----------- lib/rites/rite_ef/precedence_ef.mli | 29 +++++++++- test/test_precedence_ef.ml | 102 +++++++++++++++++++++++++++++++-- 3 files changed, 198 insertions(+), 42 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 50da4a7..0716177 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -9,22 +9,27 @@ without a separate exclusion for every later entry it pre-empts. Two kinds of evidence decide an entry: - - The temporal cycle's own office (Nativity, a Sunday, a feria, a vigil of - the Lord) is identified structurally, from the context's date/season/ - weekday and the day's Easter offset -- never from its slug, which is - just a label. [origin = Temporal] gates every such entry so a sanctoral - candidate that happens to share a date (Immaculate Conception can never - coincide with the movable cycle, but nothing stops a future rite bug - from producing one) cannot be mistaken for the office itself. - - A sanctoral feast's entry (11-13 I class, 14/16/19/20 II class, 23/24 - III class) is decided by its [rank] plus, per the brief's structural - insight, its {!Celebration.t}.layer: a celebration whose layer is not - the universal base is an overlay, hence "proper" or "indult" rather - than the universal entry (see precedence_ef.mli). [origin = Sanctoral] - gates these for the same reason: temporal-origin celebrations carry the - literal layer id "temporal" (rite_ef/temporal_ef.ml's [build]), which is - not [universal_layer] either, and would otherwise be misread as - "proper" by the layer test alone. + - The temporal cycle's own office (Nativity, a Sunday, a feria, All Souls) + is identified structurally, from the context's date/season/weekday and + the day's Easter offset -- never from its slug, which is just a label. + [origin = Temporal] gates every such entry so a sanctoral candidate that + happens to share a date (Immaculate Conception can never coincide with + the movable cycle, but nothing stops a future rite bug from producing + one) cannot be mistaken for the office itself. All Souls (entry 8, the + one non-temporal-origin member of this group) additionally reads the + context's weekday for its own register-stated exception -- see entry 8 + below. + - A sanctoral feast's entry is decided by its [rank], and -- except at + entry 14 (see its own comment below, where the register draws no such + line) -- per the brief's structural insight, also by its + {!Celebration.t}.layer: a celebration whose layer is not the universal + base is an overlay, hence "proper" or "indult" rather than the + universal entry (11-13 I class; 16/19/20 II class; 23/24 III class; see + precedence_ef.mli). [origin = Sanctoral] gates these for the same + reason: temporal-origin celebrations carry the literal layer id + "temporal" (rite_ef/temporal_ef.ml's [build]), which is not + [universal_layer] either, and would otherwise be misread as "proper" by + the layer test alone. Vigils (21, 26) are the one shape neither of those two kinds fully describes on their own: a II/III-class vigil can be temporal-origin (the @@ -46,19 +51,33 @@ let unclassified = max_int let is_indult layer = String.starts_with ~prefix:indult_prefix layer let is_universal layer = String.equal layer universal_layer -(* Ember days are identified by the temporal cycle's own slug convention - (rite_ef/temporal_ef.ml's [ember]: "ef--ember-"), not - re-derived here: the September anchor in particular is one of the more - contested dates in the 1962 calendar (temporal_ef.ml's own comment on +(* Not an RG citation either -- see [universal_layer] above. Nothing in + {!Celebration.t} otherwise marks "this is a vigil, not an ordinary office + of the same rank" (see the file's top comment), so entries 21/26 read it + off the temporal cycle's own slug suffix (rite_ef/temporal_ef.ml's + [named], e.g. "ef-ascension-vigil"). Exposed so a future task naming a + sanctoral vigil (Task 10: Assumption, John Baptist, Peter & Paul, + Lawrence -- only Ascension exists today) uses the same suffix; a + differently-named vigil would band 16/24 instead of 21/26, silently. *) +let vigil_suffix = "-vigil" + +let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug + +(* Not an RG citation -- see [universal_layer]. Entry 18's Ember days are + identified by the temporal cycle's own slug convention (rite_ef/ + temporal_ef.ml's [ember]: "ef--ember-"), not re-derived here: + the September anchor in particular is one of the more contested dates in + the 1962 calendar (temporal_ef.ml's own comment on [third_sunday_of_september]), and re-deriving it a second time would only create a second place for that same uncertainty to drift. Only the Advent, Lent and September sets are listed: RG 91 entry 18 names exactly those three; the Whitsun (Pentecost) set is I class and falls inside the - Pentecost octave, entry 10, matched below before this is ever reached. *) -let is_ember_18 slug = - String.starts_with ~prefix:"ef-advent-ember-" slug - || String.starts_with ~prefix:"ef-lent-ember-" slug - || String.starts_with ~prefix:"ef-september-ember-" slug + Pentecost octave, entry 10, matched below before this is ever reached. + Exposed for the same reason as [vigil_suffix]: a rename of temporal_ef's + format has somewhere to be caught other than a silently-wrong entry 18. *) +let ember_prefixes = [ "ef-advent-ember-"; "ef-lent-ember-"; "ef-september-ember-" ] + +let is_ember_18 slug = List.exists (fun prefix -> String.starts_with ~prefix slug) ember_prefixes let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedence.candidate) : int = @@ -68,7 +87,7 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc let layer = cel.Celebration.layer in let slug = Slug.to_string cel.Celebration.slug in let is_temporal = c.Precedence.origin = Precedence.Temporal in - let is_vigil = String.ends_with ~suffix:"-vigil" slug in + let is_vigil = is_vigil slug in let date = ctx.Precedence.date in let season = ctx.Precedence.season in let weekday = ctx.Precedence.weekday in @@ -77,6 +96,11 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc (* Easter offset, the same convention as temporal_ef.ml's [days_between easter d]: 0 is Easter itself, negative before, positive after. *) let off = Date.to_rata date - Date.to_rata (Computus.gregorian_easter (Date.year date)) in + (* Named so entry 8's Sunday exception below can read "one worse than the + Sunday it must yield to" rather than a bare integer that happens to + equal entry 15's own value; entry 15's own branch returns this same + binding, not a second literal, so the two can never drift apart. *) + let entry_15_band = 15 in let open Vocab_ef in (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *) if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1 @@ -101,8 +125,23 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc (* 7: I-class ferias not above -- Ash Wednesday; Mon/Tue/Wed of Holy Week. Thu-Sat of Holy Week are the Triduum, entry 2 above, not this entry. *) else if is_temporal && rank = Class1 && (off = -46 || (off >= -6 && off <= -4)) then 7 - (* 8: All Souls. *) - else if (not is_temporal) && rank = Class1 && m = 11 && d = 2 then 8 + (* 8: All Souls -- register line 334's own text carries a qualifier this + transcription must honour: "yields to an occurring Sunday". 2 November + is always Time_after_pentecost (well clear of Advent/Lent/Passiontide + and of every other entry's own Easter-relative or fixed date), so a + Sunday landing on it is always an ordinary entry-15 II-class Sunday -- + the one and only rival this exception ever has to lose to. On such a + Sunday this returns [entry_15_band + 1]: strictly worse than 15 (an + exact tie would fall to Precedence.resolve's slug tie-break, which + for "ef-all-souls" against a "ef-time-after-pentecost-sunday-*" slug + would make All Souls WIN -- the precise bug this guards against), but + otherwise not a citation to any other RG 91 row -- nothing else can + ever occur on 2 November to be confused with it. Entry 8's own [rank] + is untouched by this, so Task 8's disposition (RG 95: only I-class + feasts transfer) still sees the true I-class candidate it needs to + move to 3 November. *) + else if (not is_temporal) && rank = Class1 && m = 11 && d = 2 then + if is_sunday then entry_15_band + 1 else 8 (* 9: Vigil of Pentecost. *) else if is_temporal && rank = Class1 && off = 48 then 9 (* 10: Days within the Octaves of Easter and Pentecost. *) @@ -116,12 +155,16 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc not the universal layer (11), and marked as an indult overlay (12's "not indult" test having just failed). *) else if (not is_temporal) && (not is_vigil) && rank = Class1 then 13 - (* 14: Feasts of the Lord, II class. *) - else if (not is_temporal) && (not is_vigil) && rank = Class2 && is_universal layer - && subject = Subject.Lord - then 14 + (* 14: Feasts of the Lord, II class -- register line 341, deliberately + UNQUALIFIED (contrast entry 16 at line 342, which explicitly says "not + of the Lord"; RG 37c, register line 393, speaks of "II-class feasts of + the Lord" replacing an occurring II-class Sunday with no universal + qualifier either). No layer test here, unlike 11/12/13 and 16/19/20: + the register does not split this entry into universal/proper/indult, + so a proper or indult feast of the Lord still bands 14, not 19/20. *) + else if (not is_temporal) && (not is_vigil) && rank = Class2 && subject = Subject.Lord then 14 (* 15: Sundays, II class (every Sunday not already named at 6). *) - else if is_temporal && rank = Class2 && is_sunday then 15 + else if is_temporal && rank = Class2 && is_sunday then entry_15_band (* 16: II-class feasts of the universal Church, not of the Lord. *) else if (not is_temporal) && (not is_vigil) && rank = Class2 && is_universal layer then 16 (* 17: Days within the Octave of the Nativity (26-28 Dec are Stephen, diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index e8d4a11..63fb0a1 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -18,6 +18,24 @@ val universal_layer : string (** See {!universal_layer}. *) val indult_prefix : string +(** Slug suffix marking a celebration as a vigil (RG 91 entries 21, 26), + e.g. "ef-ascension-vigil". Also colitur's own convention, not an RG + citation, exposed for the same reason as {!universal_layer}: only the + Ascension Vigil exists today (rite_ef/temporal_ef.ml); the Assumption, + John Baptist, Peter & Paul and Lawrence vigils arrive as sanctoral data + in a future task, and must use this same suffix or {!band} will band + them 16/24 (an ordinary feast of the same rank) instead of 21/26. *) +val vigil_suffix : string + +(** Slug prefixes marking a celebration as one of RG 91 entry 18's three + Ember-day sets (Advent, Lent, September -- the Pentecost/Whitsun set is + I class and matched by entry 10 before this is ever consulted). Also + colitur's own convention mirroring rite_ef/temporal_ef.ml's own "ef- + -ember-" slug format, not re-derived from first principles; exposed + so a rename of that format has somewhere to be caught other than a + silently-wrong entry 18. *) +val ember_prefixes : string list + (** Returned for a candidate shape RG 91's 28-entry table has no row for -- e.g. a [Class1] vigil that is not the Nativity or Pentecost (entries 5, 9 are the only I-class vigils the table names), or a [Class4] candidate @@ -29,7 +47,12 @@ val unclassified : int (** [band ctx c]: RG 91's Table of Precedence. Returns the table's own entry number -- I class 1-13, II class 14-21, III class 22-26, IV class 27-28; - lower wins (see {!Precedence.rules.band}). Total over every candidate - {!Precedence.resolve} or {!Calendar} can construct, including shapes the - 1962 table itself does not describe (see {!unclassified}). *) + lower wins (see {!Precedence.rules.band}) -- EXCEPT where the table's own + text states an exception: entry 8 (All Souls, register line 334) reads + "yields to an occurring Sunday", so on a Sunday this returns a value that + loses to entry 15 rather than the literal integer 8 (see the comment on + entry 8 in precedence_ef.ml for the exact value and why). Total over + every candidate {!Precedence.resolve} or {!Calendar} can construct, + including shapes the 1962 table itself does not describe (see + {!unclassified}). *) val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candidate -> int diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index fb05582..7033978 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -30,6 +30,17 @@ let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(subject = Sub.Temporal) ?(l { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~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. *) @@ -76,8 +87,20 @@ let cases = ("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. *) - ("8 All Souls", mk 2026 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 8); + (* 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 @@ -101,11 +124,20 @@ let cases = cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:(PE.indult_prefix ^ "local-grant") "ef-indult-feast-1", 13 ); - (* Entry 14 -- register line 341. *) + (* 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). *) @@ -128,7 +160,10 @@ let cases = 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); - ("18 Lent Ember Wednesday", off (-39), cand ~rank:V.Class2 "ef-lent-ember-wed", 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" @@ -145,10 +180,20 @@ let cases = 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). *) - ("21 Ascension Vigil (temporal-origin)", off 38, cand ~rank:V.Class2 "ef-ascension-vigil", 21); + (* 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. *) @@ -172,6 +217,11 @@ let cases = ( "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 ); (* Entry 27 -- register line 352: an otherwise-unoccupied IV-class Saturday. *) ( "27 Office of the BVM on Saturday", off 62, @@ -187,16 +237,56 @@ let cases = ( "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) + 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 ) + cases + @ [ Alcotest.test_case "8 All Souls yields to a Sunday (resolve-level)" `Quick + test_all_souls_yields_to_sunday ] ) -- cgit v1.3 From 07c87370d6a0de687b42a41d967135578f014bdc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 22:46:30 +0200 Subject: rite(ef): occurrence dispositions (RG 92-95, 33, 94) Precedence_ef.disposition decides the loser's fate in an occurrence: a Commemoration_only celebration is always commemorated (it can never win or transfer); a I- or II-class vigil impeded by any Sunday or a I-class feast is entirely omitted (RG 33), checked before the generic rule below or the Nativity/Pentecost Vigil could wrongly transfer; any other I-class loser transfers (RG 95 -- only I class has the right of translation); everything else is commemorated, with the admit-or-omit decision left to RG 108-111's admission count (Task 9). RG 94 needs no code: resolve always compares a loser against the day's actual winner, never against a departed sibling, so no commemoration can ride along with a transferred feast in this design. This is the branch that completes Task 7's carried All Souls fix: once it loses to an occurring Sunday, its untouched Class1 rank routes it to Transfer via the generic rule, not a special case. Landing on 3 November is Rite.transfer_target's job, not wired up yet. Commemorate carries an interim Precedence.Ordinary privilege pending Task 9's RG 109 implementation, exposed as interim_privilege for that task to replace. Table-driven tests cover each rule, including RG 33's boundary from both sides and a Commemoration_only loser that is also Class1 and vigil-shaped to pin the branch ordering. Mutation-tested: disabling RG 33, either direction of RG 95's rank condition, or the Commemoration_only priority check each fail exactly the rows built to catch them. --- lib/rites/rite_ef/precedence_ef.ml | 95 +++++++++++++++++++++++ lib/rites/rite_ef/precedence_ef.mli | 46 +++++++++++ test/test_precedence_ef.ml | 148 +++++++++++++++++++++++++++++++++++- 3 files changed, 285 insertions(+), 4 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 0716177..d8d64a8 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -214,3 +214,98 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc "feria" that is also a vigil is not a feria RG 91 describes. *) else if (not is_vigil) && rank = Class4 then 28 else unclassified + +(* Task 8: what happens to the day's LOSING candidate (docs/research/ + rules-register.md §4, "Occurrence" RG 92-95 and "Vigils" RG 33, plus RG + 94). [band] above decides who wins; this decides the loser's fate, which + turns on the LOSER's own rank and status (RG 95), except RG 33's vigil + omission, which also has to read the winner. Nothing here ever returns + [Precedence.Repose]: that disposition denotes RG 100-102's *repositio* + (perpetual impediment from a proper/diocesan calendar), out of this + plan's scope -- see calendar.mli's own note that nothing in the EF + ruleset currently emits it. *) + +(* RG 33 (register line 383-384): a I- or II-class vigil falling on any + Sunday or a I-class feast is entirely omitted. Every Sunday slug this + rite's temporal cycle produces -- named (temporal_ef.ml's [named], e.g. + "ef-easter-sunday") or the generic "ef--sunday-" fallback + ([sunday_slug]) -- contains this marker; nothing else [band] classifies + does. Not an RG citation itself -- see [universal_layer]'s note on this + file's own naming conventions -- exposed for the same reason as + {!vigil_suffix}: a future rename of temporal_ef's Sunday-slug format has + somewhere to be caught other than a silently-wrong RG 33 disposition. *) +let sunday_marker = "-sunday" + +let contains_substring s ~needle = + let ls = String.length s and ln = String.length needle in + let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in + ln = 0 || at 0 + +let is_sunday_slug slug = contains_substring slug ~needle:sunday_marker + +(* RG 33's "any Sunday or a I-class feast": every RG 91 entry that can ever + outrank a II-class vigil (entry 21) without itself being a Sunday is I + class by the table's own structure (entries 1-13), so [rank = Class1] + alone already covers every way a I-class vigil (entries 5, 9 -- Nativity, + Pentecost) can be impeded at all; the slug check is what a II-class vigil + impeded by an ordinary Sunday (entry 15, rank Class2) needs, since that + winner's own rank is not Class1. *) +let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) = + let cel = winner.Precedence.cel in + cel.Celebration.rank = Vocab_ef.Class1 + || is_sunday_slug (Slug.to_string cel.Celebration.slug) + +(* Not yet RG 109 (Task 9's job: the closed list of privileged commemorations + and RG 108-111's admission counts). Every [Commemorate] this function + returns carries this one placeholder rather than a silent default, so the + choice is visible and grep-able. [Ordinary] chosen over [Privileged] + deliberately: it grants no admission entitlement RG 111 has not earned, + so code that trusts this value before Task 9 replaces it under-privileges + a commemoration rather than over-privileges one -- the safer direction to + be wrong in. *) +let interim_privilege = Precedence.Ordinary + +let disposition ~(winner : Vocab_ef.rank Precedence.candidate) + ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition = + let open Vocab_ef in + let cel = loser.Precedence.cel in + if cel.Celebration.status = Celebration.Commemoration_only then + (* Always -- checked before RG 33's omission and RG 95's transfer so + neither can override it: a Commemoration_only entry can never win + (Precedence.resolve holds it out of the band contest entirely, see + that module's [resolve]) and, per the brief, can never transfer + either. *) + Precedence.Commemorate interim_privilege + else if + (cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2) + && is_vigil (Slug.to_string cel.Celebration.slug) + && impedes_vigil winner + then + (* RG 33. Checked before the generic Class1 -> Transfer rule below, or a + I-class vigil (Nativity, Pentecost) impeded on its own Sunday/ + I-class-feast terms would wrongly transfer instead of vanishing. *) + Precedence.Omit + else if cel.Celebration.rank = Class1 then + (* RG 95: only I-class feasts have the right of translation. This is the + branch that completes Task 7's All Souls fix (register line 334, RG + 91 entry 8): All Souls is I class and not a vigil, so once it loses + to an occurring Sunday it reaches here and transfers -- to 3 + November per the register, but WHERE it lands is + Rite.transfer_target's job (RG 96), not this function's; disposition + only says THAT it moves. *) + Precedence.Transfer + else + (* RG 95's other branch, for everything below I class: "aut + commemorantur aut penitus omittuntur" -- commemorated or wholly + omitted. Which of the two survives is RG 108-111's admission count + (Task 9's [admit]), not this function's decision; this only opens the + commemoration. + + RG 94 (a fixed-day commemoration is not carried along with a + transferred feast) needs no code here: [Precedence.resolve] calls + this function once per loser, always against the day's actual + [observed] winner -- never against a fellow loser that itself + transferred away -- so no mechanism exists by which a commemoration + could ride along with a departing feast in the first place; there is + nothing to suppress. *) + Precedence.Commemorate interim_privilege diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 63fb0a1..9fe8789 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -56,3 +56,49 @@ val unclassified : int including shapes the 1962 table itself does not describe (see {!unclassified}). *) val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candidate -> int + +(** RG 33's marker: every Sunday slug this rite's temporal cycle produces + (temporal_ef.ml's [named] and [sunday_slug]) contains this substring; + nothing else {!band} classifies does. Also colitur's own convention, not + an RG citation -- see {!universal_layer} -- exposed for the same reason + as {!vigil_suffix}: a rename of temporal_ef's Sunday-slug format has + somewhere to be caught other than a silently-wrong RG 33 disposition. *) +val sunday_marker : string + +(** The placeholder {!Precedence.privilege} every [Commemorate] disposition + below carries until Task 9 implements RG 109's closed list of privileged + commemorations and RG 108-111's admission counts. Exposed so Task 9 (and + any test wanting to assert on it explicitly) does not have to duplicate + the literal [Precedence.Ordinary]. *) +val interim_privilege : Precedence.privilege + +(** [disposition ~winner ~loser]: RG 92-95, 33, 94 (docs/research/ + rules-register.md §4, "Occurrence" and "Vigils"). What becomes of a + losing candidate, decided by the LOSER's own rank and status (RG 95), + except RG 33's vigil omission, which also reads the winner: + - a {!Celebration.status} of [Commemoration_only] is always + [Commemorate] (checked first: it can never win -- see + {!Precedence.resolve} -- and, by that same status's own definition, + already denotes an office with nothing left to translate, so it never + transfers either; not itself a further RG citation beyond RG 93's + general four-mechanism statement above); + - a [Class1] or [Class2] loser whose slug marks it a vigil ({!vigil_suffix}) + is [Omit] when the winner is any Sunday ({!sunday_marker}) or itself + [Class1] (RG 33 -- entirely omitted, not merely commemorated); + - any other [Class1] loser is [Transfer] (RG 95 -- only I class has the + right of translation; this is also what moves All Souls, register + line 334, once it loses to an occurring Sunday -- WHERE it lands is + {!Rite.t.transfer_target}'s job, not this function's); + - everything else is [Commemorate], carrying {!interim_privilege} until + Task 9 replaces it with RG 109's real per-day computation. + + Total over every winner/loser pair {!Precedence.resolve} or {!Calendar} + can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both + closed variants, so the four cases above exhaust every representable + shape -- there is no fifth, "unclassified" case the way {!band} needs + one, because this function's own return type has no such slot to fall + into by accident. *) +val disposition : + winner:Vocab_ef.rank Precedence.candidate -> + loser:Vocab_ef.rank Precedence.candidate -> + Precedence.disposition diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 7033978..3b2d5f6 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -25,9 +25,9 @@ let mk y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e - 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) ?(subject = Sub.Temporal) ?(layer = "temporal") - slug = - { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~colour:Col.White ~subject ~layer (); +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 @@ -281,6 +281,136 @@ let test_all_souls_yields_to_sunday () = "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" ); + (* 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)" ); + (* 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. *) + ( "Commemoration_only loser is always Commemorate, even if I-class and \ + vigil-shaped, even losing to a Sunday", + an_ordinary_sunday, + cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer + "ef-suppressed-vigil", + "Commemorate(Ordinary)" ); + (* 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)" ) + ] + +(* 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) + let suite = ( "Precedence_ef", List.map @@ -289,4 +419,14 @@ let suite = 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 ] ) + 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 + @ [ 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 ] ) -- cgit v1.3 From 2ad350e0f97a99f76ac1d2d95cfa210f7393c777 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 23:25:39 +0200 Subject: rite(ef): commemoration privilege and admission (RG 108-111) Precedence_ef.privilege_of classifies a commemoration candidate against RG 109's closed list of privileged commemorations (a Sunday; a I-class day; a day within the Octave of the Nativity; a September Ember day; a feria of Advent, Lent or Passiontide; the Major Rogations in Mass), read entirely off the candidate's own rank/slug/origin, no context needed. Major Rogations (f) is left unimplemented rather than guessed: no producer for the Major Litanies exists anywhere in this codebase yet. disposition's two Commemorate sites now call privilege_of instead of Task 8's interim_privilege placeholder, which is removed entirely (binding and .mli export both gone). Precedence_ef.admit applies RG 111's four admission counts, keyed on the observed day's own class and Sunday-ness: a I-class day admits none except one privileged commemoration; a II-class Sunday admits one, but a privileged commemoration due displaces any ordinary one regardless of dignity; any other II-class day admits one by dignity alone, with no such override; III/IV-class days admit at most two by dignity. Ties break on slug, matching Precedence.compare_by, so the admitted set never depends on input order. Every admitted candidate is a value taken unchanged from the input list, never rebuilt, so Precedence.resolve's physical-equality-based dropped/omitted accounting keeps working -- closing a note left open since Task 2. Tests: RG 109 category rows (one per reachable category, plus boundary rows proving Advent/Lent Ember days and Minor Rogations are correctly excluded), RG 111 admission rows checked on slug identity rather than count, an order-independence check, and three end-to-end integration tests proving the admission limit's drop lands in resolution.omitted rather than vanishing. --- lib/rites/rite_ef/precedence_ef.ml | 224 ++++++++++++++++++++++++-- lib/rites/rite_ef/precedence_ef.mli | 62 ++++++-- test/test_precedence_ef.ml | 310 +++++++++++++++++++++++++++++++++++- 3 files changed, 567 insertions(+), 29 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 02181ed..06bbf7c 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -74,8 +74,18 @@ let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug those three; the Whitsun (Pentecost) set is I class and falls inside the Pentecost octave, entry 10, matched below before this is ever reached. Exposed for the same reason as [vigil_suffix]: a rename of temporal_ef's - format has somewhere to be caught other than a silently-wrong entry 18. *) -let ember_prefixes = [ "ef-advent-ember-"; "ef-lent-ember-"; "ef-september-ember-" ] + format has somewhere to be caught other than a silently-wrong entry 18. + + [september_ember_prefix] is broken out as its own name (rather than an + anonymous list literal) because Task 9's [privilege_of] needs to test the + September set alone, RG 109 privileging it while leaving the Advent and + Lent sets ordinary (register lines 375-376) -- building [ember_prefixes] + from it rather than duplicating the literal keeps the two from silently + drifting apart. *) +let advent_ember_prefix = "ef-advent-ember-" +let lent_ember_prefix = "ef-lent-ember-" +let september_ember_prefix = "ef-september-ember-" +let ember_prefixes = [ advent_ember_prefix; lent_ember_prefix; september_ember_prefix ] let is_ember_18 slug = List.exists (fun prefix -> String.starts_with ~prefix slug) ember_prefixes @@ -259,15 +269,95 @@ let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) = cel.Celebration.rank = Vocab_ef.Class1 || is_sunday_slug (Slug.to_string cel.Celebration.slug) -(* Not yet RG 109 (Task 9's job: the closed list of privileged commemorations - and RG 108-111's admission counts). Every [Commemorate] this function - returns carries this one placeholder rather than a silent default, so the - choice is visible and grep-able. [Ordinary] chosen over [Privileged] - deliberately: it grants no admission entitlement RG 111 has not earned, - so code that trusts this value before Task 9 replaces it under-privileges - a commemoration rather than over-privileges one -- the safer direction to - be wrong in. *) -let interim_privilege = Precedence.Ordinary +(* RG 91 entry 17's own slug convention (rite_ef/temporal_ef.ml's [named]: + "ef-nativity-octave-day-%d" for 29-31 Dec -- 26-28 Dec are Stephen, John, + the Innocents, sanctoral, and never carry this prefix, see [band]'s entry + 17 comment). Not an RG citation itself -- see [universal_layer] -- reused + below by [privilege_of] for RG 109(c). *) +let nativity_octave_prefix = "ef-nativity-octave-day-" + +(* RG 109's own three named seasons for (e), "of ferias of Advent, Lent and + Passiontide" (register line 376) -- temporal_ef.ml's generic + -- ferial fallback slugs, whose season word is + [season_slug_word]'s output for exactly these three (vocab_ef.ml: Advent + and Passiontide are unmodified [season_to_string]; Lent likewise). Also + matches the Lent "after Ashes" sub-case ("ef-lent-after-ashes-", + temporal_ef.ml's own [christmastide_feria_slug]-adjacent branch), which + is still a Lent feria under this same prefix. Not an RG citation -- see + [universal_layer] -- private: nothing outside [privilege_of] needs it. *) +let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ] + +(* RG 109 (register lines 374-377, docs/research/rules-register.md §4): the + closed list of privileged commemorations, checked in the register's own + lettered order. A candidate matching none of (a)-(f) is ordinary, per the + register's own closing sentence, "All others are ordinary." Read entirely + off the candidate's own fields (rank, slug, origin) -- no [context] + (date/season/weekday) is available or needed: every category names a + property of the commemorated OFFICE ITSELF ("a commemoration OF a + Sunday", "OF a I-class day", ...), not of the day it happens to fall on, + and each of (a)-(e) already has a candidate-only marker this file's own + conventions establish ([sunday_marker], rank, [nativity_octave_prefix], + [september_ember_prefix]/[alp_feria_prefixes]) -- see the task report for + the full reasoning. + + [disposition] below is this function's only caller, at both of its + [Commemorate] sites -- replacing Task 8's [interim_privilege] placeholder, + which always returned [Ordinary] regardless of the loser's real shape. + [admit] (RG 108-111's admission counts, below) trusts the privilege value + [disposition] has already attached rather than recomputing it here a + second time. *) +let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege = + let cel = c.Precedence.cel in + let rank = cel.Celebration.rank in + let slug = Slug.to_string cel.Celebration.slug in + let is_temporal = c.Precedence.origin = Precedence.Temporal in + let open Vocab_ef in + (* (a) register line 374: "of a Sunday" -- the same slug marker RG 33's + [impedes_vigil] already reads to answer "is this candidate a Sunday". *) + if is_sunday_slug slug then Precedence.Privileged + (* (b) register line 374-375: "of a I-class day" -- the candidate's own + rank. In this codebase's current disposition rules the ONLY way a + [Class1] candidate ever reaches [Commemorate] at all is via + [Celebration.status = Commemoration_only] (a plain [Feast]-status + [Class1] loser always [Transfer]s instead, RG 95, below) -- so this + branch is real but its only reachable witness today is that shape; see + the task report. *) + else if rank = Class1 then Precedence.Privileged + (* (c) register line 375: "of days within the Octave of the Nativity". *) + else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then + Precedence.Privileged + (* (d) register line 375-376: "of September Ember days" -- deliberately + ONLY the September set: RG 109 does not list the Advent or Lent Ember + sets (also II class, RG 91 entry 18), so those must fall through to + "ordinary", not be caught here or at (e) below. *) + else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then + Precedence.Privileged + (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" -- + [not (is_ember_18 slug)] is required, not redundant with (d): the + Advent and Lent Ember prefixes ("ef-advent-ember-", "ef-lent-ember-") + also start with this branch's own [alp_feria_prefixes] entries + ("ef-advent-", "ef-lent-"), and RG 109 does not privilege them (see (d) + above) -- without this exclusion they would wrongly match here. *) + else if is_temporal + && (not (is_ember_18 slug)) + && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes + then Precedence.Privileged + (* (f) register line 376-377: "of the Major Rogations, in Mass" -- the + Major Litanies (25 April, RG 80) are not yet computed anywhere in this + codebase (temporal_ef.ml's own comment on [temporal]'s Rogation branch: + "The Major Litanies... are a fixed date and are not yet computed; they + arrive with Plan 3's sanctoral"), so no candidate this engine can + currently construct represents one. There is no existing slug + convention to anchor a check to, and guessing one risks silently + misclassifying whatever a future task does name it -- a wrong citation + is worse than a missing one, so this is left unimplemented and flagged + in the task report rather than guessed. Deliberately NOT matched by + anything above: the Minor Litanies/Rogations ("ef-rogation-monday"/ + "-tuesday", RG 87) temporal_ef.ml DOES compute are a different + observance RG 109(f) does not name (RG 88: the Minor Rogations change + nothing in the Office at all), so they correctly fall through to + "ordinary" below, not this category. *) + else Precedence.Ordinary let disposition ~(winner : Vocab_ef.rank Precedence.candidate) ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition = @@ -278,8 +368,12 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) neither can override it: a Commemoration_only entry can never win (Precedence.resolve holds it out of the band contest entirely, see that module's [resolve]) and, per the brief, can never transfer - either. *) - Precedence.Commemorate interim_privilege + either. Its privilege is [privilege_of loser] like every other + [Commemorate] below -- Commemoration_only carries a real [rank] for + exactly this purpose (Celebration.mli: "RG 111 orders admitted + commemorations by dignity"), so RG 109(b) applies to it precisely as + it would to any other candidate. *) + Precedence.Commemorate (privilege_of loser) else if (cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2) && is_vigil (Slug.to_string cel.Celebration.slug) @@ -302,8 +396,9 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) (* RG 95's other branch, for everything below I class: "aut commemorantur aut penitus omittuntur" -- commemorated or wholly omitted. Which of the two survives is RG 108-111's admission count - (Task 9's [admit]), not this function's decision; this only opens the - commemoration. + ([admit], below), not this function's decision; this only opens the + commemoration, tagged with its real RG 109 privilege via + [privilege_of]. RG 94 (a fixed-day commemoration is not carried along with a transferred feast) needs no code here: [Precedence.resolve] calls @@ -312,4 +407,101 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) transferred away -- so no mechanism exists by which a commemoration could ride along with a departing feast in the first place; there is nothing to suppress. *) - Precedence.Commemorate interim_privilege + Precedence.Commemorate (privilege_of loser) + +(* Task 9: how many of the day's commemorations RG 111 admits, and which + (docs/research/rules-register.md §4, register line 378, "Commemorations" + RG 111). [band] decides who wins the day; [disposition] decides who is + even eligible to be commemorated, and tags each with its RG 109 privilege + via [privilege_of]; this decides how many of THOSE survive. + + RG 111 keys its four admission rules off the CLASS OF THE DAY ("diebus I + classis", "dominicis II classis", "aliis diebus II classis", "diebus III + et IV classis") -- read here off [observed]'s own [rank] and, for the + Sunday/non-Sunday II-class split, the same slug marker [privilege_of] and + RG 33's [impedes_vigil] already use ([is_sunday_slug]). No [context] + (date/season/weekday) is available to [admit] (see precedence.mli's + [rules.admit]) or needed: [observed] IS the day's own celebration, so its + rank and slug already carry everything RG 111's own four categories test. *) + +(* RG 8's four-class dignity order, Class1 highest. Deliberately NOT [band] + (RG 91's much finer 28-entry table): [band] needs a [context] [admit] + does not have (see above), and Celebration.mli's own comment on [status] + -- "RG 111 orders admitted commemorations by dignity" -- names [rank] + itself as that dignity, not the finer occurrence-table entry. *) +let dignity = function + | Vocab_ef.Class1 -> 1 + | Vocab_ef.Class2 -> 2 + | Vocab_ef.Class3 -> 3 + | Vocab_ef.Class4 -> 4 + +(* Deterministic selection order for RG 111: dignity first, then slug -- + the same tie-break {!Precedence.compare_by} uses for [band] itself (the + brief: "break ties on slug"), so which candidate wins a shared rank never + depends on the order [comms] arrives in. *) +let compare_dignity (a, _) (b, _) = + let da = dignity a.Precedence.cel.Celebration.rank + and db = dignity b.Precedence.cel.Celebration.rank in + if da <> db then Int.compare da db + else Slug.compare a.Precedence.cel.Celebration.slug b.Precedence.cel.Celebration.slug + +let rec take n = function + | [] -> [] + | x :: xs -> if n <= 0 then [] else x :: take (n - 1) xs + +let admit ~(observed : Vocab_ef.rank Precedence.candidate) + (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) : + (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list = + (* Sorted once, by dignity then slug (see [compare_dignity]); every branch + below either takes a prefix of this list or filters it, so the RESULT + is always a sub-list of [comms] with its elements untouched -- never + rebuilt -- which matters beyond determinism: {!Precedence.resolve}'s + own [dropped] computation tells an admitted candidate from a dropped + one by physical equality (==) on the candidate value (Task 2's own + deferred note: "assuming admit returns the same candidate values rather + than rebuilt ones; undocumented for rite authors" -- documented here, + now that this is the function that note was about). Building a fresh + [{ c with ... }] record anywhere below would silently defeat that + accounting: the dropped candidate would then match nothing in + [admitted], and {!Precedence.resolve} would count it as dropped a + SECOND time (once for real, once because its identity no longer + matches its own admitted copy) without ever raising -- a silent + double-count, not a crash, which is exactly why this comment exists. *) + let sorted = List.stable_sort compare_dignity comms in + let is_privileged (_, p) = p = Precedence.Privileged in + let observed_rank = observed.Precedence.cel.Celebration.rank in + let observed_is_sunday = + is_sunday_slug (Slug.to_string observed.Precedence.cel.Celebration.slug) + in + let open Vocab_ef in + match (observed_rank, observed_is_sunday) with + | Class1, _ -> + (* RG 111: "I class: none save one privileged." Ordinary commemorations + never get a slot at all on a I-class day, no matter how many are + due; at most one privileged one does, the highest-dignity one if + several are. *) + (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ best ]) + | Class2, true -> + (* RG 111: "II-class Sundays: one (dropped if a privileged one is + due)." Read as: the day's one slot goes to a privileged + commemoration whenever one is due, categorically -- not by + comparing its dignity against the ordinary contender's -- so an + ordinary commemoration that would otherwise win the slot on raw + dignity is still dropped once any privileged commemoration is also + due. This is the asymmetric clause the brief and task report flag + as deliberate, not present at "other II class" below; see the task + report for the reasoning and its residual uncertainty (the register + does not spell out the mechanism beyond this one sentence). *) + (match List.filter is_privileged sorted with + | best :: _ -> [ best ] + | [] -> ( match sorted with [] -> [] | best :: _ -> [ best ] )) + | Class2, false -> + (* RG 111: "other II class: one" -- no privilege-override clause here, + unlike the Sunday case immediately above, so the day's one slot + goes to whichever candidate outranks the rest by dignity alone, + privileged or not. *) + (match sorted with [] -> [] | best :: _ -> [ best ]) + | (Class3 | Class4), _ -> + (* RG 111: "III-IV class: at most two" -- by dignity, same as the + non-Sunday II-class case, just with room for two. *) + take 2 sorted diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 9fe8789..9c3af95 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -65,13 +65,6 @@ val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candid somewhere to be caught other than a silently-wrong RG 33 disposition. *) val sunday_marker : string -(** The placeholder {!Precedence.privilege} every [Commemorate] disposition - below carries until Task 9 implements RG 109's closed list of privileged - commemorations and RG 108-111's admission counts. Exposed so Task 9 (and - any test wanting to assert on it explicitly) does not have to duplicate - the literal [Precedence.Ordinary]. *) -val interim_privilege : Precedence.privilege - (** [disposition ~winner ~loser]: RG 92-95, 33, 94 (docs/research/ rules-register.md §4, "Occurrence" and "Vigils"). What becomes of a losing candidate, decided by the LOSER's own rank and status (RG 95), @@ -89,8 +82,8 @@ val interim_privilege : Precedence.privilege right of translation; this is also what moves All Souls, register line 334, once it loses to an occurring Sunday -- WHERE it lands is {!Rite.t.transfer_target}'s job, not this function's); - - everything else is [Commemorate], carrying {!interim_privilege} until - Task 9 replaces it with RG 109's real per-day computation. + - everything else is [Commemorate], carrying its real RG 109 privilege + (see {!admit} below). Total over every winner/loser pair {!Precedence.resolve} or {!Calendar} can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both @@ -102,3 +95,54 @@ val disposition : winner:Vocab_ef.rank Precedence.candidate -> loser:Vocab_ef.rank Precedence.candidate -> Precedence.disposition + +(** Slug prefix marking a celebration as one of RG 91 entry 17's days within + the Octave of the Nativity (29-31 Dec -- 26-28 Dec are Stephen, John, the + Innocents, sanctoral, never this prefix). Also colitur's own convention + mirroring rite_ef/temporal_ef.ml's own "ef-nativity-octave-day-%d" slug + format, not an RG citation -- see {!universal_layer} -- exposed for the + same reason as {!vigil_suffix}: a rename of that format has somewhere to + be caught other than a silently-wrong RG 109(c) privilege. *) +val nativity_octave_prefix : string + +(** The September set of {!ember_prefixes}, broken out on its own because RG + 109(d) privileges September Ember days specifically while leaving the + Advent and Lent sets (also {!ember_prefixes}) ordinary -- register lines + 375-376. {!ember_prefixes} is built from this constant, not a duplicated + literal, so the two cannot silently drift apart. *) +val september_ember_prefix : string + +(** [admit ~observed comms]: RG 108-111 (docs/research/rules-register.md §4, + "Commemorations", register lines 371-379). How many of [comms] -- each + already tagged with its real RG 109 privilege by {!disposition} -- RG + 111 admits, and which, given the day actually observed: + - [observed] a [Class1] day: none, except at most one privileged + commemoration (the highest-dignity one, if several are due) -- an + ordinary one is never admitted here, no matter how many are due; + - [observed] a [Class2] Sunday (its slug carries {!sunday_marker}): one + -- a privileged commemoration takes the day's one slot over any + ordinary one whenever one is due, not by comparing dignity, so an + ordinary commemoration that would otherwise win on dignity is still + dropped; + - [observed] any other [Class2] day: one, by dignity alone -- no + privilege override, unlike the Sunday case immediately above; + - [observed] a [Class3] or [Class4] day: at most two, by dignity alone. + + "Dignity" here is [Vocab_ef.rank] (RG 8's four classes), NOT {!band}'s + finer RG 91 entry number -- {!band} needs a [context] (date/season/ + weekday) this function does not receive (see {!Precedence.rules.admit}). + Ties break on slug, matching {!Precedence.compare_by}, so the result + never depends on the order [comms] arrives in. + + Every candidate this returns is a value taken unchanged from [comms], + never rebuilt: {!Precedence.resolve}'s own [dropped]/[omitted] + accounting tells an admitted candidate from a dropped one by physical + equality on the candidate value, so anything this function admitted + stays admitted, and anything it did not is reported in + {!Precedence.resolution.omitted}, never silently lost. Total: every + [Vocab_ef.rank] is one of the four cases above, and every branch is + itself total over an empty or arbitrarily long [comms]. *) +val admit : + observed:Vocab_ef.rank Precedence.candidate -> + (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list -> + (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 3b2d5f6..f3fd357 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -352,13 +352,25 @@ let disposition_cases = 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. *) + 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", + 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(Ordinary)" ); + "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. *) @@ -372,6 +384,276 @@ let disposition_cases = "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) + (* 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 @@ -426,7 +708,27 @@ let suite = 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 ] ) + 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 ] ) -- cgit v1.3 From 3583448631c1f1cf0767728d605a858fcb810cfc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 23:41:33 +0200 Subject: rite(ef): an impeded I-class Sunday commemorates, not transfers disposition's Class1 branch was unconditional on rank, so an impeded I-class Sunday (Advent/Lent/Passiontide/Low Sunday) currently transferred like a feast. 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, line 332) from feasts (entries 11-13, lines 337-339) -- and RG 109(a) (line 374) lists "of a Sunday" as a privileged commemoration category, which presupposes an impeded Sunday stays put rather than moving to another day. Excludes is_sunday_slug losers from the Transfer branch so they fall through to the existing Commemorate (privilege_of loser) branch, which already tags them Privileged via RG 109(a) with no further change. Fires on real dates in the 2005-2050 differential window: 24 December on Advent IV in 2023, 2028, 2034, 2045; 8 December on an Advent Sunday in 2024, 2030, 2041. Verified no previously-passing Transfer assertion used a Sunday-shaped loser (grepped test_precedence_ef.ml, test_calendar.ml -- which uses its own synthetic rite unrelated to Precedence_ef.disposition -- test_colitur.ml, test_validate.ml); confirmed by mutation-reverting the fix and observing exactly the one new test fail, nothing else. --- lib/rites/rite_ef/precedence_ef.ml | 36 +++++++++++++++++++++++++----------- lib/rites/rite_ef/precedence_ef.mli | 27 +++++++++++++++++---------- test/test_precedence_ef.ml | 22 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 21 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 06bbf7c..ff6f991 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -383,20 +383,34 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) I-class vigil (Nativity, Pentecost) impeded on its own Sunday/ I-class-feast terms would wrongly transfer instead of vanishing. *) Precedence.Omit - else if cel.Celebration.rank = Class1 then - (* RG 95: only I-class feasts have the right of translation. This is the + else if + cel.Celebration.rank = Class1 + && not (is_sunday_slug (Slug.to_string cel.Celebration.slug)) + then + (* RG 95 (register lines 323, 363): only I-class FEASTS have the right + of translation -- RG 91's own table lists Sundays as a separate row + (entry 6, register line 332) from feasts (entries 11-13, register + lines 337-339), so a Sunday is never a "feast" in RG 95's sense, and + [is_sunday_slug] (the same marker RG 33's [impedes_vigil] and RG + 109(a)'s [privilege_of] already use) excludes it here. This is the branch that completes Task 7's All Souls fix (register line 334, RG - 91 entry 8): All Souls is I class and not a vigil, so once it loses - to an occurring Sunday it reaches here and transfers -- to 3 - November per the register, but WHERE it lands is - Rite.transfer_target's job (RG 96), not this function's; disposition - only says THAT it moves. *) + 91 entry 8): All Souls is I class, not a vigil, and not a Sunday + slug, so once it loses to an occurring Sunday it still reaches here + and transfers -- to 3 November per the register, but WHERE it lands + is Rite.transfer_target's job (RG 96), not this function's; + disposition only says THAT it moves. *) Precedence.Transfer else - (* RG 95's other branch, for everything below I class: "aut - commemorantur aut penitus omittuntur" -- commemorated or wholly - omitted. Which of the two survives is RG 108-111's admission count - ([admit], below), not this function's decision; this only opens the + (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" -- + commemorated or wholly omitted. Reached by everything below I class, + AND by an impeded I-class Sunday (excluded from the [Transfer] branch + above): RG 109(a) (register line 374) lists "of a Sunday" as a + privileged commemoration category, which presupposes an impeded + Sunday stays put rather than moving to another day the way a feast + does -- [privilege_of] tags it [Privileged] via the same + [is_sunday_slug] marker, with no further code needed here. Which of + commemorate/omit survives is RG 108-111's admission count ([admit], + below), not this function's decision; this only opens the commemoration, tagged with its real RG 109 privilege via [privilege_of]. diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 9c3af95..e1b3638 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -78,19 +78,26 @@ val sunday_marker : string - a [Class1] or [Class2] loser whose slug marks it a vigil ({!vigil_suffix}) is [Omit] when the winner is any Sunday ({!sunday_marker}) or itself [Class1] (RG 33 -- entirely omitted, not merely commemorated); - - any other [Class1] loser is [Transfer] (RG 95 -- only I class has the - right of translation; this is also what moves All Souls, register - line 334, once it loses to an occurring Sunday -- WHERE it lands is - {!Rite.t.transfer_target}'s job, not this function's); - - everything else is [Commemorate], carrying its real RG 109 privilege - (see {!admit} below). + - any other [Class1] loser that is NOT a Sunday ({!sunday_marker}) is + [Transfer] (RG 95, register lines 323, 363 -- only I-class FEASTS have + the right of translation; RG 91's own table lists Sundays as a + separate row, entry 6, from feasts, entries 11-13, so a Sunday is + never a "feast" in RG 95's sense and does not transfer even when + impeded by a higher I-class day. This is also what moves All Souls, + register line 334, once it loses to an occurring Sunday -- WHERE it + lands is {!Rite.t.transfer_target}'s job, not this function's); + - everything else -- including an impeded I-class Sunday -- is + [Commemorate], carrying its real RG 109 privilege (see {!admit} + below); RG 109(a) (register line 374) lists "of a Sunday" as a + privileged commemoration category precisely because an impeded Sunday + stays put rather than moving to another day. Total over every winner/loser pair {!Precedence.resolve} or {!Calendar} can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both - closed variants, so the four cases above exhaust every representable - shape -- there is no fifth, "unclassified" case the way {!band} needs - one, because this function's own return type has no such slot to fall - into by accident. *) + closed variants, so the cases above exhaust every representable shape -- + there is no fifth, "unclassified" case the way {!band} needs one, + because this function's own return type has no such slot to fall into + by accident. *) val disposition : winner:Vocab_ef.rank Precedence.candidate -> loser:Vocab_ef.rank Precedence.candidate -> diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index f3fd357..09c41fd 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -318,6 +318,28 @@ let disposition_cases = 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 -- cgit v1.3 From 21dab521d6bfe8e4e7d04f761c11754059339ba2 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 23:46:51 +0200 Subject: test(ef): prove the II-class-Sunday privilege override end-to-end Adds test_ii_class_sunday_privileged_witness_admitted_end_to_end, proving RG 111's "II-class Sundays: one, dropped if a privileged one is due" clause through the real pipeline (band, disposition, privilege_of, admit together), not only at the isolated admit level. RG 109(b) is the one privileged category that is sanctoral and rank-based rather than temporal-origin: a Commemoration_only, Class1 sanctoral candidate (the same shape disposition_cases already uses as (b)'s witness) is held out of the band contest entirely by Precedence.resolve, so it always loses regardless of what wins the day. Paired with an ordinary sanctoral saint, both losing to an ordinary Class2 Sunday, gives observed_is_sunday = true with one privileged and one ordinary loser due at once, with no new fixture. Documents, and mutation-confirms, what this fixture does and does not prove: RG 109(b)'s only reachable witness is Class1 (the highest dignity), so the privileged loser here is unavoidably also the higher-dignity one, and the admission outcome alone cannot distinguish "privilege overrides dignity" from "dignity alone picked the same winner" -- that distinction remains admit_cases's job. What this test does prove, and what a category-(b) regression in privilege_of breaks, is the privilege tag itself surviving the real pipeline end-to-end. --- test/test_precedence_ef.ml | 68 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) (limited to 'test/test_precedence_ef.ml') diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 09c41fd..a95b321 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -676,6 +676,69 @@ let test_privileged_lent_feria_admitted_end_to_end () = 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 @@ -753,4 +816,7 @@ let suite = 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 ] ) + 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 ] ) -- cgit v1.3 From 7f183c847f8b67c88ad6ea3bf2d635c5c0534651 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 00:44:36 +0200 Subject: cli: colitur day dumps resolved liturgical days Assembles Rite_ef.context (lib/rites/rite_ef/rite_ef.ml[i]): temporal, anchors and vocab from Temporal_ef; rules from Precedence_ef's band, disposition and admit; season_runs = Vocab_ef.seasons; transfer_target newly implemented here. transfer_target (RG 96): the next following day that is not I or II class, with the Annunciation's own exception (Monday after Low Sunday). Terminates by a structural step bound on its internal search, independent of Calendar's own round guard, which bounds rounds across a year, not one call's walk; documented as an obligation on rite.mli's transfer_target field, which did not previously state it. Fixes the vigil-naming mismatch Task 7's review predicted: the sanctoral bootstrap names its vigils with a vigil-of-X prefix (lectio's own convention), while Precedence_ef's is_vigil only recognised the temporal cycle's own X-vigil suffix. Both are now recognised, fixing RG 91 entries 21/26 and RG 33's vigil omission for the four affected celebrations. Verified by unit test and by mutation-testing the fix (reverting it fails exactly the new rows) and against real output across several years. Suppresses data/ef/sanctoral.sexp's vigil-of-christmas via a new overlay, data/ef/adjustments.sexp: it is the same celebration as the temporal cycle's own ef-nativity-vigil, both dated 24 December. colitur day : one line per civil-year day, temporal and sanctoral fully resolved through Layer, Overlay, Precedence_ef and Calendar -- the first CLI path exercising the whole Plan 3 pipeline against real data. Verified the All Souls transfer chain (Tasks 7-8-11) end to end against real output for both a Sunday year (2025, lands on 3 Nov) and a non-Sunday year (2026, observed directly on 2 Nov). --- bin/main.ml | 137 +++++++++++++++++++++++++++++++++++- data/ef/adjustments.sexp | 13 ++++ lib/kernel/rite.mli | 22 +++++- lib/rites/rite_ef/precedence_ef.ml | 112 +++++++++++++++++++++++++++-- lib/rites/rite_ef/precedence_ef.mli | 68 ++++++++++++++++-- lib/rites/rite_ef/rite_ef.ml | 24 +++++++ lib/rites/rite_ef/rite_ef.mli | 35 +++++++++ test/cli.t | 67 +++++++++++++++++- test/dune | 2 +- test/test_precedence_ef.ml | 130 +++++++++++++++++++++++++++++++++- 10 files changed, 594 insertions(+), 16 deletions(-) create mode 100644 data/ef/adjustments.sexp create mode 100644 lib/rites/rite_ef/rite_ef.ml create mode 100644 lib/rites/rite_ef/rite_ef.mli (limited to 'test/test_precedence_ef.ml') diff --git a/bin/main.ml b/bin/main.ml index 896a6bd..53cae87 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -41,8 +41,142 @@ let temporal_report y = d := D.add_days !d 1 done +(* Task 11: the fully resolved EF calendar (temporal AND sanctoral, + occurrence and transfers applied), one line per civil-year day -- + "YYYY-MM-DD weekday season week slug rank colour [+commemoration-slug]...". + [temporal_report] above only ever showed the temporal cycle in isolation + ([Rite_ef.Temporal_ef.temporal] directly, no sanctoral layer, no + [Precedence] contest); this is the first CLI path that runs every piece + Plan 3 built -- [Colitur_kernel.Layer], [Overlay], [Precedence_ef], + [Calendar] -- against real data. *) + +(* [data/ef/sanctoral.sexp] and [data/ef/adjustments.sexp] are located + relative to the BUILD TREE, not the process's own cwd: cwd varies with + how the binary is invoked (a user's shell for `dune exec colitur --`, a + dune cram test's own sandboxed temp directory for `test/cli.t`) and + nothing in this project's build pins it to the repository root. A + build-time constant substituted via dune's [%{workspace_root}] was tried + first and rejected: it is resolved RELATIVE TO THE BUILD ACTION'S OWN + directory (empirically "." here, not an absolute path -- dune keeps + build actions relocatable), so it silently reproduces the same + cwd-dependence this is trying to eliminate, just baked in at build time + instead of read at run time; confirmed by the resulting `colitur day` + failing to find its own data outside the exact directory the build + happened to run in. + + [Sys.executable_name] does not have that problem -- on Linux it resolves + through /proc/self/exe, which the kernel always reports as the + executable's own canonical absolute path, even when the process was + launched through a symlink (verified against dune's own cram sandbox, + which places exactly such a symlink; see the task report). dune's default + ("no [(sandbox ...)] declared") build context mirrors the ENTIRE source + tree under _build/default/, unconditionally, so climbing from + _build/default/bin/main.exe up two directories and back down into data/ + always finds both files, regardless of the caller's own cwd. + + Known limitation, not yet exercised by this project: a `dune install`- + style deployment (executable copied to a prefix with no adjacent _build/ + default/data/) would need a different resolution strategy; there is no + install story yet (README.md: `dune exec` only), so this is not a + regression against anything this project currently supports. *) +let data_dir () = Filename.dirname (Filename.dirname Sys.executable_name) ^ "/data/ef" + +(* Loads the universal sanctoral layer and applies the one hand-authored + overlay over it (data/ef/adjustments.sexp -- see that file's own header): + [Overlay.apply]'s diagnostics are never silently dropped (Overlay.mli), + so any that come back -- expected to be none in the committed data; see + the overlay file's own comment on when one WOULD fire -- are printed to + stderr, loudly, without aborting the run. *) +let load_ef_layer () = + let dir = data_dir () in + let sanctoral_path = Filename.concat dir "sanctoral.sexp" in + let adjustments_path = Filename.concat dir "adjustments.sexp" in + match Colitur_kernel.Layer.load Rite_ef.Vocab_ef.rank_of_sexp sanctoral_path with + | Error e -> Error (Printf.sprintf "failed to load %s: %s" sanctoral_path e) + | Ok layer -> ( + match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp adjustments_path with + | Error e -> Error (Printf.sprintf "failed to load %s: %s" adjustments_path e) + | Ok overlay -> + let layer, diagnostics = Colitur_kernel.Overlay.apply layer overlay in + List.iter + (fun d -> Printf.eprintf "colitur: %s\n" (Colitur_kernel.Overlay.diagnostic_to_string d)) + diagnostics; + Ok layer) + +let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) + = + let t = d.Colitur_kernel.Liturgical_day.temporal in + let cel = d.Colitur_kernel.Liturgical_day.observed in + let week = + match t.Colitur_kernel.Temporal.week with Some n -> string_of_int n | None -> "-" + in + let commemoration_suffix (c, _) = + " +" ^ Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug + in + let commemorations = + String.concat "" (List.map commemoration_suffix d.Colitur_kernel.Liturgical_day.commemorations) + in + Printf.printf "%s %s %s %s %s %s %s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + (D.weekday_to_string t.Colitur_kernel.Temporal.weekday) + (Rite_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season) + week + (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug) + (Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank) + (Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour) + commemorations + +(* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- + NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single + Advent-anchored liturgical year, which straddles two civil years, so a + civil year's worth of output needs the tail of the liturgical year that + opened the PREVIOUS civil year (covers roughly 1 Jan - 28 Nov) plus the + liturgical year that opens within this one (roughly 29 Nov - 31 Dec). + Both are computed once each -- not once per day via [Calendar.day], which + would recompute the whole (~365-day) placement pass up to 365 times over + for the days sharing one liturgical year (calendar.mli's own "pays it + once" cost model assumes exactly this usage: call [year], not [day] in a + loop). *) +let day_report y = + match load_ef_layer () with + | Error msg -> + Printf.eprintf "colitur: %s\n" msg; + exit 2 + | Ok layer -> + let module Cal = Colitur_kernel.Calendar in + let by_rata : (int, (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) Hashtbl.t = + Hashtbl.create 400 + in + let index days = + Array.iter + (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) -> + Hashtbl.replace by_rata (D.to_rata d.Colitur_kernel.Liturgical_day.date) d) + days + in + index (Cal.year Rite_ef.context layer (y - 1)); + index (Cal.year Rite_ef.context layer y); + let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in + let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in + let d = ref jan1 in + while D.compare !d dec31 <= 0 do + (match Hashtbl.find_opt by_rata (D.to_rata !d) with + | Some day -> day_line day + | None -> + (* Unreachable for any [y] in 1583..9999: the two indexed + liturgical years jointly cover [year_start (y-1), year_start + (y+1)), which contains all of civil year [y] + (calendar.mli). Not a [failwith] -- an out-of-domain [d] + inside this loop is impossible by construction (jan1/dec31 + are themselves validated in range, and [add_days] only ever + advances within the same civil year here) -- but a silent + skip would violate the same "never silently dropped" + standard the kernel holds itself to, so a gap surfaces + loudly on stderr rather than as a quietly short year. *) + Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d)); + d := D.add_days !d 1 + done + let usage () = - prerr_endline "colitur: usage: colitur easter | colitur temporal "; + prerr_endline "colitur: usage: colitur easter | colitur temporal | colitur day "; exit 2 let with_year ys f = @@ -57,4 +191,5 @@ let () = match Sys.argv with | [| _; "easter"; ys |] -> with_year ys easter_report | [| _; "temporal"; ys |] -> with_year ys temporal_report + | [| _; "day"; ys |] -> with_year ys day_report | _ -> usage () diff --git a/data/ef/adjustments.sexp b/data/ef/adjustments.sexp new file mode 100644 index 0000000..85d7fa1 --- /dev/null +++ b/data/ef/adjustments.sexp @@ -0,0 +1,13 @@ +; data/ef/adjustments.sexp -- hand-authored overlay over data/ef/sanctoral.sexp +; (Task 11). NOT generated by tools/bootstrap_sanctoral.ml -- edit directly. +; +; Suppresses `vigil-of-christmas` (24 Dec, data/ef/sanctoral.sexp, lectio's +; own bootstrapped entry): it is the SAME celebration as colitur's temporal +; cycle's own `ef-nativity-vigil` (rite_ef/temporal_ef.ml's [named], also 24 +; Dec, RG 91 entry 5), not a second, distinct one. Once the sanctoral layer +; is live, that date would otherwise carry two candidates for one feast. +; Recorded as an Overlay directive rather than filtered out of the bootstrap +; or special-cased in code, per the task brief -- an auditable, diagnosable +; removal (Overlay.apply's own diagnostic fires if this slug is ever absent, +; e.g. after a re-bootstrap that renames it), not a silent drop. +((id ef-adjustments) (directives ((Suppress vigil-of-christmas)))) diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index 6d12dd4..ffe9471 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -38,5 +38,25 @@ type ('s, 'r) t = { Low Sunday (searching onward from there only if that day is itself blocked). [occupant] is supplied rather than a raw layer/temporal pair so the rite never has to re-implement occurrence resolution - just to answer "what sits here". *) + just to answer "what sits here". + + OBLIGATIONS (not enforced by the type, and {!Calendar}'s own + termination argument depends on both): the result must be + {b strictly later} than the [Date.t] argument (the date the + candidate was impeded on) -- {!Calendar}'s placement pass treats + [target = origin] or [target < origin] as a legitimate placement, + not an error, so a rite whose search can stand still or go + backward would silently loop candidates in place or resurrect an + already-superseded occupant rather than failing loudly. The call + must also {b terminate} on its own: {!Calendar}'s round guard + (calendar.ml's [max_transfer_rounds]) bounds how many ROUNDS the + whole-year placement pass takes, which is a distinct, outer thing + from whatever internal search a single call to this function runs + -- an implementation that walks forward day by day looking for an + admissible date, without its own bound, can hang the caller + outright on a rite/data shape it does not handle, never reaching + the round guard at all. See rite_ef/precedence_ef.ml's + [transfer_target] for a concrete termination argument (a + structural step bound, not an appeal to the real calendar's own + structure). *) } diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index ff6f991..e1c13d1 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -55,13 +55,22 @@ let is_universal layer = String.equal layer universal_layer {!Celebration.t} otherwise marks "this is a vigil, not an ordinary office of the same rank" (see the file's top comment), so entries 21/26 read it off the temporal cycle's own slug suffix (rite_ef/temporal_ef.ml's - [named], e.g. "ef-ascension-vigil"). Exposed so a future task naming a - sanctoral vigil (Task 10: Assumption, John Baptist, Peter & Paul, - Lawrence -- only Ascension exists today) uses the same suffix; a - differently-named vigil would band 16/24 instead of 21/26, silently. *) + [named], e.g. "ef-ascension-vigil"). *) let vigil_suffix = "-vigil" -let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug +(* Not an RG citation -- see [universal_layer]. Task 10's sanctoral bootstrap + turned out to name its four real vigils with lectio's OWN convention, a + "vigil-of-X" PREFIX (data/ef/sanctoral.sexp: vigil-of-st-lawrence, + vigil-of-sts-peter-paul, vigil-of-the-assumption, vigil-of-the-nativity- + of-st-john-the-baptist), not [vigil_suffix] -- exactly the mismatch Task + 7's review predicted when it asked for [vigil_suffix] to be exposed. + [is_vigil] below checks both conventions, so a celebration is a "vigil" + for RG 91/33's purposes regardless of which layer (temporal or sanctoral) + produced it. *) +let vigil_prefix = "vigil-of-" + +let is_vigil slug = + String.ends_with ~suffix:vigil_suffix slug || String.starts_with ~prefix:vigil_prefix slug (* Not an RG citation -- see [universal_layer]. Entry 18's Ember days are identified by the temporal cycle's own slug convention (rite_ef/ @@ -519,3 +528,96 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) (* RG 111: "III-IV class: at most two" -- by dignity, same as the non-Sunday II-class case, just with room for two. *) take 2 sorted + +(* Task 11: RG 96 -- where an impeded I-class feast lands (docs/research/ + rules-register.md §4, "Transfer/translation"). [band] decides who is + impeded; [disposition] decides that an impeded I-class FEAST (not a + Sunday, not omitted by RG 33) is [Transfer]-disposed; this is the third + and final question RG 96 poses -- WHERE the translation lands -- and is + {!Rite.t.transfer_target} itself, called by {!Calendar}'s placement pass + once per deferred candidate, never re-run once a target is accepted + (calendar.ml's own comment on [~start ~stop]). + + RG 96's own text, register-transcribed: "the next following day that is + not I or II class." [is_blocking] reads that off [Vocab_ef.rank] -- + RG 96 speaks of the day's CLASS (RG 8's four-way dignity), not [band]'s + finer 28-entry occurrence-table row, the same distinction {!admit} above + already draws for RG 111 ({!dignity}, not [band]). *) +let is_blocking (rank : Vocab_ef.rank) = rank = Vocab_ef.Class1 || rank = Vocab_ef.Class2 + +(* RG 96's own named exception, register-transcribed: "(Annunciation + exception): -> Monday after Low Sunday." Identified by slug -- the same + convention this file already uses to pick out one specific celebration + from a rank/status shape shared by many others ({!nativity_octave_prefix}, + [is_ember_18]'s date anchors) -- not an RG citation itself: RG 96 does not + encode how a computer recognises "the Annunciation", only what happens to + it once recognised. data/ef/sanctoral.sexp's own bootstrapped slug (Task + 10), reused verbatim rather than guessed. *) +let annunciation_slug = "annunciation-of-the-blessed-virgin-mary" + +(* Not an RG citation -- a defensive engineering ceiling, the same role + Calendar's own [max_transfer_rounds] plays for the OUTER round loop + (calendar.ml). That guard bounds how many ROUNDS the whole-year placement + pass takes; it does nothing for the walk a single call to this function + makes internally, which is this module's own responsibility (rite.mli + documents the obligation this constant exists to satisfy). Comfortably + longer than the longest real run of consecutive I/II-class days the 1962 + calendar produces -- 24 Dec to 1 Jan (the Nativity vigil through the + Circumcision, both I class, with the intervening octave days II class) is + 9 days; Easter through Low Sunday (the Easter octave, I class, entry 10) + is 8 -- RG 91 entry 28's own unqualified IV-class catch-all guarantees a + non-blocking feria follows any such run in real data. Not tuned to that + bound any more than 64 is tuned to RG 97-98's real collision count: a + ceiling nothing in the 1962 calendar comes close to, so a rite/data shape + this module has not anticipated fails FINITELY (see [search_from]) rather + than hanging the CLI. *) +let max_search_days = 400 + +(* Walks forward from [d], returning the first date [occupant] reports as + NOT [is_blocking]. [steps] is a strictly increasing structural bound on + the recursion, capped at [max_search_days]: the function decreases + [max_search_days - steps] by exactly one on every call and returns as + soon as that reaches zero (whether or not an admissible day was ever + found), so THIS loop terminates by construction, regardless of what + [occupant] reports -- it does not rely on the real EF calendar's own + structure to guarantee termination the way the comment above explains + why the bound is never actually reached in practice. If the bound is + reached, the last date visited is returned WITHOUT probing [occupant] + again -- one more finite (not necessarily admissible) date, not a + further search -- because the val the caller ([transfer_target]) is + still owed is "a date", never an exception; {!Calendar}'s own + [~start ~stop] bound (calendar.ml's [place_transfers]) is what turns an + implausible non-terminating real search into a recorded [omitted], not + this function pretending to have found something admissible. *) +let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps : int) (d : Date.t) : + Date.t = + if steps >= max_search_days then d + else if is_blocking (occupant d).Celebration.rank then search_from occupant (steps + 1) (Date.add_days d 1) + else d + +(* [transfer_target]'s contract (rite.mli): total, terminating, and its + result is always strictly after [origin]. Terminating: [search_from]'s + own structural bound, above. Strictly after [origin]: the ordinary branch + starts the search at [Date.add_days origin 1] and [search_from] only ever + advances forward from its own starting point, so the result is always >= + origin + 1. The Annunciation branch starts instead at the Monday after + Low Sunday for [origin]'s own civil year -- NOT provably later than + [origin] by the code alone, but true of every representable year: the + Annunciation's [origin] is always 25 March (Date_spec.Fixed in + data/ef/sanctoral.sexp), Easter always falls within that SAME civil year + in [22 March, 25 April] (Computus's own documented range, register §0), + so Low Sunday (Easter + 7) falls in [29 March, 2 May] and the Monday + after it in [30 March, 3 May] -- always after 25 March. *) +let transfer_target (c : Vocab_ef.rank Precedence.candidate) (origin : Date.t) + (occupant : Date.t -> Vocab_ef.rank Celebration.t) : Date.t = + let start = + if Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug then + (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the + Monday after it = Easter + 8. Searched onward from there exactly + like the general case searches from [origin + 1] -- "only if that + day is itself blocked" (rite.mli) is [search_from]'s ordinary + behaviour, not a second mechanism. *) + Date.add_days (Computus.gregorian_easter (Date.year origin)) 8 + else Date.add_days origin 1 + in + search_from occupant 0 start diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index e1b3638..d06b058 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -19,14 +19,31 @@ val universal_layer : string val indult_prefix : string (** Slug suffix marking a celebration as a vigil (RG 91 entries 21, 26), - e.g. "ef-ascension-vigil". Also colitur's own convention, not an RG - citation, exposed for the same reason as {!universal_layer}: only the - Ascension Vigil exists today (rite_ef/temporal_ef.ml); the Assumption, - John Baptist, Peter & Paul and Lawrence vigils arrive as sanctoral data - in a future task, and must use this same suffix or {!band} will band - them 16/24 (an ordinary feast of the same rank) instead of 21/26. *) + e.g. "ef-ascension-vigil" -- colitur's own temporal-cycle convention + (rite_ef/temporal_ef.ml's [named]). Also colitur's own convention, not + an RG citation, exposed for the same reason as {!universal_layer}. See + {!vigil_prefix} for the sanctoral data's own, different convention: a + vigil can arrive named either way, and {!band}/{!disposition} must + recognise both. *) val vigil_suffix : string +(** Slug prefix marking a celebration as a vigil, e.g. "vigil-of-st-lawrence" + -- the sanctoral data's own convention (data/ef/sanctoral.sexp, adopted + verbatim from lectio's naming, per spec §4.4's "slugs are lectionary keys, + not re-derived"). Also colitur's own convention, not an RG citation -- + see {!universal_layer}. Task 10 bootstrapped four real sanctoral vigils + named this way (St Lawrence 08-09, Sts Peter & Paul 06-28, the Assumption + 08-14, the Nativity of St John the Baptist 06-23; a fifth, Christmas, is + suppressed as a duplicate of the temporal cycle's own "ef-nativity-vigil" + -- see data/ef/adjustments.sexp), none of which end in {!vigil_suffix}: + without this prefix also being checked, {!band} would misfile all four at + 16/24 (an ordinary feast of the same rank) instead of RG 91's 21/26, and + RG 33's vigil omission ({!disposition}'s [is_vigil] test, the same + predicate) would never fire for them either -- two rubrics silently + broken for four celebrations, exactly what Task 7's review predicted + when it asked for {!vigil_suffix} to be exposed. *) +val vigil_prefix : string + (** Slug prefixes marking a celebration as one of RG 91 entry 18's three Ember-day sets (Advent, Lent, September -- the Pentecost/Whitsun set is I class and matched by entry 10 before this is ever consulted). Also @@ -153,3 +170,42 @@ val admit : observed:Vocab_ef.rank Precedence.candidate -> (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list -> (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list + +(** The Annunciation's own bootstrapped slug (data/ef/sanctoral.sexp, Task + 10), reused verbatim by {!transfer_target} to recognise RG 96's named + exception. Not an RG citation -- see {!universal_layer} -- exposed so a + future re-bootstrap that renames the slug has somewhere to be caught + other than a silently-wrong transfer target. *) +val annunciation_slug : string + +(** [transfer_target c origin occupant]: RG 96 (docs/research/rules-register + .md §4, "Transfer/translation") -- where an impeded I-class feast, once + {!disposition} has decided it is [Transfer]-disposed, is placed. This + *is* {!Colitur_kernel.Rite.t}.transfer_target; see that field's own + fuller rationale for why the search has to be rite-supplied at all. + + RG 96's own rule: the next following day whose currently-resolved + occupant is not I or II class (read off [Vocab_ef.rank], RG 8's dignity + -- not {!band}'s finer occurrence-table entry, the same distinction + {!admit} draws for RG 111). RG 96's own named exception: the + Annunciation ({!annunciation_slug}) does not search from [origin + 1] at + all -- it starts at the Monday after Low Sunday for [origin]'s own civil + year, searching onward from there only if that day is itself occupied by + a blocking class. + + Total, terminating, and its result is always strictly later than + [origin] -- {!Colitur_kernel.Rite.t}.transfer_target's own obligations, + which {!Colitur_kernel.Calendar}'s placement pass relies on and its own + round guard does not itself enforce (calendar.ml's [place_transfers] + bounds ROUNDS across a whole year, not one call's internal walk). + Terminating by a structural bound on the internal walk (max 400 days, + an engineering ceiling, not an RG citation -- see the .ml), not by an + argument about the real 1962 calendar's own structure, so a rite/data + shape this function has not anticipated fails FINITELY rather than + hanging the caller. Strictly later than [origin]: the ordinary search + starts at [origin + 1] and only ever advances forward from there; the + Annunciation's own starting point is provably later than 25 March for + every representable year (Easter's documented range, register §0) -- + see the .ml for the full argument. *) +val transfer_target : + Vocab_ef.rank Precedence.candidate -> Date.t -> (Date.t -> Vocab_ef.rank Celebration.t) -> Date.t diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml new file mode 100644 index 0000000..7a29421 --- /dev/null +++ b/lib/rites/rite_ef/rite_ef.ml @@ -0,0 +1,24 @@ +(* This module's name matches the library's own name ("rite_ef"), so dune + treats it as the library's top-level module instead of generating one + automatically -- every sibling module this library defines must be + re-exported here explicitly, or external references to e.g. + [Rite_ef.Temporal_ef] (bin/main.ml, every test/ file that opens this + rite) stop resolving. *) +module Vocab_ef = Vocab_ef +module Temporal_ef = Temporal_ef +module Precedence_ef = Precedence_ef + +open Colitur_kernel + +let context : (Vocab_ef.season, Vocab_ef.rank) Rite.t = + { Rite.id = Temporal_ef.id; + vocab = Vocab_ef.vocab; + year_start = Temporal_ef.year_start; + temporal = Temporal_ef.temporal; + anchors = Temporal_ef.anchors; + rules = + { Precedence.band = Precedence_ef.band; + disposition = Precedence_ef.disposition; + admit = Precedence_ef.admit }; + season_runs = Vocab_ef.seasons; + transfer_target = Precedence_ef.transfer_target } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli new file mode 100644 index 0000000..e2b3e6d --- /dev/null +++ b/lib/rites/rite_ef/rite_ef.mli @@ -0,0 +1,35 @@ +(** The EF (1962) rite module: this library's top-level module (its filename + matches the library's own name "rite_ef", so dune uses it as the + library's entry point directly rather than generating one -- see the + .ml's own comment). Re-exports every sibling module this library + defines, so [Rite_ef.Vocab_ef], [Rite_ef.Temporal_ef] and + [Rite_ef.Precedence_ef] keep resolving exactly as they did before this + module existed. *) + +module Vocab_ef = Vocab_ef +module Temporal_ef = Temporal_ef +module Precedence_ef = Precedence_ef + +(** The EF rite, bundled (design spec's [RITE] signature, realised as a + {!Colitur_kernel.Rite.t} value rather than a functor -- see rite.mli): + - [id], [vocab], [year_start], [temporal], [anchors]: {!Temporal_ef} + unchanged (RG 71-77 seasons, RG 91's named movable days). + - [rules]: {!Precedence_ef}'s three RG 91/92-95/108-111 functions, + wrapped as one {!Colitur_kernel.Precedence.rules} record. + - [season_runs]: {!Vocab_ef.seasons} itself -- the EF liturgical year + visits each of its eight seasons exactly once, in that same order + (Advent-anchored, matching [year_start]), so the expected + run-length-compressed sequence {!Colitur_kernel.Rite.t.season_runs} + wants IS the vocabulary's own canonical list, not a separate one. + - [transfer_target]: {!Precedence_ef.transfer_target}, RG 96 (see that + value's own documentation for the termination and forward-progress + argument {!Colitur_kernel.Rite.t.transfer_target}'s contract requires). + + Deliberately carries no [sanctoral]/[lectionary] fields the way the + original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the + type actually shipped, Plan 2) keeps the sanctoral {!Colitur_kernel.Layer.t} + a separate argument to {!Colitur_kernel.Calendar.year}/[day] rather than + embedding it here, so a caller can load data/ef/sanctoral.sexp (plus + data/ef/adjustments.sexp's overlay) however suits it -- bin/main.ml's + [load_ef_layer] is the one this module ships with. *) +val context : (Vocab_ef.season, Vocab_ef.rank) Colitur_kernel.Rite.t diff --git a/test/cli.t b/test/cli.t index feb7c49..77968e6 100644 --- a/test/cli.t +++ b/test/cli.t @@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2): No/garbage arguments give a usage error (exit 2): $ colitur - colitur: usage: colitur easter | colitur temporal + colitur: usage: colitur easter | colitur temporal | colitur day [2] The EF temporal cycle for a year, one line per day: @@ -41,3 +41,68 @@ A year outside the supported domain is rejected (exit 2): $ colitur temporal 1000 colitur: year 1000 out of range 1583..9999 [2] + +The resolved EF calendar for a year (Task 11) -- temporal AND sanctoral, +occurrence and transfers applied: one line per civil-year day, +"YYYY-MM-DD weekday season week slug rank colour [+commemoration-slug]...". + + $ colitur day 2026 | wc -l + 365 + +Easter is the observed day exactly once, and carries no commemoration (an +impeded I class day admits at most one PRIVILEGED commemoration, RG 111, and +nothing outranks Easter to be impeded by it in the first place): + + $ colitur day 2026 | grep -c '^2026-04-05 ' + 1 + $ colitur day 2026 | grep '^2026-04-05 ' + 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white + +Ash Wednesday: I class (RG 91 entry 7), violet, no numbered week (it falls 4 +days before Lent I's own origin -- rite_ef/temporal_ef.ml's [week]): + + $ colitur day 2026 | grep '^2026-02-18 ' + 2026-02-18 wednesday lent - ef-ash-wednesday class-1 violet + +Christmas: I class, white (RG 91 entry 1): + + $ colitur day 2026 | grep '^2026-12-25 ' + 2026-12-25 friday christmastide - ef-nativity class-1 white + +All Souls (2 Nov, RG 91 entry 8) end to end: 2 Nov 2025 is a Sunday (verified +independently -- 1 Jan 2025 is a Wednesday, day-of-year 306, (3+305) mod 7 = +0 = Sunday), so entry 8 yields to it (still an ordinary II-class Sunday, +"sunday" in season time-after-pentecost, colour green -- the week number +itself is not re-asserted here, already covered by test_temporal_ef.ml); RG +95 then transfers All Souls (I class, not a Sunday, not a vigil) and RG 96 +places it on 3 Nov, the next day that is not I or II class: + + $ colitur day 2025 | grep -c 'commemoration-of-all-souls' + 1 + $ colitur day 2025 | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / ef-time-after-pentecost-sunday- /' + 2025-11-02 sunday time-after-pentecost ef-time-after-pentecost-sunday- class-2 green + $ colitur day 2025 | grep '^2025-11-03 ' + 2025-11-03 monday time-after-pentecost 21 commemoration-of-all-souls class-1 black + +(week 21: Pentecost 2025 is 8 June (colitur easter 2025); 8 Jun - 3 Nov is +148 days, floor_div(148, 7) = 21 -- rite_ef/temporal_ef.ml's own [week] +formula, hand-verified before promoting this line, not merely printed and +trusted.) + +All Souls observed directly on 2 Nov in a year where it does not fall on a +Sunday: 2 Nov 2026 is a Monday (1 Jan 2026 is a Thursday, same day-of-year +306 offset, (4+305) mod 7 = 1 = Monday): + + $ colitur day 2026 | grep -c 'commemoration-of-all-souls' + 1 + $ colitur day 2026 | grep '^2026-11-02 ' + 2026-11-02 monday time-after-pentecost 23 commemoration-of-all-souls class-1 black + +(week 23: Pentecost 2026 is 24 May (colitur easter 2026); 24 May - 2 Nov is +162 days, floor_div(162, 7) = 23 -- same formula, same independent check.) + +A year outside the supported domain is rejected (exit 2): + + $ colitur day 1000 + colitur: year 1000 out of range 1583..9999 + [2] diff --git a/test/dune b/test/dune index bb8e474..be24839 100644 --- a/test/dune +++ b/test/dune @@ -6,4 +6,4 @@ (pps ppx_sexp_conv))) (cram - (deps %{bin:colitur})) + (deps %{bin:colitur} ../data/ef/sanctoral.sexp ../data/ef/adjustments.sexp)) diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index a95b321..084d708 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -222,6 +222,22 @@ let cases = ( "26 III-class vigil (non-base layer)", mk 2026 8 10, cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-patron-vigil", 26 ); + (* Task 11, issue (a): the sanctoral bootstrap (data/ef/sanctoral.sexp) + names its vigils with lectio's OWN "vigil-of-X" PREFIX convention, not + [PE.vigil_suffix]'s "-vigil" SUFFIX every row above uses -- exactly + the mismatch Task 7's review predicted. These two rows use the real + bootstrapped slugs verbatim (data/ef/sanctoral.sexp: 28 Jun, 9 Aug), + proving [band] recognises the prefix convention too: without it, both + would misfile at 16/24 (an ordinary feast of the same rank) instead + of 21/26. *) + ( "21 II-class vigil via the sanctoral data's own \"vigil-of-X\" prefix", + mk 2026 6 28, + cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-sts-peter-paul", + 21 ); + ( "26 III-class vigil via the sanctoral data's own \"vigil-of-X\" prefix", + mk 2026 8 9, + cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "vigil-of-st-lawrence", + 26 ); (* Entry 27 -- register line 352: an otherwise-unoccupied IV-class Saturday. *) ( "27 Office of the BVM on Saturday", off 62, @@ -370,6 +386,21 @@ let disposition_cases = an_ordinary_sunday, cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil", "Commemorate(Ordinary)" ); + (* Task 11, issue (a): [disposition]'s own [is_vigil] check (the RG 33 + omission test) is a SEPARATE call site from [band]'s -- both read the + same private [is_vigil], but each needed its own witness, since a fix + to one call site could in principle miss the other. Real bootstrapped + slug (data/ef/sanctoral.sexp's "vigil-of-the-assumption", 14 Aug), + not a hand-typed one, for the same coupling-safety reason [of_temporal] + rows use real data elsewhere in this file. Before the fix this vigil + was invisible to [is_vigil] entirely, so it would have fallen through + to the ordinary Commemorate branch below instead of Omit -- the exact + failure the task brief describes. *) + ( "RG33 (prefix convention): a \"vigil-of-X\"-named II-class vigil loses \ + to an ordinary Sunday -> Omit", + an_ordinary_sunday, + cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-the-assumption", + "Omit" ); (* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked here with a loser that ALSO carries a Class1 rank and a vigil-suffixed slug losing to a Sunday, so this row only passes if the @@ -778,6 +809,94 @@ let test_all_souls_transfers_end_to_end () = Alcotest.(check int) "nothing commemorated" 0 (List.length resolution.P.commemorations); Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted) +(* Task 11: [PE.transfer_target] -- RG 96 ("the next following day that is + not I or II class") plus its Annunciation exception. [occupant] is a + synthetic callback ({!Colitur_kernel.Rite.t.transfer_target}'s own + [occupant] parameter), not a real [Calendar]-driven one -- the CLI's own + end-to-end proof (colitur day, All Souls landing on 3 Nov 2025 and the + Annunciation landing on 5 Apr 2027, see test/cli.t and the task report) + is what wires this against real data; these rows isolate the search + function itself. *) + +(* [blocked] returns Class1 (blocking) for exactly the listed dates, Class4 + (not blocking) everywhere else -- enough to exercise [is_blocking]'s own + two-way test (RG 96 speaks of I OR II class; Class1 alone is enough to + prove the blocking side, [test_transfer_target_terminates...] below adds + nothing by varying it further). *) +let occupant_blocking_on blocked_dates (d : D.t) : V.rank Cel.t = + let blocking = List.exists (fun bd -> D.compare bd d = 0) blocked_dates in + Cel.make ~slug:(S.of_string_exn "occupant") ~rank:(if blocking then V.Class1 else V.Class4) + ~colour:Col.Green ~layer:"synthetic" () + +let occupant_always_blocking (_ : D.t) : V.rank Cel.t = + Cel.make ~slug:(S.of_string_exn "occupant") ~rank:V.Class1 ~colour:Col.Green ~layer:"synthetic" () + +(* General RG 96 search: two consecutive blocked days past [origin], proving + the search walks past MORE than one ineligible day rather than only + trying [origin + 1] and stopping (the same shape Calendar's own + synthetic fixture pins for the abstraction -- this pins it for the real + EF search function). *) +let test_transfer_target_general_multi_step_search () = + let origin = mk 2026 1 10 in + let occupant = occupant_blocking_on [ mk 2026 1 11; mk 2026 1 12 ] in + let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-some-i-class-feast" in + let target = PE.transfer_target c origin occupant in + Alcotest.(check string) "lands on the first day past the blocked run" + "2026-01-13" (D.to_iso8601 target) + +(* RG 96's Annunciation exception: starts the search at the Monday after Low + Sunday, NOT [origin + 1] -- occupant is unconditionally free, so a + general-path implementation would return [origin + 1] (26 March), a date + this test explicitly rules out as well as pinning the real expected one, + so the assertion genuinely discriminates the two starting points rather + than merely checking "some date after origin". *) +let test_transfer_target_annunciation_starts_at_monday_after_low_sunday () = + let origin = mk 2026 3 25 in + let occupant = occupant_blocking_on [] in + let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in + let target = PE.transfer_target c origin occupant in + let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in + Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8)" + (D.to_iso8601 monday_after_low_sunday) (D.to_iso8601 target); + Alcotest.(check bool) "NOT the general path's origin + 1 (discriminates the branch)" true + (D.compare target (D.add_days origin 1) <> 0) + +(* RG 96's own qualifier on the exception -- "searching onward from there + only if that day is itself blocked" (rite.mli) -- is [search_from]'s + ORDINARY behaviour, not a second mechanism: block the Monday after Low + Sunday itself and confirm the search continues exactly one more day. *) +let test_transfer_target_annunciation_searches_onward_if_blocked () = + let origin = mk 2026 3 25 in + let monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in + let occupant = occupant_blocking_on [ monday_after_low_sunday ] in + let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in + let target = PE.transfer_target c origin occupant in + Alcotest.(check string) "searches onward one more day when that Monday is itself blocked" + (D.to_iso8601 (D.add_days monday_after_low_sunday 1)) (D.to_iso8601 target) + +(* rite.mli's own obligations on [transfer_target] (Task 11 brief): the call + must TERMINATE and its result must be STRICTLY AFTER [origin], even for a + rite/data shape this function cannot have anticipated -- an occupant that + reports every single day as blocking, forever. Calendar's own round guard + (max_transfer_rounds) does not cover this: it bounds ROUNDS across a + whole year, not the internal walk one call to this function makes (see + precedence_ef.ml's own comment on [search_from] and [max_search_days]). + Deliberately NOT pinning the exact returned date against the private + [max_search_days] constant -- that would coalesce a behavioural contract + (terminates, makes forward progress) with an internal tuning value this + function is free to change; a generous, test-owned ceiling (1000 days, + comfortably past any realistic bound) is enough to prove termination is + genuine and not merely "didn't hang during this particular run". *) +let test_transfer_target_terminates_under_pathological_occupant () = + let origin = mk 2026 1 1 in + let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-pathological-case" in + let target = PE.transfer_target c origin occupant_always_blocking in + Alcotest.(check bool) "strictly after origin" true (D.compare target origin > 0); + Alcotest.(check bool) + "terminates within a generous bound (proves the internal search is bounded, not merely lucky)" + true + (D.compare target (D.add_days origin 1000) <= 0) + let suite = ( "Precedence_ef", List.map @@ -819,4 +938,13 @@ let suite = test_privileged_lent_feria_admitted_end_to_end; Alcotest.test_case "II-class Sunday override: RG109(b) witness admitted over an ordinary saint, end-to-end" - `Quick test_ii_class_sunday_privileged_witness_admitted_end_to_end ] ) + `Quick test_ii_class_sunday_privileged_witness_admitted_end_to_end; + Alcotest.test_case "transfer_target: general RG96 search walks past more than one blocked day" + `Quick test_transfer_target_general_multi_step_search; + Alcotest.test_case "transfer_target: Annunciation exception starts at Monday after Low Sunday" + `Quick test_transfer_target_annunciation_starts_at_monday_after_low_sunday; + Alcotest.test_case + "transfer_target: Annunciation exception searches onward if that Monday is blocked" `Quick + test_transfer_target_annunciation_searches_onward_if_blocked; + Alcotest.test_case "transfer_target: terminates and stays forward under a pathological occupant" + `Quick test_transfer_target_terminates_under_pathological_occupant ] ) -- cgit v1.3 From 94fc488cc9c6b4a050d90c4250f6e166b40088e7 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 01:16:22 +0200 Subject: rite(ef): clamp the RG96 search at the domain ceiling search_from could walk up to 400 days past origin before Calendar's own ~start ~stop clamp is ever consulted, and nothing stopped it probing occupant on a date past 31 December 9999 -- occupant chains through the real EF rite's temporal, which calls Computus.gregorian_easter, not total outside 1583..9999 (it Date.makes and failwiths on Error). Not reachable with the shipped sanctoral data alone, but reachable through the project's own primary extension path: an overlay adding an I-class feast on 25 December leaves nothing but Class2 Nativity-octave days for the rest of civil year 9999, so the unguarded search reached 1 January of year 10000 and crashed there with 'computus: year 10000 out of range 1583..9999'. 9999 is an in-range year and the kernel's contract is 'never raises on in-range input'. search_from now also stops, without probing occupant again, once it passes Date's own domain ceiling -- the same 'return a finite date, let Calendar's own out-of-range handling record it, never pretend to have found something admissible' contract the existing step-count guard already follows. Two new tests, both mutation-verified to actually reproduce the crash when the guard is removed (see the task report): a precedence_ef.ml unit test using the real Temporal_ef.temporal as occupant (a synthetic occupant can never discriminate this, since it never calls Computus itself), and a Calendar-level integration test reproducing the exact overlay-based scenario the review found. --- lib/rites/rite_ef/precedence_ef.ml | 35 +++++++++++++--- lib/rites/rite_ef/precedence_ef.mli | 11 +++-- test/dune | 2 +- test/test_colitur.ml | 2 +- test/test_precedence_ef.ml | 32 ++++++++++++++- test/test_rite_ef.ml | 81 +++++++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 test/test_rite_ef.ml (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index e1c13d1..b9b1731 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -573,6 +573,27 @@ let annunciation_slug = "annunciation-of-the-blessed-virgin-mary" than hanging the CLI. *) let max_search_days = 400 +(* The domain's own ceiling ({!Date.make}'s documented 1583..9999 bound, + also duplicated by calendar.ml's own [domain_max_date] for the same + reason: neither module exposes it to the other, and this is a three-line + constant, not worth a new signature just to share it). [search_from] + below must never call [occupant] on a date past this: [occupant] chains + through the rite's own [temporal] (calendar.ml's [resolve_with_injected]), + which for the real EF rite calls [Computus.gregorian_easter], which is + NOT total outside 1583..9999 -- it constructs a [Date.t] via [Date.make] + and [failwith]s on [Error]. [Date.add_days] itself has no such limit (it + is documented "unbounded total arithmetic"), so [search_from] CAN walk + [d] past 31 December 9999 without raising by itself -- the raise would + only happen on the NEXT [occupant d] call, which is exactly the bug this + guards against: an I-class feast impeded late enough in civil year 9999 + that every remaining day of the year is also I or II class (reachable + through the project's own overlay mechanism, confirmed by review: an + Add-ed I-class feast on 25 December leaves only Class2 Nativity-octave + days for the rest of 9999, so the unguarded walk reached 1 January 10000 + and crashed there). *) +let domain_max_date = + match Date.make ~year:9999 ~month:12 ~day:31 with Ok d -> d | Error e -> failwith e + (* Walks forward from [d], returning the first date [occupant] reports as NOT [is_blocking]. [steps] is a strictly increasing structural bound on the recursion, capped at [max_search_days]: the function decreases @@ -581,17 +602,19 @@ let max_search_days = 400 found), so THIS loop terminates by construction, regardless of what [occupant] reports -- it does not rely on the real EF calendar's own structure to guarantee termination the way the comment above explains - why the bound is never actually reached in practice. If the bound is - reached, the last date visited is returned WITHOUT probing [occupant] - again -- one more finite (not necessarily admissible) date, not a - further search -- because the val the caller ([transfer_target]) is - still owed is "a date", never an exception; {!Calendar}'s own + why the bound is never actually reached in practice. Also stops, without + calling [occupant] again, once [d] passes {!domain_max_date} -- see that + constant's own comment for why probing [occupant] beyond it can raise. + Either way the last date visited is returned WITHOUT a further + [occupant] probe -- one more finite (not necessarily admissible) date, + not a further search -- because the value the caller ([transfer_target]) + is still owed is "a date", never an exception; {!Calendar}'s own [~start ~stop] bound (calendar.ml's [place_transfers]) is what turns an implausible non-terminating real search into a recorded [omitted], not this function pretending to have found something admissible. *) let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps : int) (d : Date.t) : Date.t = - if steps >= max_search_days then d + if steps >= max_search_days || Date.compare d domain_max_date > 0 then d else if is_blocking (occupant d).Celebration.rank then search_from occupant (steps + 1) (Date.add_days d 1) else d diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index d06b058..7318ccd 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -199,10 +199,13 @@ val annunciation_slug : string round guard does not itself enforce (calendar.ml's [place_transfers] bounds ROUNDS across a whole year, not one call's internal walk). Terminating by a structural bound on the internal walk (max 400 days, - an engineering ceiling, not an RG citation -- see the .ml), not by an - argument about the real 1962 calendar's own structure, so a rite/data - shape this function has not anticipated fails FINITELY rather than - hanging the caller. Strictly later than [origin]: the ordinary search + an engineering ceiling, not an RG citation -- see the .ml) AND a guard + at {!Colitur_kernel.Date}'s own domain ceiling (31 December 9999, + beyond which probing [occupant] can itself raise -- see the .ml's + [domain_max_date]), not by an argument about the real 1962 calendar's + own structure, so a rite/data shape this function has not anticipated + fails FINITELY rather than hanging or crashing the caller. Strictly + later than [origin]: the ordinary search starts at [origin + 1] and only ever advances forward from there; the Annunciation's own starting point is provably later than 25 March for every representable year (Easter's documented range, register §0) -- diff --git a/test/dune b/test/dune index be24839..dc81213 100644 --- a/test/dune +++ b/test/dune @@ -1,7 +1,7 @@ (test (name test_colitur) (libraries colitur_kernel rite_ef alcotest qcheck qcheck-alcotest sexplib) - (deps ../data/ef/sanctoral.sexp) + (deps ../data/ef/sanctoral.sexp ../data/ef/adjustments.sexp) (preprocess (pps ppx_sexp_conv))) diff --git a/test/test_colitur.ml b/test/test_colitur.ml index ba82fcd..9f74e34 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -3,4 +3,4 @@ let () = Alcotest.run "colitur" [ Test_date.suite; Test_computus.suite; Test_colour.suite; Test_slug.suite; Test_names.suite; Test_overlay.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite; - Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite ] + Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite ] diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 084d708..0973a93 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -897,6 +897,34 @@ let test_transfer_target_terminates_under_pathological_occupant () = 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 @@ -947,4 +975,6 @@ let suite = "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 ] ) + `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 ] ) diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml new file mode 100644 index 0000000..7b60718 --- /dev/null +++ b/test/test_rite_ef.ml @@ -0,0 +1,81 @@ +(* Coordinator review (Task 11 fix round): integration tests wiring + [Rite_ef.context] together with the REAL data/ef/sanctoral.sexp + + data/ef/adjustments.sexp through [Colitur_kernel.Calendar] -- the same + pipeline `colitur day` uses, proven here at the OCaml level. *) + +module Cal = Colitur_kernel.Calendar +module Layer = Colitur_kernel.Layer +module Overlay = Colitur_kernel.Overlay +module LD = Colitur_kernel.Liturgical_day +module Slug = Colitur_kernel.Slug +module Date = Colitur_kernel.Date +module Date_spec = Colitur_kernel.Date_spec +module Cel = Colitur_kernel.Celebration +module Colour = Colitur_kernel.Colour +module V = Rite_ef.Vocab_ef + +(* Relative to this test's own build directory (_build/default/test/), same + convention test_sanctoral_ef.ml uses -- test/dune declares both as deps + of the (test ...) stanza. *) +let sanctoral_path = "../data/ef/sanctoral.sexp" +let adjustments_path = "../data/ef/adjustments.sexp" + +let real_layer () = + let layer = + match Layer.load V.rank_of_sexp sanctoral_path with + | Ok l -> l + | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e + in + let overlay = + match Overlay.load V.rank_of_sexp adjustments_path with + | Ok o -> o + | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e + in + let layer, diagnostics = Overlay.apply layer overlay in + Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" [] + (List.map Overlay.diagnostic_to_string diagnostics); + layer + +let slug_of (c : V.rank Cel.t) = Slug.to_string c.Cel.slug + +(* Coordinator review, finding 2, reproduced through the project's OWN + extension path (an overlay), the same way the reviewer found it: adding + an I-class feast on 25 December (competing against, and losing to, the + real Nativity) forces an RG 96 search starting 26 December -- which, with + the real sanctoral data (Stephen/John/the Innocents, all II class) plus + the temporal cycle's own Nativity-octave-day entries (29-31 Dec, also II + class), is blocking every single day through 31 December 9999. Before the + domain-ceiling fix this raised (Computus: year 10000 out of range); + confirmed by mutation-testing at the precedence_ef.ml unit level (see the + task report) -- this is the same defect reproduced end to end, through + Calendar, with real data, exactly as the review found it. *) +let test_transfer_search_does_not_raise_at_domain_ceiling () = + let layer = real_layer () in + let impeding_entry : V.rank Layer.entry = + { Layer.date = (match Date_spec.fixed ~month:12 ~day:25 with Ok d -> d | Error e -> failwith e); + cel = + Cel.make ~slug:(Slug.of_string_exn "test-domain-ceiling-impeder") ~rank:V.Class1 + ~colour:Colour.White ~layer:Rite_ef.Precedence_ef.universal_layer () } + in + let overlay : V.rank Overlay.t = + { Overlay.id = "test-domain-ceiling"; directives = [ Overlay.Add impeding_entry ] } + in + let layer, _diagnostics = Overlay.apply layer overlay in + (* Must not raise -- the whole point of the fix. *) + let days = Cal.year Rite_ef.context layer 9999 in + Alcotest.(check bool) "year 9999 resolves without raising, even with an impeded Christmas Day" + true (Array.length days > 0); + let impeder_placed_or_recorded = + Array.to_list days + |> List.exists (fun d -> + slug_of d.LD.observed = "test-domain-ceiling-impeder" + || List.exists (fun (c, _) -> slug_of c = "test-domain-ceiling-impeder") d.LD.omitted) + in + Alcotest.(check bool) "the impeding candidate is accounted for (observed somewhere, or omitted \ + with a recorded reason) -- never silently dropped" + true impeder_placed_or_recorded + +let suite = + ( "Rite_ef (real data: overlay-in-effect, domain-ceiling)", + [ Alcotest.test_case "RG96 search does not raise at the domain ceiling (real data)" `Quick + test_transfer_search_does_not_raise_at_domain_ceiling ] ) -- cgit v1.3 From 9ce4527550fed24036f6f116474c1f67714f5194 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 01:19:37 +0200 Subject: rite(ef): the Annunciation's RG96 exception is conditional, not unconditional The register was transcribed faithfully but was itself wrong: RG 96's Attamen (a), primary-source-verified from the scans and now corrected in the register, reads 'festum Annuntiationis B. Mariae Virg., quando est transferendum post Pascha, transfertur ... in feriam II post dominicam in albis' -- the Monday-after-Low-Sunday seat applies ONLY 'quando est transferendum post Pascha', when the feast is to be transferred PAST EASTER. The unconditional transcription made the exception fire on every impeded Annunciation regardless of cause. transfer_target now computes the general RG 96 target first, for every candidate, and overrides to the Monday after Low Sunday only when that general target itself falls after Easter Sunday -- testing the rubric's own condition directly rather than re-deriving a date-proximity rule from first principles. Confirmed against three real years the review named: 2007, 2012 and 2057 all previously sent the Annunciation to Easter + 8 (16 April, 16 April, 30 April respectively) when the correct, now-produced target is the next free day before Easter (26 March in each case -- Passion Sunday in 2007/2012, Lent III Sunday in 2057). Verified with actual CLI output for all three, before and after. Also cites RG 96 Attamen (b), the same primary-source passage, as the direct authority for All Souls' own move to the following Monday when impeded by a Sunday -- previously inferred from RG 91 entry 8's parenthetical plus the general walk, which happened to produce the right date; now stated directly. Rewrote the two existing Annunciation unit tests, whose synthetic occupants no longer trigger the (now correctly conditional) exception, and added a 2057 regression test using the real Temporal_ef.temporal as occupant plus a real-data cram pin -- both mutation-verified against the unconditional reading. --- lib/rites/rite_ef/precedence_ef.ml | 88 ++++++++++++++++++++++++------------- lib/rites/rite_ef/precedence_ef.mli | 32 +++++++++----- test/cli.t | 13 ++++++ test/test_precedence_ef.ml | 74 ++++++++++++++++++++++++------- 4 files changed, 152 insertions(+), 55 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index b9b1731..0feb3e0 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -405,9 +405,16 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) branch that completes Task 7's All Souls fix (register line 334, RG 91 entry 8): All Souls is I class, not a vigil, and not a Sunday slug, so once it loses to an occurring Sunday it still reaches here - and transfers -- to 3 November per the register, but WHERE it lands - is Rite.transfer_target's job (RG 96), not this function's; - disposition only says THAT it moves. *) + and transfers -- to 3 November, now DIRECTLY authorised by RG 96 + Attamen (b) (primary-source-verified 2026-08-12): "Commemoratio + omnium Fidelium defunctorum, quando occurrit cum dominica, + transfertur, tamquam in sedem propriam, in feriam II sequentem" -- + when it coincides with a Sunday, transferred, as to its own proper + seat, to the following Monday. Previously this rested only on entry + 8's own parenthetical plus the general RG 96 walk, which happened to + produce the right date; WHERE it lands either way is + Rite.transfer_target's job, not this function's -- disposition only + says THAT it moves. *) Precedence.Transfer else (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" -- @@ -545,14 +552,23 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) already draws for RG 111 ({!dignity}, not [band]). *) let is_blocking (rank : Vocab_ef.rank) = rank = Vocab_ef.Class1 || rank = Vocab_ef.Class2 -(* RG 96's own named exception, register-transcribed: "(Annunciation - exception): -> Monday after Low Sunday." Identified by slug -- the same - convention this file already uses to pick out one specific celebration - from a rank/status shape shared by many others ({!nativity_octave_prefix}, - [is_ember_18]'s date anchors) -- not an RG citation itself: RG 96 does not - encode how a computer recognises "the Annunciation", only what happens to - it once recognised. data/ef/sanctoral.sexp's own bootstrapped slug (Task - 10), reused verbatim rather than guessed. *) +(* RG 96's own named exception (docs/research/rules-register.md §4, + "Transfer/translation", RG 96 Attamen (a) -- primary-source-verified + 2026-08-12, corrected from an earlier unconditional transcription; see + the register's own correction note). Verbatim: "festum Annuntiationis + B. Mariae Virg., quando est transferendum post Pascha, transfertur, + tamquam in sedem propriam, in feriam II post dominicam in albis" -- when + [the feast] is to be transferred PAST EASTER, [it] is transferred, as to + its own proper seat, to the Monday after Low Sunday. The exception is + CONDITIONAL on that "past Easter" clause -- {!transfer_target} tests it + by comparing the GENERAL RG 96 target against Easter itself, not by + testing the date here. Identified by slug -- the same convention this + file already uses to pick out one specific celebration from a rank/ + status shape shared by many others ({!nativity_octave_prefix}, + [is_ember_18]'s date anchors) -- not an RG citation itself: RG 96 does + not encode how a computer recognises "the Annunciation", only what + happens to it once recognised. data/ef/sanctoral.sexp's own bootstrapped + slug (Task 10), reused verbatim rather than guessed. *) let annunciation_slug = "annunciation-of-the-blessed-virgin-mary" (* Not an RG citation -- a defensive engineering ceiling, the same role @@ -620,27 +636,39 @@ let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps : (* [transfer_target]'s contract (rite.mli): total, terminating, and its result is always strictly after [origin]. Terminating: [search_from]'s - own structural bound, above. Strictly after [origin]: the ordinary branch - starts the search at [Date.add_days origin 1] and [search_from] only ever - advances forward from its own starting point, so the result is always >= - origin + 1. The Annunciation branch starts instead at the Monday after - Low Sunday for [origin]'s own civil year -- NOT provably later than - [origin] by the code alone, but true of every representable year: the - Annunciation's [origin] is always 25 March (Date_spec.Fixed in + own structural bound, above. Strictly after [origin]: the general branch + is exactly [search_from]'s own result starting at [Date.add_days origin + 1], which only ever advances forward from there, so it is always >= + origin + 1. The Annunciation branch, when it fires, instead searches from + the Monday after Low Sunday for [origin]'s own civil year -- NOT provably + later than [origin] by the code alone, but true of every representable + year: the Annunciation's [origin] is always 25 March (Date_spec.Fixed in data/ef/sanctoral.sexp), Easter always falls within that SAME civil year in [22 March, 25 April] (Computus's own documented range, register §0), so Low Sunday (Easter + 7) falls in [29 March, 2 May] and the Monday - after it in [30 March, 3 May] -- always after 25 March. *) + after it in [30 March, 3 May] -- always after 25 March. + + RG 96 Attamen (a) (see {!annunciation_slug}'s own comment) makes the + Annunciation exception CONDITIONAL on the general walk carrying the + feast past Easter -- so the general target is always computed FIRST, + for every candidate, and only overridden for the Annunciation when that + target itself falls after Easter Sunday. A version of this function that + tested the DATE of [origin] instead (e.g. "is 25 March within some fixed + window of Easter") would be re-deriving the register's own "quando est + transferendum post Pascha" condition from first principles, exactly the + kind of guess this project's "a wrong citation is worse than a missing + one" rule warns against; comparing the general target against Easter + directly tests the rubric's own words. *) let transfer_target (c : Vocab_ef.rank Precedence.candidate) (origin : Date.t) (occupant : Date.t -> Vocab_ef.rank Celebration.t) : Date.t = - let start = - if Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug then - (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the - Monday after it = Easter + 8. Searched onward from there exactly - like the general case searches from [origin + 1] -- "only if that - day is itself blocked" (rite.mli) is [search_from]'s ordinary - behaviour, not a second mechanism. *) - Date.add_days (Computus.gregorian_easter (Date.year origin)) 8 - else Date.add_days origin 1 - in - search_from occupant 0 start + let general_target = search_from occupant 0 (Date.add_days origin 1) in + let is_annunciation = Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug in + let easter = Computus.gregorian_easter (Date.year origin) in + if is_annunciation && Date.compare general_target easter > 0 then + (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the + Monday after it = Easter + 8. Searched onward from there exactly + like the general case searches from [origin + 1] -- "only if that + day is itself blocked" (rite.mli) is [search_from]'s ordinary + behaviour, not a second mechanism. *) + search_from occupant 0 (Date.add_days easter 8) + else general_target diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 7318ccd..8234bf3 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -187,11 +187,23 @@ val annunciation_slug : string RG 96's own rule: the next following day whose currently-resolved occupant is not I or II class (read off [Vocab_ef.rank], RG 8's dignity -- not {!band}'s finer occurrence-table entry, the same distinction - {!admit} draws for RG 111). RG 96's own named exception: the - Annunciation ({!annunciation_slug}) does not search from [origin + 1] at - all -- it starts at the Monday after Low Sunday for [origin]'s own civil - year, searching onward from there only if that day is itself occupied by - a blocking class. + {!admit} draws for RG 111). This general target is computed for EVERY + candidate, always, first. + + RG 96's own named exception (Attamen (a), primary-source-verified -- + see {!annunciation_slug}'s comment for the Latin and the register's own + correction note): for the Annunciation specifically, IF that general + target would fall after Easter Sunday itself ("quando est transferendum + post Pascha" -- when it is to be transferred past Easter), the + Annunciation is placed instead at the Monday after Low Sunday (its + [sedes propria]), searching onward from there only if that day is + itself occupied by a blocking class. The exception is CONDITIONAL, not + unconditional: an Annunciation impeded for a reason that resolves + BEFORE Easter (e.g. an ordinary Lent Sunday with a free feria the next + day) takes the general target like any other I-class feast. Operationally + the condition holds exactly when 25 March falls close enough to Easter + that the general walk crosses it -- concretely, when 25 March itself + falls within Holy Week or Easter Week. Total, terminating, and its result is always strictly later than [origin] -- {!Colitur_kernel.Rite.t}.transfer_target's own obligations, @@ -205,10 +217,10 @@ val annunciation_slug : string [domain_max_date]), not by an argument about the real 1962 calendar's own structure, so a rite/data shape this function has not anticipated fails FINITELY rather than hanging or crashing the caller. Strictly - later than [origin]: the ordinary search - starts at [origin + 1] and only ever advances forward from there; the - Annunciation's own starting point is provably later than 25 March for - every representable year (Easter's documented range, register §0) -- - see the .ml for the full argument. *) + later than [origin]: the general search starts at [origin + 1] and only + ever advances forward from there; the Annunciation's own alternate + starting point is provably later than 25 March for every representable + year (Easter's documented range, register §0) -- see the .ml for the + full argument. *) val transfer_target : Vocab_ef.rank Precedence.candidate -> Date.t -> (Date.t -> Vocab_ef.rank Celebration.t) -> Date.t diff --git a/test/cli.t b/test/cli.t index 77968e6..5a646a4 100644 --- a/test/cli.t +++ b/test/cli.t @@ -101,6 +101,19 @@ Sunday: 2 Nov 2026 is a Monday (1 Jan 2026 is a Thursday, same day-of-year (week 23: Pentecost 2026 is 24 May (colitur easter 2026); 24 May - 2 Nov is 162 days, floor_div(162, 7) = 23 -- same formula, same independent check.) +RG 96's Annunciation exception (25 March) is CONDITIONAL, not unconditional +-- fix round 1, coordinator review, register corrected 2026-08-12. In 2057, +25 March is Lent III Sunday (I class, impedes it); 26 March is an ordinary +Lent feria (III class, well before Easter, 22 April 2057), so the GENERAL +RG 96 target -- not the Monday after Low Sunday -- is what governs, since +the general walk never crosses Easter. Before this fix the unconditional +reading sent it to 30 April (Easter + 8) instead: + + $ colitur day 2057 | grep '^2057-03-26 ' + 2057-03-26 monday lent 3 annunciation-of-the-blessed-virgin-mary class-1 white +ef-lent-3-monday + $ colitur day 2057 | grep -c 'annunciation-of-the-blessed-virgin-mary' + 1 + A year outside the supported domain is rejected (exit 2): $ colitur day 1000 diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 0973a93..aa98686 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -844,36 +844,76 @@ let test_transfer_target_general_multi_step_search () = 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". *) +(* 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 occupant = occupant_blocking_on [] 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 (Comp.gregorian_easter 2026) 8 in - Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8)" + 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 path's origin + 1 (discriminates the branch)" true - (D.compare target (D.add_days origin 1) <> 0) + 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: block the Monday after Low - Sunday itself and confirm the search continues exactly one more day. *) + 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 monday_after_low_sunday = D.add_days (Comp.gregorian_easter 2026) 8 in - let occupant = occupant_blocking_on [ monday_after_low_sunday ] 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 @@ -974,6 +1014,10 @@ let suite = 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 -- cgit v1.3 From 079e332e0a797105588787f7e5fdabd95ba1fa12 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 04:12:41 +0200 Subject: kernel(precedence): three RG-verified EF precedence bugs, found via oracle Task 16's missalemeum oracle comparison (2026-2027) surfaced three distinct precedence-engine bugs, each confirmed against the primary 1962 Missale Romanum text and cross-checked against the oracle: 1. RG 33 (vigil omission) was transcribed backwards. The primary text reads "Vigilia II AUT III classis penitus omittitur" (a II OR III class vigil is entirely omitted on any Sunday or I-class feast) -- not "I aut II" as the register and this module's own rank check (Class1 || Class2) previously had it. I-class vigils (Nativity, Pentecost) can never actually lose in this engine (band always ranks them above anything that could coincide with their dates), so the Class1 half was dead code; the real bug was that Class3 (St Lawrence's vigil, the sole III-class vigil) was MISSING, so it fell through to an ordinary commemoration instead of RG 33's mandatory omission whenever 9 August landed on a Sunday. 2. RG 93/95/109/113 read together: an ordinary, non-privileged TEMPORAL-cycle office (a bog-standard green-season feria, a plain Advent/Lent Ember day, a Minor Rogation day) has no standing to be commemorated at all when impeded -- RG 109's six-item list is a CLOSED set of the only temporal circumstances that generate a commemoration, not a floor under which anything ordinary still gets one. The engine previously commemorated the losing feria itself in this situation; confirmed wrong against ~190 independent missalemeum days (2026-2027) showing zero commemorations for the exact shape. Vigils are explicitly excluded from this new rule -- RG 31/32 give them their own "if impeded, commemorated" mandate, independent of RG 109's list. 3. RG 111(b)'s Sunday admission slot is restricted to "de festo II classis" -- a rank restriction, not merely the best available ordinary candidate. A III/IV-class ordinary saint competing for a II-class Sunday's single commemoration slot had no such restriction applied before; confirmed wrong via St Hyginus (11 Jan, Class3) losing to Holy Family, which missalemeum shows entirely displaced, never commemorated. All three fixes are pure disposition/admission changes -- they never touch band, so the observed day (season/slug/rank/colour) is unaffected in every case; the lectio differential (which never compares commemorations) stays green untouched. test_precedence_ef.ml updated throughout: two previously-wrong test expectations corrected (the RG33 III-class-vigil boundary row, the IV-class-feria totality row), three privilege boundary rows sharpened from "commemorated but unprivileged" to "omitted entirely" now that temporal+ordinary means omission, and a new contrast row added to keep the SANCTORAL side of rule 2 covered separately from the TEMPORAL side. --- lib/rites/rite_ef/precedence_ef.ml | 215 +++++++++++++++++++++++++++++++------ test/test_precedence_ef.ml | 93 +++++++++++++--- 2 files changed, 256 insertions(+), 52 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 0feb3e0..fd83383 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -244,15 +244,60 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc plan's scope -- see calendar.mli's own note that nothing in the EF ruleset currently emits it. *) -(* RG 33 (register line 383-384): a I- or II-class vigil falling on any - Sunday or a I-class feast is entirely omitted. Every Sunday slug this - rite's temporal cycle produces -- named (temporal_ef.ml's [named], e.g. - "ef-easter-sunday") or the generic "ef--sunday-" fallback - ([sunday_slug]) -- contains this marker; nothing else [band] classifies - does. Not an RG citation itself -- see [universal_layer]'s note on this - file's own naming conventions -- exposed for the same reason as - {!vigil_suffix}: a future rename of temporal_ef's Sunday-slug format has - somewhere to be caught other than a silently-wrong RG 33 disposition. *) +(* RG 33 -- CORRECTED 2026-08-12 (Task 16, primary-source-verified against + docs/research/1962-06-23,_SS_Ioannes_XXIII,_Missale_Romanum,_LT.pdf, the + General Rubrics' own Chapter XI "De Vigiliis"). The register previously + transcribed this as "a I/II-class vigil is entirely omitted"; the + PRIMARY TEXT reads the other way round: + + "33. Vigilia II aut III classis penitus omittitur, si occurrat in + dominica quavis, aut in festo I classis, vel si festum cui + præmittitur in alium diem transferri aut ad commemorationem reduci + contingat." + + -- "A vigil of the II OR III class is entirely omitted, if it occurs on + ANY Sunday whatsoever, or on a feast of the I class, or if the feast it + precedes happens to be transferred to another day or reduced to a + commemoration." I-class vigils (Nativity, Pentecost, RG 30) are outside + this rule entirely -- RG 30's own text says they "festis quibuslibet + præferunt, et nullam admittunt commemorationem" (are preferred to ANY + feast whatsoever, and admit no commemoration at all), i.e. they can never + lose in the first place: {!band} entries 1/5/9 already rank Nativity Eve + and the Pentecost Vigil above every Sunday and every other I-class row + that could coincide with their fixed/Easter-relative dates (verified: no + date collision is even representable), so no I-class vigil can ever reach + this function as a [loser] -- the branch below never needs to test for + [Class1] and, before this fix, its stray inclusion of [Class1] here was + simply dead code, not a second bug (see the task report for the + argument). The bug was the OTHER half: [Class3] (the sole III-class + vigil, St Lawrence, RG 32) was MISSING from this branch, so it fell + through to the generic "commemorated or omitted" branch below instead of + RG 33's mandatory omission -- confirmed wrong for real data: 9 August + 2026 is a Sunday, and before this fix "vigil-of-st-lawrence" competed for + (and could in principle win) that Sunday's single commemoration slot, + when RG 33 says it must not even be a candidate. The oracle comparison + (missalemeum, Task 16) independently confirms: 9 Aug 2026 shows no trace + of the vigil surviving as a commemoration. + + The third omission trigger in RG 33's own text -- "or if the feast it + precedes is transferred to another day or reduced to a commemoration" -- + is NOT implemented: no II/III-class vigil's own feast (Ascension, + Assumption, John Baptist, Sts Peter & Paul, Lawrence) is ever + transferred or reduced to a commemoration anywhere in this codebase's + current data (all fixed I-class, none coincide with anything of equal or + higher rank within any year this project has sampled), so no witness + exists to build or test this clause against; flagged in the register + (§6) rather than guessed. *) +let is_omissible_vigil (rank : Vocab_ef.rank) = rank = Vocab_ef.Class2 || rank = Vocab_ef.Class3 + +(* Every Sunday slug this rite's temporal cycle produces -- named + (temporal_ef.ml's [named], e.g. "ef-easter-sunday") or the generic + "ef--sunday-" fallback ([sunday_slug]) -- contains this + marker; nothing else [band] classifies does. Not an RG citation itself -- + see [universal_layer]'s note on this file's own naming conventions -- + exposed for the same reason as {!vigil_suffix}: a future rename of + temporal_ef's Sunday-slug format has somewhere to be caught other than a + silently-wrong RG 33 disposition. *) let sunday_marker = "-sunday" let contains_substring s ~needle = @@ -372,6 +417,7 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition = let open Vocab_ef in let cel = loser.Precedence.cel in + let is_temporal = loser.Precedence.origin = Precedence.Temporal in if cel.Celebration.status = Celebration.Commemoration_only then (* Always -- checked before RG 33's omission and RG 95's transfer so neither can override it: a Commemoration_only entry can never win @@ -384,13 +430,17 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) it would to any other candidate. *) Precedence.Commemorate (privilege_of loser) else if - (cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2) + is_omissible_vigil cel.Celebration.rank && is_vigil (Slug.to_string cel.Celebration.slug) && impedes_vigil winner then - (* RG 33. Checked before the generic Class1 -> Transfer rule below, or a - I-class vigil (Nativity, Pentecost) impeded on its own Sunday/ - I-class-feast terms would wrongly transfer instead of vanishing. *) + (* RG 33, corrected (see {!is_omissible_vigil}'s own comment): II- or + III-class vigils only -- a real I-class vigil can never reach this + function as a loser at all (see that comment), so this branch would + never have fired for [Class1] even before the fix; what changed is + that [Class3] (St Lawrence) now correctly reaches RG 33's omission + instead of falling through to the generic "commemorated or omitted" + branch below. *) Precedence.Omit else if cel.Celebration.rank = Class1 @@ -416,19 +466,93 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) Rite.transfer_target's job, not this function's -- disposition only says THAT it moves. *) Precedence.Transfer + else if + is_temporal + && (not (is_vigil (Slug.to_string cel.Celebration.slug))) + && (match privilege_of loser with Precedence.Ordinary -> true | Precedence.Privileged -> false) + then + (* Task 16, primary-source-verified (RG 93, 95, 109, 113): an ordinary, + NON-privileged TEMPORAL-cycle office has no standing to be + commemorated at all when impeded -- it is simply omitted, not the + "commemorated or omitted, per rubric" residual RG 95 leaves open for + everything else. Three primary texts read together settle this: + + - RG 95: "Alia festa, ab Officio gradus superioris accidentaliter + impedita, AUT COMMEMORANTUR AUT, eo anno, PENITUS OMITTUNTUR, IUXTA + RUBRICAS" -- impeded offices are "either commemorated or, that + year, entirely omitted, ACCORDING TO THE RUBRICS" -- i.e. some + OTHER rule decides which fate applies; RG 95 itself does not grant + a commemoration to everything impeded. + - RG 109 gives that other rule for the temporal cycle: an EXHAUSTIVE, + closed six-item list of the only temporal-origin circumstances that + ever generate a commemoration -- (a) of a Sunday; (b) of a I-class + day; (c) of days within the Nativity Octave; (d) of the September + Ember days; (e) of Advent/Lent/Passiontide ferias; (f) of the Major + Rogations. [privilege_of] above already implements exactly this + list (its own six branches, each cited to its own RG 109 letter); + its terminal "[else Precedence.Ordinary]" is what a TEMPORAL-origin + candidate falls through to when it matches NONE of (a)-(f) -- an + ordinary green-season feria of Time after Epiphany/Pentecost/ + Easter, a plain (non-Ember) Advent/Lent weekday already caught by + (e), or a Minor Rogation day (RG 87 -- deliberately NOT named by + RG 109(f), see [privilege_of]'s own comment on that letter). + - RG 113: "Commemoratio de Tempore fit primo loco" -- the + commemoration OF THE TEMPORAL DAY is made FIRST [in the list, when + one is due] -- presupposes RG 109 already answered whether one is + due; it does not itself create a right for every impeded feria. + + So testing [privilege_of loser = Ordinary] here, for a TEMPORAL-origin + loser specifically, is not a second, parallel "is this commemorable" + predicate that could drift from RG 109's own list -- it IS RG 109's + list, already computed by [privilege_of] for the commemoration this + branch is about to deny. SANCTORAL losers are entirely unaffected + (the [is_temporal] guard): RG 111(c)/(d) admit an "ordinary" + commemoration of a losing SAINT freely, with no such closed-list + gate -- this omission is specific to the temporal cycle's own + ferial/Sunday-tail offices, never to a saint. + + Empirically confirmed against the missalemeum oracle (Task 16, + 2026-2027, both years): every one of ~190 days where a saint's feast + impedes an ordinary (non-privileged) temporal feria shows ZERO + commemorations in the oracle, including the exact shape this fixes + (e.g. "St. Marcellus I" impeding the plain "Friday after Epiphany", + 6/730 identical instances of the pattern per week of ordinary time) + -- and the SAME fix, for the same reason, independently corrects the + Minor Rogation days (RG 87) losing to a saint (9/730 days), which + [privilege_of]'s own (f) comment already flags as NOT RG 109(f). + + [is_vigil] is EXCLUDED from this branch deliberately: a II/III-class + vigil is temporal-origin too (when it is the Ascension/Pentecost- + adjacent case {!of_temporal} produces) and [privilege_of] rightly + calls it [Ordinary] (a vigil is none of RG 109(a)-(f)), but vigils + are NOT governed by RG 109 at all -- they carry their OWN, separate, + explicit commemoration mandate: RG 31 (II class) "Hae vigiliae + praeferuntur diebus liturgicis III et IV classis; ET, SI + IMPEDIUNTUR, COMMEMORANTUR, iuxta rubricas" and RG 32 (III class, St + Lawrence) "si impeditur, COMMEMORATUR, iuxta rubricas" -- "if + impeded, ARE/IS commemorated". So a vigil impeded WITHOUT triggering + RG 33's full omission (the [is_omissible_vigil] branch above, e.g. + impeded by an ordinary sanctoral feast that is neither a Sunday nor + I class) must still fall through to the final [Commemorate] branch + below, exactly like a sanctoral loser -- RG 31/32's own text, not + RG 109's closed list, is what governs it. *) + Precedence.Omit else (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" -- - commemorated or wholly omitted. Reached by everything below I class, - AND by an impeded I-class Sunday (excluded from the [Transfer] branch - above): RG 109(a) (register line 374) lists "of a Sunday" as a - privileged commemoration category, which presupposes an impeded - Sunday stays put rather than moving to another day the way a feast - does -- [privilege_of] tags it [Privileged] via the same - [is_sunday_slug] marker, with no further code needed here. Which of - commemorate/omit survives is RG 108-111's admission count ([admit], - below), not this function's decision; this only opens the - commemoration, tagged with its real RG 109 privilege via - [privilege_of]. + commemorated or wholly omitted. Reached by every SANCTORAL loser + below I class (RG 111(c)/(d)'s "ordinary" commemoration, no closed + list the way the temporal branch above has), AND by an impeded + I-class Sunday (excluded from the [Transfer] branch above, and from + the temporal-Ordinary [Omit] branch above because [privilege_of]'s + (a) makes a Sunday loser [Privileged], never [Ordinary]): RG 109(a) + (register line 374) lists "of a Sunday" as a privileged commemoration + category, which presupposes an impeded Sunday stays put rather than + moving to another day the way a feast does -- [privilege_of] tags it + [Privileged] via the same [is_sunday_slug] marker, with no further + code needed here. Which of commemorate/omit survives is RG 108-111's + admission count ([admit], below), not this function's decision; this + only opens the commemoration, tagged with its real RG 109 privilege + via [privilege_of]. RG 94 (a fixed-day commemoration is not carried along with a transferred feast) needs no code here: [Precedence.resolve] calls @@ -512,19 +636,40 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) several are. *) (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ best ]) | Class2, true -> - (* RG 111: "II-class Sundays: one (dropped if a privileged one is - due)." Read as: the day's one slot goes to a privileged - commemoration whenever one is due, categorically -- not by - comparing its dignity against the ordinary contender's -- so an - ordinary commemoration that would otherwise win the slot on raw - dignity is still dropped once any privileged commemoration is also - due. This is the asymmetric clause the brief and task report flag - as deliberate, not present at "other II class" below; see the task - report for the reasoning and its residual uncertainty (the register - does not spell out the mechanism beyond this one sentence). *) + (* RG 111(b), primary text: "in dominicis II classis, una tantum + admittitur commemoratio, SCILICET DE FESTO II CLASSIS, quæ tamen + omittitur si commemoratio privilegiata facienda sit" -- "on Sundays + of the II class, only ONE commemoration is admitted, NAMELY OF A + FEAST OF THE II CLASS, which however is dropped if a privileged + commemoration is due." Two clauses, not one: (i) a privileged + commemoration, whenever due, categorically takes the day's one slot + -- not by comparing its dignity against the ordinary contender's, + so an ordinary commemoration that would otherwise win on raw + dignity is still dropped once any privileged one is also due (the + asymmetric clause the brief and task report flag as deliberate, not + present at "other II class" below); (ii) failing that, the slot is + reserved SPECIFICALLY for a [Class2] candidate -- "de festo II + classis" is a RANK restriction, not merely "whichever ordinary + candidate has the best dignity": a III- or IV-class ordinary loser + (a plain commemoration-only saint with no privilege of its own) has + NO standing for this slot at all and must be entirely omitted, even + when it is the only candidate present. + + Fix, Task 16 (primary-source-verified + missalemeum-confirmed): + previously this fell back to "the best of [sorted], whatever its + rank" once no privileged candidate was due, silently admitting a + III/IV-class ordinary saint that RG 111(b)'s own wording excludes. + Confirmed wrong for real data by the oracle comparison: e.g. 11 Jan + 2026 (Holy Family, a II-class Sunday) has St Hyginus (Class3, + commemoration-only) as its only competing candidate -- missalemeum + shows him "displaced" (omitted), never commemorated; the + pre-fix code admitted him regardless. *) (match List.filter is_privileged sorted with | best :: _ -> [ best ] - | [] -> ( match sorted with [] -> [] | best :: _ -> [ best ] )) + | [] -> ( + match List.filter (fun (c, _) -> c.Precedence.cel.Celebration.rank = Class2) sorted with + | [] -> [] + | best :: _ -> [ best ])) | Class2, false -> (* RG 111: "other II class: one" -- no privilege-override clause here, unlike the Sunday case immediately above, so the day's one slot diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index aa98686..2f08864 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -375,17 +375,34 @@ let disposition_cases = 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": *) + 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; the + vigil is temporal-origin ({!of_temporal}, the real Ascension Vigil) + and would otherwise land in Task 16's new "ordinary temporal loser -> + Omit" branch too (see the IV-class-feria row further down) -- this + row is what proves that branch's own [not (is_vigil ...)] guard: 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. *) ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \ - II-class day -> Commemorate, NOT Omit", + 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)" ); - ( "RG33 boundary: a III-class vigil (outside RG33's I/II-class scope) \ - loses to a Sunday -> Commemorate, NOT Omit", + (* 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", - "Commemorate(Ordinary)" ); + "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 @@ -424,16 +441,45 @@ let disposition_cases = 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. *) + (* 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)" ); - ( "IV-class feria loses to a II-class Sunday -> Commemorate", + (* 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)" ) ] @@ -506,29 +552,42 @@ let privilege_cases = 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. *) + [Privileged] -- the exact trap this pair of rows guards against. + + CHANGED, Task 16: these used to expect [Commemorate(Ordinary)] -- + "not privileged" originally meant "commemorated, but without RG 109's + higher liturgical honours". Now that a TEMPORAL-origin [Ordinary] + loser is Task 16's own [Omit] branch (see precedence_ef.ml), "not + privileged" for a temporal candidate means "not commemorable at all" + -- a sharper, more direct assertion of the same underlying + [privilege_of] classification, via the one vantage point available + on that private function. *) ( "boundary: an Advent Ember day is NOT privileged (only September is, \ - RG109(d))", + RG109(d)) -- and being temporal+ordinary, TASK16 omits it entirely", cand "ef-nativity", of_temporal (mk 2026 12 16), - "Commemorate(Ordinary)" ); + "Omit" ); ( "boundary: a Lent Ember day is NOT privileged (only September is, \ - RG109(d))", + RG109(d)) -- and being temporal+ordinary, TASK16 omits it entirely", cand "ef-nativity", of_temporal (off (-39)), - "Commemorate(Ordinary)" ); + "Omit" ); (* 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. *) + 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)", + the Major Litanies, not these) -- TASK16 omits it entirely", cand "ef-nativity", of_temporal (off 36), - "Commemorate(Ordinary)" ) + "Omit" ) ] (* Task 9: [PE.admit] -- RG 111's admission counts (register line 378), -- cgit v1.3 From 0121abf66700879b31ef0b9a13e936b95e123c8f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 05:25:58 +0200 Subject: kernel(precedence): fix round 1 -- RG23-26 govern ferial commemoration, not RG109 Review round 1 (F1, Critical): the commemoration-eligibility fix from the previous commit silently dropped II-class Advent and Lent Ember ferias when impeded, while ordinary (lower-solemnity) ferias of the same seasons kept their commemoration -- backwards on any reading. Reproduced: 1900-12-21 (an Advent Ember Friday) lost its commemoration entirely; 2026-12-21 (an ordinary Advent feria the same week) kept its. F2 (Important, the direct cause of F1): the branch's own justification mis-stated RG 109 as an exhaustive list of the only temporal circumstances that generate a commemoration. RG 109 is headed "Commemorationes privilegiatae sunt commemorationes" and closes "Omnes aliae commemorationes sunt commemorationes ordinariae" -- it sorts commemorations that already exist into two HONOUR classes (RG 108's differing liturgical hours), and says nothing about which offices have the right to be commemorated at all. That right belongs to Caput IV, "De feriis" (RG 21-27), never opened by the original pass: RG 24 (II-class ferias): "si vero impediuntur, commemorari debent" -- if impeded, MUST be commemorated. Not optional. RG 25 (III-class ferias): same mandate. RG 26: every feria not named in 23-25 is IV class, and IV-class ferias are NEVER commemorated -- the one exclusion RG 21-27 actually states. The omission branch is now gated on `rank = Class4` (RG 26) directly, not on `privilege_of = Ordinary` -- the old gate happened to reach the right answer for IV-class ferias (RG 26 also excludes them, for a reason the old citation did not give) and the wrong one for II/III- class Ember ferias RG 109(e) did not name by letter. RG 109(e) itself is also corrected: its bare "feriis Adventus, Quadragesimae et Passionis" previously excluded the Advent and Lent Ember sub-days by analogy with (d)'s separate September carve-out. RG 91's own TABLE needs an explicit "exceptis feriis Quatuor Temporum" at 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 exception, so it is read at that same default, inclusive scope: the Advent and Lent Ember ferias are privileged under (e), not merely ordinary-but-commemorable. (d)'s own existence is unaffected -- September Ember days sit outside Advent/Lent/Passiontide under any reading, so (d) remains necessary regardless. Verified: 1900-12-21 and 1902-02-22 now correctly commemorate their Ember ferias; 2026-12-21 and 1902-02-24 (ordinary ferias) unchanged. Blast radius re-measured against the prior commit, 1900-2100: 102 civil days changed, every one an Advent/Lent Ember (or Ember-vs- Joseph-collision) day regaining its commemoration, nothing else. Validate re-swept exhaustively, 1583-9998: 0 failures. F6: two privilege-boundary test rows that had been bent to expect the bug's own output (Commemorate -> Omit) are restored to what RG 24/25 actually require, now Commemorate(Privileged) given the RG 109(e) correction above -- these are the rows that should have caught F1. F7: admit_cases had no witness for RG 111(b)'s rank floor that wasn't already Class2, so reverting that filter only reddened the oracle suite, never this file. Added a row (II-class Sunday, sole candidate an ordinary Class3) that fails without the filter and passes with it. F9: RG 122 cited alongside RG 128(b) for Holy Thursday's white -- states the same fact affirmatively ("Demum adhibetur color albus, feria V Hebdomadae sanctae...") rather than as an exception to violet. --- lib/rites/rite_ef/precedence_ef.ml | 210 +++++++++++++++++++++++-------------- lib/rites/rite_ef/temporal_ef.ml | 7 ++ test/test_precedence_ef.ml | 96 ++++++++++++----- 3 files changed, 207 insertions(+), 106 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index fd83383..88f1a54 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -380,22 +380,46 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege (* (c) register line 375: "of days within the Octave of the Nativity". *) else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then Precedence.Privileged - (* (d) register line 375-376: "of September Ember days" -- deliberately - ONLY the September set: RG 109 does not list the Advent or Lent Ember - sets (also II class, RG 91 entry 18), so those must fall through to - "ordinary", not be caught here or at (e) below. *) + (* (d) register line 375-376: "of September Ember days" -- named on its + own because September falls entirely outside (e)'s three seasons + (Advent/Lent/Passiontide) under ANY reading, not because it needs + excluding FROM (e) the way review round 1's F1/F2 finding corrected + the Advent/Lent Ember sets below to no longer need. *) else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then Precedence.Privileged (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" -- - [not (is_ember_18 slug)] is required, not redundant with (d): the - Advent and Lent Ember prefixes ("ef-advent-ember-", "ef-lent-ember-") - also start with this branch's own [alp_feria_prefixes] entries - ("ef-advent-", "ef-lent-"), and RG 109 does not privilege them (see (d) - above) -- without this exclusion they would wrongly match here. *) - else if is_temporal - && (not (is_ember_18 slug)) - && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes - then Precedence.Privileged + CORRECTED, fix round 1 (F1/F2): this branch previously excluded the + Advent and Lent Ember sets via [not (is_ember_18 slug)], reading RG + 109(e)'s bare "feriis Adventus, Quadragesimae" as tacitly narrower than + the ordinary ferias of those seasons, on the theory that (d)'s separate + September carve-out implied Ember days needed excluding from (e) too. + That reading does not survive comparing (e)'s text against RG 91's own + TABLE entries for the same seasons (register §4, "Ferias of Lent and + Passiontide... EXCEPTIS feriis Quatuor Temporum" at entry 22; "Ferias + of Advent... EXCEPTIS feriis Quatuor Temporum" at entry 25): the table + needs an explicit "exceptis" to keep Ember days from being double- + listed at both their own entry 18 AND entries 22/25 -- and an explicit + exception is only necessary because, ABSENT one, "feriae Adventus"/ + "feriae Quadragesimae" already DO include their Ember sub-days by + default (an unnecessary exception is not how a rubrical text is + drafted). RG 109(e) carries no such "exceptis" clause, so its bare + "feriis Adventus, Quadragesimae" is read at that same default, + INCLUSIVE scope: the Advent and Lent Ember ferias ARE "ferias of + Advent"/"of Lent" in RG 109(e)'s sense, hence privileged, not merely + ordinary. (d)'s own separate existence is unaffected by this reading + either way -- September Ember days sit in "time after Pentecost", + never within Advent/Lent/Passiontide under any reading, so (d) remains + necessary regardless; it is not evidence for excluding Advent/Lent + Ember from (e), only for including September at all.) Consequently + [is_ember_18] is no longer tested here -- an Advent/Lent Ember slug + matches this branch exactly like an ordinary Advent/Lent feria slug + does, via the same [alp_feria_prefixes] prefix test; only a September + Ember slug is structurally excluded, because "ef-september-ember-*" + never starts with any of [alp_feria_prefixes] ("ef-advent-"/"ef-lent-"/ + "ef-passiontide-") in the first place -- (d) above already privileges + it under its own name. *) + else if is_temporal && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes then + Precedence.Privileged (* (f) register line 376-377: "of the Major Rogations, in Mass" -- the Major Litanies (25 April, RG 80) are not yet computed anywhere in this codebase (temporal_ef.ml's own comment on [temporal]'s Rogation branch: @@ -469,73 +493,102 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) else if is_temporal && (not (is_vigil (Slug.to_string cel.Celebration.slug))) - && (match privilege_of loser with Precedence.Ordinary -> true | Precedence.Privileged -> false) + && cel.Celebration.rank = Class4 then - (* Task 16, primary-source-verified (RG 93, 95, 109, 113): an ordinary, - NON-privileged TEMPORAL-cycle office has no standing to be - commemorated at all when impeded -- it is simply omitted, not the - "commemorated or omitted, per rubric" residual RG 95 leaves open for - everything else. Three primary texts read together settle this: - - - RG 95: "Alia festa, ab Officio gradus superioris accidentaliter - impedita, AUT COMMEMORANTUR AUT, eo anno, PENITUS OMITTUNTUR, IUXTA - RUBRICAS" -- impeded offices are "either commemorated or, that - year, entirely omitted, ACCORDING TO THE RUBRICS" -- i.e. some - OTHER rule decides which fate applies; RG 95 itself does not grant - a commemoration to everything impeded. - - RG 109 gives that other rule for the temporal cycle: an EXHAUSTIVE, - closed six-item list of the only temporal-origin circumstances that - ever generate a commemoration -- (a) of a Sunday; (b) of a I-class - day; (c) of days within the Nativity Octave; (d) of the September - Ember days; (e) of Advent/Lent/Passiontide ferias; (f) of the Major - Rogations. [privilege_of] above already implements exactly this - list (its own six branches, each cited to its own RG 109 letter); - its terminal "[else Precedence.Ordinary]" is what a TEMPORAL-origin - candidate falls through to when it matches NONE of (a)-(f) -- an - ordinary green-season feria of Time after Epiphany/Pentecost/ - Easter, a plain (non-Ember) Advent/Lent weekday already caught by - (e), or a Minor Rogation day (RG 87 -- deliberately NOT named by - RG 109(f), see [privilege_of]'s own comment on that letter). - - RG 113: "Commemoratio de Tempore fit primo loco" -- the - commemoration OF THE TEMPORAL DAY is made FIRST [in the list, when - one is due] -- presupposes RG 109 already answered whether one is - due; it does not itself create a right for every impeded feria. - - So testing [privilege_of loser = Ordinary] here, for a TEMPORAL-origin - loser specifically, is not a second, parallel "is this commemorable" - predicate that could drift from RG 109's own list -- it IS RG 109's - list, already computed by [privilege_of] for the commemoration this - branch is about to deny. SANCTORAL losers are entirely unaffected - (the [is_temporal] guard): RG 111(c)/(d) admit an "ordinary" - commemoration of a losing SAINT freely, with no such closed-list - gate -- this omission is specific to the temporal cycle's own - ferial/Sunday-tail offices, never to a saint. + (* CORRECTED, fix round 1 (F1/F2 -- both real, the second the direct + cause of the first): the branch this replaces gated on + [privilege_of loser = Ordinary], justified by treating RG 109 as an + "exhaustive, closed list of the only temporal-origin circumstances + that ever generate a commemoration". That justification does not + survive reading RG 109 itself: it is headed "Commemorationes + PRIVILEGIATAE sunt commemorationes" and closes "Omnes aliae + commemorationes sunt commemorationes ORDINARIAE" -- it sorts + commemorations that ALREADY exist into two HONOUR classes + (privileged vs ordinary, RG 108's differing liturgical hours), and + says nothing about which offices have the RIGHT to be commemorated + in the first place. Testing [privilege_of = Ordinary] as an + ELIGIBILITY gate therefore happened to reach the right answer for + IV-class ferias (they are never commemorated, but for a reason + RG 109 does not state) and the WRONG answer for II- and III-class + ferias impeded during a season RG 109(e) does not privilege by name + (Advent 17-23 Dec's own ordinary-non-Ember ferias were fine, already + matching (e)'s slug prefix; the Advent and Lent EMBER ferias were + not, since the pre-fix (e) excluded them -- see [privilege_of]'s own + fix-round-1 comment above, which independently corrects THAT half + too). Confirmed wrong by direct reproduction (fix-round-1 review): + 1900-12-21 (an Advent Ember Friday, RG 91 entry 18, II class) lost + its own commemoration entirely under the pre-fix code, while an + ORDINARY (non-Ember, lower-solemnity) Advent feria the same week + kept its commemoration -- backwards on any reading. + + The actual rule is Caput IV, "De feriis" (RG 21-27), which the + original Task 16 pass never opened -- a FERIAL-CLASS-keyed rule, + entirely separate from RG 109's HONOUR-class one: + - RG 23 (I-class ferias -- Ash Wednesday, Holy Week): "nullam + admittunt commemorationem, nisi unam privilegiatam" -- admit no + commemoration except one privileged one. Never actually reaches + this function as a loser (these ferias structurally always + outrank anything that could coincide with their dates -- {!band} + entries 2/7, see that function's own file comment and the Task 11 + Easter-window invariant), so this clause has no live witness, the + same as before. + - RG 24 (II-class ferias -- Advent 17-23 Dec, the Advent/Lent/ + September Ember ferias, RG 91 entry 18): "si vero impediuntur, + COMMEMORARI DEBENT" -- if indeed impeded, they MUST be + commemorated. Not optional, not conditioned on RG 109's list. + - RG 25 (III-class ferias -- ordinary Lent/Passiontide ferias, RG 91 + entry 22; ordinary Advent ferias to 16 Dec, entry 25): "Hae + feriae, si impediuntur, commemorari debent" -- same mandate. + - RG 26: "Omnes feriae, numeris 23-25 non nominatae, sunt feriae IV + classis; quae NUNQUAM COMMEMORANTUR" -- every feria not named in + 23-25 is IV class, and IV-class ferias are NEVER commemorated. + This is [ferial_rank]'s own unqualified IV-class catch-all + (temporal_ef.ml), covering the ordinary green-season ferias of + Time after Epiphany/Pentecost, Septuagesima, Paschaltide outside + its privileged octave, and the Minor Rogation days (RG 87/88 -- + they change nothing in the Office, so they take their season's + plain ferial class, which for Paschaltide-adjacent dates is + IV, not a special one). + + So this branch is now gated directly on RG 26's own condition + ([rank = Class4]), which is the ONLY ferial class RG 21-27 excludes + from commemoration -- Class1 is structurally unreachable here (RG + 23, above); Class2 and Class3 both fall through to the final + [Commemorate] branch below (RG 24/25's mandate), tagged with + whatever HONOUR class [privilege_of] separately computes for them + under RG 109 -- a question this branch no longer conflates with + eligibility. SANCTORAL losers are entirely unaffected (the + [is_temporal] guard): Caput IV governs FERIAE, RG 21's own opening + definition ("Nomine feriae intelleguntur singuli dies hebdomadae, + praeter dominicam"), never a saint's day; RG 111(c)/(d) admit an + "ordinary" commemoration of a losing SAINT freely, with no such + class-keyed gate. Empirically confirmed against the missalemeum oracle (Task 16, 2026-2027, both years): every one of ~190 days where a saint's feast - impedes an ordinary (non-privileged) temporal feria shows ZERO - commemorations in the oracle, including the exact shape this fixes - (e.g. "St. Marcellus I" impeding the plain "Friday after Epiphany", - 6/730 identical instances of the pattern per week of ordinary time) - -- and the SAME fix, for the same reason, independently corrects the - Minor Rogation days (RG 87) losing to a saint (9/730 days), which - [privilege_of]'s own (f) comment already flags as NOT RG 109(f). - - [is_vigil] is EXCLUDED from this branch deliberately: a II/III-class - vigil is temporal-origin too (when it is the Ascension/Pentecost- - adjacent case {!of_temporal} produces) and [privilege_of] rightly - calls it [Ordinary] (a vigil is none of RG 109(a)-(f)), but vigils - are NOT governed by RG 109 at all -- they carry their OWN, separate, - explicit commemoration mandate: RG 31 (II class) "Hae vigiliae - praeferuntur diebus liturgicis III et IV classis; ET, SI - IMPEDIUNTUR, COMMEMORANTUR, iuxta rubricas" and RG 32 (III class, St - Lawrence) "si impeditur, COMMEMORATUR, iuxta rubricas" -- "if - impeded, ARE/IS commemorated". So a vigil impeded WITHOUT triggering - RG 33's full omission (the [is_omissible_vigil] branch above, e.g. - impeded by an ordinary sanctoral feast that is neither a Sunday nor - I class) must still fall through to the final [Commemorate] branch - below, exactly like a sanctoral loser -- RG 31/32's own text, not - RG 109's closed list, is what governs it. *) + impedes an ordinary (IV-class, non-privileged) temporal feria shows + ZERO commemorations in the oracle (e.g. "St. Marcellus I" impeding + the plain "Friday after Epiphany"), and the SAME rank-4 gate, + independently, correctly still omits the Minor Rogation days (RG 87) + losing to a saint -- both consequences of RG 26 alone now, not of a + reading of RG 109 that RG 109's own text does not support. + + [is_vigil] is EXCLUDED from this branch for the same reason as + before, restated under the corrected citation: a II/III-class vigil + is temporal-origin too (the Ascension/Pentecost-adjacent case + {!of_temporal} produces) and typically Class2, so it would already + fall through this branch's [rank = Class4] test harmlessly on its + own -- RG 91 has no IV-class vigil at all (this file's own entry-27/ + 28 comments), so [is_vigil && rank = Class4] should never occur on + real data. Kept as an explicit, defensive guard (not load-bearing + for real data, but total over every candidate {!Precedence.resolve} + or {!Calendar} can construct, including shapes RG 91's table itself + does not describe) rather than relying on that absence silently: a + vigil, per RG 31 (II class, "si impediuntur, commemorantur") / RG 32 + (III class, "si impeditur, commemoratur"), is ALWAYS commemorated + once RG 33 does not omit it outright, regardless of ferial class -- + a rule Caput IV does not speak to at all (vigils are Caput V, RG + 28-34, not "feriae"). *) Precedence.Omit else (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" -- @@ -543,8 +596,9 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) below I class (RG 111(c)/(d)'s "ordinary" commemoration, no closed list the way the temporal branch above has), AND by an impeded I-class Sunday (excluded from the [Transfer] branch above, and from - the temporal-Ordinary [Omit] branch above because [privilege_of]'s - (a) makes a Sunday loser [Privileged], never [Ordinary]): RG 109(a) + the temporal Class4 [Omit] branch above because a Sunday is never + IV class -- RG 11-12/91 entry 6/15 make every Sunday I or II class, + never a "feria" at all in Caput IV's own sense, RG 21): RG 109(a) (register line 374) lists "of a Sunday" as a privileged commemoration category, which presupposes an impeded Sunday stays put rather than moving to another day the way a feast does -- [privilege_of] tags it diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml index b39da79..1b3d219 100644 --- a/lib/rites/rite_ef/temporal_ef.ml +++ b/lib/rites/rite_ef/temporal_ef.ml @@ -426,6 +426,13 @@ let temporal d = §3b's own RG126 note on the not-yet-modelled per-action nuance), so this is a clean whole-day colour fact, not a per-action one the day/colour model cannot express. + RG 122, fix round 1 (F9), states the same fact + affirmatively rather than as an exception to RG 128's + violet: "Demum adhibetur color albus, feria V + Hebdomadae sanctae, in Missa Chrismatis et in Missa in + Cena Domini" -- white is used, finally [among the + White section's own list], on Thursday of Holy Week, + in the Mass of Chrism and in the Mass in Cena Domini. Task 16, found via the missalemeum oracle comparison: every other Triduum day's oracle colour SET includes violet as one option (Good Friday "bv", Holy Saturday diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 2f08864..9bd9b7f 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -523,9 +523,13 @@ let privilege_cases = [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. *) + 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), @@ -542,36 +546,49 @@ let privilege_cases = 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. + (* 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). - CHANGED, Task 16: these used to expect [Commemorate(Ordinary)] -- - "not privileged" originally meant "commemorated, but without RG 109's - higher liturgical honours". Now that a TEMPORAL-origin [Ordinary] - loser is Task 16's own [Omit] branch (see precedence_ef.ml), "not - privileged" for a temporal candidate means "not commemorable at all" - -- a sharper, more direct assertion of the same underlying - [privilege_of] classification, via the one vantage point available - on that private function. *) - ( "boundary: an Advent Ember day is NOT privileged (only September is, \ - RG109(d)) -- and being temporal+ordinary, TASK16 omits it entirely", + 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), - "Omit" ); - ( "boundary: a Lent Ember day is NOT privileged (only September is, \ - RG109(d)) -- and being temporal+ordinary, TASK16 omits it entirely", + "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)), - "Omit" ); + "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 @@ -623,6 +640,17 @@ let privileged_hi = cand ~rank:V.Class2 "ef-privileged-hi" 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". *) @@ -658,6 +686,18 @@ let admit_cases = 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 -- cgit v1.3 From f2b8700f5e9c01bbca219967470a4ed34ad5584f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 10:18:17 +0200 Subject: test(precedence-ef): witness the RG26 Class4 gate and correct a vacuous guard claim Item 2(a): the RG 26 omission gate (rank = Class4) is extensionally identical, over every candidate this engine actually constructs, to the refuted "privilege_of loser = Ordinary" predicate it replaced -- confirmed by enumerating the whole candidate space over 1583-9999, substituting the old predicate back leaves 257/257 green and byte-identical output. Adds a synthetic row: a temporal-origin, Ordinary-privilege Class3 loser, which the two gates disagree on (RG26: Commemorate; the refuted gate: Omit, since it never looked at rank). Reverting the gate to the old predicate turns exactly this row red: Expected: "Commemorate(Ordinary)" Received: "Omit" Item 2(b): the existing "RG33 boundary" row's comment claimed it proved the RG26 branch's own (not (is_vigil ...)) guard. It does not: the vigil sourced there is Class2, already excluded by the branch's rank = Class4 test alone, with or without the vigil guard -- verified by deleting the guard, which leaves 257/257 green. Corrected the comment to say what the row actually shows (RG31's "if impeded, commemorated" vigil mandate, a real and separate fact worth keeping) and added a genuinely discriminating synthetic row: a temporal-origin Class4 loser whose slug is ALSO vigil-shaped, a combination RG 91 does not describe but Precedence.resolve can still be asked to construct. Deleting the guard turns this row red: Expected: "Commemorate(Ordinary)" Received: "Omit" Both reversions were applied locally to precedence_ef.ml, confirmed, and reverted; the committed .ml is unchanged. Test count 257 -> 259, both new rows green under the current, correct code. --- test/test_precedence_ef.ml | 71 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 6 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index 9bd9b7f..efbdb04 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -376,19 +376,50 @@ let disposition_cases = "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; the - vigil is temporal-origin ({!of_temporal}, the real Ascension Vigil) - and would otherwise land in Task 16's new "ordinary temporal loser -> - Omit" branch too (see the IV-class-feria row further down) -- this - row is what proves that branch's own [not (is_vigil ...)] guard: a + 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. *) + 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 @@ -480,6 +511,34 @@ let disposition_cases = 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)" ) ] -- cgit v1.3 From 7ac8a25700c9d930eaaa4f9895665248dac32c52 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 10:26:11 +0200 Subject: docs(citations): migrate register line-number pointers to stable anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 68 "register line N" pointers in tracked code and data cited the register's line numbers directly. The register has moved since: line 334 (cited six times as RG 91 entry 8) is now rose/colour prose; lines 375-376 (cited as "RG 109 leaves Advent/Lent Ember ordinary") are now RG 26, the paragraph that refutes that exact reading; line 374 (cited as RG 109(a)) is now RG 25's Latin; line 378 (RG 111) is blank. expected-divergences.sexp even wrote "RG 91 line 332" three times, as though the line offset were part of the rubric's own identity. Since docs/research/rules-register.md is gitignored, no reader of the public repo could resolve any of these, and -- since the register has moved on its own copy too -- neither can the author's. Replaced every pointer with an anchor that survives editing: the RG paragraph/entry number itself (already present in nearly every case, since the citation text already names "RG 91 entry N" or "RG 109(a)" next to the stale line number -- the line number was redundant, not load-bearing) plus a "§4" or quoted-subsection-heading pointer into the register for readers who want to locate the fuller discussion. Also corrected one genuinely stale content error found while migrating its anchor: test_precedence_ef.ml's RG33 disposition-cases comment still said "a I/II-class vigil impeded by any Sunday" -- the pre-Task-16 mistranscription the register itself corrects to II/III class; fixed the prose alongside its citation, since leaving a wrong RG-class claim next to a freshly-verified anchor would be worse than the stale line number it replaced. Covers lib/rites/rite_ef/precedence_ef.ml (18), precedence_ef.mli (already fixed with item 1), test/test_precedence_ef.ml (44, including two instances that only word-wrapped "register\nline N" across a line break and so did not match a same-line grep), and data/ef/expected-divergences.sexp (5, including three "RG 91 line 332" instances). All 68 original pointers resolved -- none needed guessing; every citation's target rule was already named in the surrounding prose. No behaviour change: every edit is a comment/citation/data-note change. Verified byte-identical `colitur day` output across 1583, 1900, 1902, 2008, 2011, 2026, 2038, 9999. 259/259 tests green. --- data/ef/expected-divergences.sexp | 10 ++-- lib/rites/rite_ef/precedence_ef.ml | 37 +++++++-------- test/test_precedence_ef.ml | 97 +++++++++++++++++++------------------- 3 files changed, 72 insertions(+), 72 deletions(-) (limited to 'test/test_precedence_ef.ml') diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index 6b7bbdd..c8d863d 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -20,15 +20,15 @@ ((id C1) (citation "RG 72-73 (Nativity/Epiphany season boundary, Jan 1-13) + RG 119a (white through \"expletum tempus Epiphaniae\"); register §3c item 1") (verdict colitur) - (note "6-13 January is Christmastide in colitur, matching RG 72-73's explicit boundary at 13 January (corroborated independently by RG 119a's colour rule, register lines 241-246). lectio's efSeason switches to time-after-epiphany on 6 January. The season disagreement cascades into colour (green vs white) and, on 7-8 January specifically, into the ferial slug family name (colitur's ef-christmas-2- vs lectio's ef-time-after-epiphany-1- for the same two days).") + (note "6-13 January is Christmastide in colitur, matching RG 72-73's explicit boundary at 13 January (corroborated independently by RG 119a's colour rule, §3b). lectio's efSeason switches to time-after-epiphany on 6 January. The season disagreement cascades into colour (green vs white) and, on 7-8 January specifically, into the ferial slug family name (colitur's ef-christmas-2- vs lectio's ef-time-after-epiphany-1- for the same two days).") (expected_rows 368)) ((id C2) - (citation "RG 91 line 332 (\"Sundays of Advent, Lent, Passiontide, and Low Sunday\" -- I class, unqualified); extends register §3c item 4 (stated there for Advent only) to Lent on the same textual basis") + (citation "RG 91 entry 6, §4 (\"Sundays of Advent, Lent, Passiontide, and Low Sunday\" -- I class, unqualified); extends register §3c item 4 (stated there for Advent only) to Lent on the same textual basis") (verdict colitur) - (note "Advent II & IV and Lent I-III are I class in colitur, matching RG 91's own unqualified line 332 (already confirmed for Passion Sunday, Palm Sunday, Low Sunday and Advent I, where lectio agrees). lectio's temporal_ef.go generic Sunday branch assigns Class2 and special-cases only Advent I, Passion/Palm/Low Sunday -- a lectio gap for the remaining five Sundays this entry covers.") + (note "Advent II & IV and Lent I-III are I class in colitur, matching RG 91's own unqualified entry 6 (already confirmed for Passion Sunday, Palm Sunday, Low Sunday and Advent I, where lectio agrees). lectio's temporal_ef.go generic Sunday branch assigns Class2 and special-cases only Advent I, Passion/Palm/Low Sunday -- a lectio gap for the remaining five Sundays this entry covers.") (expected_rows 215)) ((id C3) - (citation "RG 91 line 332 (same I-class Sunday rule as C2) + RG 131 (rose indult, Gaudete/Laetare, \"in Officio et Missa diei dominici tantum\")") + (citation "RG 91 entry 6, §4 (same I-class Sunday rule as C2) + RG 131 (rose indult, Gaudete/Laetare, \"in Officio et Missa diei dominici tantum\")") (verdict colitur) (note "Gaudete (Advent III) and Laetare (Lent IV): I class per C2's citation, plus the RG 131 rose indult. lectio has no Rose case in its EF colour function and zero rose rows in tridentine-calendar.ini -- Rose exists only on lectio's OF path -- so it prints violet for both Sundays.") (expected_rows 89)) @@ -58,7 +58,7 @@ (note "Rogation Monday and Tuesday exist as colitur's own ef-rogation-{monday,tuesday} slugs (violet) whenever no higher-ranked saint intervenes. lectio computes no Rogation days at all and shows the plain paschaltide-week-6 feria instead.") (expected_rows 31)) ((id C9) - (citation "RG 95 (translation is a right of I-class FEASTS only) + RG 91 line 332 (Sundays are a separate table row, not feasts) + RG 109(a) (\"of a Sunday\" is a privileged commemoration, presupposing the Sunday stays put) + RG 96 (the general translation walk) -- the same reasoning already fixed for the analogous impeded-Sunday defect in Precedence_ef (Task 9)") + (citation "RG 95 (translation is a right of I-class FEASTS only) + RG 91 entry 6, §4 (Sundays are a separate table row, not feasts) + RG 109(a) (\"of a Sunday\" is a privileged commemoration, presupposing the Sunday stays put) + RG 96 (the general translation walk) -- the same reasoning already fixed for the analogous impeded-Sunday defect in Precedence_ef (Task 9)") (verdict colitur) (note "St Joseph (19 March, I class) colliding with a Lent Sunday (I class per C2): colitur keeps the Sunday observed and transfers Joseph to the next free day (usually 20 March; in years the walk is congested enough to cross Easter, as far as 1 April -- 2008, 2035, 2046, independently pinned by this project's own transfer tests). lectio instead puts Joseph ON the Sunday, displacing the Lenten Sunday office RG 95 says cannot be displaced by a translated feast.") (expected_rows 15)) diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 3c417bb..9997bb2 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -152,7 +152,7 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc (* 7: I-class ferias not above -- Ash Wednesday; Mon/Tue/Wed of Holy Week. Thu-Sat of Holy Week are the Triduum, entry 2 above, not this entry. *) else if is_temporal && rank = Class1 && (off = -46 || (off >= -6 && off <= -4)) then 7 - (* 8: All Souls -- register line 334's own text carries a qualifier this + (* 8: All Souls -- RG 91 entry 8's own text (§4) carries a qualifier this transcription must honour: "yields to an occurring Sunday". 2 November is always Time_after_pentecost (well clear of Advent/Lent/Passiontide and of every other entry's own Easter-relative or fixed date), so a @@ -182,9 +182,9 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc not the universal layer (11), and marked as an indult overlay (12's "not indult" test having just failed). *) else if (not is_temporal) && (not is_vigil) && rank = Class1 then 13 - (* 14: Feasts of the Lord, II class -- register line 341, deliberately - UNQUALIFIED (contrast entry 16 at line 342, which explicitly says "not - of the Lord"; RG 37c, register line 393, speaks of "II-class feasts of + (* 14: Feasts of the Lord, II class -- RG 91 entry 14, deliberately + UNQUALIFIED (contrast entry 16, which explicitly says "not + of the Lord"; RG 37c (§4, "Sundays") speaks of "II-class feasts of the Lord" replacing an occurring II-class Sunday with no universal qualifier either). No layer test here, unlike 11/12/13 and 16/19/20: the register does not split this entry into universal/proper/indult, @@ -232,7 +232,7 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc Office (RG 88, see temporal_ef.ml's [temporal]), so those never carry this entry unless they happen to fall on the Saturday itself. Excludes vigils for the same reason 11-13/14/16/19/20/23/24 do: RG 91 has no - IV-class vigil at all (its own vigil list, register lines 381-384, + IV-class vigil at all (RG 91's own vigil list, §4 "Vigils", stops at III class), so one would be an anomaly, not this entry. *) else if is_temporal && (not is_vigil) && rank = Class4 && weekday = Date.Sat then 27 (* 28: IV-class ferias -- the unqualified catch-all (temporal_ef.ml's own @@ -339,7 +339,7 @@ let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) = let nativity_octave_prefix = "ef-nativity-octave-day-" (* RG 109's own three named seasons for (e), "of ferias of Advent, Lent and - Passiontide" (register line 376) -- temporal_ef.ml's generic + Passiontide" (§4, "Commemorations") -- temporal_ef.ml's generic -- ferial fallback slugs, whose season word is [season_slug_word]'s output for exactly these three (vocab_ef.ml: Advent and Passiontide are unmodified [season_to_string]; Lent likewise). Also @@ -349,7 +349,7 @@ let nativity_octave_prefix = "ef-nativity-octave-day-" [universal_layer] -- private: nothing outside [privilege_of] needs it. *) let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ] -(* RG 109 (register lines 374-377, docs/research/rules-register.md §4): the +(* RG 109 (docs/research/rules-register.md §4, "Commemorations"): the closed list of privileged commemorations, checked in the register's own lettered order. A candidate matching none of (a)-(f) is ordinary, per the register's own closing sentence, "All others are ordinary." Read entirely @@ -374,10 +374,10 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege let slug = Slug.to_string cel.Celebration.slug in let is_temporal = c.Precedence.origin = Precedence.Temporal in let open Vocab_ef in - (* (a) register line 374: "of a Sunday" -- the same slug marker RG 33's + (* (a) RG 109(a) (§4): "of a Sunday" -- the same slug marker RG 33's [impedes_vigil] already reads to answer "is this candidate a Sunday". *) if is_sunday_slug slug then Precedence.Privileged - (* (b) register line 374-375: "of a I-class day" -- the candidate's own + (* (b) RG 109(b) (§4): "of a I-class day" -- the candidate's own rank. In this codebase's current disposition rules the ONLY way a [Class1] candidate ever reaches [Commemorate] at all is via [Celebration.status = Commemoration_only] (a plain [Feast]-status @@ -385,17 +385,17 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege branch is real but its only reachable witness today is that shape; see the task report. *) else if rank = Class1 then Precedence.Privileged - (* (c) register line 375: "of days within the Octave of the Nativity". *) + (* (c) RG 109(c) (§4): "of days within the Octave of the Nativity". *) else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then Precedence.Privileged - (* (d) register line 375-376: "of September Ember days" -- named on its + (* (d) RG 109(d) (§4): "of September Ember days" -- named on its own because September falls entirely outside (e)'s three seasons (Advent/Lent/Passiontide) under ANY reading, not because it needs excluding FROM (e) the way review round 1's F1/F2 finding corrected the Advent/Lent Ember sets below to no longer need. *) else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then Precedence.Privileged - (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" -- + (* (e) RG 109(e) (§4): "of ferias of Advent, Lent and Passiontide" -- CORRECTED, fix round 1 (F1/F2): this branch previously excluded the Advent and Lent Ember sets via [not (is_ember_18 slug)], reading RG 109(e)'s bare "feriis Adventus, Quadragesimae" as tacitly narrower than @@ -428,7 +428,7 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege it under its own name. *) else if is_temporal && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes then Precedence.Privileged - (* (f) register line 376-377: "of the Major Rogations, in Mass" -- the + (* (f) RG 109(f) (§4): "of the Major Rogations, in Mass" -- the Major Litanies (25 April, RG 80) are not yet computed anywhere in this codebase (temporal_ef.ml's own comment on [temporal]'s Rogation branch: "The Major Litanies... are a fixed date and are not yet computed; they @@ -478,13 +478,12 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) cel.Celebration.rank = Class1 && not (is_sunday_slug (Slug.to_string cel.Celebration.slug)) then - (* RG 95 (register lines 323, 363): only I-class FEASTS have the right + (* RG 95 (§4, "Occurrence" and "Transfer/translation"): only I-class FEASTS have the right of translation -- RG 91's own table lists Sundays as a separate row - (entry 6, register line 332) from feasts (entries 11-13, register - lines 337-339), so a Sunday is never a "feast" in RG 95's sense, and + (entry 6) from feasts (entries 11-13), so a Sunday is never a "feast" in RG 95's sense, and [is_sunday_slug] (the same marker RG 33's [impedes_vigil] and RG 109(a)'s [privilege_of] already use) excludes it here. This is the - branch that completes Task 7's All Souls fix (register line 334, RG + branch that completes Task 7's All Souls fix (RG 91 entry 8): All Souls is I class, not a vigil, and not a Sunday slug, so once it loses to an occurring Sunday it still reaches here and transfers -- to 3 November, now DIRECTLY authorised by RG 96 @@ -613,7 +612,7 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) the temporal Class4 [Omit] branch above because a Sunday is never IV class -- RG 11-12/91 entry 6/15 make every Sunday I or II class, never a "feria" at all in Caput IV's own sense, RG 21): RG 109(a) - (register line 374) lists "of a Sunday" as a privileged commemoration + (§4) lists "of a Sunday" as a privileged commemoration category, which presupposes an impeded Sunday stays put rather than moving to another day the way a feast does -- [privilege_of] tags it [Privileged] via the same [is_sunday_slug] marker, with no further @@ -632,7 +631,7 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) Precedence.Commemorate (privilege_of loser) (* Task 9: how many of the day's commemorations RG 111 admits, and which - (docs/research/rules-register.md §4, register line 378, "Commemorations" + (docs/research/rules-register.md §4, "Commemorations", RG 111). [band] decides who wins the day; [disposition] decides who is even eligible to be commemorated, and tags each with its RG 109 privilege via [privilege_of]; this decides how many of THOSE survive. diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml index efbdb04..a0e1db1 100644 --- a/test/test_precedence_ef.ml +++ b/test/test_precedence_ef.ml @@ -49,45 +49,45 @@ 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. *) + [ (* 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 -- register line 328: Sacred Triduum. Thu-Sat of Holy Week, + (* 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 -- register line 329. *) + (* 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 -- register line 330. Sanctoral-origin: neither feast is part + (* 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 -- register line 331. *) + (* 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 -- register line 332. *) + (* 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 -- register line 333: Ash Wednesday and Mon/Tue/Wed of Holy + (* 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 -- register line 334. 2 Nov 2026 is a Monday (verified + (* 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 @@ -101,31 +101,31 @@ let cases = [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. *) + (* Entry 9 -- RG 91 entry 9 (§4). *) ("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9); - (* Entry 10 -- register line 336: both range boundaries, to guard the + (* 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 -- register line 337. *) + (* 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 -- register line 338. The one non-base-layer case the brief + (* 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 -- register line 339. *) + (* 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 -- register line 341, deliberately UNQUALIFIED (contrast - entry 16, line 342, which explicitly says "not of the Lord"). *) + (* 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", @@ -138,21 +138,21 @@ let cases = 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 + (* 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 -- register line 342. *) + (* 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 -- register line 343: days WITHIN the Nativity octave (26-28 + (* 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 -- register line 343-344: Advent 17-23 Dec ferias AND the + (* 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 @@ -164,17 +164,17 @@ let cases = 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. *) + (* 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 -- register line 345. *) + (* 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 -- register line 345 (RG 28-34). Two rows: the Ascension + (* 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 @@ -194,12 +194,12 @@ let cases = 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 + (* 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 -- register line 349. NOTE the table's own order here is the + (* 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 @@ -207,13 +207,13 @@ let cases = ( "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. *) + (* 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 -- register line 350. *) + (* 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 -- register line 350. *) + (* 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 ); @@ -238,12 +238,12 @@ let cases = 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 + (* 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 -- register line 352: the unqualified IV-class catch-all. *) + (* 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 @@ -264,7 +264,7 @@ let cases = [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 -- + (* 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 ) @@ -298,7 +298,7 @@ let test_all_souls_yields_to_sunday () = (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 + 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 @@ -320,7 +320,7 @@ 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 + [ (* 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 @@ -334,14 +334,14 @@ let disposition_cases = 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) + (* 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, register line 332) - from feasts (entries 11-13, lines 337-339) -- so an impeded I-class + 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) (register - line 374) confirms this from the other direction: "of a Sunday" is a + 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 @@ -356,8 +356,9 @@ let disposition_cases = 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 + (* 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 @@ -456,7 +457,7 @@ let disposition_cases = 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 + 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 @@ -542,7 +543,7 @@ let disposition_cases = "Commemorate(Ordinary)" ) ] -(* Task 9: [privilege_of]'s RG 109 categories (register lines 374-377), +(* 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 @@ -560,14 +561,14 @@ let disposition_cases = 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, + [ (* (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) register line 375: "of days within the Octave of the Nativity" -- + (* (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 @@ -577,7 +578,7 @@ let privilege_cases = cand "ef-nativity", of_temporal (mk 2026 12 29), "Commemorate(Privileged)" ); - (* (d) register line 375-376: "of September Ember days" -- 23 Sep 2026 is + (* (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 @@ -593,7 +594,7 @@ let privilege_cases = cand "ef-nativity", of_temporal (mk 2026 9 23), "Commemorate(Privileged)" ); - (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" -- + (* (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. *) @@ -666,7 +667,7 @@ let privilege_cases = "Omit" ) ] -(* Task 9: [PE.admit] -- RG 111's admission counts (register line 378), +(* 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 @@ -719,7 +720,7 @@ 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." *) + [ (* 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) ], @@ -928,7 +929,7 @@ let test_ii_class_sunday_privileged_witness_admitted_end_to_end () = [ ("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 +(* 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 -- cgit v1.3