summaryrefslogtreecommitdiff
path: root/lib/kernel
diff options
context:
space:
mode:
Diffstat (limited to 'lib/kernel')
-rw-r--r--lib/kernel/calendar.ml46
-rw-r--r--lib/kernel/rite.mli82
2 files changed, 92 insertions, 36 deletions
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
index a1309f9..a8c4725 100644
--- a/lib/kernel/calendar.ml
+++ b/lib/kernel/calendar.ml
@@ -113,33 +113,43 @@ let occupant_of (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index)
let _, _, resolution = resolve_with_injected rite idx injected date in
resolution.Precedence.observed.Precedence.cel
-(* Hard guard on the placement fixed point (spec §2.4): every genuine
+(* Hard guard on the placement fixed point (spec §2.4): almost every
transfer moves a celebration strictly forward and the celebration set is
finite, so the round below always empties [deferred] within a handful of
rounds in practice (an RG 97-98 collision of N feasts on one date costs at
most N-1 extra rounds -- each round resolves the winner of whatever pile-up
- occurred and re-defers the rest, one fewer each time). 64 is not tuned to
- that bound; it is a defensive ceiling nothing in the 1962 calendar comes
- close to, so that a rite/data combination this module has not anticipated
- fails as a recorded, inspectable [omitted] reason (below) instead of
- hanging the CLI. *)
+ occurred and re-defers the rest, one fewer each time). CORRECTED
+ (Normae n. 56(f)/W1, 2026-08-26): "every" no longer holds literally -- a
+ rite MAY now return a target earlier than origin (rite.mli's own
+ obligation was relaxed from "strictly later" to "different"; see its
+ comment for the argument) -- but the bound this guard exists for does
+ not actually rest on direction: [~start]/[~stop] and this round count
+ both test the target's own value, never a comparison against [origin],
+ so a bounded backward jump converges the same way a bounded forward one
+ does. 64 is not tuned to that bound; it is a defensive ceiling nothing
+ in either shipped calendar comes close to, so that a rite/data
+ combination this module has not anticipated fails as a recorded,
+ inspectable [omitted] reason (below) instead of hanging the CLI. *)
let max_transfer_rounds = 64
let unconverged_reason =
"omitted: transfer placement did not converge within max_transfer_rounds (RG 96-98)"
-(* A rite-supplied [transfer_target] is trusted to search strictly forward
- (rite.mli), but nothing stops it naming a date past the end of the
- liturgical year it was asked about -- e.g. an I-class feast impeded in
- the last days before Advent I, whose first admissible day genuinely
- falls in the following liturgical year's own territory (unproven to
- occur in the real EF calendar, but not something this module can rule
- out by construction). [place_transfers] never injects such a target: the
- [dates] array is exactly what [year]/[build_day] walk to produce the
- result, so a candidate placed outside it would be [observed]/
- [transferred_in] nowhere in the output at all -- gone, not merely
- mis-filed, and silently so, contradicting [calendar.mli]'s "never
- silently dropped". This reason makes that failure mode visible instead. *)
+(* A rite-supplied [transfer_target] is trusted to return a date genuinely
+ different from its argument and to terminate on its own (rite.mli), but
+ nothing stops it naming a date past either end of the liturgical year it
+ was asked about -- e.g. an I-class feast impeded in the last days before
+ Advent I, whose first admissible day genuinely falls in the following
+ liturgical year's own territory (unproven to occur in the real EF
+ calendar, but not something this module can rule out by construction),
+ or (since Normae n. 56(f)/W1) a backward-anticipated feast impeded close
+ enough to the liturgical year's own opening that its target lands before
+ it. [place_transfers] never injects such a target: the [dates] array is
+ exactly what [year]/[build_day] walk to produce the result, so a
+ candidate placed outside it would be [observed]/[transferred_in] nowhere
+ in the output at all -- gone, not merely mis-filed, and silently so,
+ contradicting [calendar.mli]'s "never silently dropped". This reason
+ makes that failure mode visible instead. *)
let out_of_range_reason = "omitted: transfer target falls outside the liturgical year (RG 96)"
(* RG 33: "Vigilia II aut III classis penitus omittitur... vel si festum cui
diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli
index ec850f8..b0ba7ac 100644
--- a/lib/kernel/rite.mli
+++ b/lib/kernel/rite.mli
@@ -75,24 +75,70 @@ type ('s, 'r) t = {
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). *)
+ termination argument depends on both): the result must {b differ
+ from} the [Date.t] argument (the date the candidate was impeded
+ on) -- {!Calendar}'s placement pass treats [target = origin] as a
+ legitimate placement, not an error, and {!Precedence.resolve}
+ itself never deduplicates by slug, so a rite that can stand still
+ would hand it the SAME candidate twice at the SAME date in the
+ SAME contest (once via {!Layer.on_date}'s permanent natural
+ entry, once via the injection this module's placement pass adds)
+ -- a genuine self-collision (the candidate disposed of as both
+ [winner] and [loser] against itself), not merely a docstring
+ worry: reachable the moment any rite returns its argument
+ unchanged. 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 day by
+ day (in EITHER direction) 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).
+
+ {b EARLIER targets are permitted} (CORRECTED,
+ [of-normae-56f]/W1, 2026-08-26: this obligation previously read
+ "strictly later", full stop, and warned that an earlier target
+ "would silently loop candidates in place or resurrect an
+ already-superseded occupant rather than failing loudly" -- traced
+ against the placement pass actually implemented, not merely
+ against this comment's own prior claim, and found to overstate
+ the risk). {!Calendar}'s round loop re-resolves the {b entire}
+ year fresh from the currently-settled [assignment] every round
+ (never patched incrementally), and its two safety nets --
+ [~start]/[~stop] catching a target outside the liturgical year,
+ and [max_transfer_rounds] catching non-convergence -- both test
+ the target's value directly ([target < start || target > stop];
+ round count) with no comparison against [origin] anywhere, so
+ neither depends on which side of [origin] the target falls.
+ "Resurrecting a superseded occupant" cannot occur either: a day's
+ occupant is decided fresh each round by the same RG 91/Tabula
+ contest every other day is, so a backward-placed candidate either
+ wins that contest or is itself deferred and re-targeted like any
+ other loser -- there is no stale state to resurrect FROM. A
+ candidate's own permanent loss at its structural origin is
+ rediscovered every round regardless of target direction and is
+ filtered as stale under the identical rule either way (actionable
+ only if never yet assigned, or if the sighting is at the
+ candidate's OWN current target -- calendar.ml's [place_transfers],
+ the [deferred] filter's own comment).
+
+ This is a general property of the mechanism, not a special case
+ carved out for one rite: it holds for ANY single call that
+ returns a date [<> origin] and terminates on its own, per the two
+ obligations above. What it does {b not} license is assuming an
+ UNBOUNDED backward search is safe merely because a bounded one is
+ -- the termination obligation still falls entirely on the rite,
+ exactly as it always did for a forward search. The safest shape,
+ and the only one exercised so far, is a CONSTANT one-shot
+ displacement for a specifically-named collision (no internal
+ search at all, hence trivially terminating) -- see
+ rite_of/precedence_of.ml's [transfer_target] (Normae n. 56(f), St
+ Joseph anticipated backward onto Palm Sunday) for the concrete
+ worked example, the same role rite_ef/precedence_ef.ml's own
+ [transfer_target] plays for a bounded FORWARD search above. *)
readings :
observed:'r Celebration.t ->
temporal:('s, 'r) Temporal.t ->