aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 01:19:37 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 01:19:52 +0200
commit9ce4527550fed24036f6f116474c1f67714f5194 (patch)
treefa941f55a64c930b3c8c12606d1c7a3eadcf3aaa
parent4774ef07edc3c197cbd0134e2839eeee2701db2e (diff)
downloadcolitur-9ce4527550fed24036f6f116474c1f67714f5194.tar.gz
colitur-9ce4527550fed24036f6f116474c1f67714f5194.zip
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.
-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