summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/kernel/calendar.ml21
-rw-r--r--lib/kernel/calendar.mli13
-rw-r--r--lib/kernel/liturgical_day.ml3
-rw-r--r--lib/kernel/liturgical_day.mli3
-rw-r--r--test/test_calendar.ml63
5 files changed, 89 insertions, 14 deletions
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
index fd23377..1cdd1fa 100644
--- a/lib/kernel/calendar.ml
+++ b/lib/kernel/calendar.ml
@@ -35,6 +35,15 @@ let year_bounds (rite : ('s, 'r) Rite.t) (y : int) : Date.t * Date.t =
in
(start, stop)
+(* [resolution.deferred] (RG 96-98 transfer candidates) has nowhere to be
+ PLACED yet -- Task 6 adds the fixed-point pass that does -- but it must
+ still be accounted for on the day it lost, not silently dropped: Task
+ 12's no-celebration-lost invariant reads [Liturgical_day.omitted], so a
+ deferred candidate folds in there too, with its own reason distinct from
+ Precedence's native omissions ("omitted: yielded to a higher day",
+ "omitted: admission limit reached"). *)
+let deferred_reason = "deferred: transfer placement not yet implemented (Task 6)"
+
(* RG 91's contest for one date: the temporal office against every sanctoral
entry whose Date_spec resolves to it. [Layer.on_date] is keyed on exactly
(month, day), which for a [Fixed] spec -- the only form Plan 2 ships -- is
@@ -53,13 +62,10 @@ let resolve_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date) (date : Date.t
in
let ctx = { Precedence.date; season = temporal.Temporal.season; weekday = temporal.Temporal.weekday } in
let resolution = Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral in
- (* [resolution.deferred] (RG 96-98 transfer candidates) and
- [resolution.omitted] (yielded/admission-limit losers) have no field to
- land in on Liturgical_day.t yet, so both are simply absent from today's
- result -- deliberately incomplete for a deferred candidate, which is
- thereby neither observed nor commemorated here, and not yet placed on
- any later day either ("deferred: transfer placement not yet implemented
- (Task 6)"). Task 6's fixed-point pass closes this gap. *)
+ let omitted =
+ List.map (fun (c, reason) -> (c.Precedence.cel, reason)) resolution.Precedence.omitted
+ @ List.map (fun c -> (c.Precedence.cel, deferred_reason)) resolution.Precedence.deferred
+ in
{
Liturgical_day.date;
rite = rite.Rite.id;
@@ -69,6 +75,7 @@ let resolve_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date) (date : Date.t
List.map (fun (c, p) -> (c.Precedence.cel, p)) resolution.Precedence.commemorations;
transferred_in = None;
transferred_out = None;
+ omitted;
citations = [];
}
diff --git a/lib/kernel/calendar.mli b/lib/kernel/calendar.mli
index 50ff8f8..2469f2a 100644
--- a/lib/kernel/calendar.mli
+++ b/lib/kernel/calendar.mli
@@ -9,10 +9,15 @@
This module resolves each day's temporal-vs-sanctoral contest but does
not yet place deferred transfers (RG 96-98): a losing candidate the
- rite's rules send to [Precedence.Transfer] is absent from the result
- entirely on this pass -- not observed, not commemorated, and
- [transferred_in]/[transferred_out] both stay [None] everywhere. Task 6
- adds the fixed-point placement pass that closes this gap. *)
+ rite's rules send to [Precedence.Transfer] is not observed and not
+ commemorated on the day it lost, and [transferred_in]/[transferred_out]
+ both stay [None] everywhere -- but it is not silently dropped either. It
+ lands in that day's [Liturgical_day.omitted] with the reason ["deferred:
+ transfer placement not yet implemented (Task 6)"], alongside
+ [Precedence]'s own native omissions (yielded to a higher day; admission
+ limit reached), each with its own reason. Task 6 adds the fixed-point
+ placement pass that actually places these; until then, this is the
+ day's complete, honest accounting of what happened to every candidate. *)
(** [year rite layer y] resolves every day of the liturgical year that opens
in civil year [y]: from [rite.year_start y] through the day before
diff --git a/lib/kernel/liturgical_day.ml b/lib/kernel/liturgical_day.ml
index 65e3ba5..cbaca9c 100644
--- a/lib/kernel/liturgical_day.ml
+++ b/lib/kernel/liturgical_day.ml
@@ -14,6 +14,9 @@ type ('s, 'r) t = {
(** arrived here from an impeded day *)
transferred_out : Date.t option;
(** this day's celebration went there *)
+ omitted : ('r Celebration.t * string) list;
+ (** with the reason, never silent -- Task 12's no-celebration-lost
+ invariant reads this *)
citations : Citation.t list; (** always empty until Plan 4 *)
}
[@@deriving sexp]
diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli
index 3c331c3..38e7c76 100644
--- a/lib/kernel/liturgical_day.mli
+++ b/lib/kernel/liturgical_day.mli
@@ -12,6 +12,9 @@ type ('s, 'r) t = {
(** arrived here from an impeded day *)
transferred_out : Date.t option;
(** this day's celebration went there *)
+ omitted : ('r Celebration.t * string) list;
+ (** with the reason, never silent -- Task 12's no-celebration-lost
+ invariant reads this *)
citations : Citation.t list; (** always empty until Plan 4 *)
}
[@@deriving sexp]
diff --git a/test/test_calendar.ml b/test/test_calendar.ml
index a3c4125..d34611a 100644
--- a/test/test_calendar.ml
+++ b/test/test_calendar.ml
@@ -68,7 +68,12 @@ module Fixture = struct
let disposition ~winner:_ ~(loser : rank P.candidate) =
match loser.P.cel.Cel.rank with Lo -> P.Commemorate P.Ordinary | Hi -> P.Transfer
- let rules : (season, rank) P.rules = { P.band; disposition; admit = (fun ~observed:_ cs -> cs) }
+ (* Admits at most one commemoration -- mirrors test_precedence.ml's own
+ example and, unlike "admit everything", actually gives the accounting
+ test below a genuine Precedence-native omission (distinct from a
+ deferred one) to exercise. *)
+ let rules : (season, rank) P.rules =
+ { P.band; disposition; admit = (fun ~observed:_ cs -> List.filteri (fun i _ -> i < 1) cs) }
let rite : (season, rank) Rite.t =
{ Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []);
@@ -82,7 +87,23 @@ module Fixture = struct
let big_feast = entry ~month:12 ~day:8 ~slug:"big-feast" ~rank:Hi
let commem_worthy = entry ~month:12 ~day:15 ~slug:"commem-worthy" ~rank:Lo
- let layer = Layer.of_entries ~id:"synthetic" ~name:"Synthetic sanctoral" [ big_feast; commem_worthy ]
+ (* 20 Dec: four sanctoral entries on one date, for the full-accounting test.
+ "day-winner" and "eclipsed" tie on band (both Hi, both Sanctoral); ties
+ break on slug, so "day-winner" wins and "eclipsed" -- a Hi-rank loser --
+ is [Transfer]-disposed, landing in [deferred]. "loser-a" and "loser-b"
+ are both Lo, both [Commemorate]-disposed, but [admit] only keeps one:
+ the other lands in Precedence's own [omitted] ("admission limit
+ reached"), distinct from "eclipsed"'s deferred reason. Four candidates,
+ three different fates -- observed, one specific omission reason, two
+ more. *)
+ let day_winner = entry ~month:12 ~day:20 ~slug:"day-winner" ~rank:Hi
+ let eclipsed = entry ~month:12 ~day:20 ~slug:"eclipsed" ~rank:Hi
+ let loser_a = entry ~month:12 ~day:20 ~slug:"loser-a" ~rank:Lo
+ let loser_b = entry ~month:12 ~day:20 ~slug:"loser-b" ~rank:Lo
+
+ let layer =
+ Layer.of_entries ~id:"synthetic" ~name:"Synthetic sanctoral"
+ [ big_feast; commem_worthy; day_winner; eclipsed; loser_a; loser_b ]
let liturgical_year_of date =
let cy = D.year date in
@@ -159,6 +180,41 @@ let test_day_near_domain_floor_does_not_raise () =
Alcotest.(check string) "observed is the day's own feria"
(Sl.to_string (Fixture.office date).Cel.slug) (Sl.to_string d.LD.observed.Cel.slug)
+(* Full-day accounting through the whole Calendar pipeline (Layer -> Calendar
+ -> Liturgical_day), not just Precedence in isolation: every candidate fed
+ in for 20 Dec 2026 -- the feria plus Fixture's four colliding sanctoral
+ entries -- appears exactly once across observed/commemorations/omitted.
+ Checked as a slug SET (Alcotest.slist), matching test_precedence.ml's own
+ "nothing silently lost" test: a length-only check would pass even if one
+ slug were duplicated into two buckets and another dropped, which this
+ project has shipped before (register finding). *)
+let test_full_day_accounting () =
+ let date = mk 2026 12 20 in
+ let d = C.day Fixture.rite Fixture.layer date in
+ let feria_slug = Sl.to_string (Fixture.office date).Cel.slug in
+ let bucketed =
+ (Sl.to_string d.LD.observed.Cel.slug
+ :: List.map (fun (c, _) -> Sl.to_string c.Cel.slug) d.LD.commemorations)
+ @ List.map (fun (c, _) -> Sl.to_string c.Cel.slug) d.LD.omitted
+ in
+ Alcotest.(check (slist string compare)) "every candidate appears exactly once"
+ [ feria_slug; "day-winner"; "eclipsed"; "loser-a"; "loser-b" ]
+ bucketed;
+ (* Identity within [omitted], not just membership: "eclipsed" (a deferred
+ transfer candidate, RG 96-98) must carry the deferred reason, not
+ Precedence's native "admission limit reached" that "loser-a"/"loser-b"
+ -- the ones Precedence itself dropped -- carry. Without this, a bug
+ that folded [resolution.deferred] into [omitted] with the wrong reason,
+ or dropped [resolution.omitted]'s own reasons, would still pass the
+ slug-set check above. *)
+ let reason_of slug =
+ d.LD.omitted |> List.find (fun (c, _) -> Sl.to_string c.Cel.slug = slug) |> snd
+ in
+ Alcotest.(check string) "eclipsed carries the deferred reason"
+ "deferred: transfer placement not yet implemented (Task 6)" (reason_of "eclipsed");
+ Alcotest.(check string) "loser-a carries Precedence's own admission-limit reason"
+ "omitted: admission limit reached" (reason_of "loser-a")
+
let suite =
( "Calendar",
[ Alcotest.test_case "year covers every day" `Quick test_year_covers_every_day;
@@ -169,4 +225,5 @@ let suite =
test_lower_ranked_sanctoral_is_commemorated;
Alcotest.test_case "year 9999 does not raise" `Quick test_year_9999_does_not_raise;
Alcotest.test_case "day near the domain floor does not raise" `Quick
- test_day_near_domain_floor_does_not_raise ] )
+ test_day_near_domain_floor_does_not_raise;
+ Alcotest.test_case "full day accounting" `Quick test_full_day_accounting ] )