aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/bootstrap_sanctoral.ml235
-rw-r--r--tools/dune10
2 files changed, 245 insertions, 0 deletions
diff --git a/tools/bootstrap_sanctoral.ml b/tools/bootstrap_sanctoral.ml
new file mode 100644
index 0000000..19adc0b
--- /dev/null
+++ b/tools/bootstrap_sanctoral.ml
@@ -0,0 +1,235 @@
+(* Bootstraps data/ef/sanctoral.sexp from lectio's tridentine-calendar.ini
+ (spec §4, docs/superpowers/specs/2026-08-11-colitur-plan3-resolution-
+ engine-design.md). A documented one-shot under tools/, not part of the
+ kernel: reading someone else's INI needs a reader, which does not
+ contradict "no hand-written parser" -- that rule is about colitur's own
+ format (S-expressions, parsed by sexplib/ppx_sexp_conv, never by hand).
+
+ Every field is built through the kernel's own validating constructors
+ (Slug.of_string, Colour.of_string, Vocab_ef.rank_of_string, ...), so the
+ emitted sexp is valid by construction: a bad slug or an unknown rank fails
+ the bootstrap here, not a later Layer.load.
+
+ Usage: dune exec tools/bootstrap_sanctoral.exe -- [source.ini] [dest.sexp]
+ Defaults assume the sibling checkout layout documented in CLAUDE.md
+ (~/git/projects/lectio next to ~/git/projects/colitur) and a run from the
+ colitur repo root. *)
+
+module Cel = Colitur_kernel.Celebration
+module Slug = Colitur_kernel.Slug
+module Colour = Colitur_kernel.Colour
+module Subject = Colitur_kernel.Subject
+module Names = Colitur_kernel.Names
+module Lang = Colitur_kernel.Lang
+module DS = Colitur_kernel.Date_spec
+module Layer = Colitur_kernel.Layer
+module V = Rite_ef.Vocab_ef
+module PE = Rite_ef.Precedence_ef
+
+let default_source = "../lectio/internal/caldata/tridentine-calendar.ini"
+let default_dest = "data/ef/sanctoral.sexp"
+
+(* Fails loudly and stops, per the brief: a source shape the mapping does not
+ cover must never fall back to a silent default. *)
+let die fmt = Printf.ksprintf (fun s -> prerr_endline ("bootstrap_sanctoral: " ^ s); exit 1) fmt
+
+(* --- A minimal INI reader for lectio's format only ------------------------
+
+ Not a general INI library: comment lines start with ';', section headers
+ are "[name]", and every other non-blank line is "key = value" with the
+ value running to end of line (verified against the actual source file --
+ no embedded '=', quotes, or trailing whitespace in any value; see the
+ task report). This is a reader for someone else's format, not colitur's
+ own -- see the module comment above. *)
+
+type section = { name : string; fields : (string * string) list }
+
+let parse_ini path =
+ if not (Sys.file_exists path) then die "source file not found: %s" path;
+ let ic = open_in path in
+ Fun.protect
+ ~finally:(fun () -> close_in_noerr ic)
+ (fun () ->
+ let sections = ref [] in
+ let cur_name = ref None in
+ let cur_fields = ref [] in
+ let flush () =
+ match !cur_name with
+ | Some n -> sections := { name = n; fields = List.rev !cur_fields } :: !sections
+ | None -> ()
+ in
+ (try
+ while true do
+ let raw = input_line ic in
+ let line = String.trim raw in
+ if line = "" || line.[0] = ';' then ()
+ else if line.[0] = '[' && line.[String.length line - 1] = ']' then begin
+ flush ();
+ cur_name := Some (String.sub line 1 (String.length line - 2));
+ cur_fields := []
+ end
+ else
+ match String.index_opt line '=' with
+ | None -> die "%s: unparseable line (no '='): %S" path line
+ | Some i ->
+ let key = String.trim (String.sub line 0 i) in
+ let value = String.trim (String.sub line (i + 1) (String.length line - i - 1)) in
+ cur_fields := (key, value) :: !cur_fields
+ done
+ with End_of_file -> ());
+ flush ();
+ List.rev !sections)
+
+(* --- Field-level mapping (spec §4.2) --------------------------------------- *)
+
+let field sec key =
+ match List.assoc_opt key sec.fields with
+ | Some v -> v
+ | None -> die "section [%s]: missing required field %S" sec.name key
+
+let field_opt sec key = List.assoc_opt key sec.fields
+
+let parse_slug sec =
+ match Slug.of_string sec.name with
+ | Ok s -> s
+ | Error e -> die "section [%s]: invalid slug: %s" sec.name e
+
+(* All 322 entries use plain MM-DD (spec §4.1); anything else is a date form
+ the mapping does not cover. *)
+let parse_date sec =
+ let raw = field sec "date" in
+ match String.split_on_char '-' raw with
+ | [ mm; dd ] when String.length mm = 2 && String.length dd = 2 -> (
+ match (int_of_string_opt mm, int_of_string_opt dd) with
+ | Some month, Some day -> (
+ match DS.fixed ~month ~day with
+ | Ok d -> d
+ | Error e -> die "section [%s]: date %S: %s" sec.name raw e)
+ | _ -> die "section [%s]: date %S is not numeric MM-DD" sec.name raw)
+ | _ -> die "section [%s]: unsupported date form %S -- only MM-DD is mapped" sec.name raw
+
+(* rank = class-1..4 -> (Class1..4, Feast); rank = commemoration ->
+ (Class3, Commemoration_only) -- decision 2 in the task brief and spec
+ §4.3: the source gives no rank for a bare commemoration, but RG 111 orders
+ admitted commemorations by dignity, so one is needed. Class3 is a
+ documented INFERENCE (it is what the 1960 reform reduced most simple
+ feasts from), not an RG citation -- see docs/research/rules-register.md
+ §6 and Celebration.status's own doc comment for why status, not rank,
+ carries "can this ever be observed". *)
+let parse_rank_status sec =
+ let raw = field sec "rank" in
+ if raw = "commemoration" then (V.Class3, Cel.Commemoration_only)
+ else
+ match V.rank_of_string raw with
+ | Some r -> (r, Cel.Feast)
+ | None -> die "section [%s]: unknown rank %S" sec.name raw
+
+(* Sanctoral colours are white, red, violet, black only (spec §4.1) -- green
+ and rose belong to the temporal cycle. Colour.of_string's domain is wider
+ (it also names green/rose), so this rejects them explicitly rather than
+ passing through a colour that would be a modelling error if it ever
+ appeared in sanctoral data. *)
+let parse_colour sec =
+ let raw = field sec "colour" in
+ match Colour.of_string raw with
+ | Some (Colour.White | Colour.Red | Colour.Violet | Colour.Black as c) -> c
+ | Some (Colour.Green | Colour.Rose) ->
+ die "section [%s]: colour %S is temporal-only, unexpected in sanctoral data" sec.name raw
+ | None -> die "section [%s]: unknown colour %S" sec.name raw
+
+(* class = lord|bvm|saint -> Subject.t; absent -> Subject.Saint (decision 1:
+ Celebration.make's kernel default is Subject.Temporal, correct for the
+ temporal cycle and wrong for every sanctoral entry -- overridden here,
+ never left to the default). Only 6 of 322 entries carry an explicit
+ class in the source, and all 6 are "lord" as of this bootstrap; bvm/saint
+ are mapped in case a future lectio update adds one, but "temporal" is
+ rejected -- no sanctoral entry is the temporal cycle's own office. *)
+let parse_subject sec =
+ match field_opt sec "class" with
+ | None -> Subject.Saint
+ | Some raw -> (
+ match Subject.of_string raw with
+ | Some Subject.Temporal ->
+ die "section [%s]: class %S maps to Subject.Temporal, invalid for sanctoral data" sec.name
+ raw
+ | Some s -> s
+ | None -> die "section [%s]: unknown class %S" sec.name raw)
+
+let parse_names sec =
+ let en = field sec "name.en" in
+ let pl = field sec "name.pl" in
+ Names.of_list [ (Lang.of_string_exn "en", en); (Lang.of_string_exn "pl", pl) ]
+
+(* reading.* is deliberately ignored -- Plan 4's lectionary bootstrap. *)
+let convert_entry sec : V.rank Layer.entry =
+ let slug = parse_slug sec in
+ let date = parse_date sec in
+ let rank, status = parse_rank_status sec in
+ let colour = parse_colour sec in
+ let subject = parse_subject sec in
+ let names = parse_names sec in
+ (* PE.universal_layer, NOT lectio's own "tridentine" layer id and NOT the
+ Celebration.make default "temporal": Precedence_ef.band reads
+ Celebration.t.layer to classify RG 91 entries 11/16/24 (universal) vs
+ 12/19/23 (proper) vs 13/20 (indult, PE.indult_prefix). Every entry here
+ is the General Roman Calendar's own universal sanctoral, so all 322 get
+ the same provenance tag. *)
+ { Layer.date; cel = Cel.make ~slug ~names ~rank ~status ~colour ~subject ~citations:[]
+ ~layer:PE.universal_layer () }
+
+(* --- Provenance (spec §4.5) ------------------------------------------------ *)
+
+let sha256_of_file path =
+ let cmd = Printf.sprintf "sha256sum %s" (Filename.quote path) in
+ let ic = Unix.open_process_in cmd in
+ let line = try input_line ic with End_of_file -> die "sha256sum produced no output for %s" path in
+ (match Unix.close_process_in ic with
+ | Unix.WEXITED 0 -> ()
+ | _ -> die "sha256sum failed for %s" path);
+ match String.index_opt line ' ' with
+ | Some i -> String.sub line 0 i
+ | None -> die "unexpected sha256sum output: %S" line
+
+let today () =
+ let tm = Unix.gmtime (Unix.time ()) in
+ Printf.sprintf "%04d-%02d-%02d" (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday
+
+(* --- Main ------------------------------------------------------------------ *)
+
+let () =
+ let source = if Array.length Sys.argv > 1 then Sys.argv.(1) else default_source in
+ let dest = if Array.length Sys.argv > 2 then Sys.argv.(2) else default_dest in
+ let sections = parse_ini source in
+ if not (List.exists (fun s -> s.name = "layer") sections) then
+ die "%s: missing the [layer] header section" source;
+ let entry_sections = List.filter (fun s -> s.name <> "layer") sections in
+ let entries = List.map convert_entry entry_sections in
+ let layer = Layer.of_entries ~id:PE.universal_layer ~name:"EF (1962) universal sanctoral" entries in
+ let sha = sha256_of_file source in
+ let feasts = List.length (List.filter (fun e -> e.Layer.cel.Cel.status = Cel.Feast) entries) in
+ let comms = List.length entries - feasts in
+ let header =
+ Printf.sprintf
+ {|; data/ef/sanctoral.sexp -- EF (1962) universal sanctoral (General Roman
+; Calendar), bootstrapped from lectio (sibling project; see CLAUDE.md).
+; Generator: tools/bootstrap_sanctoral.ml -- do not hand-edit; re-run the
+; generator against a newer lectio and commit the diff instead.
+;
+; Source: %s
+; SHA-256: %s
+; Converted (UTC): %s
+; %d entries (%d feast, %d commemoration-only). Regenerate with:
+; eval $(opam env) && dune exec tools/bootstrap_sanctoral.exe -- %s %s
+|}
+ source sha (today ()) (List.length entries) feasts comms source dest
+ in
+ let body = Sexplib.Sexp.to_string_hum ~indent:2 (Layer.sexp_of_t V.sexp_of_rank layer) in
+ let oc = open_out dest in
+ Fun.protect
+ ~finally:(fun () -> close_out_noerr oc)
+ (fun () ->
+ output_string oc header;
+ output_string oc body;
+ output_string oc "\n");
+ Printf.printf "bootstrap_sanctoral: wrote %d entries (%d feast, %d commemoration-only) to %s\n"
+ (List.length entries) feasts comms dest
diff --git a/tools/dune b/tools/dune
new file mode 100644
index 0000000..d53cd01
--- /dev/null
+++ b/tools/dune
@@ -0,0 +1,10 @@
+; A documented one-shot, not part of the build's normal product (spec §4.5):
+; converts lectio's tridentine-calendar.ini into data/ef/sanctoral.sexp. Run
+; via `dune exec tools/bootstrap_sanctoral.exe -- <source.ini> <dest.sexp>`.
+; `unix` is the OCaml distribution's bundled library (already in the switch,
+; not a new opam dependency) -- used only here, never by the kernel, to shell
+; out to `sha256sum` for the provenance header; the kernel itself never reads
+; the environment or a clock.
+(executable
+ (name bootstrap_sanctoral)
+ (libraries colitur_kernel rite_ef unix sexplib))