1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
|
(* 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;
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, 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
(formulary_override, bvm_numeral, gloria, creed, 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 }
let start_sentinel = [ "End"; "of"; "November"; "2024" ]
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 start_idx =
let rec go i = if i >= n then die "start sentinel %S not found" (String.concat " " start_sentinel)
else if split_ws (trimmed i) = start_sentinel then i else go (i + 1)
in
go 0
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)\n" start_idx stop_idx n;
(* --- the scan: month/year tracker + block accumulator ------------------ *)
let cur_month = ref 11 and cur_year = ref 2024 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, 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; 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
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));
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\n"
(List.length rows) with_variant with_override with_bvm;
(* --- 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
let header =
Printf.sprintf
{|; test/fixtures/lms-ordo-2024-2025.sexp -- Task 6 (2026-08-21-colitur-
; celebrant-rubrics-phase1)'s sixth validation layer: the Latin Mass
; Society's own printed Ordo, an England & Wales diocesan Ordo -- 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 2024-2025", 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 9781739209636, Copyright (c) The Latin Mass Society, UK 2024).
; PDF metadata: Author "Peter Day-Milne", Producer "LibreOffice 7.4",
; CreationDate 2024-11-06, 140 pages.
; URL (identified 2026-08-22 via web search matching the PDF's own
; version number and filename to the publisher's own hosting path;
; NOT independently re-downloaded and byte-compared in this session --
; recorded honestly rather than presented as verified):
; https://lms.org.uk/sites/default/files/u5374/ordo_2.21_2024-2025.pdf
; (product page: https://lms.org.uk/product/latin-mass-society-ordo-2024-25)
; The local PDF (docs/research/ordo/lms-ordo-2024-2025.pdf) 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/lms-ordo-2024-2025.pdf: %s
; Extracted (UTC): %s
; Exact commands:
; 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
;
; COVERAGE: %d day-rows, %s through %s (2024-11-27, "End of November 2024",
; 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; this Ordo was probed with the same discipline
; before a single divergence was adjudicated):
;
; 1. Gl/Cr are printed PER MASS-OPTION, not per day (confirmed: this PDF
; alone -- not the multi-year corpus the task brief's own grep count
; was measured against -- 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).
;
; 2. OPPOSITE-PREDICTION PROBES against Rite_ef.Rubrics_ef.creed (RG
; 475-476), checked BEFORE trusting a single row of this fixture --
; the technique CLAUDE.md records as the one that catches a source
; silently omitting the very thing it is meant to witness:
; 2024-12-01 (Advent Sunday, RG 475(a)) -- Ordo "No Gl Cr" -> TRUE
; 2024-12-02 (ordinary Advent feria) -- Ordo "Gl No Cr" -> FALSE
; 2024-12-26 (St Stephen, II class, but inside the Nativity octave,
; RG 475(d)) -- Ordo "Gl Cr" -> TRUE
; 2025-08-15 (the Assumption, I class, RG 475(b))
; -- Ordo "Gl Cr" -> TRUE
; 2025-08-16 (St Joachim, II class, no Lord/BVM/apostle clause
; applies, RG 476(b)) -- Ordo "Gl No Cr" -> FALSE
; All five predictions confirmed, both directions (TRUE and FALSE each
; independently witnessed, including the Stephen/Joachim pair, which
; are BOTH II-class non-Sunday saints and differ ONLY on the octave
; question) -- this source is discriminating, not a constant, on the
; Creed. See task-6-report.md for the full record.
;
; 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 this
; task existed).
;
; 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 ("2025 1 The OCTAVE DAY of
; the NATIVITY..." printed with "Thu" wrapped onto the following
; line and the year token "2025" where "2026" belongs -- a literal
; duplicate, word for word, of the fixture's own already-extracted
; 2025-01-01 row) is excluded outright: the extractor's own stop
; marker (a line whose first token is a bare 4-digit year) halts
; the scan before it, so this fixture's last row is 2025-12-31.
; - 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 sentinels bound it to the
; Ordo proper, "End of November 2024" through the stop marker
; above).
|}
pdf_sha (today ()) (List.length rows)
(match rows with r :: _ -> r.date | [] -> "?")
(match List.rev rows with r :: _ -> r.date | [] -> "?")
with_variant (List.length rows)
(Printf.sprintf "%.1f" (100.0 *. float_of_int with_variant /. float_of_int (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
|