aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml88
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli32
-rw-r--r--test/cli.t13
-rw-r--r--test/test_precedence_ef.ml74
4 files changed, 152 insertions, 55 deletions
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