summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
commite19adb9c7ea369cafe76e7eff50e9248a4a954d0 (patch)
tree57824e121606286ebdf8fff68439986dd3300eae /tools
parent0506388da160a15ceddb0cea1a697d737103d5c4 (diff)
parent36df2bd0af9a555a09334e14b0d89118be1dbbc0 (diff)
downloadcolitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.tar.gz
colitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.zip
Merge branch 'ef-plan3': Plan 3, the EF resolution engine
Adds the rite-parameterised Precedence resolver, Liturgical_day, Rite, and Calendar (year as the primitive, because transfers need whole-year knowledge), the full EF precedence ruleset (RG 91's 28-entry table, occurrence RG 92-95, commemorations RG 108-111, transfers RG 96-98), 322 bootstrapped sanctoral entries, colitur day <year>, and validation layers 3-5. Layer 3 diffs 16801 days against lectio; layer 4 diffs 730 days against missalemeum; layer 5 pins ~30 dates on the known-tricky years. Both comparison layers carry cited allow-lists that name the governing RG paragraph and which engine is right. The oracle layer earned its place immediately: Holy Thursday was violet in colitur and lectio alike, because colitur's data was bootstrapped from lectio and both carried the same error. Only an independent source could see it. RG 128(b) and RG 122 name it white.
Diffstat (limited to 'tools')
-rw-r--r--tools/bootstrap_sanctoral.ml235
-rw-r--r--tools/dune10
-rw-r--r--tools/extract_missalemeum_oracle.py87
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())