aboutsummaryrefslogtreecommitdiff
path: root/test/test_oracle.ml
diff options
context:
space:
mode:
Diffstat (limited to 'test/test_oracle.ml')
-rw-r--r--test/test_oracle.ml607
1 files changed, 607 insertions, 0 deletions
diff --git a/test/test_oracle.ml b/test/test_oracle.ml
new file mode 100644
index 0000000..2cc9972
--- /dev/null
+++ b/test/test_oracle.ml
@@ -0,0 +1,607 @@
+(* Task 16: oracle harness vs missalemeum (sibling project's snapshot of
+ missalemeum.com/Divinum Officium data), EF only, 2026-2027 -- validation
+ layer 4 of the design spec's five (colitur CLAUDE.md "Validation" section;
+ layer 3, the lectio differential, is test_differential.ml, already green).
+
+ *** WHY THIS LAYER EXISTS, AND WHAT IT CAN DO THAT THE LECTIO DIFFERENTIAL
+ CANNOT (test_differential.ml's own header comment, limit 1) ***
+
+ The lectio differential compares colitur against data colitur was itself
+ BOOTSTRAPPED from (data/ef/sanctoral.sexp comes from lectio's tridentine-
+ calendar.ini) -- it cannot catch an error inherited from that bootstrap,
+ and it explicitly does not compare commemorations at all (lectio's
+ trailing tokens are losing candidates, not an RG 111 admitted set, per
+ that file's own limit 1). missalemeum is INDEPENDENT of colitur's
+ bootstrap chain, and its JSON carries a real admitted-commemorations
+ array (its own RG 111-shaped selection, not lectio's rejects) -- so this
+ file compares THREE axes the lectio differential cannot touch at all:
+ rank, colour (as SET membership, see below), and commemoration presence/
+ count. Slug IDENTITY is deliberately NOT compared (the brief: needs a
+ title->slug mapping, which is the data audit's business -- see the task
+ report -- not this automated comparator's).
+
+ *** THE FIXTURE'S OWN SHAPE (fixtures/missalemeum-ef-2026-2027.txt,
+ fixtures/missalemeum-ef-2026-2027.provenance, tools/
+ extract_missalemeum_oracle.py) ***
+
+ info.id (e.g. "sancti:01-02:1:w") encodes the PROPERS REUSED that day,
+ not the day's own rank or colour -- 137 of 730 days (19%) disagree
+ between the id-embedded rank and info.rank itself (task brief's own
+ premise was wrong here; the brief's extraction note has been corrected).
+ This file, like the extraction script, reads ONLY info.rank and
+ info.colors, never parses anything out of info.id.
+
+ info.colors is an ARRAY -- 14 of 730 days carry two colours (rose+violet
+ on Gaudete/Laetare per RG 131's indult, red+violet on Palm Sunday, black+
+ violet on Good Friday, violet+white on Holy Saturday -- one colour per
+ liturgical ACTION within that one civil day, which colitur's one-colour-
+ per-day model cannot represent, register §3b's own RG 126 note). So the
+ colour axis below is MEMBERSHIP (colitur's single colour must be one of
+ the oracle's set), never equality.
+
+ Four days carry more than one Mass in the JSON array (Christmas, All
+ Souls) -- the extraction script takes entry[0] throughout, VERIFIED
+ (not assumed) identical to every other Mass of the same day on every
+ field this file reads (see that script's own header and the task
+ report).
+
+ *** THE ALLOW-LIST (data/ef/expected-divergences-missalemeum.sexp) ***
+
+ A SEPARATE file from data/ef/expected-divergences.sexp (the lectio
+ allow-list): the two oracles disagree with colitur in different places
+ and for different reasons, and this file's own convention differs in one
+ deliberate way -- [verdict] is not always "colitur". Some entries are
+ genuine DATA GAPS this task found and could not fix here (data/ef/
+ sanctoral.sexp is bootstrapped from lectio, which is itself missing the
+ entries; RG 110's inseparable-Peter/Paul commemoration is unimplemented
+ code, a real feature this task did not build) -- honestly verdicted
+ [missalemeum] (colitur is short a feature or a row, not right), never
+ silently absorbed as if colitur were correct. One entry (M13) is
+ [verdict open]: adjudicated as unresolved, not resolved either way --
+ the brief's own explicit permission ("say so as an open item") used for
+ real, not defaulted past. See the task report for every entry's full
+ reasoning and primary-source citation. *)
+
+module Cal = Colitur_kernel.Calendar
+module Layer = Colitur_kernel.Layer
+module Overlay = Colitur_kernel.Overlay
+module LD = Colitur_kernel.Liturgical_day
+module Slug = Colitur_kernel.Slug
+module Date = Colitur_kernel.Date
+module Cel = Colitur_kernel.Celebration
+module Colour = Colitur_kernel.Colour
+module V = Rite_ef.Vocab_ef
+
+(* Same relative paths test_differential.ml uses: dune test runs from
+ _build/default/test/. *)
+let sanctoral_path = "../data/ef/sanctoral.sexp"
+let adjustments_path = "../data/ef/adjustments.sexp"
+let fixture_path = "fixtures/missalemeum-ef-2026-2027.txt"
+let allow_list_path = "../data/ef/expected-divergences-missalemeum.sexp"
+
+(* fixtures/missalemeum-ef-2026-2027.provenance carries the same digest and
+ the exact regeneration command. Asserted (not merely documented) for the
+ same reason test_differential.ml's own [fixture_sha256] is: a hand-edit
+ or partial re-extraction would otherwise silently turn the oracle into
+ an unlabelled snapshot of whatever someone last ran. *)
+let fixture_sha256 = "718eff1a03fa09c3927ef18d19fe7921c2d4d7611890c22566058b81fd7cc5ea"
+
+(* Identical technique to test_differential.ml's own [sha256_of_file]
+ (that file's own comment explains why: shelling out to [sha256sum]
+ rather than adding a crypto library dependency Task 15/16's frozen deps
+ do not include). Duplicated, not shared, for the same reason
+ [real_layer] below is -- neither file exposes an .mli the other could
+ depend on, and this is three lines. *)
+let sha256_of_file path =
+ let tmp = Filename.temp_file "colitur_oracle_sha256" ".txt" in
+ Fun.protect
+ ~finally:(fun () -> try Sys.remove tmp with Sys_error _ -> ())
+ (fun () ->
+ let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
+ let rc = Sys.command cmd in
+ if rc <> 0 then Alcotest.failf "sha256sum exited %d for %s (is it on PATH?)" rc path;
+ let ic = open_in tmp in
+ let line =
+ try input_line ic
+ with End_of_file ->
+ close_in ic;
+ Alcotest.failf "sha256sum produced no output for %s" path
+ in
+ close_in ic;
+ match String.index_opt line ' ' with
+ | Some i -> String.sub line 0 i
+ | None -> Alcotest.failf "unexpected sha256sum output for %s: %S" path line)
+
+let real_layer () =
+ let layer =
+ match Layer.load V.rank_of_sexp sanctoral_path with
+ | Ok l -> l
+ | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e
+ in
+ let overlay =
+ match Overlay.load V.rank_of_sexp adjustments_path with
+ | Ok o -> o
+ | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e
+ in
+ let layer, diagnostics = Overlay.apply layer overlay in
+ Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" []
+ (List.map Overlay.diagnostic_to_string diagnostics);
+ layer
+
+(* ---------------------------------------------------------------------- *)
+(* The oracle side: one line per day, as tools/extract_missalemeum_oracle *)
+(* .py's own header documents. *)
+(* ---------------------------------------------------------------------- *)
+
+type oracle_row = {
+ o_date : string;
+ o_rank : int;
+ o_colours : char list;
+ o_title : string;
+ o_commemorations : string list;
+ o_displaced : string list;
+}
+
+let explode s = List.init (String.length s) (String.get s)
+
+let split_list_field f = if f = "-" then [] else String.split_on_char ';' f
+
+let oracle_row_of_line line =
+ match String.split_on_char '|' line with
+ | [ date; rank; colours; title; _tempora; commemorations; displaced; _n_masses ] ->
+ { o_date = date;
+ o_rank = int_of_string rank;
+ o_colours = explode colours;
+ o_title = title;
+ o_commemorations = split_list_field commemorations;
+ o_displaced = split_list_field displaced }
+ | _ -> Alcotest.failf "malformed fixture line (expected 8 '|'-separated fields): %S" line
+
+let read_lines path =
+ let ic = open_in path in
+ let rec loop acc =
+ match input_line ic with
+ | line -> loop (line :: acc)
+ | exception End_of_file ->
+ close_in ic;
+ List.rev acc
+ in
+ loop []
+
+let oracle_rows () = List.map oracle_row_of_line (read_lines fixture_path)
+
+(* ---------------------------------------------------------------------- *)
+(* The colitur side: the SAME pipeline bin/main.ml's `colitur day` and *)
+(* test_differential.ml's own [colitur_rows_2005_2050] use -- *)
+(* Colitur_kernel.Calendar over the real committed data. Restricted to *)
+(* 2026-2027 (indexing 2025 too, for the same reason test_differential.ml *)
+(* indexes [y-1]: Calendar.year resolves one Advent-anchored liturgical *)
+(* year, which straddles two civil years). *)
+(* ---------------------------------------------------------------------- *)
+
+type colitur_row = { c_date : string; c_rank : int; c_colour : char; c_commemorations : string list }
+
+let rank_to_int = function V.Class1 -> 1 | V.Class2 -> 2 | V.Class3 -> 3 | V.Class4 -> 4
+
+let colour_to_char = function
+ | Colour.White -> 'w'
+ | Colour.Red -> 'r'
+ | Colour.Green -> 'g'
+ | Colour.Violet -> 'v'
+ | Colour.Rose -> 'p'
+ | Colour.Black -> 'b'
+
+let colitur_rows_2026_2027 () =
+ let layer = real_layer () in
+ let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 800 in
+ for y = 2025 to 2027 do
+ let days = Cal.year Rite_ef.context layer y in
+ Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days
+ done;
+ let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e in
+ let rows = ref [] in
+ let d = ref (mk 2026 1 1) in
+ let stop = mk 2027 12 31 in
+ while Date.compare !d stop <= 0 do
+ (match Hashtbl.find_opt by_rata (Date.to_rata !d) with
+ | Some day ->
+ let cel = day.LD.observed in
+ let commemoration_slugs =
+ List.map (fun (c, _) -> Slug.to_string c.Cel.slug) day.LD.commemorations
+ in
+ rows :=
+ { c_date = Date.to_iso8601 day.LD.date;
+ c_rank = rank_to_int cel.Cel.rank;
+ c_colour = colour_to_char cel.Cel.colour;
+ c_commemorations = commemoration_slugs
+ }
+ :: !rows
+ | None -> Alcotest.failf "internal error: no resolved day for %s" (Date.to_iso8601 !d));
+ d := Date.add_days !d 1
+ done;
+ List.rev !rows
+
+(* ---------------------------------------------------------------------- *)
+(* Field-diff computation: the four axes the oracle actually supports *)
+(* (identity-by-slug is deliberately out of scope, see this file's own *)
+(* header). *)
+(* ---------------------------------------------------------------------- *)
+
+type field = Rank | Colour_f | Comm_presence | Comm_count
+
+let field_name = function
+ | Rank -> "rank"
+ | Colour_f -> "colour"
+ | Comm_presence -> "commemoration-presence"
+ | Comm_count -> "commemoration-count"
+
+let diff_fields (c : colitur_row) (o : oracle_row) =
+ let c_has = c.c_commemorations <> [] and o_has = o.o_commemorations <> [] in
+ List.filter_map
+ (fun x -> x)
+ [ (if c.c_rank = o.o_rank then None else Some Rank);
+ (if List.mem c.c_colour o.o_colours then None else Some Colour_f);
+ (if c_has = o_has then None else Some Comm_presence);
+ (* Count is only meaningful once presence already agrees (both non-empty):
+ a presence mismatch already flags the row via [Comm_presence] above,
+ and comparing lengths across a 0-vs-N split would just restate that
+ same disagreement under a second name. *)
+ (if c_has && o_has && List.length c.c_commemorations <> List.length o.o_commemorations then
+ Some Comm_count
+ else None)
+ ]
+
+(* ---------------------------------------------------------------------- *)
+(* The cited allow-list (data/ef/expected-divergences-missalemeum.sexp). *)
+(* Each predicate is gated on an EXACT, literal date set (never a date *)
+(* range or a slug pattern -- with at most 4 dates per id, a literal list *)
+(* costs nothing and cannot silently widen to absorb an unrelated future *)
+(* mismatch the way a range could), AND an exact/subset diff shape, the *)
+(* same double-gate test_differential.ml's own [layer_c_reason] uses. *)
+(* ---------------------------------------------------------------------- *)
+
+let subset xs ys = List.for_all (fun x -> List.mem x ys) xs
+
+let contains_substring s ~needle =
+ let ls = String.length s and ln = String.length needle in
+ let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in
+ ln = 0 || at 0
+
+(* M1 -- RG 33 (corrected, precedence_ef.ml's own [is_omissible_vigil]):
+ missalemeum does not implement RG 33's mandatory omission of a II/III-
+ class vigil impeded by a Sunday -- 28 June 2026 (Vigil of Sts Peter &
+ Paul, II class) still shows a commemoration of the vigil on the V Sunday
+ after Pentecost; 9 August 2026 (Vigil of St Lawrence, III class) shows
+ the vigil as the day's FULL OBSERVED OFFICE, not merely a commemoration,
+ displacing the XI Sunday after Pentecost entirely. Both wrong per RG 33's
+ own "in dominica quavis" (ANY Sunday, unqualified) -- verdict colitur. *)
+let m1_dates = [ "2026-06-28"; "2026-08-09" ]
+
+(* M2 -- RG 91 entry 27 ("Officium sanctae Mariae in sabbato"): every
+ otherwise-unoccupied IV-class Saturday should carry the votive Office of
+ the BVM (white; missalemeum's own title cycles "I".."V Mass of the
+ B. V. M. - Salve, Sancta Parens"), a genuinely DISTINCT office from an
+ ordinary green/violet feria. temporal_ef.ml's [band] already carries an
+ entry-27 comment acknowledging this row of RG 91 exists, but nothing in
+ [Temporal_ef.temporal]'s actual CONSTRUCTION builds this office -- an
+ unimpeded Time-after-Epiphany/-Pentecost Saturday still gets the bare
+ ferial slug and [season_colour]'s green. A genuine, primary-attested
+ FEATURE GAP (not a citation dispute) -- verdict missalemeum: colitur is
+ short a whole office here, not merely differently-opinioned. Registered
+ as an open item (register §6) rather than built in this task: it needs
+ its own proper texts/citations, the same scope line "Major Litanies"
+ (M5 below) already sits on. Matched on the oracle's own title substring,
+ not a hardcoded date list -- the title IS the distinguishing evidence,
+ and every Paschaltide occurrence (where colitur's season colour is
+ already white, RG 119) never reaches this predicate at all, since its
+ diffs are already empty before [layer_m_reason] is ever called. *)
+let bvm_saturday_title = "Salve, Sancta Parens"
+
+(* M3 -- RG 87 (Minor Litanies/Rogations): the SAME gap the lectio
+ differential's own C8 already names (data/ef/expected-divergences.sexp)
+ -- missalemeum, like lectio, computes no Rogation day at all and shows
+ the plain paschaltide-week feria instead. Only ONE date in this window
+ shows it (3 May 2027, Rogation Monday) because every other Rogation day
+ here is won by a saint anyway (both sides then agree on the SAINT's own
+ identity, with only the underlying temporal identity differing, which
+ this comparator's rank/colour/commemoration axes do not expose). Verdict
+ colitur, same citation as lectio's own C8. *)
+let m3_dates = [ "2027-05-03" ]
+
+(* M4 -- a genuine DATA GAP, primary-source-confirmed: the 1962 calendarium
+ itself lists, under 28 January, "S. Petri Nolasci Conf., III classis. /
+ Com. S. Agnetis Virg. et Mart., secundo" -- a second commemoration of
+ St Agnes (distinct from her main 21 January feast) that data/ef/
+ sanctoral.sexp does not carry at all. Traced upstream: lectio's own
+ tridentine-calendar.ini has no 01-28 entry beyond [peter-nolasco]
+ either, so colitur inherited the gap from its bootstrap source, not
+ from the bootstrap tool mis-reading a present entry. Cannot be fixed in
+ this task (data/ef/sanctoral.sexp is bootstrapped from lectio, per
+ CLAUDE.md's binding decision 3; lectio itself needs the entry first,
+ and this task does not touch ~/git/projects/lectio) -- verdict
+ missalemeum, tracked as a register §6 open item. *)
+let m4_dates = [ "2026-01-28"; "2027-01-28" ]
+
+(* M5 -- register §6's OWN already-open item, independently confirmed by
+ the oracle: "Major Litanies (25 April, RG 80) are not yet computed" --
+ missalemeum's commemoration "Pro rogationibus" ("for the Rogations") on
+ St Mark's day (25 April, the Major Litanies' fixed date) is exactly the
+ missing office. Not a new finding; recorded here so the automated
+ comparator does not report it as an unexplained mismatch, and cross-
+ referenced from the register so a reader sees both. Verdict missalemeum
+ (colitur is missing a real, cited office; register §6 already tracks
+ it). Only ONE date below, not two: 25 April 2026 is a Saturday (Mark
+ wins outright, colitur's commemoration list is empty where the oracle's
+ is not -- a visible presence gap); 25 April 2027 is a Sunday, where the
+ Sunday wins on BOTH sides and each side's own single slot goes to a
+ DIFFERENT candidate (colitur: Mark himself, admitted under RG 111(b)'s
+ "de festo II classis"; missalemeum: the Major Litanies) -- both
+ non-empty, same count, so this comparator's deliberately-not-slug-
+ identity axes cannot see that year's instance at all (see the task
+ report). *)
+let m5_dates = [ "2026-04-25" ]
+
+(* M6 -- the reverse of M4/M7/M9: the primary calendarium's own row for 14
+ August reads, in full, "Vigilia, II classis." -- nothing else. No
+ commemoration is listed for that date anywhere in the source this
+ project treats as authoritative (CLAUDE.md binding decision 1). data/ef/
+ sanctoral.sexp agrees (a single entry, [vigil-of-the-assumption], 14
+ Aug). missalemeum's own "St. Eusebius" commemoration for that date has
+ no support in the 1962 universal calendar found here (a DIFFERENT St
+ Eusebius, bishop and martyr, is genuinely commemorated 16 December,
+ matching lectio's own [eusebius] entry at that date -- not this one).
+ Verdict colitur: this is the one direction among M4/M6/M7/M9 where the
+ oracle itself appears to be carrying data outside the 1962 universal
+ calendar (a pre-1955 survival or a different edition), not colitur
+ missing something real. *)
+let m6_dates = [ "2026-08-14"; "2027-08-14" ]
+
+(* M7 -- a genuine DATA GAP, primary-source-confirmed, the same shape as
+ M4: the calendarium's own 26 October row reads "Com. S. Evaristi Papæ
+ et Mart." (Commemoration of St Evaristus, Pope and Martyr) -- absent
+ from data/ef/sanctoral.sexp, and absent from lectio's own tridentine-
+ calendar.ini for 10-26 (no entry at all for that date), so this is
+ another bootstrap-source gap, not fixable without touching lectio.
+ Verdict missalemeum, register §6 open item. *)
+let m7_dates = [ "2026-10-26"; "2027-10-26" ]
+
+(* M8 -- RG 109(a) ("of a Sunday" is always privileged) + RG 111(a) ("on
+ I-class days... none save one privileged"): when a FIXED I-class feast
+ (All Saints, 1 Nov; the Assumption, 15 Aug) lands on an ordinary Sunday,
+ the impeded Sunday IS the one privileged commemoration RG 111(a) admits
+ -- already an established, independently-tested rule in this codebase
+ (test_precedence_ef.ml's "RG95/RG109(a): an impeded I-class SUNDAY does
+ NOT transfer -- it is Commemorated and Privileged"). missalemeum shows
+ no commemoration at all on either occurrence in this window. Verdict
+ colitur. *)
+let m8_dates = [ "2026-11-01"; "2027-08-15" ]
+
+(* M9 -- a genuine DATA GAP, the same shape as M4/M7: the calendarium's own
+ 9 November row reads, alongside the Dedication of the Archbasilica (II
+ class), "Com. S. Theodori Mart." (Commemoration of St Theodore,
+ Martyr) -- absent from data/ef/sanctoral.sexp and from lectio's own
+ 11-09 entry (dedication-of-the-archbasilica-of-our-holy-savior alone,
+ no second entry for that date). Verdict missalemeum, register §6 open
+ item. *)
+let m9_dates = [ "2026-11-09"; "2027-11-09" ]
+
+(* M14 -- a genuine DATA GAP, the same shape as M4/M7/M9: the calendarium's
+ own 14 May row reads "Com. S. Bonifatii Mart." (a commemoration-only St
+ Boniface, Martyr -- DIFFERENT from the II-class St Boniface, Bishop and
+ Martyr, of 5 June, which data/ef/sanctoral.sexp and lectio both already
+ carry correctly) -- absent from data/ef/sanctoral.sexp and from lectio's
+ own tridentine-calendar.ini for 05-14 (no entry at all for that date).
+ Verdict missalemeum, register §6 open item. *)
+let m14_dates = [ "2027-05-14" ]
+
+(* M10 -- RG 109(e), unqualified text ("de feriis Adventus, Quadragesimae
+ et Passionis" -- OF THE FERIAS OF ADVENT, not "of the Advent ferias 17-
+ 23 December only"; that narrower window is RG 91 entry 18's own
+ OCCURRENCE-table dignity, a different axis from RG 109's commemoration-
+ privilege list, register §4's own citations keep the two separate).
+ colitur commemorates the losing early-Advent feria (the Monday after
+ Advent I, 30 Nov; the Tuesday/Wednesday after Advent II, 8 Dec) when a
+ saint wins those days -- missalemeum shows nothing on these specific
+ four rows, even though it DOES show equivalent later-Advent-feria
+ commemorations elsewhere in this same window (2-21 December, matching
+ colitur exactly on every one of those -- see the task report), which is
+ why this reads as a missalemeum-side inconsistency on these four
+ particular rows rather than a textually-narrower rule this task missed.
+ Verdict colitur. *)
+let m10_dates = [ "2026-11-30"; "2027-11-30"; "2026-12-08"; "2027-12-08" ]
+
+(* M11 -- missalemeum's own "For Octave of the Nativity" commemoration/
+ displaced entry on 26, 27(2027)/28 December carries info.rank 4 in the
+ raw JSON (checked directly, not inferred) -- a generic, always-present,
+ lowest-dignity placeholder its OWN data model seems to attach to every
+ day within the Nativity Octave, distinct from RG 91 entry 17's real
+ "days within the Octave" office (which colitur already models correctly
+ -- but only for 29-31 December, since 26-28 are the NAMED feasts
+ Stephen/John/the Innocents themselves, sanctoral not temporal,
+ temporal_ef.ml's own comment on [named]). No RG paragraph found here
+ supports a SEPARATE rank-4 commemoration alongside Stephen/John/the
+ Innocents' own (much higher) offices. Verdict colitur: read as
+ missalemeum's own bookkeeping artifact, not a liturgical fact -- flagged
+ as an open item rather than confidently dismissed, since the primary-
+ source search for this specific point was not exhaustive (see the task
+ report). *)
+let m11_dates = [ "2026-12-26"; "2026-12-28"; "2027-12-27"; "2027-12-28" ]
+
+(* M12 -- RG 110, primary text: "In Officio et Missa S. Petri semper fit
+ commemoratio S. Pauli, et vicissim" -- in the Office and Mass of EITHER
+ Peter or Paul, a commemoration of the OTHER is ALWAYS made (the
+ "inseparable commemoration"), uncapped by RG 111's ordinary admission
+ count ("in numero orationum computando, pro unica habeantur" -- counted
+ as one for counting purposes, i.e. it rides alongside whatever RG 111
+ already admits). Unimplemented in this codebase -- confirmed by the one
+ row in this window where it is visible: 22 February 2027 (Chair of
+ St Peter) shows only the privileged Lent feria commemorated in colitur,
+ while missalemeum shows that AND "St. Paul". A genuine, primary-cited
+ feature gap -- verdict missalemeum, register §6 open item. Real but
+ NARROW: every other Peter/Paul office in this window (29 June, both
+ years; 1 August M4-adjacent dates were checked and found clean) either
+ had no room for the extra commemoration to become visible against this
+ comparator's axes, or fell on a day this predicate does not otherwise
+ touch -- see the task report for the specific check. *)
+let m12_dates = [ "2027-02-22" ]
+
+(* M13 -- OPEN, NOT adjudicated (the brief's own explicit permission,
+ "say so as an open item rather than absorbing it"). 19 March 2027: St
+ Joseph (I class, 19 March) falls on the Friday of Passion Week. The
+ 1962 calendarium's own March table carries a standing note, "Feria VI
+ post dominicam I Passionis: Commemoratio septem Dolorum B. Mariae
+ Virg." (a fixed commemoration of Our Lady's Seven Sorrows for that
+ Friday, EVERY year, confirmed real) -- and missalemeum shows Joseph
+ entirely DISPLACED that year, with the Friday's own office (III class,
+ violet) observed and the Seven Sorrows commemorated instead. RG 91's
+ plain table (entry 11-13, I-class feast, vs entry 22, an ordinary III-
+ class Passiontide feria) gives Joseph the day outright, with no general
+ RG 96 collision requiring a transfer -- so either (a) colitur's plain
+ reading is right and missalemeum is wrong, (b) a MORE SPECIFIC rubric
+ (most plausibly attached to St Joseph's own Proprium Sanctorum entry,
+ the same shape as the Annunciation's own Attamen clause, register §4)
+ overrides the general table for this exact collision and this task did
+ not find its text, or (c) the Seven Sorrows commemoration itself
+ somehow outranks an ordinary I-class feast on this one Friday, which
+ nothing found here supports either. Extensive but non-exhaustive
+ primary-source search (see the task report) did not settle it.
+ Separately, but confirmed regardless of the adjudication above: colitur
+ does not implement the Seven-Sorrows-of-Passion-Friday commemoration at
+ all, in any year -- a real, primary-attested gap, register §6 open
+ item. *)
+let m13_dates = [ "2027-03-19" ]
+
+let layer_m_reason (c : colitur_row) (o : oracle_row) diffs =
+ if diffs = [] then None
+ else if List.mem c.c_date m1_dates && subset diffs [ Rank; Colour_f; Comm_presence ] then Some "M1"
+ else if contains_substring o.o_title ~needle:bvm_saturday_title && diffs = [ Colour_f ] then Some "M2"
+ else if List.mem c.c_date m3_dates && diffs = [ Colour_f ] then Some "M3"
+ else if List.mem c.c_date m4_dates && diffs = [ Comm_presence ] then Some "M4"
+ else if List.mem c.c_date m5_dates && diffs = [ Comm_presence ] then Some "M5"
+ else if List.mem c.c_date m6_dates && diffs = [ Comm_presence ] then Some "M6"
+ else if List.mem c.c_date m7_dates && diffs = [ Comm_presence ] then Some "M7"
+ else if List.mem c.c_date m8_dates && diffs = [ Comm_presence ] then Some "M8"
+ else if List.mem c.c_date m9_dates && diffs = [ Comm_presence ] then Some "M9"
+ else if List.mem c.c_date m14_dates && diffs = [ Comm_presence ] then Some "M14"
+ else if List.mem c.c_date m10_dates && diffs = [ Comm_presence ] then Some "M10"
+ else if List.mem c.c_date m11_dates && diffs = [ Comm_presence ] then Some "M11"
+ else if List.mem c.c_date m12_dates && diffs = [ Comm_count ] then Some "M12"
+ else if List.mem c.c_date m13_dates && subset diffs [ Rank; Colour_f ] then Some "M13"
+ else None
+
+(* ---------------------------------------------------------------------- *)
+(* data/ef/expected-divergences-missalemeum.sexp loading -- same shape as *)
+(* test_differential.ml's own [allow_entry]/[load_allow_list]. *)
+(* ---------------------------------------------------------------------- *)
+
+open Sexplib0.Sexp_conv
+
+type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int }
+[@@deriving sexp]
+
+let load_allow_list () =
+ let sexps =
+ try Sexplib.Sexp.load_sexps allow_list_path
+ with e -> Alcotest.failf "%s: failed to load: %s" allow_list_path (Printexc.to_string e)
+ in
+ List.map allow_entry_of_sexp sexps
+
+(* ---------------------------------------------------------------------- *)
+(* The comparison itself. *)
+(* ---------------------------------------------------------------------- *)
+
+type outcome = Matched | Explained of string | Unexplained of field list
+
+let compare_streams () =
+ let oracle = oracle_rows () in
+ let colitur = colitur_rows_2026_2027 () in
+ (oracle, colitur)
+
+let classify oracle colitur =
+ List.map2
+ (fun (o : oracle_row) (c : colitur_row) ->
+ if not (String.equal o.o_date c.c_date) then
+ Alcotest.failf "streams misaligned: oracle %s vs colitur %s" o.o_date c.c_date;
+ let diffs = diff_fields c o in
+ if diffs = [] then (c, o, Matched)
+ else
+ match layer_m_reason c o diffs with
+ | Some id -> (c, o, Explained id)
+ | None -> (c, o, Unexplained diffs))
+ oracle colitur
+
+let describe_unexplained (c : colitur_row) (o : oracle_row) diffs =
+ Printf.sprintf "%s: %s differ -- colitur=(rank=%d colour=%c comms=[%s]) oracle=(rank=%d colours=[%s] \
+ title=%S comms=[%s] displaced=[%s])"
+ c.c_date
+ (String.concat "," (List.map field_name diffs))
+ c.c_rank c.c_colour
+ (String.concat ";" c.c_commemorations)
+ o.o_rank
+ (String.concat "" (List.map (String.make 1) o.o_colours))
+ o.o_title
+ (String.concat ";" o.o_commemorations)
+ (String.concat ";" o.o_displaced)
+
+let test_fixture_checksum () =
+ Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256
+ (sha256_of_file fixture_path)
+
+let test_dates_align () =
+ let oracle, colitur = compare_streams () in
+ Alcotest.(check int) "both streams have 730 rows (2026 + 2027, both non-leap)" 730 (List.length oracle);
+ Alcotest.(check int) "colitur recomputed the same number of rows" (List.length oracle) (List.length colitur);
+ let mismatched =
+ List.filter_map
+ (fun (o, c) -> if String.equal o.o_date c.c_date then None else Some (o.o_date, c.c_date))
+ (List.combine oracle colitur)
+ in
+ Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched
+
+(* The core assertion: every one of the 42 raw differences across all four
+ axes (rank, colour-membership, commemoration-presence, commemoration-
+ count) is named in the cited allow-list. Nothing else passes silently. *)
+let test_no_unexplained_differences () =
+ let oracle, colitur = compare_streams () in
+ let classified = classify oracle colitur in
+ let unexplained =
+ List.filter_map
+ (fun (c, o, outcome) ->
+ match outcome with Unexplained diffs -> Some (describe_unexplained c o diffs) | _ -> None)
+ classified
+ in
+ Alcotest.(check (list string)) "no differences outside the cited allow-list" [] unexplained
+
+(* Teeth, not just green: EVERY allow-list id's actual row count over this
+ fixture must equal what data/ef/expected-divergences-missalemeum.sexp
+ declares, in BOTH directions -- the same double-check test_differential
+ .ml's own "Layer C counts match citations" test makes for the lectio
+ allow-list. *)
+let test_layer_m_counts_match_citations () =
+ let oracle, colitur = compare_streams () in
+ let classified = classify oracle colitur in
+ let actual_counts = Hashtbl.create 16 in
+ List.iter
+ (fun (_, _, outcome) ->
+ match outcome with
+ | Explained id ->
+ Hashtbl.replace actual_counts id (1 + Option.value ~default:0 (Hashtbl.find_opt actual_counts id))
+ | _ -> ())
+ classified;
+ let declared = load_allow_list () in
+ let expected = List.sort compare (List.map (fun e -> (e.id, e.expected_rows)) declared) in
+ let actual = List.sort compare (Hashtbl.fold (fun id n acc -> (id, n) :: acc) actual_counts []) in
+ Alcotest.(check (list (pair string int)))
+ "every allow-list id's actual row count matches its citation's expected_rows, and no id is unused or \
+ undeclared"
+ expected actual
+
+let suite =
+ ( "oracle (missalemeum, EF, 2026-2027)",
+ [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum;
+ Alcotest.test_case "streams are 730 rows each, dates aligned 1:1" `Quick test_dates_align;
+ Alcotest.test_case "every difference is named in the cited allow-list -- none unexplained" `Quick
+ test_no_unexplained_differences;
+ Alcotest.test_case "allow-list counts match data/ef/expected-divergences-missalemeum.sexp exactly"
+ `Quick test_layer_m_counts_match_citations
+ ] )