diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-26 09:53:22 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-26 09:53:22 +0200 |
| commit | 8fa9db25f55f4ae92ad6a28115484dd981593a3c (patch) | |
| tree | 8cdc9f253cc1f44759fccc1ca25be7eda950a34a | |
| parent | 155c2e55948dc179a45458d856808f7105878d1f (diff) | |
| download | colitur-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/dune | 2 | ||||
| -rw-r--r-- | bin/main.ml | 251 | ||||
| -rw-r--r-- | lib/rites/rite_of/rite_of.ml | 85 | ||||
| -rw-r--r-- | lib/rites/rite_of/rite_of.mli | 57 | ||||
| -rw-r--r-- | test/cli.t | 50 | ||||
| -rw-r--r-- | test/dune | 18 | ||||
| -rw-r--r-- | test/test_colitur.ml | 1 | ||||
| -rw-r--r-- | test/test_rite_of.ml | 340 |
8 files changed, 798 insertions, 6 deletions
@@ -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 @@ -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 @@ -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 ] ) |
