diff options
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.ml | 109 | ||||
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.mli | 29 | ||||
| -rw-r--r-- | test/test_precedence_ef.ml | 102 |
3 files changed, 198 insertions, 42 deletions
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-<set>-ember-<day>"), 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-<set>-ember-<day>"), 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-<set> + -ember-<day>" 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 ] ) |
