diff options
Diffstat (limited to 'test/test_oracle.ml')
| -rw-r--r-- | test/test_oracle.ml | 607 |
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 + ] ) |
