aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-26 09:53:22 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-26 09:53:22 +0200
commit8fa9db25f55f4ae92ad6a28115484dd981593a3c (patch)
tree8cdc9f253cc1f44759fccc1ca25be7eda950a34a
parent155c2e55948dc179a45458d856808f7105878d1f (diff)
downloadcolitur-8fa9db25f55f4ae92ad6a28115484dd981593a3c.tar.gz
colitur-8fa9db25f55f4ae92ad6a28115484dd981593a3c.zip
feat(of): assemble Rite_of.context and wire --rite of into the CLI
Task 5 of Phases 3-5 (the payoff task): bundles Tasks 1-4's separately built pieces (Temporal_of, Precedence_of, Rubrics_of, Lectionary_of) into a (Vocab_of.season, Vocab_of.rank) Colitur_kernel.Rite.t, and wires colitur day/readings --rite of <year> into the CLI. Default stays ef; every pre-existing invocation is byte-identical (asserted directly in test/cli.t and against a real v1.0.0 build for three years, plus git diff --stat v1.0.0..HEAD over lib/rites/rite_ef/, lib/kernel/, data/ef/ is empty). No kernel change was needed, confirming the spec's central claim. season_runs needed real thought rather than the brief's own suggested Vocab_of.seasons: Temporal_of.season's own 'first run'/'second run' comments confirm Ordinary Time is genuinely interrupted by Lent and Easter over one liturgical year, so the expected run-length-compressed sequence names it twice, six runs over five seasons -- exactly the case Rite.t.season_runs's own doc comment already names as its illustration for why this field is rite-supplied rather than derived. easter/fixed_key are as specified, each carrying its own citation (Gregorian computus; the 2002 Missal's one bissextilis occurrence is a leap-year table entry, not a kalends-doubling rule, so fixed_key is identity). Follows rite_ef.ml's template and its lectionary-parameter lesson exactly: lectionary is caller-supplied, not closed over. test/test_rite_of.ml resolves the real assembled rite (calendar-2002 .sexp + all 13 amendment overlays + lectionary.sexp) through Calendar and Validate.run, the same discipline test_validate.ml/test_rite_ef.ml already apply to EF. This is the first time rite_of has been checked this way, and it surfaced two new, real, cited gaps outside this task's own scope to fix: Temporal_of never reaches Normae n.35(a)'s 30-December Holy Family fallback in a year with no Sunday in 26-31 December (first hit 1583, the domain floor), and Temporal_of.anchors disagrees with temporal on 24 December when that date is itself a Sunday (Advent IV outranks the Vigil, first hit 2006). Both pinned as known-wrong, not fixed, the same treatment the brief's own pre-known St Joseph/Palm Sunday gap (Normae n.56(f)) gets, pinned on 2062. A third gap was found in data/of/lectionary.sexp's own coverage (audited against one civil year only, so the other weekday-cycle letter is unaudited and at least one pairing is asymmetric on shipped data) -- that file is excluded from this task, so the wide 2005-2050 sweep checks structural invariants only and leaves the citation-chain checks to the small, individually-audited landmark-year set. make check (COLITUR_EXHAUSTIVE_SWEEP=1): exit 0, 867 tests, ~316s.
-rw-r--r--bin/dune2
-rw-r--r--bin/main.ml251
-rw-r--r--lib/rites/rite_of/rite_of.ml85
-rw-r--r--lib/rites/rite_of/rite_of.mli57
-rw-r--r--test/cli.t50
-rw-r--r--test/dune18
-rw-r--r--test/test_colitur.ml1
-rw-r--r--test/test_rite_of.ml340
8 files changed, 798 insertions, 6 deletions
diff --git a/bin/dune b/bin/dune
index 0702a84..09c2009 100644
--- a/bin/dune
+++ b/bin/dune
@@ -11,4 +11,4 @@
; lang/traditions.ini (which book a reference DENOTES, {!Colitur_citation.Book}) --
; a sibling library to [colitur_naming] (what a book is CALLED), not a
; replacement for it.
- (libraries colitur_kernel rite_ef colitur_render colitur_naming colitur_citation unix))
+ (libraries colitur_kernel rite_ef rite_of colitur_render colitur_naming colitur_citation unix))
diff --git a/bin/main.ml b/bin/main.ml
index 59f18f2..6827fbc 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -257,6 +257,127 @@ let load_ef_commons () =
| Error e -> Error (Printf.sprintf "failed to load %s: %s" path e)
| Ok commons -> Ok commons
+(* Task 5 (2026-08-25-colitur-of-phases-3-5): `--rite of`, the OF (post-1970)
+ counterpart of everything above. Mirrors [data_dir]'s own probe order
+ (installed prefix, then the build tree) but scoped to "of" and keyed on
+ [calendar-2002.sexp] rather than [sanctoral.sexp] -- Task 1's own
+ transcription of the General Roman Calendar, the OF base layer.
+
+ Deliberately does NOT honour [COLITUR_DATA_DIR]: that variable's existing
+ contract (documented on [data_dir] above) is "an explicit override
+ pointing at a single flat directory containing sanctoral.sexp" -- an
+ EF-shaped contract this function would either have to silently reinterpret
+ (the same directory, now also expected to carry calendar-2002.sexp) or
+ split into a second variable, and neither is this task's call to make.
+ Left unaddressed rather than guessed at. *)
+let of_data_dir () =
+ let has_data d = Sys.file_exists (Filename.concat d "calendar-2002.sexp") in
+ let prefix = Filename.dirname (Filename.dirname Sys.executable_name) in
+ let installed = List.fold_left Filename.concat prefix [ "share"; "colitur"; "of" ] in
+ let build_tree = Filename.concat prefix (Filename.concat "data" "of") in
+ if has_data installed then installed else build_tree
+
+(* [load_of_layer]'s own [~user_overlays] follows [load_ef_layer]'s exact
+ discipline: applied AFTER the shipped base, never instead of it. The OF
+ base is [calendar-2002.sexp] (Task 1) plus all 13 decree-chronological
+ amendment overlays (Task 2, data/of/amendments/*.sexp) -- never edited
+ after the fact, per that file's own header -- so a diocesan/local
+ overlay a user supplies via [--overlay] layers on top of THOSE, exactly
+ mirroring how an EF user overlay layers on top of adjustments.sexp. *)
+let of_amendment_files =
+ [ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
+ "004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp"; "006-mary-mother-of-the-church.sexp";
+ "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp"; "009-faustina-kowalska.sexp";
+ "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp"; "012-teresa-of-calcutta.sexp";
+ "013-john-henry-newman.sexp" ]
+
+let load_of_layer ?(user_overlays = []) () =
+ let dir = of_data_dir () in
+ let base_path = Filename.concat dir "calendar-2002.sexp" in
+ let amendment_paths =
+ List.map (fun name -> Filename.concat dir (Filename.concat "amendments" name)) of_amendment_files
+ in
+ let load_overlay path =
+ match Colitur_kernel.Overlay.load Rite_of.Vocab_of.rank_of_sexp path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e)
+ | Ok o -> Ok o
+ in
+ let rec load_all acc = function
+ | [] -> Ok (List.rev acc)
+ | p :: rest -> ( match load_overlay p with Error e -> Error e | Ok o -> load_all (o :: acc) rest)
+ in
+ match Colitur_kernel.Layer.load Rite_of.Vocab_of.rank_of_sexp base_path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" base_path e)
+ | Ok layer -> (
+ match load_all [] (amendment_paths @ user_overlays) with
+ | Error e -> Error e
+ | Ok overlays -> Ok (Colitur_kernel.Overlay.merge layer overlays))
+
+(* Sibling to [load_ef_lectionary], same [~lectionary]-is-a-caller-supplied-
+ parameter discipline [Rite_of.context]'s own .mli documents: a missing or
+ malformed data/of/lectionary.sexp is reported via [Error], never an
+ uncaught exception. *)
+let load_of_lectionary () =
+ let path = Filename.concat (of_data_dir ()) "lectionary.sexp" in
+ match Colitur_kernel.Lectionary.load path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e)
+ | Ok lectionary -> Ok lectionary
+
+(* [load_of_data]/[resolved_of_year_days]/[day_report_of]/[readings_report_of]
+ mirror [load_ef_data]/[resolved_year_days]/[day_report]/[readings_report]
+ exactly, one rite down: no [~commons] (OF's [Lectionary_of.readings] has
+ none to thread, see rite_of.mli), and every EF-specific module reference
+ swapped for its OF sibling. Duplicated rather than parameterised over the
+ rite for the same reason test_amendments_of.ml's own header gives for its
+ near-identical EF twin: no [.mli] either file shares, and a shared
+ higher-order version would have to abstract over TWO different [Vocab.t]
+ instantiations plus TWO different [Rite.t] result types, which
+ [day_line]/[readings_line] below cannot be generic over either (they print
+ rite-specific vocabulary strings). *)
+let load_of_data ?(user_overlays = []) () =
+ match
+ Result.map
+ (fun (layer, diagnostics) ->
+ List.iter
+ (fun d -> Printf.eprintf "colitur: %s\n" (Colitur_kernel.Overlay.diagnostic_to_string d))
+ diagnostics;
+ layer)
+ (load_of_layer ~user_overlays ())
+ with
+ | Error msg -> Error msg
+ | Ok layer -> ( match load_of_lectionary () with Error msg -> Error msg | Ok lectionary -> Ok (layer, lectionary))
+
+let resolved_of_year_days ~overlays y =
+ match load_of_data ~user_overlays:overlays () with
+ | Error msg ->
+ Printf.eprintf "colitur: %s\n" msg;
+ exit 2
+ | Ok (layer, lectionary) ->
+ let context = Rite_of.context ~lectionary in
+ let module Cal = Colitur_kernel.Calendar in
+ let by_rata : (int, (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) Hashtbl.t =
+ Hashtbl.create 400
+ in
+ let index days =
+ Array.iter
+ (fun (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) ->
+ Hashtbl.replace by_rata (D.to_rata d.Colitur_kernel.Liturgical_day.date) d)
+ days
+ in
+ index (Cal.year context layer (y - 1));
+ index (Cal.year context layer y);
+ let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in
+ let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in
+ let d = ref jan1 in
+ let acc = ref [] in
+ while D.compare !d dec31 <= 0 do
+ (match Hashtbl.find_opt by_rata (D.to_rata !d) with
+ | Some day -> acc := day :: !acc
+ | None -> Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d));
+ d := D.add_days !d 1
+ done;
+ List.rev !acc
+
(* [~lang] resolves the observed slug to a display NAME, appended as a new
LAST field rather than substituted into the slug's own position: a name
contains spaces (Latin and English both), and inserting it where the slug
@@ -543,6 +664,59 @@ let readings_report ~lang ~sigla ~overlays y =
resolved_year_report ~line:(readings_line ~lang ~sigla) ~overlays y
let rubrics_report ~overlays y = resolved_year_report ~line:rubrics_line ~overlays y
+(* Task 5 (2026-08-25-colitur-of-phases-3-5): [day_line]/[readings_line]'s
+ OF twins -- same two row shapes, same [~lang]/[~sigla] append-only rules
+ (see those functions' own citations just above for the full reasoning,
+ not repeated here), [Rite_of.Vocab_of] in place of [Rite_ef.Vocab_ef]. *)
+let day_line_of ~lang (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) =
+ let t = d.Colitur_kernel.Liturgical_day.temporal in
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let week = match t.Colitur_kernel.Temporal.week with Some n -> string_of_int n | None -> "-" in
+ let commemoration_suffix (c, _) =
+ " +" ^ Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug
+ in
+ let commemorations =
+ String.concat "" (List.map commemoration_suffix d.Colitur_kernel.Liturgical_day.commemorations)
+ in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let name = Colitur_naming.Lang.celebration lang slug_s in
+ let name_suffix = if name = slug_s then "" else " " ^ name in
+ Printf.printf "%s %s %s %s %s %s %s%s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ (D.weekday_to_string t.Colitur_kernel.Temporal.weekday)
+ (Rite_of.Vocab_of.season_to_string t.Colitur_kernel.Temporal.season)
+ week slug_s
+ (Rite_of.Vocab_of.rank_to_string cel.Colitur_kernel.Celebration.rank)
+ (Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour)
+ commemorations name_suffix
+
+let readings_line_of ~lang ~sigla
+ (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) =
+ let cel = d.Colitur_kernel.Liturgical_day.observed in
+ let part_ref p =
+ match
+ List.find_opt
+ (fun (c : Colitur_kernel.Citation.t) -> c.Colitur_kernel.Citation.part = p)
+ d.Colitur_kernel.Liturgical_day.citations
+ with
+ | Some c -> Colitur_citation.Sigla.format sigla c.Colitur_kernel.Citation.reference
+ | None -> "-"
+ in
+ let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in
+ let name = Colitur_naming.Lang.celebration lang slug_s in
+ let name_suffix = if name = slug_s then "" else " | " ^ name in
+ Printf.printf "%s %s | %s | %s%s\n"
+ (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date)
+ slug_s
+ (part_ref Colitur_kernel.Citation.First)
+ (part_ref Colitur_kernel.Citation.Gospel)
+ name_suffix
+
+let resolved_of_year_report ~line ~overlays y = List.iter line (resolved_of_year_days ~overlays y)
+
+let day_report_of ~lang ~overlays y = resolved_of_year_report ~line:(day_line_of ~lang) ~overlays y
+let readings_report_of ~lang ~sigla ~overlays y =
+ resolved_of_year_report ~line:(readings_line_of ~lang ~sigla) ~overlays y
+
(* Task 8: `colitur emit` -- the five template-family emitters built in
Tasks 5-7, wired to a year RANGE rather than a single year, because a
published feed (ics) or a data export (csv/json/xml) is usually wanted
@@ -1269,7 +1443,7 @@ usage:
colitur day <year> the resolved day identity, one line per day
colitur readings <year> the Mass reading citations, one line per day
colitur rubrics <year> the Mass formulary said, one line per day
- colitur day|readings <year> [--overlay FILE ...] [--lang CODE|FILE] [--raw]
+ colitur day|readings <year> [--rite ef|of] [--overlay FILE ...] [--lang CODE|FILE] [--raw]
colitur rubrics <year> [--overlay FILE ...]
colitur emit --format csv|json|sexp|xml|ics --from Y --to Y
[--overlay FILE ...] [--dtstamp S] [--lang CODE|FILE] [--raw]
@@ -1344,6 +1518,12 @@ output formats:
inclusive. --dtstamp fixes the ics DTSTAMP so two runs over the
same data are byte-identical -- the engine reads no clock.
+rite:
+ --rite ef|of selects the rite `day`/`readings` compute against; default ef
+ (so every invocation written before this flag existed is
+ unaffected). `of` is the 1970 Missale Romanum (editio typica
+ tertia, 2002); no other command accepts the flag yet.
+
overlays:
--overlay FILE (repeatable, ordered; -o) applies a user calendar ON TOP of
the shipped universal one, never instead of it, so local feasts
@@ -1559,6 +1739,23 @@ let with_year ys f =
exit 2
| None -> usage ()
+(* Task 5 (2026-08-25-colitur-of-phases-3-5): resolves [--rite] to a closed
+ choice -- [None] (the flag was never given) and [Some "ef"] both mean
+ "ef", so that existing invocations with no [--rite] at all are
+ byte-identical to before this flag existed (spec's own requirement,
+ verified in test/cli.t). Anything other than "ef"/"of" is a usage error,
+ not a silent fallback to "ef" -- the same "an unrecognised value is
+ refused, not guessed at" discipline every other closed-choice flag in
+ this file follows (`--sigla-book full|abbr`, `lang --dump CODE`). Shared
+ by `day`/`readings`, the only two commands [reject_rite_for] lets the
+ flag reach. *)
+let resolve_rite = function
+ | None | Some "ef" -> `Ef
+ | Some "of" -> `Of
+ | Some other ->
+ Printf.eprintf "colitur: unknown --rite %S (expected \"ef\" or \"of\")\n" other;
+ exit 2
+
(* Flags are stripped first, then the remaining words are matched as
command + year. The alternative -- extending the exact-array patterns
below -- does not survive a REPEATABLE flag: [--overlay a --overlay b] is a
@@ -1590,8 +1787,18 @@ let with_year ys f =
single-valued optional settings, the same shape as [lang] -- each has a
config-key counterpart in [Colitur_naming.Config] and is resolved through
the identical [Config.resolve] precedence (flag > config > default). *)
+(* [rite] (Task 5, 2026-08-25-colitur-of-phases-3-5): single-valued like
+ [lang], and deliberately a bare [string option] with no config-key
+ counterpart -- unlike [lang]/[template]/[format], nothing in
+ [Colitur_naming.Config] resolves a default rite, and this task does not
+ add one (out of scope: the brief asks for `--rite of` on `day`/
+ `readings` only). [None] means "ef", not "unset and therefore an
+ error" -- existing invocations with no [--rite] at all must stay
+ byte-identical, so the default has to be the CURRENT behaviour, not a
+ forced choice. *)
type parsed_args = {
overlays : string list;
+ rite : string option;
format : string option;
from_y : string option;
to_y : string option;
@@ -1618,6 +1825,8 @@ let parse_args argv =
| [] -> Ok { acc with overlays = List.rev acc.overlays; positional = List.rev acc.positional }
| ("--overlay" | "-o") :: path :: rest -> go { acc with overlays = path :: acc.overlays } rest
| [ ("--overlay" | "-o") ] -> Error "--overlay needs a file path"
+ | "--rite" :: v :: rest -> go { acc with rite = Some v } rest
+ | [ "--rite" ] -> Error "--rite needs a value (ef or of)"
| "--format" :: v :: rest -> go { acc with format = Some v } rest
| [ "--format" ] -> Error "--format needs a value"
| "--from" :: v :: rest -> go { acc with from_y = Some v } rest
@@ -1661,7 +1870,7 @@ let parse_args argv =
| arg :: rest -> go { acc with positional = arg :: acc.positional } rest
in
go
- { overlays = []; format = None; from_y = None; to_y = None; dtstamp = None; year = None;
+ { overlays = []; rite = None; format = None; from_y = None; to_y = None; dtstamp = None; year = None;
template = None; flavour = None; out = None; prune = false; lang = None; raw = false;
dump = None; check = None; list = false; show = false; sigla_style = None; sigla_book = None;
sigla_tradition = None; positional = [] }
@@ -1689,6 +1898,20 @@ let reject_overlays_for cmd overlays =
exit 2
end
+(* Sibling to [reject_overlays_for]: `--rite` (Task 5) is meaningful only on
+ `day`/`readings` -- the brief's own scope for this task. Every other
+ command either reads no rite-specific data at all (`easter`, `temporal`,
+ `check`, `convert`, `new-overlay`, `lang`, `--help`, `--version`) or has
+ not been widened to a second rite in this task (`rubrics`, `emit`,
+ `table`/`render`, `publish`, `config`) -- accepting the flag there and
+ silently ignoring it would be the exact failure mode [reject_overlays_for]
+ above already refuses. *)
+let reject_rite_for cmd rite =
+ if rite <> None then begin
+ Printf.eprintf "colitur: --rite has no effect on `%s`; refusing rather than ignoring it\n" cmd;
+ exit 2
+ end
+
(* Sibling to [reject_emit_flags_for]/[reject_overlays_for]: `table`/`render`'s
own three flags (Task 9) have no meaning on any other command, so accepting
and silently dropping them would be the same failure mode this project
@@ -2211,9 +2434,10 @@ let () =
| Error msg ->
Printf.eprintf "colitur: %s\n" msg;
usage ()
- | Ok { overlays; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang;
+ | Ok { overlays; rite; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang;
raw; dump; check; list; show; sigla_style; sigla_book; sigla_tradition; positional } -> (
let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in
+ let reject_rite cmd = reject_rite_for cmd rite in
let reject_table = reject_table_flags_for ~year ~template ~flavour in
let reject_publish = reject_publish_flags_for ~out ~prune in
let reject_lang = reject_lang_for ~lang ~raw in
@@ -2244,6 +2468,7 @@ let () =
match positional with
| [ ("-h" | "--help" | "help") ] ->
reject_overlays_for "--help" overlays;
+ reject_rite "--help";
reject_emit "--help";
reject_table "--help";
reject_publish "--help";
@@ -2253,6 +2478,7 @@ let () =
print_help ()
| [ ("-V" | "--version" | "version") ] ->
reject_overlays_for "--version" overlays;
+ reject_rite "--version";
reject_emit "--version";
reject_table "--version";
reject_publish "--version";
@@ -2263,6 +2489,7 @@ let () =
exit 0
| [ "easter"; ys ] ->
reject_overlays_for "easter" overlays;
+ reject_rite "easter";
reject_emit "easter";
reject_table "easter";
reject_publish "easter";
@@ -2272,6 +2499,7 @@ let () =
with_year ys easter_report
| [ "temporal"; ys ] ->
reject_overlays_for "temporal" overlays;
+ reject_rite "temporal";
reject_emit "temporal";
reject_table "temporal";
reject_publish "temporal";
@@ -2281,6 +2509,7 @@ let () =
with_year ys temporal_report
| "check" :: (_ :: _ as files) ->
reject_overlays_for "check" overlays;
+ reject_rite "check";
reject_emit "check";
reject_table "check";
reject_publish "check";
@@ -2290,6 +2519,7 @@ let () =
check_report files
| [ "convert"; path ] ->
reject_overlays_for "convert" overlays;
+ reject_rite "convert";
reject_emit "convert";
reject_table "convert";
reject_publish "convert";
@@ -2299,6 +2529,7 @@ let () =
convert_report path
| [ "new-overlay" ] ->
reject_overlays_for "new-overlay" overlays;
+ reject_rite "new-overlay";
reject_emit "new-overlay";
reject_table "new-overlay";
reject_publish "new-overlay";
@@ -2309,6 +2540,7 @@ let () =
exit 0
| [ "lang" ] -> (
reject_overlays_for "lang" overlays;
+ reject_rite "lang";
reject_emit "lang";
reject_table "lang";
reject_publish "lang";
@@ -2339,6 +2571,7 @@ let () =
config preview is still refused, not silently ignored. *)
reject_table "config";
reject_publish "config";
+ reject_rite "config";
if from_y <> None || to_y <> None || dtstamp <> None then begin
Printf.eprintf
"colitur: --from/--to/--dtstamp have no effect on `config`; refusing rather than ignoring them\n";
@@ -2367,7 +2600,9 @@ let () =
reject_publish "day";
reject_lang_sub "day";
reject_sigla "day";
- with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays)
+ (match resolve_rite rite with
+ | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays)
+ | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~overlays:effective_overlays))
| [ "readings"; ys ] ->
reject_emit "readings";
reject_table "readings";
@@ -2378,7 +2613,9 @@ let () =
load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book
~sigla_tradition_flag:sigla_tradition ~config
in
- with_year ys (readings_report ~lang:lang_t ~sigla ~overlays:effective_overlays)
+ (match resolve_rite rite with
+ | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~overlays:effective_overlays)
+ | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~overlays:effective_overlays))
| [ "rubrics"; ys ] ->
(* --overlay accepted, same reasoning as `readings`: an overlay can
change which celebration is observed, hence which Mass formulary
@@ -2391,11 +2628,13 @@ let () =
reject_lang "rubrics";
reject_lang_sub "rubrics";
reject_sigla "rubrics";
+ reject_rite "rubrics";
with_year ys (rubrics_report ~overlays:effective_overlays)
| [ "emit" ] -> (
reject_table "emit";
reject_publish "emit";
reject_lang_sub "emit";
+ reject_rite "emit";
match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with
| None ->
Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n";
@@ -2419,6 +2658,7 @@ let () =
reject_emit cmd;
reject_publish cmd;
reject_lang_sub cmd;
+ reject_rite cmd;
match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with
| None ->
Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd;
@@ -2450,6 +2690,7 @@ let () =
reject_table "publish";
reject_format_for "publish" format;
reject_lang_sub "publish";
+ reject_rite "publish";
match out with
| None ->
Printf.eprintf "colitur: publish requires --out DIR\n";
diff --git a/lib/rites/rite_of/rite_of.ml b/lib/rites/rite_of/rite_of.ml
new file mode 100644
index 0000000..9eeb5ef
--- /dev/null
+++ b/lib/rites/rite_of/rite_of.ml
@@ -0,0 +1,85 @@
+(* This module's name matches the library's own name ("rite_of"), so dune
+ treats it as the library's top-level module instead of generating one
+ automatically -- every sibling module this library defines must be
+ re-exported here explicitly, or external references to e.g.
+ [Rite_of.Temporal_of] (bin/main.ml, every test/ file that opens this
+ rite) stop resolving. Same property {!Rite_ef.Rite_ef}'s own top-of-file
+ comment records for the EF side. *)
+module Vocab_of = Vocab_of
+module Temporal_of = Temporal_of
+module Precedence_of = Precedence_of
+module Rubrics_of = Rubrics_of
+module Lectionary_of = Lectionary_of
+
+open Colitur_kernel
+
+(* [~lectionary], not a value closed over an internal load: {!Rite_ef.Rite_ef
+ .context}'s own comment records the exact failure this shape avoids --
+ fix round 1 (coordinator review) found a prior version that closed over
+ [Lectionary_ef] loading data/ef/lectionary.sexp as a side effect of being
+ linked, which made `colitur easter <year>` (a command touching no
+ lectionary data at all) die at startup the moment that file was absent
+ from a bare `dune build`. Do not repeat that here: [context] is a
+ function, and [~lectionary] is caller-supplied, exactly mirroring how the
+ sanctoral [Layer.t] itself already travels (a loose argument to
+ {!Colitur_kernel.Calendar.year}/[day], never embedded in this record).
+
+ No [~commons] parameter: unlike EF, the OF lectionary chain
+ ({!Lectionary_of.readings}) has no Commons indirection to thread through
+ -- see that function's own .mli citation for why (OF has no shared-
+ formulary table the way EF's Commons.sexp is one). *)
+let context ~lectionary : (Vocab_of.season, Vocab_of.rank) Rite.t =
+ { Rite.id = Temporal_of.id;
+ vocab = Vocab_of.vocab;
+ year_start = Temporal_of.year_start;
+ temporal = Temporal_of.temporal;
+ anchors = Temporal_of.anchors;
+ (* The Roman rite -- OF as much as EF -- reckons Easter on the Gregorian
+ calendar; nothing in the Normae or the IGMR revisits that. Supplied
+ here rather than assumed by the kernel, so a Julian-reckoning rite
+ can supply {!Colitur_kernel.Computus.julian_easter} instead -- the
+ exact reasoning {!Rite_ef.Rite_ef.context}'s own [easter] field
+ carries, restated here because it is a per-rite decision, not one
+ this module inherits from EF. Read by [Layer.index] to resolve
+ {!Date_spec.Easter_offset} (the two movable universal solemnities,
+ Sacred Heart and the Immaculate Heart of Mary, and any
+ [--overlay]-supplied movable local feast). *)
+ easter = Colitur_kernel.Computus.gregorian_easter;
+ (* Identity: [Some (Date.month d, Date.day d)] for every date, unlike
+ {!Rite_ef.Temporal_ef.bissextile_fixed_key}. The OF has NO bissextile
+ (leap-year kalends-doubling) convention for [Layer.on_date]'s fixed
+ half to correct for -- verified, not merely assumed absent: the only
+ occurrence of "bissextilis" anywhere in the 2002 Missal (extracted
+ docs/research/of/missale-romanum-2002.pdf, pdftotext -layout, line
+ 4502) marks leap years in the calendarium's own table of MOVABLE
+ feasts (an Easter-date lookup table), not a rule that doubles a
+ kalends position the way the 1962 Missal's February footnote does
+ (LT.txt:5011-5014, {!Rite_ef.Temporal_ef.bissextile_fixed_key}'s own
+ citation). A reader coming from the EF side of this codebase should
+ expect [fixed_key] to carry that mechanism; the OF's own primary
+ source simply has no such rule to encode, so this field is the
+ [Rite.t.fixed_key] doc comment's own stated default for "a rite with
+ no reason to differ". *)
+ fixed_key = (fun d -> Some (Date.month d, Date.day d));
+ rules =
+ { Precedence.band = Precedence_of.band;
+ disposition = Precedence_of.disposition;
+ admit = Precedence_of.admit;
+ vigil_feast = Precedence_of.vigil_feast };
+ (* NOT [Vocab_of.seasons] (which lists each season once) -- this is
+ exactly the case {!Colitur_kernel.Rite.t.season_runs}'s own doc
+ comment and {!Colitur_kernel.Vocab.t.seasons}'s both name as their
+ illustration of why [season_runs] is rite-supplied rather than
+ derived: "a rite may legitimately have one season appear in two
+ separate runs (the modern form's Ordinary Time does)". Confirmed
+ directly in {!Temporal_of.season} (Normae n. 43-44, its own "first
+ run"/"second run" comments): over one Advent-anchored liturgical
+ year, Ordinary Time is interrupted by Lent+Easter, so the
+ run-length-compressed sequence {!Validate}'s "seasons" check expects
+ has SIX runs over five seasons, not five. *)
+ season_runs = Vocab_of.[ Advent; Christmas; Ordinary_time; Lent; Easter; Ordinary_time ];
+ transfer_target = Precedence_of.transfer_target;
+ readings = Lectionary_of.readings ~lectionary ~year_start:Temporal_of.year_start;
+ creed = Rubrics_of.creed;
+ gloria = Rubrics_of.gloria;
+ preface = Rubrics_of.preface }
diff --git a/lib/rites/rite_of/rite_of.mli b/lib/rites/rite_of/rite_of.mli
new file mode 100644
index 0000000..eeb38d5
--- /dev/null
+++ b/lib/rites/rite_of/rite_of.mli
@@ -0,0 +1,57 @@
+(** The OF (post-1970) rite, bundled: this library's top-level module (its
+ filename matches the library's own name "rite_of", so dune uses it as
+ the library's entry point directly rather than generating one -- see the
+ .ml's own comment). Re-exports every sibling module this library
+ defines, so [Rite_of.Vocab_of], [Rite_of.Temporal_of],
+ [Rite_of.Precedence_of], [Rite_of.Rubrics_of] and [Rite_of.Lectionary_of]
+ keep resolving exactly as they did before this module existed.
+
+ Task 5 (2026-08-25-colitur-of-phases-3-5): assembles Tasks 1-4's
+ separately-built pieces into a {!Colitur_kernel.Rite.t}, with NO kernel
+ change -- the spec's own central claim for this module. *)
+
+module Vocab_of = Vocab_of
+module Temporal_of = Temporal_of
+module Precedence_of = Precedence_of
+module Rubrics_of = Rubrics_of
+module Lectionary_of = Lectionary_of
+
+(** The OF rite (design spec's [RITE] signature, realised as a
+ {!Colitur_kernel.Rite.t} value, the same shape {!Rite_ef.Rite_ef.context}
+ already establishes):
+ - [id], [vocab], [year_start], [temporal], [anchors]: {!Temporal_of}
+ unchanged (Normae n. 35-44's seasons and named movable/fixed days).
+ - [easter]: {!Colitur_kernel.Computus.gregorian_easter} -- the Roman
+ rite's own computus, supplied by the rite rather than assumed by the
+ kernel, so a Julian-reckoning rite can differ (see the .ml's own
+ citation).
+ - [fixed_key]: identity. The OF carries no bissextile (kalends-doubling)
+ convention the way the 1962 Missal does -- see the .ml's own citation
+ for the primary-source check this rests on.
+ - [rules]: {!Precedence_of}'s Tabula/Normae n. 60 functions, wrapped as
+ one {!Colitur_kernel.Precedence.rules} record.
+ - [season_runs]: NOT {!Vocab_of.seasons} -- see the .ml's own citation:
+ Ordinary Time runs in two separate blocks over one liturgical year
+ (Normae n. 43-44), so the expected run-length-compressed sequence
+ names it twice.
+ - [transfer_target]: {!Precedence_of.transfer_target}, Normae n. 60/n. 5.
+ Known incomplete on one named date shape (Normae n. 56(f), St
+ Joseph's backward anticipation onto Palm Sunday) -- documented on that
+ value's own .mli, not repeated or silently worked around here.
+ - [readings]: {!Lectionary_of.readings} partially applied to the
+ caller's own [~lectionary] and {!Temporal_of.year_start} (the OF
+ reading-cycle arithmetic's own Advent anchor) -- no [~commons]
+ parameter, unlike EF: the OF lectionary chain has no Commons
+ indirection to thread through.
+ - [creed], [gloria], [preface]: {!Rubrics_of}, IGMR n. 53/67-68/364-365.
+
+ [~lectionary] is a caller-supplied parameter, not a value closed over an
+ internal load -- {!Rite_ef.Rite_ef.context}'s own .ml comment records
+ the exact startup failure this shape avoids (a command touching no
+ lectionary data, like `colitur easter <year>`, dying at load time the
+ moment the data file is merely absent from a bare build). Follow that
+ same seam here: a caller loads data/of/lectionary.sexp however suits it
+ (bin/main.ml's own loader is the one this module ships with), and a
+ future diocesan/proper OF lectionary overlay has the identical seam to
+ attach to. *)
+val context : lectionary:Colitur_kernel.Lectionary.t -> (Vocab_of.season, Vocab_of.rank) Colitur_kernel.Rite.t
diff --git a/test/cli.t b/test/cli.t
index 4ae116f..8671694 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -199,6 +199,56 @@ A year outside the supported domain is rejected (exit 2):
colitur: year 1000 out of range 1583..9999
[2]
+Task 5 (2026-08-25-colitur-of-phases-3-5): `--rite ef|of` on `day`/`readings`.
+Default stays ef -- every invocation above, written before this flag existed,
+must be byte-identical to what it printed before, so this is asserted
+directly rather than assumed: `--rite ef` is a no-op, and omitting the flag
+entirely resolves to the exact same thing.
+
+ $ colitur day 2026 > /tmp/rite-day-default.out
+ $ colitur day --rite ef 2026 > /tmp/rite-day-ef.out
+ $ cmp /tmp/rite-day-default.out /tmp/rite-day-ef.out && echo identical
+ identical
+ $ colitur readings 2026 > /tmp/rite-readings-default.out
+ $ colitur readings --rite ef 2026 > /tmp/rite-readings-ef.out
+ $ cmp /tmp/rite-readings-default.out /tmp/rite-readings-ef.out && echo identical
+ identical
+ $ colitur day --rite ef 2026 --raw | head -1
+ 2026-01-01 thursday christmastide - ef-circumcision class-1 white
+
+The OF rite resolves too, same two commands, its own vocabulary:
+
+ $ colitur day --rite of 2026 | wc -l
+ 365
+ $ colitur day --rite of 2026 | head -1
+ 2026-01-01 thursday christmas - of-mary-mother-of-god sollemnitas white
+ $ colitur day --rite of 2026 | grep -c '^2026-12-25 '
+ 1
+ $ colitur day --rite of 2026 | grep '^2026-12-25 '
+ 2026-12-25 friday christmas - of-nativity sollemnitas white
+
+ $ colitur readings --rite of 2026 | wc -l
+ 365
+ $ colitur readings --rite of 2026 | head -1
+ 2026-01-01 of-mary-mother-of-god | Num 6:22-27 | Luc 2:16-21
+
+An unrecognised --rite value is a usage error, not a silent fallback to ef
+(exit 2):
+
+ $ colitur day --rite tridentine 2026
+ colitur: unknown --rite "tridentine" (expected "ef" or "of")
+ [2]
+
+`--rite` is refused, not silently ignored, on every command besides
+day/readings (exit 2):
+
+ $ colitur easter --rite of 2026
+ colitur: --rite has no effect on `easter`; refusing rather than ignoring it
+ [2]
+ $ colitur rubrics --rite of 2026
+ colitur: --rite has no effect on `rubrics`; refusing rather than ignoring it
+ [2]
+
Rubrics (Task 4, celebrant-rubrics-phase1): the day's own Mass formulary, one
row per day: date, the formulary's slug (which Mass is actually said, not
always the day's own), and how that was decided
diff --git a/test/dune b/test/dune
index 50bdee2..0a3ade6 100644
--- a/test/dune
+++ b/test/dune
@@ -79,6 +79,24 @@
../data/ef/adjustments.sexp
../data/ef/lectionary.sexp
../data/ef/commons.sexp
+ ; Task 5 (2026-08-25-colitur-of-phases-3-5), `colitur day|readings --rite
+ ; of`: the OF calendar's own runtime files, same "the cram sandbox only
+ ; ever gets what this stanza names" trap as every entry around them.
+ ../data/of/calendar-2002.sexp
+ ../data/of/amendments/001-padre-pio.sexp
+ ../data/of/amendments/002-juan-diego-cuauhtlatoatzin.sexp
+ ../data/of/amendments/003-our-lady-of-guadalupe.sexp
+ ../data/of/amendments/004-john-xxiii-john-paul-ii.sexp
+ ../data/of/amendments/005-mary-magdalene-rank.sexp
+ ../data/of/amendments/006-mary-mother-of-the-church.sexp
+ ../data/of/amendments/007-paul-vi.sexp
+ ../data/of/amendments/008-our-lady-of-loreto.sexp
+ ../data/of/amendments/009-faustina-kowalska.sexp
+ ../data/of/amendments/010-narek-avila-hildegard.sexp
+ ../data/of/amendments/011-martha-mary-lazarus.sexp
+ ../data/of/amendments/012-teresa-of-calcutta.sexp
+ ../data/of/amendments/013-john-henry-newman.sexp
+ ../data/of/lectionary.sexp
; Task 12, `colitur publish`: the cram sandbox only ever gets what this
; stanza names explicitly (unlike a plain `dune build`, it does not fall
; back to the workspace root's own default alias), so schema/day-v1.json
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 30e649e..4b7c78a 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -17,6 +17,7 @@ let () =
Test_precedence.suite;
Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_calendar_of_data.suite;
Test_rite_ef.suite;
+ Test_rite_of.suite;
Test_rubrics_ef.suite;
Test_rubrics_of.suite;
Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035;
diff --git a/test/test_rite_of.ml b/test/test_rite_of.ml
new file mode 100644
index 0000000..90cedc1
--- /dev/null
+++ b/test/test_rite_of.ml
@@ -0,0 +1,340 @@
+(* Task 5 (2026-08-25-colitur-of-phases-3-5): [Rite_of.context], wired
+ together with the REAL data/of/calendar-2002.sexp + all 13 decree
+ overlays + data/of/lectionary.sexp, resolved through
+ {!Colitur_kernel.Calendar} and checked by {!Colitur_kernel.Validate.run}
+ -- the same pipeline `colitur day --rite of` uses, and the same
+ discipline test_validate.ml/test_rite_ef.ml already apply to the EF
+ side: exactly one observed office per day, the year covered once with no
+ gaps, slug-uniqueness within the liturgical year, and transfers reaching
+ a fixed point are all invariants {!Colitur_kernel.Validate.run} already
+ checks generically -- this file supplies the REAL OF rite and data and
+ asserts the result is clean, rather than re-deriving each invariant by
+ hand. *)
+
+module Val = Colitur_kernel.Validate
+module Layer = Colitur_kernel.Layer
+module Overlay = Colitur_kernel.Overlay
+module Cal = Colitur_kernel.Calendar
+module LD = Colitur_kernel.Liturgical_day
+module Slug = Colitur_kernel.Slug
+module Date = Colitur_kernel.Date
+module Cel = Colitur_kernel.Celebration
+module P = Colitur_kernel.Precedence
+module Computus = Colitur_kernel.Computus
+module V = Rite_of.Vocab_of
+
+(* Relative to this test's own build directory (_build/default/test/), same
+ convention test_amendments_of.ml/test_calendar_of_data.ml already use --
+ test/dune declares each of these as a dep of the (test ...) stanza. *)
+let base_path = "../data/of/calendar-2002.sexp"
+let amendments_dir = "../data/of/amendments/"
+let lectionary_path = "../data/of/lectionary.sexp"
+
+(* Decree-chronological order -- the exact list and order
+ test_amendments_of.ml's own [files] table already pins by SHA-256; not
+ re-pinning the hashes here (that is that file's own job), just applying
+ them in the same order so the rite under test here is the one bin/main.ml
+ will assemble. *)
+let amendment_files =
+ [ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
+ "004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp"; "006-mary-mother-of-the-church.sexp";
+ "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp"; "009-faustina-kowalska.sexp";
+ "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp"; "012-teresa-of-calcutta.sexp";
+ "013-john-henry-newman.sexp" ]
+
+let real_of_layer =
+ let base =
+ match Layer.load V.rank_of_sexp base_path with
+ | Ok l -> l
+ | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" base_path e)
+ in
+ let overlays =
+ List.map
+ (fun name ->
+ let path = amendments_dir ^ name in
+ match Overlay.load V.rank_of_sexp path with
+ | Ok o -> o
+ | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" path e))
+ amendment_files
+ in
+ let layer, diagnostics = Overlay.merge base overlays in
+ if diagnostics <> [] then
+ failwith
+ (Printf.sprintf "unexpected amendment diagnostics: %s"
+ (String.concat "; " (List.map Overlay.diagnostic_to_string diagnostics)));
+ layer
+
+(* [Rite_of.context] takes [~lectionary] as a caller-supplied parameter, not
+ a value closed over an internal load -- see rite_of.ml's own citation for
+ why (the exact `colitur easter <year>` startup-death lesson
+ {!Rite_ef.Rite_ef.context}'s own comment records). *)
+let real_of_lectionary =
+ match Colitur_kernel.Lectionary.load lectionary_path with
+ | Ok l -> l
+ | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" lectionary_path e)
+
+let real_of_rite = Rite_of.context ~lectionary:real_of_lectionary
+
+let run year = Val.run real_of_rite real_of_layer ~year
+
+(* data/of/lectionary.sexp's own provenance header names ONE informational
+ temporal-slug gap: [of-nativity] (25 December -- Christmas Day itself)
+ has no entry, because niedziela.pl's own source keys the Nativity's
+ several Masses (Vigil/Night/Dawn/Day) differently than a single flat
+ slug can address, and [Lectionary_of.readings] has no fallback beyond
+ its own three-step chain (unlike EF's RG 299 preceding-Sunday rule,
+ which OF's own OLM Praenotanda gives no equivalent for -- see
+ lectionary_of.mli's own citation). This makes {!Colitur_kernel.Validate
+ .run}'s ["citations-unresolved"] check fire exactly once a year, on 25
+ December, on real shipped data -- a documented, measured gap in
+ lectionary_of's own data file (out of this task's scope to touch or
+ close: lectionary_of.* is excluded from this task's brief), not a defect
+ this module's wiring introduced. Named and filtered explicitly, never
+ silently dropped, so a future SECOND gap (a regression) still reddens
+ this test. *)
+let ends_with ~suffix s =
+ let ls = String.length s and lx = String.length suffix in
+ ls >= lx && String.sub s (ls - lx) lx = suffix
+
+(* No [Str]/regex (deps frozen) -- a plain substring scan. *)
+let contains ~substring s =
+ let ls = String.length s and lx = String.length substring in
+ let rec go i = i + lx <= ls && (String.sub s i lx = substring || go (i + 1)) in
+ lx = 0 || go 0
+
+let is_known_nativity_gap (f : Val.failure) =
+ (f.Val.check = "citations-unresolved" || f.Val.check = "formulary") && ends_with ~suffix:"12-25" f.Val.date
+
+(* A SECOND, NEWLY-FOUND gap this task's own wiring surfaced -- [rite_of] was
+ never previously wired into a real {!Colitur_kernel.Rite.t}, so
+ [Validate.run]'s ["anchor"] check has never before run [Temporal_of
+ .anchors] against [Temporal_of.temporal] for real. Normae n. 35(a)'s own
+ text is "Dominica infra octavam Nativitatis, VEL, EA DEFICIENTE, die 30
+ decembris" -- the Holy Family fallback is a FIXED-DATE fallback (30
+ December, whatever weekday it falls on that year), confirmed by
+ {!Temporal_of.holy_family}'s own [None -> mk y 12 30] branch and its own
+ .mli citation ("this 6-day window CAN be [empty of a Sunday] -- confirmed
+ by the fallback clause's own existence"). But {!Temporal_of.temporal}
+ only ever TESTS [same d (holy_family y)] inside its [sunday_slug d]
+ dispatch arm -- i.e. only when [d] genuinely IS a Sunday -- so in a year
+ whose 26-31 December window has NO Sunday (equivalently: 25 December,
+ Christmas Day, is itself a Sunday -- a 6-day window starting the day
+ after a Sunday omits Sunday from every other position), the fallback
+ date is computed correctly by [holy_family]/[anchors] but [temporal]
+ itself silently falls through to an ordinary ferial slug instead
+ (["of-christmas-0-friday"] et al.) -- exactly the "generic day slug
+ masking a real named feast" shape CLAUDE.md already records EF's own
+ Holy Family/Holy Name gaps as, before they were fixed there. Not rare:
+ this fires whenever Christmas Day is a Sunday, roughly one year in
+ seven (1583, 2005, 2011, 2016, 2022, 2033, 2039, 2044, 2050 all
+ independently confirmed via Python's own proleptic-Gregorian
+ [datetime]). OUT OF THIS TASK'S SCOPE to fix -- temporal_of.ml's own
+ rubric logic is Task 1's deliverable, not Task 5's, and the task brief
+ models exactly this "pin the known-wrong behaviour, do not fix it here"
+ treatment for a different, already-known gap (Normae n.56(f), St
+ Joseph/Palm Sunday) -- so the same discipline is applied to this
+ newly-found one rather than silently working around it or fixing rite
+ logic this task was not chartered to touch. Reported in this task's own
+ report as a concern. *)
+let is_known_holy_family_fallback_gap (f : Val.failure) =
+ f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail
+
+let check_year_allowing_known_gaps year =
+ let fs = run year in
+ let is_known f = is_known_nativity_gap f || is_known_holy_family_fallback_gap f in
+ let unexpected = List.filter (fun f -> not (is_known f)) fs in
+ (match unexpected with
+ | [] -> ()
+ | fs ->
+ Alcotest.failf "%d: %s" year
+ (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs))));
+ let nativity_gaps = List.filter is_known_nativity_gap fs in
+ Alcotest.(check int)
+ (Printf.sprintf "%d: exactly the two documented of-nativity checks (citations-unresolved + \
+ formulary), no more" year)
+ 2 (List.length nativity_gaps)
+
+(* Landmark years, the same choice test_validate.ml's own
+ [test_landmark_years] makes for EF: both domain edges plus two ordinary
+ years well inside it. *)
+let test_landmark_years () = List.iter check_year_allowing_known_gaps [ 1583; 2026; 2035; 9998 ]
+
+(* 9999 mirrors test_validate.ml's own [test_year_9999_does_not_raise]: the
+ liturgical year opening in civil year 9999 continues into out-of-domain
+ civil year 10000, so [Val.run]/[Calendar.year] clamp the walk to 31
+ December 9999 rather than raising, and the truncated season run is
+ *expected* to fail the "seasons" check -- pinning that this surfaces as
+ an ordinary failure, not an uncaught exception, and that resolution
+ itself ("coverage"/"lost"/"duplicated") stays clean through the clamp. *)
+let test_year_9999_does_not_raise () =
+ let fs = run 9999 in
+ Alcotest.(check bool) "no coverage failures (temporal stayed total through the clamp)" true
+ (not (List.exists (fun f -> f.Val.check = "coverage") fs));
+ Alcotest.(check bool) "seasons check flags the truncated final year as incomplete" true
+ (List.exists (fun f -> f.Val.check = "seasons") fs)
+
+(* A real transfer-bearing sample, the OF counterpart of test_rite_ef.ml's
+ own 2005-2050 sweep: every impeded Sollemnitas [Precedence_of.disposition]
+ defers is either placed by [transfer_target] and reaches a fixed point
+ (no "unconverged" failure -- Validate's own check), or is recorded
+ [omitted] with a reason, never silently dropped ("lost"/"duplicated"),
+ over the project's own 2005-2050 differential-testing window (CLAUDE.md).
+
+ STRUCTURAL checks only -- ["coverage"; "weekday"; "rank"; "colour";
+ "determinism"; "slugs"; "seasons"; "week"; "lost"; "duplicated";
+ "unconverged"; "admission"; "observed"] -- deliberately NOT the
+ citation-chain ones ("anchor"/"citations"/"citations-unresolved"/
+ "formulary"), and this exclusion is itself a finding, not a shortcut:
+ sweeping the full 46-year window (not just the 4 landmark years above)
+ surfaced that [data/of/lectionary.sexp]'s own coverage was measured
+ against exactly ONE civil year (2026, per that file's own provenance
+ header) -- OTHER years exercise the OTHER weekday-cycle letter for the
+ same temporal slug (OLM n.69.4, {!Lectionary_of.weekday_cycle}: which
+ letter applies flips on the LITURGICAL year label's parity, independent
+ of which civil year is being swept), and at least one such pairing is
+ asymmetric on real shipped data: [of-christmas-0-friday-ii] exists,
+ [of-christmas-0-friday-i] does not (confirmed directly against
+ data/of/lectionary.sexp; first reached in this sweep at 2006-12-29).
+ Separately, 2006-12-24 (a year Christmas Eve itself is a Sunday) also
+ exposed a THIRD newly-found gap: {!Temporal_of.anchors} lists
+ [of-nativity-vigil] on 24 December UNCONDITIONALLY, but
+ {!Temporal_of.named}'s own coded exception (Tabula I.2/Normae n.5:
+ Advent IV outranks the Vigil when they coincide) means
+ {!Temporal_of.temporal} does not always agree. All three are OUT OF
+ THIS TASK'S SCOPE to fix -- lectionary_of.* and data/of/lectionary.sexp
+ are excluded from this task's brief, and temporal_of.ml's own rubric
+ logic is Task 1's deliverable -- reported as concerns in this task's own
+ report rather than fixed here or silently excluded without explanation. *)
+let sample_years =
+ let rec range a b = if a > b then [] else a :: range (a + 1) b in
+ range 2005 2050
+
+let structural_check = function
+ | "anchor" | "citations" | "citations-unresolved" | "formulary" -> false
+ | _ -> true
+
+let check_year_structural_only year =
+ match List.filter (fun f -> structural_check f.Val.check) (run year) with
+ | [] -> ()
+ | fs ->
+ Alcotest.failf "%d: %s" year
+ (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs)))
+
+let test_transfers_reach_a_fixed_point_2005_2050 () =
+ List.iter check_year_structural_only sample_years
+
+let slug_of (c : V.rank Cel.t) = Slug.to_string c.Cel.slug
+
+(* precedence_of.mli's own documented, KNOWN-UNIMPLEMENTED fourth
+ transfer_target rule (final review Important #5, ledger ruling R9):
+ Normae n. 56(f) -- St Joseph's solemnity, falling on Palm Sunday, is
+ ANTICIPATED BACKWARD to 18 March, not carried forward. colitur's
+ [transfer_target] has no backward-search branch (rite.mli's own
+ [transfer_target] contract requires the result be strictly LATER than
+ the origin -- an EF-shaped kernel obligation, per that value's own
+ citation), so it instead sends Joseph forward via the general rule 3,
+ the same target date the Annunciation would already be heading to that
+ same year (Easter + 8).
+
+ This is NOT fixed by this task (explicitly out of scope, per the task
+ brief) -- pinned here, on a REAL affected year, so the current
+ (known-wrong) behaviour is asserted deliberately rather than passing
+ silently as if it were correct. All years with Gregorian Easter = 26
+ March put Palm Sunday on 19 March, coinciding with Joseph; 2062 is the
+ nearest one after today (2026) still inside the differential-adjacent
+ range this project favours for worked examples.
+
+ 2062 is a doubly-worked example, not chosen only for Joseph: that same
+ year, 25 March (the Annunciation) falls on HOLY SATURDAY (Easter - 1),
+ so BOTH solemnities are impeded and BOTH nominally target Easter + 8 via
+ the general rule -- {!Colitur_kernel.Calendar}'s own placement pass
+ settles that collision by landing whichever it processes first (Joseph,
+ departing the earlier date, 19 March) on Easter + 8 and pushing the
+ other to the next admissible day; empirically, on shipped data, it is
+ JOSEPH who is pushed one day further still, to Easter + 9 -- read as
+ further confirmation this is genuinely rule 3's general forward search
+ (sensitive to whatever else is competing that year), not the fixed,
+ collision-proof Easter+8 destination rule 3's cousin (the Annunciation's
+ own Holy-Week branch) names outright -- not as a claim about exactly
+ which of the two wins the tie, which this test does not depend on. *)
+let test_joseph_palm_sunday_2062_known_wrong_forward_transfer () =
+ let easter_2062 = Computus.gregorian_easter 2062 in
+ Alcotest.(check string) "2062 Easter is really 26 March (the Normae n.56(f) trigger)" "2062-03-26"
+ (Date.to_iso8601 easter_2062);
+ let palm_sunday = Date.add_days easter_2062 (-7) in
+ Alcotest.(check string) "Palm Sunday 2062 is really 19 March, coinciding with St Joseph" "2062-03-19"
+ (Date.to_iso8601 palm_sunday);
+ let holy_saturday = Date.add_days easter_2062 (-1) in
+ Alcotest.(check string) "25 March 2062 is really Holy Saturday, coinciding with the Annunciation too"
+ "2062-03-25" (Date.to_iso8601 holy_saturday);
+ let days = Cal.year real_of_rite real_of_layer 2061 in
+ let joseph_day =
+ Array.to_list days |> List.find (fun d -> Date.compare d.LD.date palm_sunday = 0)
+ in
+ Alcotest.(check string) "Palm Sunday itself is observed on 19 March (Joseph impeded, correctly)"
+ "of-palm-sunday" (slug_of joseph_day.LD.observed);
+ let joseph_slug = "joseph-husband-of-the-blessed-virgin-mary" in
+ let departure =
+ List.find_opt (fun (c, _) -> slug_of c = joseph_slug) joseph_day.LD.transferred_out
+ in
+ match departure with
+ | None -> Alcotest.fail "expected Joseph to be transferred_out from 19 March 2062 (impeded by Palm Sunday)"
+ | Some (_, target) ->
+ (* Concrete, not merely qualitative: 2062-04-04 is the actual pinned
+ value on shipped data today (Easter + 9, one day past the
+ Annunciation's own Easter + 8 -- see this test's own header). A
+ future data or Calendar change that moves it is meant to be
+ caught here; the point being pinned is the SHAPE (forward, past
+ Easter, not 18 March), asserted again below in a form that does
+ not depend on the exact day. *)
+ Alcotest.(check string)
+ "KNOWN WRONG (Normae n.56(f) unimplemented, precedence_of.mli): Joseph lands forward, past \
+ Easter (currently Easter+9, one day beyond the Annunciation's own Easter+8), not the \
+ rubrically-correct 18 March (backward)"
+ "2062-04-04" (Date.to_iso8601 target);
+ Alcotest.(check bool) "not 18 March -- confirming this really is the anticipation defect, not a \
+ coincidence" false
+ (Date.compare target (Date.add_days palm_sunday (-1)) = 0);
+ Alcotest.(check bool) "and it lands on or after Easter itself (rule 3's forward search, not a \
+ backward anticipation)" true
+ (Date.compare target easter_2062 >= 0)
+
+(* Second NEWLY-FOUND defect, pinned the same way (see
+ [is_known_holy_family_fallback_gap]'s own citation just above for the
+ full argument): 1583 is the domain floor AND a year Christmas Day falls
+ on a Sunday, so 26-31 December has no Sunday of its own and
+ [Temporal_of.holy_family] falls back to its own fixed 30 December --
+ correctly, per [Temporal_of.anchors] -- but [Temporal_of.temporal]
+ itself never reaches that fallback (its Holy-Family test lives inside
+ the Sunday-only dispatch arm), so 30 December 1583 is observed as an
+ ordinary Friday-of-Christmastide ferial office instead of the Feast of
+ the Holy Family. *)
+let test_holy_family_fallback_1583_known_wrong_ferial () =
+ let christmas_1583 = Date.of_iso8601 "1583-12-25" |> Result.get_ok in
+ Alcotest.(check bool) "1583 Christmas Day really is a Sunday (the Normae n.35(a) trigger)" true
+ (Date.weekday christmas_1583 = Date.Sun);
+ let days = Cal.year real_of_rite real_of_layer 1583 in
+ let dec30 = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (Date.add_days christmas_1583 5) = 0) in
+ Alcotest.(check string)
+ "KNOWN WRONG (Normae n.35(a) fallback unreached, see [is_known_holy_family_fallback_gap]'s own \
+ citation): 30 December 1583 is an ordinary ferial slug, not of-holy-family"
+ "of-christmas-0-friday" (slug_of dec30.LD.observed);
+ Alcotest.(check bool) "and that really is NOT the Holy Family (confirming this really is the defect)" false
+ (slug_of dec30.LD.observed = "of-holy-family")
+
+let suite =
+ ( "Rite_of (real data: assembled Rite.t end-to-end via Calendar/Validate)",
+ [ Alcotest.test_case "landmark years validate cleanly (modulo two documented gaps)" `Quick
+ test_landmark_years;
+ Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, coverage stays clean" `Quick
+ test_year_9999_does_not_raise;
+ Alcotest.test_case "2005-2050: transfers reach a fixed point, nothing lost or duplicated" `Slow
+ test_transfers_reach_a_fixed_point_2005_2050;
+ Alcotest.test_case
+ "KNOWN OPEN DEFECT pinned, not fixed: St Joseph on Palm Sunday 2062 transfers forward, not \
+ backward per Normae n.56(f)" `Quick
+ test_joseph_palm_sunday_2062_known_wrong_forward_transfer;
+ Alcotest.test_case
+ "NEWLY-FOUND DEFECT pinned, not fixed: Holy Family's Normae n.35(a) 30-December fallback is \
+ unreached when Christmas Day is a Sunday (1583)" `Quick
+ test_holy_family_fallback_1583_known_wrong_ferial ] )