summaryrefslogtreecommitdiff
path: root/bin/main.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
commit7f183c847f8b67c88ad6ea3bf2d635c5c0534651 (patch)
tree0c4a7adb94e5374b61d979c2b4c54637da677cda /bin/main.ml
parent4624441d0bc9c1f28dea54606ab7999041d4b323 (diff)
downloadcolitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.tar.gz
colitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.zip
cli: colitur day <year> dumps resolved liturgical days
Assembles Rite_ef.context (lib/rites/rite_ef/rite_ef.ml[i]): temporal, anchors and vocab from Temporal_ef; rules from Precedence_ef's band, disposition and admit; season_runs = Vocab_ef.seasons; transfer_target newly implemented here. transfer_target (RG 96): the next following day that is not I or II class, with the Annunciation's own exception (Monday after Low Sunday). Terminates by a structural step bound on its internal search, independent of Calendar's own round guard, which bounds rounds across a year, not one call's walk; documented as an obligation on rite.mli's transfer_target field, which did not previously state it. Fixes the vigil-naming mismatch Task 7's review predicted: the sanctoral bootstrap names its vigils with a vigil-of-X prefix (lectio's own convention), while Precedence_ef's is_vigil only recognised the temporal cycle's own X-vigil suffix. Both are now recognised, fixing RG 91 entries 21/26 and RG 33's vigil omission for the four affected celebrations. Verified by unit test and by mutation-testing the fix (reverting it fails exactly the new rows) and against real output across several years. Suppresses data/ef/sanctoral.sexp's vigil-of-christmas via a new overlay, data/ef/adjustments.sexp: it is the same celebration as the temporal cycle's own ef-nativity-vigil, both dated 24 December. colitur day <year>: one line per civil-year day, temporal and sanctoral fully resolved through Layer, Overlay, Precedence_ef and Calendar -- the first CLI path exercising the whole Plan 3 pipeline against real data. Verified the All Souls transfer chain (Tasks 7-8-11) end to end against real output for both a Sunday year (2025, lands on 3 Nov) and a non-Sunday year (2026, observed directly on 2 Nov).
Diffstat (limited to 'bin/main.ml')
-rw-r--r--bin/main.ml137
1 files changed, 136 insertions, 1 deletions
diff --git a/bin/main.ml b/bin/main.ml
index 896a6bd..53cae87 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -41,8 +41,142 @@ let temporal_report y =
d := D.add_days !d 1
done
+(* Task 11: the fully resolved EF calendar (temporal AND sanctoral,
+ occurrence and transfers applied), one line per civil-year day --
+ "YYYY-MM-DD weekday season week slug rank colour [+commemoration-slug]...".
+ [temporal_report] above only ever showed the temporal cycle in isolation
+ ([Rite_ef.Temporal_ef.temporal] directly, no sanctoral layer, no
+ [Precedence] contest); this is the first CLI path that runs every piece
+ Plan 3 built -- [Colitur_kernel.Layer], [Overlay], [Precedence_ef],
+ [Calendar] -- against real data. *)
+
+(* [data/ef/sanctoral.sexp] and [data/ef/adjustments.sexp] are located
+ relative to the BUILD TREE, not the process's own cwd: cwd varies with
+ how the binary is invoked (a user's shell for `dune exec colitur --`, a
+ dune cram test's own sandboxed temp directory for `test/cli.t`) and
+ nothing in this project's build pins it to the repository root. A
+ build-time constant substituted via dune's [%{workspace_root}] was tried
+ first and rejected: it is resolved RELATIVE TO THE BUILD ACTION'S OWN
+ directory (empirically "." here, not an absolute path -- dune keeps
+ build actions relocatable), so it silently reproduces the same
+ cwd-dependence this is trying to eliminate, just baked in at build time
+ instead of read at run time; confirmed by the resulting `colitur day`
+ failing to find its own data outside the exact directory the build
+ happened to run in.
+
+ [Sys.executable_name] does not have that problem -- on Linux it resolves
+ through /proc/self/exe, which the kernel always reports as the
+ executable's own canonical absolute path, even when the process was
+ launched through a symlink (verified against dune's own cram sandbox,
+ which places exactly such a symlink; see the task report). dune's default
+ ("no [(sandbox ...)] declared") build context mirrors the ENTIRE source
+ tree under _build/default/, unconditionally, so climbing from
+ _build/default/bin/main.exe up two directories and back down into data/
+ always finds both files, regardless of the caller's own cwd.
+
+ Known limitation, not yet exercised by this project: a `dune install`-
+ style deployment (executable copied to a prefix with no adjacent _build/
+ default/data/) would need a different resolution strategy; there is no
+ install story yet (README.md: `dune exec` only), so this is not a
+ regression against anything this project currently supports. *)
+let data_dir () = Filename.dirname (Filename.dirname Sys.executable_name) ^ "/data/ef"
+
+(* Loads the universal sanctoral layer and applies the one hand-authored
+ overlay over it (data/ef/adjustments.sexp -- see that file's own header):
+ [Overlay.apply]'s diagnostics are never silently dropped (Overlay.mli),
+ so any that come back -- expected to be none in the committed data; see
+ the overlay file's own comment on when one WOULD fire -- are printed to
+ stderr, loudly, without aborting the run. *)
+let load_ef_layer () =
+ let dir = data_dir () in
+ let sanctoral_path = Filename.concat dir "sanctoral.sexp" in
+ let adjustments_path = Filename.concat dir "adjustments.sexp" in
+ match Colitur_kernel.Layer.load Rite_ef.Vocab_ef.rank_of_sexp sanctoral_path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" sanctoral_path e)
+ | Ok layer -> (
+ match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp adjustments_path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" adjustments_path e)
+ | Ok overlay ->
+ let layer, diagnostics = Colitur_kernel.Overlay.apply layer overlay in
+ List.iter
+ (fun d -> Printf.eprintf "colitur: %s\n" (Colitur_kernel.Overlay.diagnostic_to_string d))
+ diagnostics;
+ Ok layer)
+
+let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.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
+ Printf.printf "%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_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season)
+ week
+ (Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug)
+ (Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank)
+ (Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour)
+ commemorations
+
+(* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan --
+ NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single
+ Advent-anchored liturgical year, which straddles two civil years, so a
+ civil year's worth of output needs the tail of the liturgical year that
+ opened the PREVIOUS civil year (covers roughly 1 Jan - 28 Nov) plus the
+ liturgical year that opens within this one (roughly 29 Nov - 31 Dec).
+ Both are computed once each -- not once per day via [Calendar.day], which
+ would recompute the whole (~365-day) placement pass up to 365 times over
+ for the days sharing one liturgical year (calendar.mli's own "pays it
+ once" cost model assumes exactly this usage: call [year], not [day] in a
+ loop). *)
+let day_report y =
+ match load_ef_layer () with
+ | Error msg ->
+ Printf.eprintf "colitur: %s\n" msg;
+ exit 2
+ | Ok layer ->
+ let module Cal = Colitur_kernel.Calendar in
+ let by_rata : (int, (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) Hashtbl.t =
+ Hashtbl.create 400
+ in
+ let index days =
+ Array.iter
+ (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.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 Rite_ef.context layer (y - 1));
+ index (Cal.year Rite_ef.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
+ while D.compare !d dec31 <= 0 do
+ (match Hashtbl.find_opt by_rata (D.to_rata !d) with
+ | Some day -> day_line day
+ | None ->
+ (* Unreachable for any [y] in 1583..9999: the two indexed
+ liturgical years jointly cover [year_start (y-1), year_start
+ (y+1)), which contains all of civil year [y]
+ (calendar.mli). Not a [failwith] -- an out-of-domain [d]
+ inside this loop is impossible by construction (jan1/dec31
+ are themselves validated in range, and [add_days] only ever
+ advances within the same civil year here) -- but a silent
+ skip would violate the same "never silently dropped"
+ standard the kernel holds itself to, so a gap surfaces
+ loudly on stderr rather than as a quietly short year. *)
+ Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d));
+ d := D.add_days !d 1
+ done
+
let usage () =
- prerr_endline "colitur: usage: colitur easter <year> | colitur temporal <year>";
+ prerr_endline "colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year>";
exit 2
let with_year ys f =
@@ -57,4 +191,5 @@ let () =
match Sys.argv with
| [| _; "easter"; ys |] -> with_year ys easter_report
| [| _; "temporal"; ys |] -> with_year ys temporal_report
+ | [| _; "day"; ys |] -> with_year ys day_report
| _ -> usage ()