diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-12 00:05:57 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-12 00:05:57 +0200 |
| commit | 4624441d0bc9c1f28dea54606ab7999041d4b323 (patch) | |
| tree | af9b9308c70fb5df6888990e7780e6e951d76644 /tools/bootstrap_sanctoral.ml | |
| parent | 21dab521d6bfe8e4e7d04f761c11754059339ba2 (diff) | |
| download | colitur-4624441d0bc9c1f28dea54606ab7999041d4b323.tar.gz colitur-4624441d0bc9c1f28dea54606ab7999041d4b323.zip | |
data(ef): bootstrap the 1962 sanctoral from lectio
Convert lectio's tridentine-calendar.ini (322 entries) into
data/ef/sanctoral.sexp via a validating OCaml converter, tools/
bootstrap_sanctoral.ml, rather than a hand-written script: every field is
built through Slug.of_string, Colour.of_string and Vocab_ef.rank_of_string,
so the emitted sexp is valid by construction.
Two conversion decisions, both documented rather than buried:
- subject defaults to Subject.Saint, overriding Celebration.make's kernel
default of Subject.Temporal, for the 316 entries with no explicit class;
- rank = commemoration maps to status = Commemoration_only with an inferred
Class3 (not a citation -- it is what the 1960 reform reduced most simple
feasts from), recorded as an open item in the rules register for the
oracle to adjudicate.
Every celebration is tagged layer = Precedence_ef.universal_layer, the
provenance id RG 91's band classifier reads to tell the universal calendar
from proper/indult data.
The generated file carries a provenance header: source path, its SHA-256,
and the UTC conversion date, so re-bootstrapping against a newer lectio is
reproducible and diffable. Output is byte-identical across runs.
test/test_sanctoral_ef.ml loads the file through Layer.load and checks the
counts independently derived from the source INI (322 entries, 114
Commemoration_only, 12 Class1, no Subject.Temporal, every date resolves in
a leap year), plus two named spot-checks against the INI's own text -- one
entry with an explicit class field, one commemoration -- so a passing count
cannot hide the wrong 322 entries having been converted.
Diffstat (limited to 'tools/bootstrap_sanctoral.ml')
| -rw-r--r-- | tools/bootstrap_sanctoral.ml | 235 |
1 files changed, 235 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 |
