aboutsummaryrefslogtreecommitdiff
path: root/tools/extract_lms_ordo.ml
diff options
context:
space:
mode:
Diffstat (limited to 'tools/extract_lms_ordo.ml')
-rw-r--r--tools/extract_lms_ordo.ml729
1 files changed, 729 insertions, 0 deletions
diff --git a/tools/extract_lms_ordo.ml b/tools/extract_lms_ordo.ml
new file mode 100644
index 0000000..582fb6e
--- /dev/null
+++ b/tools/extract_lms_ordo.ml
@@ -0,0 +1,729 @@
+(* Task 6 (2026-08-21-colitur-celebrant-rubrics-phase1): turns pdftotext's
+ -layout dump of the Latin Mass Society's printed Ordo into
+ test/fixtures/lms-ordo-2024-2025.sexp, one row per UNIVERSAL-calendar
+ day (England & Wales diocesan variants are recorded as PRESENT via
+ [has_diocesan_variant] but their own text is discarded -- see the
+ fixture's own provenance header, and task-6-report.md's "how much of
+ the Ordo's year is comparable" accounting, for why).
+
+ NOT a general Ordo parser: hand-rolled against ONE PDF's own column
+ layout (Author "Peter Day-Milne", Producer "LibreOffice 7.4", pdftotext
+ 1.6), verified line-by-line against the source before being trusted --
+ see task-6-report.md for the full characterisation record (the
+ opposite-prediction probe technique CLAUDE.md's "burned twice" note
+ demands). Frozen deps forbid Str/regex; every match below is
+ String.sub/index/split_on_char, the same discipline tools/
+ bootstrap_sanctoral.ml's own hand-rolled INI reader already uses for
+ someone else's format.
+
+ THE KEY STRUCTURAL FACT this parser leans on, established during
+ characterisation: every day's UNIVERSAL entry (the General Roman
+ Calendar's own text) appears FIRST in reading order, before any
+ diocesan variant block (which is introduced by a line ending in ':',
+ e.g. "Westminster, Clifton, Plymouth:" or "In all Dioceses of ENGLAND
+ and WALES ... follows:"). So the FIRST "Gl"/"Cr" token pair and the
+ FIRST "Mass of ..."/roman-numeral-BVM line found within a day's block
+ are ALWAYS the universal entry's own, even if the colon-boundary
+ detection below is imperfect -- diocesan text can only ever appear
+ AFTER, never before, so it cannot introduce a false FIRST match. This
+ is why [scan_block] restricts its search to lines before the first
+ colon-terminated line (belt), while the ordering argument above is the
+ suspenders.
+
+ Usage:
+ pdftotext -layout docs/research/ordo/lms-ordo-2024-2025.pdf /tmp/lms.txt
+ dune exec tools/extract_lms_ordo.exe -- /tmp/lms.txt \
+ docs/research/ordo/lms-ordo-2024-2025.pdf \
+ test/fixtures/lms-ordo-2024-2025.sexp *)
+
+open Sexplib0.Sexp_conv
+module Date = Colitur_kernel.Date
+
+let die fmt = Printf.ksprintf (fun s -> prerr_endline ("extract_lms_ordo: " ^ s); exit 1) fmt
+
+(* Mirrored, not shared, by test/test_lms_ordo.ml -- tools/ and test/ have
+ no common .mli either could hang a shared type from, the same reasoning
+ test_oracle.ml's own header gives for duplicating [sha256_of_file]. *)
+type row = {
+ date : string; (** ISO-8601 *)
+ weekday : string;
+ (** As printed ("Sat", "Sun", ...) -- cross-checked below against
+ [date]'s own computed weekday at extraction time (fatal on
+ mismatch: the whole point of carrying both is to catch a date-
+ resolution bug here, not paper over one). *)
+ title : string; (** the day's own title line: feast name, class, colour, votive codes *)
+ formulary_override : string option;
+ (** the literal "Mass of ..." text, when the universal block names
+ one; [None] = the day says its own Mass. *)
+ bvm_numeral : string option;
+ (** "I".."V" when [formulary_override] is one of the five anchored
+ "<N> Mass of BVM" lines (whole-line match, never a substring --
+ see the module header on why "V Mass of BVM" must never be
+ matched as a substring of "IV Mass of BVM"). *)
+ gloria : bool option; (** [None] only if genuinely not found -- reported, not silently dropped *)
+ creed : bool option;
+ praef : string option;
+ (** RAW trailing text from the FIRST "Pr of ..." / "Common Pr" match
+ in the universal block's own reading order -- CAPTURED, NOT
+ CLASSIFIED here, the same discipline
+ tools/extract_fiuv_ordo.ml's own [row.praef] documents for a
+ different publisher's format. See the provenance header's own
+ "Pr of" characterisation bullet for what this column can and
+ cannot show: this Ordo prints OPTION LISTS ("Pr of X or Pr of Y
+ or Common Pr"), some of whose named options ("Martyrs", "All
+ Saints and Patron Saints", "the Dedication of a Church", "the
+ Most Holy Sacrament", "St John the Baptist", "the Angels") are
+ NOT among the fourteen the 1962 Missale Romanum's own RG 484-497
+ enumerate -- ad libitum extras this publisher includes alongside
+ a genuine RG 482 answer, never the sole option on any row found
+ in this corpus. Classification into {!Colitur_kernel.Preface.t}
+ happens test-side (test/test_lms_ordo.ml), by MEMBERSHIP in the
+ parsed option set, not string equality -- see that file's own
+ [classify_praef] citation for the full account. *)
+ has_diocesan_variant : bool;
+ (** whether a diocese-specific block followed the universal one
+ anywhere in this day's raw text (informational only -- see the
+ module header on why correctness of the other fields does not
+ depend on this being exact). *)
+}
+[@@deriving sexp]
+
+(* --- tiny hand-rolled helpers, no Str/regex ------------------------------ *)
+
+let split_ws s =
+ String.split_on_char ' ' (String.map (fun c -> if c = '\t' then ' ' else c) s)
+ |> List.filter (fun t -> t <> "")
+
+let is_all_digits s = s <> "" && String.for_all (fun c -> c >= '0' && c <= '9') s
+
+let has_prefix ~prefix s =
+ String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix
+
+let ends_with_colon s = s <> "" && s.[String.length s - 1] = ':'
+
+let months =
+ [| "January"; "February"; "March"; "April"; "May"; "June"; "July"; "August"; "September"; "October";
+ "November"; "December" |]
+
+let month_index name =
+ let rec go i = if i >= Array.length months then None else if months.(i) = name then Some (i + 1) else go (i + 1) in
+ go 0
+
+let weekday_of_abbrev = function
+ | "Sun" -> Some Date.Sun
+ | "Mon" -> Some Date.Mon
+ | "Tue" -> Some Date.Tue
+ | "Wed" -> Some Date.Wed
+ | "Thu" -> Some Date.Thu
+ | "Fri" -> Some Date.Fri
+ | "Sat" -> Some Date.Sat
+ | _ -> None
+
+(* Left-column codes (Abbreviations, "Left column": "+EW"/"+"/"Pl"/"Ind") --
+ printed in a dedicated margin column that pdftotext -layout reproduces
+ as literal leading tokens on an otherwise-ordinary content line. Strip
+ ALL that appear (there can be two, "Pl Ind" together -- the 31 Dec/
+ 1 Jan examples in the source). *)
+let strip_left_column tokens =
+ let is_marker = function "Pl" | "Ind" | "+EW" | "+" -> true | _ -> false in
+ let rec go = function t :: rest when is_marker t -> go rest | ts -> ts in
+ go tokens
+
+let bvm_numeral_strings = [ ("I", "I Mass of BVM"); ("II", "II Mass of BVM"); ("III", "III Mass of BVM");
+ ("IV", "IV Mass of BVM"); ("V", "V Mass of BVM") ]
+
+(* Anchored WHOLE-LINE match against all five, never a substring test --
+ this is the exact discipline the coordinator addendum records: "V Mass
+ of BVM" is a substring of "IV Mass of BVM", so any check here MUST
+ compare the full line, not search for the numeral's own string inside
+ it. *)
+let bvm_numeral_of_line line = List.assoc_opt line (List.map (fun (n, s) -> (s, n)) bvm_numeral_strings)
+
+(* --- the day-block scanner ------------------------------------------------ *)
+
+(* [lines]: this day's own raw continuation lines, marker-stripped and
+ trimmed, title line NOT included (the title never carries Gl/Cr/"Mass
+ of" itself in this source). Returns (formulary_override, bvm_numeral,
+ gloria, creed, praef, has_diocesan_variant), each derived from the
+ portion BEFORE the first colon-terminated (diocesan) line -- see the
+ module header for why this is belt-and-suspenders, not load-bearing on
+ its own. *)
+let scan_block lines =
+ let rec universal_prefix = function
+ | [] -> []
+ | l :: _ when ends_with_colon l -> []
+ | l :: rest -> l :: universal_prefix rest
+ in
+ let has_diocesan = List.exists ends_with_colon lines in
+ let universal = universal_prefix lines in
+ let formulary_override, bvm_numeral =
+ let rec find = function
+ | [] -> (None, None)
+ | l :: rest -> (
+ match bvm_numeral_of_line l with
+ | Some n -> (Some l, Some n)
+ | None -> if has_prefix ~prefix:"Mass of " l then (Some l, None) else find rest)
+ in
+ find universal
+ in
+ let all_tokens = List.concat_map split_ws universal in
+ let rec find_flag word = function
+ | prev :: (cur :: _ as rest) -> if cur = word then Some (prev <> "No") else find_flag word rest
+ | [ cur ] -> if cur = word then Some true else None
+ | [] -> None
+ in
+ (* [find_flag] needs a sentinel predecessor for a match at position 0
+ (never observed in this source -- Gl/Cr always follow at least "No"
+ or a preceding word on the same "Gl Cr Pr of ..." line -- but handled
+ rather than assumed: a bare leading "Gl"/"Cr" with no "No" before it
+ reads as [true], the same as any other non-"No" predecessor would). *)
+ let gloria = find_flag "Gl" ("" :: all_tokens) in
+ let creed = find_flag "Cr" ("" :: all_tokens) in
+ (* [praef]: LINE-SCOPED (not [all_tokens]'s flattened stream), because
+ the captured text runs to the END of whichever physical line carries
+ it -- flattening first would glue an unrelated following line's own
+ prose onto it. "Pr" always abbreviates "Preface" in this source's own
+ body text (checked directly: its only OTHER appearance is the front-
+ matter Abbreviations glossary entry "Pr Preface", outside the Ordo
+ body this tool scans), so a whole-token ("Pr","of") pair, or a bare
+ ("Common","Pr") pair when no proper/de-Tempore option is offered, is
+ the sole anchor needed -- no reject-list, unlike the BVM roman
+ numeral's own substring trap, because nothing else in this corpus's
+ body ever produces either two-token sequence. *)
+ let find_praef_in_line line =
+ let toks = Array.of_list (split_ws line) in
+ let n = Array.length toks in
+ let rec go i =
+ if i + 1 >= n then None
+ else if (toks.(i) = "Pr" && toks.(i + 1) = "of") || (toks.(i) = "Common" && toks.(i + 1) = "Pr") then
+ Some (String.concat " " (Array.to_list (Array.sub toks i (n - i))))
+ else go (i + 1)
+ in
+ go 0
+ in
+ let praef =
+ let rec find = function
+ | [] -> None
+ | l :: rest -> ( match find_praef_in_line l with Some s -> Some s | None -> find rest)
+ in
+ find universal
+ in
+ (formulary_override, bvm_numeral, gloria, creed, praef, has_diocesan)
+
+(* --- the top-level line scan --------------------------------------------- *)
+
+type block = { b_weekday : string; b_day : int; b_month : int; b_year : int; b_title : string; b_lines : string list }
+
+(* Generalised (Witnesses task, 2026-08-22) from a single hardcoded
+ sentinel ["End";"of";"November";"2024"], which assumed every edition
+ opens its Ordo body with an "End of November <year>" stub section. That
+ assumption FAILED characterisation against the two newly-acquired
+ editions: the 2023-2024 PDF has NO such stub -- Advent Sunday 2023 fell
+ on 3 December, so only 2 tail days of the old liturgical year remained
+ (1-2 December), and the compiler folded them directly into the
+ "December 2023" header rather than giving them their own mini-section.
+ So a start marker anchored to "End of November" cannot generalise.
+
+ What DOES generalise, checked directly against all three editions'
+ pdftotext -layout dumps: exactly one line in the whole document begins
+ with the bare, all-uppercase token "ORDO" -- either alone ("ORDO",
+ 2024-2025/2025-2026, introducing an "End of <Month> <Year>" stub next)
+ or fused with the edition's own year range ("ORDO 2023-2024",
+ introducing the first REAL month header directly, no stub). This is
+ NEVER the Table of Contents' own entry, which prints title-case "Ordo
+ 20XX-20XX" followed by dot leaders and a page number -- a different
+ string ("Ordo", not "ORDO"), confirmed by grepping all three raw dumps
+ for both patterns before relying on either. *)
+let is_ordo_marker tokens = match tokens with "ORDO" :: _ -> true | _ -> false
+
+(* The "End of <Month> <Year>" stub heading, when an edition has one --
+ parsed directly (never hardcoded to November/a specific year) so the
+ edition's own initial (month, year) state comes from the text itself,
+ not an assumption baked into the tool. *)
+let parse_end_of_month tokens =
+ match tokens with
+ | [ "End"; "of"; m; y ] when is_all_digits y -> ( match month_index m with Some mi -> Some (mi, int_of_string y) | None -> None)
+ | _ -> None
+
+let is_stop_tail_line tokens =
+ match tokens with tok :: _ -> String.length tok = 4 && is_all_digits tok | [] -> false
+
+let is_month_header tokens =
+ match tokens with
+ | m :: rest when month_index m <> None -> List.for_all is_all_digits rest
+ | _ -> false
+
+let read_lines path =
+ let ic = open_in path in
+ let rec loop acc = match input_line ic with l -> loop (l :: acc) | exception End_of_file -> List.rev acc in
+ let ls = loop [] in
+ close_in ic;
+ ls
+
+let () =
+ if Array.length Sys.argv <> 4 then
+ die "usage: extract_lms_ordo <pdftotext-layout.txt> <source.pdf> <dest.sexp>";
+ let txt_path = Sys.argv.(1) in
+ let pdf_path = Sys.argv.(2) in
+ let dest = Sys.argv.(3) in
+ let all_lines = read_lines txt_path in
+ let arr = Array.of_list all_lines in
+ let n = Array.length arr in
+ let trimmed i = String.trim arr.(i) in
+ let ordo_marker_idx =
+ let rec go i = if i >= n then die "no line begins with the bare \"ORDO\" body marker (see the tool's own comment on [is_ordo_marker] -- this edition's format may have drifted)"
+ else if is_ordo_marker (split_ws (trimmed i)) then i else go (i + 1)
+ in
+ go 0
+ in
+ (* The first non-blank line after the marker is either an "End of <Month>
+ <Year>" stub (consumed here as a pure sentinel, exactly as the old
+ hardcoded design did -- [start_idx] becomes ITS OWN index so the main
+ loop below begins right after it) or a real "<Month> <Year>" header
+ with an explicit year token (an edition with no stub, e.g. 2023-2024 --
+ [start_idx] becomes the ORDO marker's own index instead, so the main
+ loop re-encounters this header line on its very first iteration and
+ [is_month_header]'s own existing per-line handling sets [cur_month]/
+ [cur_year] from it normally). Either way [init_month]/[init_year] come
+ directly from parsed text, never a hardcoded constant -- a missing
+ explicit year on this FIRST header is a genuine format break, so it
+ dies loudly rather than silently seeding a wrong year. *)
+ let rec first_nonblank i =
+ if i >= n then die "no content found after the ORDO marker at line %d" ordo_marker_idx
+ else if trimmed i = "" then first_nonblank (i + 1)
+ else i
+ in
+ let content_idx = first_nonblank (ordo_marker_idx + 1) in
+ let content_toks = split_ws (trimmed content_idx) in
+ let start_idx, init_month, init_year =
+ match parse_end_of_month content_toks with
+ | Some (mi, y) -> (content_idx, mi, y)
+ | None -> (
+ match content_toks with
+ | m :: (y :: _ as rest) when month_index m <> None && List.for_all is_all_digits rest ->
+ (ordo_marker_idx, (match month_index m with Some x -> x | None -> assert false), int_of_string y)
+ | _ ->
+ die "first content after the ORDO marker (%S) is neither an \"End of <Month> <Year>\" stub nor a \"<Month> <Year>\" header carrying an explicit year"
+ (String.concat " " content_toks))
+ in
+ let stop_idx =
+ let rec go i =
+ if i >= n then n else if is_stop_tail_line (split_ws (trimmed i)) then i else go (i + 1)
+ in
+ go (start_idx + 1)
+ in
+ Printf.printf "extract_lms_ordo: Ordo body lines %d..%d (of %d total), initial month/year %d/%d\n" start_idx stop_idx n
+ init_month init_year;
+ (* --- the scan: month/year tracker + block accumulator ------------------ *)
+ let cur_month = ref init_month and cur_year = ref init_year in
+ let blocks = ref [] in
+ let cur = ref None in
+ let flush () = match !cur with Some b -> blocks := b :: !blocks; cur := None | None -> () in
+ for i = start_idx + 1 to stop_idx - 1 do
+ let raw = trimmed i in
+ if raw = "" then ()
+ else if String.length raw >= 1 && (let rec contains s sub =
+ let ls = String.length s and lu = String.length sub in
+ if lu = 0 then true else if ls < lu then false
+ else if String.sub s 0 lu = sub then true
+ else contains (String.sub s 1 (ls - 1)) sub
+ in
+ contains raw "Latin Mass Society Ordo")
+ then () (* page footer *)
+ else
+ let toks = split_ws raw in
+ if is_month_header toks then begin
+ match toks with
+ | m :: rest -> (
+ match month_index m with
+ | Some mi ->
+ if mi <> !cur_month then begin
+ if mi < !cur_month then incr cur_year;
+ cur_month := mi
+ end;
+ (* an explicit 4-digit year token, when present, overrides
+ the transition inference outright -- belt and
+ suspenders, same reasoning as [scan_block] above *)
+ List.iter (fun t -> if String.length t = 4 && is_all_digits t then cur_year := int_of_string t) rest
+ | None -> ())
+ | [] -> ()
+ end
+ else
+ match toks with
+ | wd :: dn :: _ when weekday_of_abbrev wd <> None && is_all_digits dn && int_of_string dn >= 1
+ && int_of_string dn <= 31 ->
+ flush ();
+ let title = String.concat " " (List.tl (List.tl toks)) in
+ cur := Some { b_weekday = wd; b_day = int_of_string dn; b_month = !cur_month; b_year = !cur_year;
+ b_title = title; b_lines = [] }
+ | _ -> (
+ match !cur with
+ | None -> ()
+ | Some b ->
+ let stripped = strip_left_column toks in
+ if stripped = [] then ()
+ else cur := Some { b with b_lines = b.b_lines @ [ String.concat " " stripped ] })
+ done;
+ flush ();
+ let blocks = List.rev !blocks in
+ Printf.printf "extract_lms_ordo: %d day-blocks parsed\n" (List.length blocks);
+ let rows =
+ List.map
+ (fun b ->
+ let date =
+ match Date.make ~year:b.b_year ~month:b.b_month ~day:b.b_day with
+ | Ok d -> d
+ | Error e -> die "%s %d/%d/%d: %s" b.b_weekday b.b_year b.b_month b.b_day e
+ in
+ (match weekday_of_abbrev b.b_weekday with
+ | Some w when w = Date.weekday date -> ()
+ | Some _ ->
+ die "%s: printed weekday %S does not match computed weekday %S -- date resolution bug"
+ (Date.to_iso8601 date) b.b_weekday (Date.weekday_to_string (Date.weekday date))
+ | None -> die "%s: unrecognised weekday abbreviation %S" (Date.to_iso8601 date) b.b_weekday);
+ let formulary_override, bvm_numeral, gloria, creed, praef, has_diocesan_variant = scan_block b.b_lines in
+ { date = Date.to_iso8601 date; weekday = b.b_weekday; title = b.b_title; formulary_override; bvm_numeral;
+ gloria; creed; praef; has_diocesan_variant })
+ blocks
+ in
+ let missing_gloria = List.filter (fun r -> r.gloria = None) rows in
+ let missing_creed = List.filter (fun r -> r.creed = None) rows in
+ let missing_praef = List.filter (fun r -> r.praef = None) rows in
+ if missing_gloria <> [] then
+ Printf.printf "extract_lms_ordo: WARNING %d rows with no Gloria found: %s\n" (List.length missing_gloria)
+ (String.concat ", " (List.map (fun r -> r.date) missing_gloria));
+ if missing_creed <> [] then
+ Printf.printf "extract_lms_ordo: WARNING %d rows with no Creed found: %s\n" (List.length missing_creed)
+ (String.concat ", " (List.map (fun r -> r.date) missing_creed));
+ if missing_praef <> [] then
+ Printf.printf "extract_lms_ordo: WARNING %d rows with no Pr-of/Common-Pr found: %s\n" (List.length missing_praef)
+ (String.concat ", " (List.map (fun r -> r.date) missing_praef));
+ let with_variant = List.length (List.filter (fun r -> r.has_diocesan_variant) rows) in
+ let with_override = List.length (List.filter (fun r -> r.formulary_override <> None) rows) in
+ let with_bvm = List.length (List.filter (fun r -> r.bvm_numeral <> None) rows) in
+ Printf.printf "extract_lms_ordo: %d rows; %d with a diocesan variant present; %d with a formulary override; \
+ %d with a BVM roman numeral; %d with no Pr-of/Common-Pr text\n"
+ (List.length rows) with_variant with_override with_bvm (List.length missing_praef);
+ (* --- provenance header + write ----------------------------------------- *)
+ 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
+ in
+ 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
+ in
+ let pdf_sha = sha256_of_file pdf_path in
+ (* --- per-edition provenance + characterisation, Witnesses task ---------
+ Generalised 2026-08-22 from Task 6's single hand-written header (which
+ named "lms-ordo-2024-2025.sexp" literally throughout). Facts 1/3/4
+ below are STRUCTURAL -- true of the publisher's whole known corpus,
+ re-checked directly against the two new editions' own pdftotext dumps
+ before being asserted here again, not merely copied forward. Fact 2
+ (the Creed opposite-prediction probes) is inherently edition-specific
+ (the actual calendar dates differ year to year, and one edition's own
+ calendar even breaks the standard pairing -- see the 2025-2026 note
+ below), so it is looked up per [dest] and, critically, RE-VERIFIED
+ against this run's own [rows] before being printed: a probe that
+ fails is a [die], not a silently-wrong "confirmed" claim in a fixture
+ header nobody will re-check by eye. *)
+ let base = Filename.basename dest in
+ let pdf_base = Filename.basename pdf_path in
+ let edition_label, isbn, copyright_year, pdf_meta, url, url_note, window_open_desc =
+ match base with
+ | "lms-ordo-2023-2024.sexp" ->
+ ( "2023-2024",
+ "978-1-7392096-2-9",
+ "2023",
+ {|Author "Peter Day-Milne", Producer "LibreOffice 6.4", CreationDate 2023-11-01, 140 pages|},
+ "https://lms.org.uk/sites/default/files/u5374/ordo_2023-2024_2.2_final.pdf",
+ "identified 2026-08-22 via web search matching the PDF's own edition year to the publisher's own hosting path; NOT independently re-downloaded and byte-compared in this session -- recorded honestly rather than presented as verified",
+ {|1 December 2023 (this edition carries NO "End of November" stub section at all -- Advent Sunday 2023 fell on 3 December, so only 2 tail days of the prior liturgical year remained, and the compiler folded them directly into the "December 2023" header instead of giving them their own mini-section; see CHARACTERISATION below)|}
+ )
+ | "lms-ordo-2025-2026.sexp" ->
+ ( "2025-2026",
+ "978-1-7392096-5-0",
+ "2025",
+ {|Author "Peter Day-Milne", Producer "LibreOffice 25.2.6.2 (X86_64) / LibreOffice Community", CreationDate 2025-10-23, 146 pages|},
+ "(not found -- see the note on this line)",
+ "NOT found by URL-pattern web search this session (unlike the 2023-2024 and 2024-2025 editions, no exact download link matching this PDF's own filename/version turned up under lms.org.uk/sites/default/files/u5374/; several near-miss candidates were checked and rejected -- none matched this PDF's own metadata) -- left honestly blank rather than guessed; the file was supplied locally to this session, not fetched by this tool",
+ {|28 November 2025 ("End of November 2025", the same shape as the 2024-2025 edition -- Advent Sunday 2025 fell on 30 November, so only 3 tail days remained; note this excludes 27 November 2025, which the PRECEDING edition's own window (2024-2025, through 2025-12-31) already covers -- a one-day-short overlap between consecutive editions' own windows, not a gap or a bug in either extraction|}
+ )
+ | "lms-ordo-2024-2025.sexp" ->
+ (* Preface-witnesses task (2026-08-23): brought into this same
+ auto-generated, probe-verified header template so all three
+ editions carry the identical characterisation discipline --
+ Task 6's own original HAND-WRITTEN header (never regenerated by
+ the Witnesses task, which only added table entries for the two
+ NEW editions) is superseded by this run; every fact below is
+ re-derived from this PDF's own text/metadata, not copied
+ blind from that older header (though it agrees with it in
+ every particular checked). *)
+ ( "2024-2025",
+ "9781739209636",
+ "2024",
+ {|Author "Peter Day-Milne", Producer "LibreOffice 7.4", CreationDate 2024-11-06, 140 pages|},
+ "https://lms.org.uk/sites/default/files/u5374/ordo_2.21_2024-2025.pdf",
+ "identified via web search matching the PDF's own version number and filename to the publisher's own hosting path (product page: https://lms.org.uk/product/latin-mass-society-ordo-2024-25); NOT independently re-downloaded and byte-compared in this session -- recorded honestly rather than presented as verified",
+ {|27 November 2024 ("End of November 2024", the same shape as the 2025-2026 edition -- Advent Sunday 2024 fell on 1 December, so 4 tail days remained (27-30 November))|}
+ )
+ | _ -> die "no provenance/characterisation entry for %s -- add one to extract_lms_ordo.ml's own per-edition table before generating this fixture (the tool refuses to emit an under-characterised header)" base
+ in
+ let creed_of_date rows d = List.find_map (fun r -> if String.equal r.date d then r.creed else None) rows in
+ let probes =
+ match base with
+ | "lms-ordo-2023-2024.sexp" ->
+ [ ("2023-12-03", true, "Advent Sunday, RG 475(a)");
+ ("2023-12-04", false, "St Peter Chrysologus, III class, ordinary Advent day, RG 476(b)/(d)");
+ ("2023-12-26", true, "St Stephen, II class, inside the Nativity octave, RG 475(d)");
+ ("2024-08-15", true, "the Assumption, I class, RG 475(b)");
+ ("2024-08-16", false, "St Joachim, II class, no Lord/BVM/apostle clause applies, RG 476(b)") ]
+ | "lms-ordo-2025-2026.sexp" ->
+ [ ("2025-11-30", true, "Advent Sunday, RG 475(a)");
+ ("2025-12-01", false, "ordinary Advent feria, RG 476(b)/(d)");
+ ("2025-12-26", true, "St Stephen, II class, inside the Nativity octave, RG 475(d)");
+ ( "2026-08-10", false,
+ "St Laurence, II class martyr, no Lord/BVM/apostle clause applies, RG 476(b) -- St Joachim (16 August) is IMPEDED by a Sunday in this edition's own calendar (12th Sunday after Pentecost), so this edition needed a DIFFERENT sharp non-octave II-class pairing than the 2023-2024/2024-2025 editions used; a real, edition-specific finding, not an extraction quirk"
+ );
+ ("2026-08-15", true, "the Assumption, I class, RG 475(b)") ]
+ | "lms-ordo-2024-2025.sexp" ->
+ [ ("2024-12-01", true, "Advent Sunday, RG 475(a)");
+ ("2024-12-02", false, "ordinary Advent feria, RG 476(b)/(d)");
+ ("2024-12-26", true, "St Stephen, II class, inside the Nativity octave, RG 475(d)");
+ ("2025-08-15", true, "the Assumption, I class, RG 475(b)");
+ ("2025-08-16", false, "St Joachim, II class, no Lord/BVM/apostle clause applies, RG 476(b)") ]
+ | _ -> die "no probe list for %s" base
+ in
+ List.iter
+ (fun (d, expected, desc) ->
+ match creed_of_date rows d with
+ | Some got when Bool.equal got expected -> ()
+ | Some got -> die "characterisation probe FAILED: %s (%s) -- expected creed=%b, extracted creed=%b" d desc expected got
+ | None -> die "characterisation probe FAILED: %s (%s) -- no row extracted for this date at all" d desc)
+ probes;
+ Printf.printf "extract_lms_ordo: all %d characterisation probes confirmed for %s\n" (List.length probes) edition_label;
+ (* Preface-witnesses task (2026-08-23): a SECOND opposite-prediction
+ probe set, over [praef] rather than [creed] -- the same discipline,
+ applied to the new column this task adds, per the task brief's own
+ "probe dates where the rule predicts OPPOSITE answers before
+ adjudicating anything" instruction. Three universal, unambiguous
+ fixed-date feasts per edition, each landing on a DIFFERENT named
+ preface (RG 484 Nativity, RG 497 Apostles, RG 499 Requiem) -- checked
+ directly against this run's own [rows] before this header is
+ written, a hard [die] on failure, not a printed claim. All Souls'
+ own date is edition-specific (2 November, TRANSFERRED to 3 November
+ in the one edition -- 2024-2025 -- where 2 November falls on a
+ Sunday; verified directly against each PDF's own text, not assumed
+ from RG 96 alone). *)
+ let praef_of_date rows d = List.find_map (fun r -> if String.equal r.date d then r.praef else None) rows in
+ let praef_probes =
+ match base with
+ | "lms-ordo-2023-2024.sexp" ->
+ [ ("2023-12-25", "Pr of the Nativity", "the Nativity of Our Lord, I class, RG 484(a)");
+ ("2024-06-29", "Pr of the Apostles", "SS Peter & Paul, I class, RG 497");
+ ("2024-11-02", "Pr of the Dead", "All Souls' Day, I class, RG 499") ]
+ | "lms-ordo-2024-2025.sexp" ->
+ [ ("2024-12-25", "Pr of the Nativity", "the Nativity of Our Lord, I class, RG 484(a)");
+ ("2025-06-29", "Pr of the Apostles", "SS Peter & Paul, I class, RG 497");
+ ( "2025-11-03", "Pr of the Dead",
+ "All Souls' Day, I class, RG 499 -- TRANSFERRED from 2 November, a Sunday in this edition's own calendar" )
+ ]
+ | "lms-ordo-2025-2026.sexp" ->
+ [ ("2025-12-25", "Pr of the Nativity", "the Nativity of Our Lord, I class, RG 484(a)");
+ ("2026-06-29", "Pr of the Apostles", "SS Peter & Paul, I class, RG 497");
+ ("2026-11-02", "Pr of the Dead", "All Souls' Day, I class, RG 499") ]
+ | _ -> die "no praef probe list for %s" base
+ in
+ List.iter
+ (fun (d, expected_prefix, desc) ->
+ match praef_of_date rows d with
+ | Some got when has_prefix ~prefix:expected_prefix got -> ()
+ | Some got ->
+ die "praef characterisation probe FAILED: %s (%s) -- expected prefix %S, extracted %S" d desc
+ expected_prefix got
+ | None -> die "praef characterisation probe FAILED: %s (%s) -- no praef extracted for this date at all" d desc)
+ praef_probes;
+ Printf.printf "extract_lms_ordo: all %d praef characterisation probes confirmed for %s\n" (List.length praef_probes)
+ edition_label;
+ let praef_probe_lines =
+ String.concat "\n"
+ (List.map (fun (d, expected_prefix, desc) -> Printf.sprintf "; %s (%s) -> %S" d desc expected_prefix) praef_probes)
+ in
+ let missing_praef_count = List.length missing_praef in
+ let probe_lines =
+ String.concat "\n"
+ (List.map
+ (fun (d, expected, desc) -> Printf.sprintf "; %s (%s) -> %s" d desc (if expected then "TRUE" else "FALSE"))
+ probes)
+ in
+ let header =
+ Printf.sprintf
+ {|; %s -- Witnesses task (2026-08-22-colitur-celebrant-rubrics-phase1),
+; extending Task 6's single LMS fixture (2024-2025) to three editions of
+; the Latin Mass Society's own printed Ordo for England & Wales -- a
+; lineage independent of Divinum Officium/missalemeum/lectio (layers 3-4)
+; and of the electronic LT.txt transcription (layers 1-2/5): a physically
+; printed, professionally-compiled liturgical calendar with its own ISBN.
+;
+; Generator: tools/extract_lms_ordo.ml -- do not hand-edit; re-run against
+; a fresh pdftotext dump and commit the diff instead.
+;
+; Source: "The Ordo %s", compiled by Peter Day-Milne, The Latin
+; Mass Society, 9 Mallow Street, London EC1Y 8RQ (title page and
+; colophon, verified directly against the PDF's own extracted text --
+; ISBN %s, Copyright (c) The Latin Mass Society, UK %s).
+; PDF metadata: %s.
+; URL (%s):
+; %s
+; The local PDF (docs/research/ordo/%s) is gitignored
+; (CLAUDE.md: "docs/ is gitignored"), so this header, not git, is this
+; fixture's only record of where it came from.
+; SHA-256 of docs/research/ordo/%s: %s
+; Extracted (UTC): %s
+; Exact commands:
+; pdftotext -layout docs/research/ordo/%s /tmp/lms.txt
+; dune exec tools/extract_lms_ordo.exe -- /tmp/lms.txt \
+; docs/research/ordo/%s %s
+;
+; COVERAGE: %d day-rows, %s through %s (starting %s,
+; through the day before a malformed trailing entry the extractor
+; deliberately stops before -- see CHARACTERISATION below).
+;
+; CHARACTERISATION FINDINGS (Step 1, non-negotiable per the task brief --
+; the extraordinaryform.org Ordo silently omitted St Lawrence's vigil on
+; EVERY date it covered, which nearly produced a false corroboration
+; during the RG 33 work; every LMS edition, including this one, is probed
+; with the same discipline before a single divergence is adjudicated):
+;
+; 1. Gl/Cr are printed PER MASS-OPTION, not per day (confirmed again for
+; THIS edition: it carries many more Gl/Cr pairs than day-rows, because
+; every diocesan variant repeats its own "Gl ... Cr ... Pr of ..."
+; line). This extractor resolves the day's OWN office by taking the
+; FIRST Gl/Cr pair in reading order, which the source's own layout
+; guarantees is the universal entry's (diocesan variants are always
+; introduced by a colon-terminated diocese-list line that comes AFTER
+; the universal block, never before -- checked structurally, not
+; assumed, in every sampled block during development, this edition
+; included).
+;
+; 2. OPPOSITE-PREDICTION PROBES against Rite_ef.Rubrics_ef.creed (RG
+; 475-476), RE-CHECKED BY THIS TOOL RUN (not merely copied from a prior
+; edition's findings) before a single row of this fixture is trusted --
+; the technique CLAUDE.md records as the one that catches a source
+; silently omitting the very thing it is meant to witness. This run's
+; own extracted [rows] were checked against every one of the following
+; before this header was even written; a failure here is a hard [die],
+; not a printed claim:
+%s
+; All %d predictions confirmed, both directions (TRUE and FALSE each
+; independently witnessed) -- this edition is discriminating, not a
+; constant, on the Creed.
+;
+; 3. The BVM-Saturday roman numeral (I-V, "Missae de sancta Maria in
+; sabbato", RG 309(a)) is anchored by WHOLE-LINE match against the five
+; literal strings below, NEVER by substring search: "V Mass of BVM" is
+; a substring of "IV Mass of BVM", so a naive `contains` check
+; misreads every fourth-Mass day as the fifth (the coordinator's own
+; misreading, recorded and retracted in commit 27b07b4 before Task 6
+; existed; the same anchoring logic runs unchanged for every edition).
+;
+; 4. SCOPE EXCLUDED, quantified, not silently dropped:
+; - Diocesan variants: present on %d of %d days (%s%%) -- their own
+; text is read only far enough to set [has_diocesan_variant],
+; never compared, because colitur computes the UNIVERSAL General
+; Roman Calendar only, with no diocesan overlay loaded.
+; - The single malformed trailing entry ("...The OCTAVE DAY of the
+; NATIVITY..." with a bare 4-digit token where a weekday+day-number
+; pair belongs) is excluded outright: the extractor's own stop
+; marker (a line whose first token is a bare 4-digit token) halts
+; the scan before it. CONFIRMED IDENTICAL, word for word, across
+; ALL FOUR known editions of this publisher's Ordo (2023-2024,
+; 2024-2025, 2025-2026, and the 2024-2025 fixture's own prior
+; header) -- always the literal stray year token "2025", regardless
+; of which edition or which real trailing year it should read: a
+; stale copy-paste leftover the compiler's own master document
+; carries forward unedited release to release (even the 2024-2025
+; PDF's own tail page footer nearby still reads "Latin Mass Society
+; Ordo 2021-2022"), not something specific to this edition.
+; - Front matter, the Abbreviations/Rubrical-Primer/Breviary sections,
+; and the "When may I say...?"/Appendix tail are never scanned at
+; all (the extractor's own start/stop markers bound it to the Ordo
+; proper).
+;
+; 5. THE "Pr of" (PREFACE) COLUMN (Preface-witnesses task, 2026-08-23) --
+; [row.praef], the RAW text from the FIRST "Pr of ..." / "Common Pr"
+; match in the universal block's own reading order (the same
+; FIRST-in-reading-order discipline finding 1 above already
+; establishes for Gl/Cr). CAPTURED, NOT CLASSIFIED by this tool --
+; classification into {!Colitur_kernel.Preface.t} and the comparison
+; against {!Rite_ef.Rubrics_ef.preface} both happen test-side
+; (test/test_lms_ordo.ml's own [classify_praef]).
+;
+; CHARACTERISATION FINDING, non-negotiable per the same discipline as
+; finding 2: this Ordo prints OPTION LISTS ("Pr of X or Pr of Y or
+; Common Pr"), not always a single value -- and several of the named
+; options ("Martyrs", "All Saints and Patron Saints" [almost always
+; diocesan -- a Patronal feast's own preface -- but checked directly to
+; occur ONCE in THIS edition's own UNIVERSAL block too, All Saints' Day
+; itself, 1 November, paired with "or Common Pr"/"or Pr of the
+; Trinity" depending on whether 1 November falls on a Sunday that
+; edition, exactly matching {!Rite_ef.Rubrics_ef.preface}'s own RG
+; 494(b) Sunday-gated Trinity fallback either way -- see that
+; function's own citation], "the Dedication of a Church", "the Most Holy
+; Sacrament" [Corpus Christi's own universal entry], "St John the
+; Baptist", "the Angels") are genuinely NOT among the fourteen the
+; 1962 Missale Romanum's own RG 484-497 enumerate (docs/research/LT.txt,
+; lines 3936-4020, checked directly: no "de Martyribus"/"de Angelis"/
+; "de Dedicatione"/"de Ss.mo Sacramento"/"de S. Ioanne Baptista" clause
+; exists anywhere in 482-499). Every one of these extras, in every
+; instance found in this edition's UNIVERSAL block, is printed
+; ALONGSIDE a genuine RG 482 answer (most often "or Common Pr"; on 24
+; March-shaped days, "or Pr of Lent", the live RG 486(b) de-Tempore
+; answer) -- never as the row's OWN AND ONLY option. This is why the
+; test-side comparison checks colitur's single computed answer is a
+; MEMBER of this row's own parsed option set (dropping any unmapped
+; extra name from that set first), not string equality against
+; whichever option happens to print first -- the same "capture, don't
+; force a match invented at extraction time" discipline
+; tools/extract_fiuv_ordo.ml's own [row.praef] doc comment already
+; states for a different publisher's format.
+;
+; OPPOSITE-PREDICTION PROBES against Rite_ef.Rubrics_ef.preface (RG
+; 482-499), the SAME discipline as finding 2, applied to this new
+; column -- RE-CHECKED BY THIS TOOL RUN before this header was
+; written; a failed probe is a hard [die]:
+%s
+; All %d predictions confirmed -- this column, too, is discriminating,
+; not a constant, across three different RG-cited outcomes (Nativity/
+; Apostles/Requiem).
+;
+; COVERAGE: %d of %d rows have no "Pr of"/"Common Pr" text found at
+; all -- checked directly against this window's own Good Friday
+; (Easter-2): its block carries NO Gl/Cr/Pr line whatsoever in this
+; source (the 1955-restored Holy Week's own liturgical action has no
+; Mass that day at all -- the SAME structural fact
+; {!test_creed_coverage}'s own [window_good_fridays] already asserts
+; for Gl/Cr), so [praef] shares that single expected gap rather than
+; having a coverage gap of its own.
+|}
+ dest edition_label isbn copyright_year pdf_meta url_note url pdf_base pdf_base pdf_sha (today ()) pdf_base
+ pdf_base dest
+ (List.length rows)
+ (match rows with r :: _ -> r.date | [] -> "?")
+ (match List.rev rows with r :: _ -> r.date | [] -> "?")
+ window_open_desc probe_lines (List.length probes) with_variant (List.length rows)
+ (Printf.sprintf "%.1f" (100.0 *. float_of_int with_variant /. float_of_int (List.length rows)))
+ praef_probe_lines (List.length praef_probes) missing_praef_count (List.length rows)
+ in
+ let body = Sexplib.Sexp.to_string_hum ~indent:2 (sexp_of_list sexp_of_row rows) 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 "extract_lms_ordo: wrote %d rows to %s\n" (List.length rows) dest