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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
|
(* Witnesses task (2026-08-22-colitur-celebrant-rubrics-phase1): turns
pdftotext's -layout dump of the FIUV (Foederatio Internationalis Una
Voce) universal Ordo into test/fixtures/fiuv-ordo-2025-2026.sexp, one
row per day, 2025-11-27..2026-12-31.
A DIFFERENT format from tools/extract_lms_ordo.ml's own LMS PDFs in
every way that matters (see docs/research/ordo/PROVENANCE-ordo-corpus.md):
UNIVERSAL (no diocesan variants to exclude at all -- one block per day,
not "the first of several"), IN LATIN, using the rubrics' own
vocabulary ("Missa pr., Gloria, sine Credo, praef. comm."), and BOTH
directions of Gloria/Credo are stated explicitly in the source's own
words ("Gloria"/"sine Gloria", "Credo"/"sine Credo") rather than by a
printed flag letter. Hand-rolled, no Str/regex (frozen deps) -- the
same String.sub/index/split_on_char discipline extract_lms_ordo.ml
already uses for a different publisher's layout.
THE KEY STRUCTURAL FACTS this parser leans on, established during
characterisation (see the fixture's own provenance header for the
full record):
1. A day-start line's own FIRST token is always "<1-2 digit day>."
(e.g. "27.", "3."), immediately followed by a colour code (not
anchored on below -- colour is captured as raw prose, never parsed
into a closed set, because a few real days use a COMPOUND colour
("Viol. in Off., Alb. in Missa." on Holy Thursday) or drop the
token to a blank cell entirely (13 December 2026, a rendering
quirk) -- neither is needed for anything this task compares).
2. Month headers/running-footers name a month EITHER in English with a
trailing ROMAN-NUMERAL year ("November MMXXV", the book's own first
two months only) OR bare in Latin with no year at all
("Januarius", "Februarius", ... every month after). The English
Nov/Dec spellings happen to be IDENTICAL to their Latin
equivalents, so one 12-entry table covers both vocabularies.
CHARACTERISATION FINDING: the SECOND header ("December MMXV") is a
publisher-side TYPO -- MMXV is 2015, ten years off, contradicted by
every neighbouring date and by month-order inference alone. This
parser therefore reads a Roman-numeral year ONLY off the very
FIRST header encountered (the anchor) and tracks every subsequent
month purely by wraparound inference (increment the year exactly
once, at the Dec->Jan transition) -- the typo is never read at all,
not merely tolerated.
3. Every real Mass rubric line starts with the literal token "Missa"
(401 of ~400 real day-blocks, confirmed structurally). A SECOND,
alternate Mass option, when the day offers one, is introduced by
the CAPITALISED two-token sequence "Vel Missa" -- never bare lower-
case "vel" alone, which is the ordinary Latin conjunction "or" and
appears constantly inside ordinary prose (e.g. "praef. comm. vel de
Martyribus", part of ONE preface's own text, not a second Mass
option) -- so only "Vel Missa", both tokens, anchors a real
alternate-option boundary. This parser reads Gloria/Credo/praef.
from the FIRST Mass option only, bounded above by whichever comes
first: "Vel Missa" or "VESPERAE"/"VESPERA" -- the identical
"day's own PRIMARY office, not a menu entry" discipline
extract_lms_ordo.ml already uses for the LMS diocesan-variant
colon boundary.
4. SUBSTRING TRAP, found and defended against: "Gloria" is also the
first word of "Gloria Patri" (the psalm doxology, "Glory be to the
Father"), a DIFFERENT liturgical unit that has nothing to do with
whether the Mass's own Gloria in excelsis is said -- and it can
appear INSIDE the very Mass clause being scanned (Good Friday:
"Missa pr., (omittuntur ps. Iudica me et Gloria Patri), Gloria,
sine Credo, praef. comm." -- the real, standalone "Gloria," follows
immediately after). [find_word] below rejects any "Gloria" hit
whose very next token is "Patri", the same whole-token discipline
extract_lms_ordo.ml's own header describes for "V Mass of BVM"
being a substring of "IV Mass of BVM".
Usage:
pdftotext -layout docs/research/ordo/fiuv-ordo-2025-2026.pdf /tmp/fiuv.txt
dune exec tools/extract_fiuv_ordo.exe -- /tmp/fiuv.txt \
docs/research/ordo/fiuv-ordo-2025-2026.pdf \
test/fixtures/fiuv-ordo-2025-2026.sexp *)
open Sexplib0.Sexp_conv
module Date = Colitur_kernel.Date
let die fmt = Printf.ksprintf (fun s -> prerr_endline ("extract_fiuv_ordo: " ^ s); exit 1) fmt
(* Mirrored, not shared, by test/test_fiuv_ordo.ml -- tools/ and test/
have no common .mli either could hang a shared type from, the same
reasoning extract_lms_ordo.ml's own header already gives. *)
type row = {
date : string; (** ISO-8601 *)
class_ : string option; (** raw, e.g. "III cl.", "III cl. (Priv.)", "I cl." -- [None] only if genuinely unparseable *)
title : string; (** the day-start line's own text before the class marker *)
te_deum : bool option; (** [None] only if neither "Te Deum" nor "non dicitur Te Deum" is found *)
gloria : bool option;
credo : bool option;
praef : string option; (** raw trailing text after "praef." within the primary Mass clause -- CAPTURED, NOT VALIDATED *)
}
[@@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
(* Strips ASCII trailing punctuation only (.,();) -- tokens in this source
never carry other trailing marks that matter to comparisons below. *)
let rec strip_trailing_punct s =
let n = String.length s in
if n = 0 then s
else
match s.[n - 1] with
| '.' | ',' | ')' | ';' | ':' -> strip_trailing_punct (String.sub s 0 (n - 1))
| _ -> s
let contains s ~sub =
let ls = String.length s and lu = String.length sub in
if lu = 0 then true
else
let rec go i = if i + lu > ls then false else if String.sub s i lu = sub then true else go (i + 1) in
go 0
let index_of s ~sub =
let ls = String.length s and lu = String.length sub in
if lu = 0 then Some 0
else
let rec go i = if i + lu > ls then None else if String.sub s i lu = sub then Some i else go (i + 1) in
go 0
let months =
[| "Januarius"; "Februarius"; "Martius"; "Aprilis"; "Maius"; "Junius"; "Julius"; "Augustus"; "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
(* Roman numerals, subtractive form -- only ever called once, on the
FIRST month header's own year token (the anchor); see the module
header on why every LATER header's own year, when present at all, is
deliberately never read. *)
let roman_value = function
| 'I' -> Some 1 | 'V' -> Some 5 | 'X' -> Some 10 | 'L' -> Some 50 | 'C' -> Some 100 | 'D' -> Some 500
| 'M' -> Some 1000 | _ -> None
let parse_roman s =
let n = String.length s in
let rec go i acc =
if i >= n then Some acc
else
match roman_value s.[i] with
| None -> None
| Some v -> (
let next = if i + 1 < n then roman_value s.[i + 1] else None in
match next with
| Some nv when nv > v -> go (i + 2) (acc + nv - v)
| _ -> go (i + 1) (acc + v))
in
if n = 0 then None else go 0 0
(* --- day-block scanning --------------------------------------------------- *)
type block = { b_day : int; b_month : int; b_year : int; b_lines : string list }
let is_day_start_line toks =
match toks with
| t :: _ :: _ when String.length t >= 2 && t.[String.length t - 1] = '.' ->
let core = String.sub t 0 (String.length t - 1) in
if is_all_digits core then
let d = int_of_string core in
if d >= 1 && d <= 31 then Some d else None
else None
| _ -> None
(* A month running-header/footer line: first token is a recognised month
name (English Nov/Dec spelled identically to Latin), optionally
followed by a Roman-numeral year and/or a page number -- ALWAYS pure
noise for the day-block scanner (never a continuation line's own
content), but the FIRST time a given month index is seen it also
drives the [cur_month]/[cur_year] state transition. *)
let is_month_marker_line toks = match toks with m :: _ -> month_index m <> None | [] -> false
(* Left-margin indulgence-notation codes ("Ind." = an indulgence is
available, "Plen." = a plenary one, "DFP" unexplained but structurally
identical -- all three print in the SAME left-margin column pdftotext
-layout preserves, glued onto the following content line as literal
leading tokens, the identical trap extract_lms_ordo.ml's own
[strip_left_column] already documents for the LMS PDFs' "Pl"/"Ind"
column. Found by tracing a real parse failure (8 December 2025's own
class marker split across two physical lines by "DFP" sitting between
"I" and "cl."), not assumed in advance: catalogued via a frequency
scan of every short capitalised token opening a continuation line, and
deliberately does NOT include "RM" (11 occurrences, ALSO short and
capitalised) -- checked directly, "RM" is a real citation abbreviation
("Rubricae Missalis", e.g. "vide RM 440"), not a margin code, so
stripping it would corrupt real text for no parsing benefit. *)
let strip_left_column tokens =
let is_marker t = t = "Ind." || t = "Plen." || t = "DFP" in
let rec go = function t :: rest when is_marker t -> go rest | ts -> ts in
go tokens
(* The malformed trailing entry's own distinctive two-token prefix,
"1st Jan" -- NOT a generic "first token is a bare 4-digit number"
check (extract_lms_ordo.ml's own approach): that generic shape false-
positived here, on real body content deep in October ("...20 augusti
/ 1885, 26 augusti 1886..." -- an indulgence-decree date citation
whose line-wrap happens to put a 4-digit year token first on its own
physical line). This source's OWN tail artefact is reliably
distinguished only by its literal, unique "1st Jan" opening -- found
by tracing the false stop, not assumed in advance. *)
let is_stop_tail_line toks = match toks with "1st" :: "Jan" :: _ -> true | _ -> 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
(* --- per-block field extraction ------------------------------------------- *)
(* First WHOLE-TOKEN hit of [word] in [tokens] starting at-or-after
[from], REJECTING a hit whose immediately-following token (stripped)
is [reject_next] -- the "Gloria Patri" trap, see the module header.
Returns the hit's own index and whether the token immediately BEFORE
it (stripped) is "sine" (a negation). *)
let find_word tokens ~from ~word ~reject_next =
let n = Array.length tokens in
let rec go i =
if i >= n then None
else if strip_trailing_punct tokens.(i) = word then
let next_is_rejected = i + 1 < n && strip_trailing_punct tokens.(i + 1) = reject_next in
if next_is_rejected then go (i + 1)
else
let negated = i > 0 && strip_trailing_punct tokens.(i - 1) = "sine" in
Some (i, negated)
else go (i + 1)
in
go from
(* [class_]: the roman-numeral token immediately followed by a token
starting "cl" (covers "cl.", "cl.,"), whole-token matched -- never a
substring search (the same discipline the LMS extractor's own BVM
numeral anchor uses, for the identical reason: "I" is a substring of
"II"/"III"/"IV"). Also detects the trailing "(Priv.)" two tokens later. *)
let extract_class tokens =
let n = Array.length tokens in
let is_class_numeral t = t = "I" || t = "II" || t = "III" || t = "IV" in
let rec go i =
if i + 1 >= n then None
else if is_class_numeral tokens.(i) && has_prefix ~prefix:"cl" tokens.(i + 1) then
let base = tokens.(i) ^ " cl." in
if i + 2 < n && strip_trailing_punct tokens.(i + 2) = "(Priv" then Some (base ^ " (Priv.)") else Some base
else go (i + 1)
in
go 0
let extract_title tokens =
let n = Array.length tokens in
let is_class_numeral t = t = "I" || t = "II" || t = "III" || t = "IV" in
let rec find i = if i + 1 >= n then n else if is_class_numeral tokens.(i) && has_prefix ~prefix:"cl" tokens.(i + 1) then i else find (i + 1) in
let stop = find 0 in
String.concat " " (Array.to_list (Array.sub tokens 0 stop))
(* Te Deum: bounded to the "Ad Mat." .. "Ad Laudes" span specifically
(both casings occur: "Ad MAT."/"Ad Mat."/"ad Mat.", "Ad LAUDES"/
"Ad Laudes") -- NOT the whole block, because a day can separately
mention "Te Deum" in an unrelated INDULGENCE note (31 December: "Hodie
ad solemnem recitationem hymni Te Deum, indulgentia plenaria lucrari
potest" -- about gaining an indulgence for the New Year's Eve Te Deum
of thanksgiving, not about whether it is sung at that day's own
Matins) that a whole-block search would wrongly read as a positive hit. *)
let extract_te_deum full_text =
let find_ci needles start =
List.fold_left
(fun acc needle ->
match acc with
| Some _ -> acc
| None -> (
match index_of (String.sub full_text start (String.length full_text - start)) ~sub:needle with
| Some i -> Some (start + i)
| None -> None))
None needles
in
match find_ci [ "Ad Mat."; "Ad MAT."; "ad Mat." ] 0 with
| None -> None
| Some mat_start -> (
let laudes_start =
match find_ci [ "Ad Laudes"; "Ad LAUDES"; "ad Laudes" ] mat_start with
| Some i -> i
| None -> String.length full_text
in
let span = String.sub full_text mat_start (laudes_start - mat_start) in
(* TWO negative phrasings the source actually uses, found live
(celebrant-rubrics-phase1 Phase 2, 2026-08-22): "non dicitur Te
Deum" (e.g. ordinary Time-after-Pentecost ferias, 27/28 November)
AND, separately, "sine Te Deum" (e.g. every Sunday, every Ember
day, every privileged Lenten/Passiontide feria carrying a
commemoration -- Advent I, 30 November: "...3a de homilia (cum
suo R), sine Te Deum."). The ORIGINAL version of this function
checked only the first phrasing, so "sine Te Deum" fell through
to the bare "Te Deum" substring test and was wrongly read as a
POSITIVE hit -- confirmed by grepping the raw pdftotext dump
directly against a first, uncorrected run's own output: EVERY
date this bug affected showed "sine Te Deum" in the source and
[Some true] in the fixture, a 100% correlation, not a handful of
coincidences. This is the SAME "does the source negate the
hymn's own name with a DIFFERENT word than the one this parser
already checks for" shape [find_word]'s own "Gloria Patri"
substring trap already documents for Gloria -- this trap simply
went unnoticed until Phase 2 actually compared the extracted
values against colitur's own output and against the raw text by
hand, rather than only checking coverage counts. *)
if contains span ~sub:"non dicitur Te Deum" then Some false
else if contains span ~sub:"sine Te Deum" then Some false
else if contains span ~sub:"Te Deum" then Some true
else None)
(* Gloria/Credo/praef.: bounded to the FIRST Mass option only -- from the
token "Missa" to whichever comes first: "Vel" immediately followed by
"Missa" (a real second option), or a token starting "VESPER" (Vespers
info -- a PREFIX match, not one or two hardcoded exact spellings,
found necessary by tracing a real over-capture: this source uses at
least four surface forms depending on grammatical case and whether
pdftotext renders the Æ ligature as one glyph or splits it --
"VESPERÆ", "VESPERAS", "VESPERA" (Ad VESPERA, rare) and "VESPERÆ"
with the ligature reproduced as a single non-ASCII codepoint an exact-
string match against the two ASCII spellings alone could never catch,
which let a whole day's own trailing Vespers/Compline prose leak into
[praef] uncaught until checked against the task brief's own worked
example day, 19 September), or the end of the block. *)
let extract_missa_fields tokens =
let n = Array.length tokens in
(* [Missa] (singular, 549 of the corpus's own occurrences of any "Miss-"
word) anchors 398 of the 400 real days; the two genuine exceptions
are Good Friday and Holy Saturday, which have no Mass at all in the
1955-restored Holy Week (matching extract_lms_ordo.ml's own
identically-shaped finding for the LMS corpus). CHRISTMAS DAY is a
THIRD, found by tracing a real false-None result: its own rubric
reads "Hodie celebrantur tres Missae pr., Gloria, Credo..." ("today
three Masses are celebrated..."), using the PLURAL "Missae"/"Missæ"
(both spellings occur across the fixture's two Christmas Days) --
[tokens.(i) = "Missa"] alone never matches it, silently returning
[None] for Gloria/Credo/praef on the single most doctrinally
unambiguous day in the whole calendar. Widened to accept "Missae"/
"Missæ" too, guarded against the ONE real collision risk: "Hodie
prohibentur omnes Missae defunctorum..." (a Requiem-Mass-prohibition
notice, common at the end of many day-blocks, unrelated to the
day's own Mass) also contains "Missae" -- rejected here by checking
the immediately FOLLOWING token is not "defunctorum". *)
let missa_idx =
let rec go i =
if i >= n then None
else if (tokens.(i) = "Missa" || tokens.(i) = "Missae" || tokens.(i) = "Missæ")
&& not (i + 1 < n && strip_trailing_punct tokens.(i + 1) = "defunctorum")
then Some i
else go (i + 1)
in
go 0
in
match missa_idx with
| None -> (None, None, None)
| Some m ->
let end_idx =
let rec go i =
if i >= n then n
else if tokens.(i) = "Vel" && i + 1 < n && tokens.(i + 1) = "Missa" then i
else if has_prefix ~prefix:"VESPER" tokens.(i) then i
else go (i + 1)
in
go (m + 1)
in
let span = Array.sub tokens m (end_idx - m) in
let gloria =
match find_word span ~from:0 ~word:"Gloria" ~reject_next:"Patri" with
| Some (_, negated) -> Some (not negated)
| None -> None
in
let credo =
match find_word span ~from:0 ~word:"Credo" ~reject_next:"__never__" with
| Some (_, negated) -> Some (not negated)
| None -> None
in
let praef =
let sn = Array.length span in
let rec go i = if i >= sn then None else if strip_trailing_punct span.(i) = "praef" then Some i else go (i + 1) in
match go 0 with
| None -> None
| Some i ->
let rest = Array.to_list (Array.sub span (i + 1) (sn - i - 1)) in
if rest = [] then None else Some (String.concat " " rest)
in
(gloria, credo, praef)
(* --- top level -------------------------------------------------------------- *)
let () =
if Array.length Sys.argv <> 4 then
die "usage: extract_fiuv_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
(* Anchor: the FIRST month-marker line carrying a parseable Roman year
-- established during characterisation to be line 47, "November
MMXXV". Search generically rather than hardcoding the line number,
so a re-extraction against a re-flowed pdftotext dump still finds it. *)
let anchor_idx, anchor_month, anchor_year =
let rec go i =
if i >= n then die "no month header with a parseable Roman-numeral year found (expected the book's own first header, e.g. \"November MMXXV\")"
else
let toks = split_ws (trimmed i) in
match toks with
| m :: y :: _ -> (
match (month_index m, parse_roman y) with
| Some mi, Some yr when yr > 1000 -> (i, mi, yr)
| _ -> go (i + 1))
| _ -> 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 (anchor_idx + 1)
in
Printf.printf "extract_fiuv_ordo: Ordo body lines %d..%d (of %d total), anchor %d/%d\n" anchor_idx stop_idx n
anchor_month anchor_year;
let cur_month = ref anchor_month and cur_year = ref anchor_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 = anchor_idx + 1 to stop_idx - 1 do
let raw = trimmed i in
if raw = "" then ()
else
let toks = split_ws raw in
if is_month_marker_line toks then (
match toks with
| m :: _ -> (
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
| None -> ())
| [] -> ())
else
match is_day_start_line toks with
| Some day ->
flush ();
cur := Some { b_day = day; b_month = !cur_month; b_year = !cur_year; b_lines = toks }
| None -> (
match !cur with
| None -> ()
| Some b -> cur := Some { b with b_lines = b.b_lines @ strip_left_column toks })
done;
flush ();
let blocks = List.rev !blocks in
Printf.printf "extract_fiuv_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 "%d/%d/%d: %s" b.b_year b.b_month b.b_day e
in
let tokens = Array.of_list b.b_lines in
(* [b_lines] tokens 0 is the day's own leading "<n>." marker --
dropped before field extraction, kept for nothing (the day
number is already known from [b_day]). *)
let content = if Array.length tokens > 0 then Array.sub tokens 1 (Array.length tokens - 1) else tokens in
let class_ = extract_class content in
let title = extract_title content in
let full_text = String.concat " " (Array.to_list content) in
let te_deum = extract_te_deum full_text in
let gloria, credo, praef = extract_missa_fields content in
{ date = Date.to_iso8601 date; class_; title; te_deum; gloria; credo; praef })
blocks
in
let missing_class = List.filter (fun r -> r.class_ = None) rows in
let missing_gloria = List.filter (fun r -> r.gloria = None) rows in
let missing_credo = List.filter (fun r -> r.credo = None) rows in
let missing_te_deum = List.filter (fun r -> r.te_deum = None) rows in
if missing_class <> [] then
Printf.printf "extract_fiuv_ordo: WARNING %d rows with no class found: %s\n" (List.length missing_class)
(String.concat ", " (List.map (fun r -> r.date) missing_class));
Printf.printf
"extract_fiuv_ordo: %d rows; %d with no Gloria found; %d with no Credo found; %d with no Te Deum found\n"
(List.length rows) (List.length missing_gloria) (List.length missing_credo) (List.length missing_te_deum);
(* CHARACTERISATION, Step C of the task brief, non-negotiable: probed
BEFORE trusting a single row of this fixture, and RE-VERIFIED here
against this run's own [rows] -- a failed probe is a hard [die], the
same self-verifying discipline extract_lms_ordo.ml already uses,
never a printed claim resting on a one-off manual check that could
silently rot on a re-extraction. Every date/expectation pair below
was confirmed directly against the raw pdftotext dump before being
encoded here. *)
let creed_of_date d = List.find_map (fun r -> if String.equal r.date d then r.credo else None) rows in
let probes =
[ ("2025-11-30", true, "Advent Sunday, I class, RG 475(a)");
("2025-12-01", false, "ordinary Advent feria, III class, 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)");
("2026-08-15", true, "the Assumption, I class, RG 475(b)") ]
in
List.iter
(fun (d, expected, desc) ->
match creed_of_date 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_fiuv_ordo: all %d characterisation probes confirmed\n" (List.length probes);
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 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 pdf_base = Filename.basename pdf_path in
let gloria_true = List.length (List.filter (fun r -> r.gloria = Some true) rows) in
let gloria_false = List.length (List.filter (fun r -> r.gloria = Some false) rows) in
let credo_true = List.length (List.filter (fun r -> r.credo = Some true) rows) in
let credo_false = List.length (List.filter (fun r -> r.credo = Some false) rows) in
let te_deum_true = List.length (List.filter (fun r -> r.te_deum = Some true) rows) in
let te_deum_false = List.length (List.filter (fun r -> r.te_deum = Some false) rows) in
let header =
Printf.sprintf
{|; %s -- Witnesses task (2026-08-22-colitur-
; celebrant-rubrics-phase1)'s SEVENTH validation layer, and the first
; UNIVERSAL (non-diocesan), SECOND-COMPILER witness: the FIUV (Foederatio
; Internationalis Una Voce) Ordo, compiled independently of the Latin Mass
; Society's own three editions (test/test_lms_ordo.ml) -- see
; docs/research/ordo/PROVENANCE-ordo-corpus.md for the full four-reasons
; account of why this source is the more valuable of the two newly-
; acquired ones.
;
; Generator: tools/extract_fiuv_ordo.ml -- do not hand-edit; re-run
; against a fresh pdftotext dump and commit the diff instead.
;
; Source: "Ordo Divini Officii recitandi sacrique peragendi secundum
; antiquam Ritus Romani formam pro anno Domini 2026", Foederatio
; Internationalis Una Voce, compiled by Joseph Shaw (Praefatio signed
; "Joseph Shaw, President", Feast of the Nativity of Our Lady [8 Sept]
; 2025) -- title page and Praefatio, verified directly against the PDF's
; own extracted text. 2025 is stated as the Federation's own 60th
; anniversary year.
; PDF metadata: Creator "TeX", Producer "pdfTeX-1.40.22", CreationDate
; 2025-11-08, 128 pages.
; URL: identified via web search as
; https://lms.org.uk/sites/default/files/u5374/fiuv_ordo_2025-2026_1.1_a5_format.pdf
; (hosted by the Latin Mass Society's own site alongside its own three
; editions -- Joseph Shaw is both FIUV President and LMS Chairman, which
; plausibly also explains why this PDF's own trailing malformed entry
; (see COVERAGE below) is structurally identical to the LMS PDFs' own,
; despite the two being compiled by different people); NOT independently
; re-downloaded and byte-compared in this session -- recorded honestly
; rather than presented as verified, the same discipline the LMS
; fixtures' own headers use.
; 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/fiuv.txt
; dune exec tools/extract_fiuv_ordo.exe -- /tmp/fiuv.txt \
; docs/research/ordo/%s %s
;
; COVERAGE: %d day-rows, %s through %s. Starts at the book's own first
; real content line ("November MMXXV", the FIRST month header found with
; a parseable Roman-numeral year -- used as this parser's sole date
; anchor, see the tool's own header on why no LATER header's year is ever
; read, the second one being a publisher typo, "December MMXV" for 2025).
; Stops before a malformed trailing entry, structurally the SAME artefact
; found in all three LMS editions (a stray, mislabelled duplicate of the
; fixture's own already-extracted 1 January row, headed "1st Jan / 2025."
; -- see extract_lms_ordo.ml's own COVERAGE section for the fuller
; account of this shared publisher-side quirk).
;
; CHARACTERISATION FINDINGS (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. UNIVERSAL, not diocesan: unlike the three LMS editions (which had to
; exclude diocesan variants from every comparison, quantified in each
; of their own fixture headers), this Ordo carries ONE block per day,
; always. There is no [has_diocesan_variant] field in this fixture's
; own [row] at all -- there is nothing to exclude.
;
; 2. OPPOSITE-PREDICTION PROBES against Rite_ef.Rubrics_ef.creed (RG
; 475-476), RE-CHECKED BY THIS TOOL RUN against its own extracted
; [rows] before this header was even written -- a failed probe is a
; hard [die], never a printed claim:
%s
; All %d predictions confirmed, both directions -- this source is
; discriminating, not a constant, on the Creed.
;
; 3. THE SUBSTRING TRAP this parser defends against, found live in this
; corpus: "Gloria" is also the first word of "Gloria Patri" (the psalm
; doxology), which can appear INSIDE the very Mass clause being
; scanned (Good Friday, 3 April 2026: "Missa pr., (omittuntur ps.
; Iudica me et Gloria Patri), Gloria, sine Credo, praef. comm." -- the
; real, standalone Gloria mention follows immediately after). See
; [find_word]'s own citation.
;
; 4. RAW SUBSTRING COUNTS measured against this session's own pdftotext
; dump, for comparison against the task brief's own figures (328
; Gloria / 132 sine Gloria / 119 Credo / 260 sine Credo, 262 Te Deum):
; "sine Gloria" 132 (EXACT match), "sine Credo" 260 (EXACT match), raw
; "Te Deum" substring 262 (EXACT match, includes negated occurrences
; as substring hits -- 42 of them are "non dicitur Te Deum"). Raw
; "Gloria"/"Credo" substring totals (494/411) do NOT match the brief's
; implied positive totals (328/119) even after subtracting the 25
; "Gloria Patri" occurrences (469, still 9 over) -- not chased further
; here; this extractor's own token-scoped, Missa-clause-bounded counts
; below are what this fixture actually asserts, not a forced match to
; the brief's approximate figures.
;
; MEASURED DISTRIBUTION (this extraction, not the brief's figures): Gloria
; true=%d false=%d unresolved=%d; Credo true=%d false=%d unresolved=%d;
; Te Deum true=%d false=%d unresolved=%d.
|}
dest 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 | [] -> "?")
probe_lines (List.length probes) gloria_true gloria_false (List.length missing_gloria) credo_true credo_false
(List.length missing_credo) te_deum_true te_deum_false (List.length missing_te_deum)
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_fiuv_ordo: wrote %d rows to %s\n" (List.length rows) dest
|