diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/bootstrap_sanctoral.ml | 235 | ||||
| -rw-r--r-- | tools/dune | 10 | ||||
| -rw-r--r-- | tools/extract_missalemeum_oracle.py | 87 |
3 files changed, 332 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)) diff --git a/tools/extract_missalemeum_oracle.py b/tools/extract_missalemeum_oracle.py new file mode 100644 index 0000000..044aa54 --- /dev/null +++ b/tools/extract_missalemeum_oracle.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# A documented one-shot, not part of the OCaml build (spec's "no hand-written +# JSON parser" rule is about colitur's OWN sexp format -- reading someone +# else's JSON needs a reader, same reasoning as tools/bootstrap_sanctoral.ml's +# own header comment on its INI reader). Task 16: extracts the missalemeum +# oracle fixture (test/fixtures/missalemeum-ef-2026-2027.txt) that +# test/test_oracle.ml compares colitur against -- there is no JSON library in +# this project's frozen deps, so the fixture is shaped here, once, outside +# the test, exactly as test/test_differential.ml's lectio fixture already is +# (see that file's own header comment). +# +# Usage: +# python3 tools/extract_missalemeum_oracle.py <snapshot-dir> <out-file> +# +# <snapshot-dir> is ~/git/projects/lectio/sources/ already unpacked (`tar xzf +# snapshot.tar.gz` in that directory -- lectio's own scripts/snapshot- +# sources.sh does this), so <snapshot-dir>/missalemeum/en/YYYY-MM-DD.json +# exists for every day. +# +# One line per day, pipe-separated: +# date|rank|colors|title|tempora|commemorations|displaced|n_masses +# +# - rank/colors/title/tempora/commemorations/displaced are info.rank, +# info.colors (sorted, concatenated, e.g. "pv"), info.title, info.tempora +# ("-" for JSON null), the ";"-joined titles of info.commemorations and +# info.displaced ("-" for an empty list), verbatim from the JSON's own +# English strings -- not translated or slugified, so the comparator (and a +# human auditor) can match colitur's own celebration names against them. +# - n_masses is len(the day's JSON array). Four days (Christmas, All Souls) +# carry more than one Mass; entry[0]'s info block is used throughout (rank/ +# colors/tempora/commemorations/displaced are IDENTICAL across every Mass +# of the same day for all 730 days -- checked below, not assumed; see the +# task report for why entry[0] loses nothing). +# - Spaces in tempora are turned to "_" (matching the "no field has an +# internal space" convention test/fixtures/lectio-ef-2005-2050.txt already +# uses, so this fixture can be read the same simple way -- split on '|', +# not on whitespace, so title/commemoration/displaced text keeps its own +# spaces; only tempora, which nothing in this project parses further than +# pass-through display, is space-collapsed for a cheap column-count check). +import json +import os +import sys + + +def main(): + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} <snapshot-dir> <out-file>", file=sys.stderr) + return 2 + snapshot_dir, out_path = sys.argv[1], sys.argv[2] + src = os.path.join(snapshot_dir, "missalemeum", "en") + rows = [] + for fname in sorted(os.listdir(src)): + if not fname.endswith(".json"): + continue + date = fname[:-5] + with open(os.path.join(src, fname), encoding="utf-8") as f: + data = json.load(f) + first = data[0]["info"] + for other in data[1:]: + o = other["info"] + for k in ("rank", "colors", "tempora", "commemorations", "displaced"): + if first[k] != o[k]: + print(f"WARNING: {date} masses disagree on {k}: {first[k]!r} vs {o[k]!r}", file=sys.stderr) + comm_titles = [c["title"] for c in first["commemorations"]] + disp_titles = [d["title"] for d in first["displaced"]] + for t in [first["title"]] + comm_titles + disp_titles: + if "|" in t or ";" in t: + print(f"ERROR: {date}: field {t!r} contains a delimiter this fixture uses", file=sys.stderr) + return 1 + colors = "".join(sorted(first["colors"])) + tempora = (first["tempora"] or "-").replace(" ", "_") + comms = ";".join(comm_titles) or "-" + disp = ";".join(disp_titles) or "-" + rows.append(f"{date}|{first['rank']}|{colors}|{first['title']}|{tempora}|{comms}|{disp}|{len(data)}") + + if len(rows) != 730: + print(f"ERROR: expected 730 days, got {len(rows)}", file=sys.stderr) + return 1 + + with open(out_path, "w", encoding="utf-8") as f: + f.write("\n".join(rows) + "\n") + print(f"wrote {len(rows)} lines to {out_path}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
