(* Task 15: differential harness vs lectio (sibling project, Go), EF only, 2005-2050 -- validation layer 3 of the design spec's five (colitur CLAUDE.md "Validation" section; layers 1-2, Types and Property, are already built and green). lectio's own EF stream is committed as a fixture (test/fixtures/lectio-ef-2005-2050.txt, provenance -- including the SHA-256 this file's own [test_fixture_checksum] asserts, so a hand-edit or partial re-copy of the fixture fails loudly rather than silently becoming an unlabelled snapshot -- in the sibling .provenance file next to it); colitur's side is recomputed fresh from this library on every run, through the SAME pipeline `colitur day` uses (Colitur_kernel.Calendar over the real data/ef/sanctoral.sexp + adjustments.sexp), not the compiled binary. *** THREE STATED LIMITS THIS COMPARATOR DOES NOT PRETEND TO EXCEED *** 1. Commemorations are NOT compared. lectio's trailing "+other-slug" tokens are the LOSING sanctoral candidates for the day (its own dumper's doc comment says so), not an RG 111 admitted set -- lectio has no RG 111 admission logic at all. Comparing that column would compare colitur's real admitted commemorations against lectio's rejects, which proves nothing. Of the eleven fixed columns the fixture's own dumper now prints, nine are read from either stream: the original seven (date weekday season week slug rank colour) plus, since Task 8, the two reading-citation columns (first, gospel) -- name_en/name_pl (the remaining two) are parsed and immediately discarded, never compared (this file's own [row_of_line] comment says why). 2. lectio's own EF oracle test (~/git/projects/lectio, internal/calendar/oracle_ef_test.go) asserts ONLY season, strictly, against missalemeum, and ONLY for 2025-2026. Rank and colour are logged there, never asserted. So lectio's advertised "0-error vs references 2005-2050" covers season-exactness for the EF, not full-row-exactness. A rank or colour difference below is therefore NOT presumptive evidence that colitur is wrong -- every one is adjudicated against the Missal/RG register directly (data/ef/expected-divergences.sexp), never against lectio's say-so. (Layer 4, the oracle differential against missalemeum itself, is what actually validates rank and colour; that is a later task.) 3. The WEEK column is not compared, at all, on either side. Two reasons, both confirmed by reading lectio's own source (cmd/lectio-ef-dump/main.go's doc comment): first, lectio prints week "0" (rendered "-") "where no season week applies, e.g. named I class feasts and per annum green-season ferias" -- an internal DISPLAY convention of lectio's, not a liturgical fact, so there is nothing to cross-check it against. Second, even where both sides print a real number, the two engines anchor Time-after-Epiphany week numbering differently (colitur: the first Sunday on/after Epiphany; lectio: a fixed offset from 6 January -- register §3c item 5) and the offset between them is NOT constant (it depends on which weekday 6 January falls on, and re-synchronises mid-season), so no clean formula-based check is possible without reimplementing lectio's own algorithm here. Slug identity, rank and colour -- where the real liturgical substance lives -- remain fully and separately compared; only the bare integer is out of scope. See the report for the concrete rows this weakens. *** THE THREE-LAYER DESIGN (controller ruling, Task 15 dispatch) *** REFRESHED (2026-08-12, task 2026-08-12-colitur-rg16a, branch ef-rebootstrap): the fixture was regenerated from lectio's CURRENT HEAD (3b32c00), which has fixed seven EF calendar defects since the fixture was first pinned (commit 2386a45) -- see the fixture's own provenance note for the exact commits and defect list. Of 16801 day-pairs (2005-2050), 11826 now already match on the raw seven columns (up from roughly 11201 against the stale fixture); of the 4975 that don't, only 10 distinct RAW (field-diff, including the never-compared week column) signatures cover all of them, independently recomputed against this fixture and colitur's current output, not copied from the original task-15-class-summary.txt (which described the OLD fixture and is stale). After Layers A and B below strip the week column and the vocabulary/numbering artifacts, only 540 of those 4975 remain genuine differences -- all 540 explained by Layer C (368 + 138 + 31 + 3, see each entry's own citation), 0 unexplained. They resolve into three strictly separate layers: - Layer A (this file's [norm_season]/[norm_slug]): vocabulary. A declarative, explicit, closed table of naming synonyms that carry no liturgical substance -- lectio's "easter"/"christmas" ARE colitur's "paschaltide"/"christmastide"; a handful of slugs are two different engines' names for the identical office (Christmas Vigil, Holy Name Sunday, the Pentecost-octave Ember days, the two Passiontide weeks). Every entry is a literal string pair, never a pattern -- widening this to a wildcard is exactly how a real bug would get hidden, so it is not done even where it would shorten the table. - Layer B ([strip_epiphany_index]): numbering. The ONE case in the whole 4975 where a slug's embedded index genuinely cannot be reconciled by a literal table (Time-after-Epiphany's non-constant offset, limit 3 above) -- both sides' embedded week digit is stripped to a common family+weekday form before comparing, while rank and colour (which carry no week-index information for an ordinary green-season feria/Sunday) remain fully compared, so a genuine identity bug in this family would still be caught by everything except the digit itself. - Layer C (data/ef/expected-divergences.sexp, matched by [layer_c_reason] below): the CITED allow-list. Each entry cites its RG paragraph and states which engine is right (always colitur, verified against the Missal/register, never against lectio's own behaviour -- "lectio does it differently" is not itself a justification anywhere in this file). This is the ONLY layer that may cover a difference in rank, colour, or which celebration is observed; A and B never do (enforced structurally below: A/B only ever touch the season/slug fields, and Layer C's predicates each require an exact, narrow field-diff SET, not "anything goes"). REFRESHED (2026-08-12, ef-rebootstrap fixture regeneration): lectio's own fix wave (2386a45 -> 3b32c00, provenance note has the full commit list) independently fixed the exact defects nine of the thirteen entries this file used to carry were about -- C2, C3, C4, C5, C7, C10, C11, C12 and C13 all now match ZERO rows against the refreshed fixture and were CLOSED (removed from data/ef/expected-divergences.sexp and from [layer_c_reason] below), each with its citation preserved and the closure recorded in docs/research/rules-register.md, not silently deleted -- see the report for the full before/after account. C9's old shape ("St Joseph observed ON a Lent Sunday", lectio's own defect 4) also closed the same way, but a NARROWER, different divergence involving the same saint survived the refresh and was re-cited as a new entry, C14 (below), not folded back into the old C9 id (the two are different rules; conflating them would be exactly the "count proving cardinality where identity was required" vacuity flavour this project's harnesses are specifically checked against). Three entries survive unchanged in kind: C1 (368 rows, up from 361 -- the 7 rows C13 used to carve out fold back into C1's own shape now that lectio's slug matches there too, see C1's own note), C6 (138 rows, unchanged count but a narrower shape -- lectio fixed the RANK this entry used to also cover, leaving only the SLUG naming difference, see C6's own note) and C8 (31 rows, wholly unchanged -- lectio still computes no Rogation days at all). 13 January (register §6's long-open "Baptism of the Lord" item) is EMPIRICALLY CONFIRMED FIXED, not separately allow-listed, in ALL 46 years again as of this refresh: lectio's own fix wave independently fixed its equivalent of the same RG16(a) defect the now-closed C13 used to allow- list (its slug now matches colitur's `commemoration-of-the-baptism-of- the-lord` on all 7 of the Sunday years too, not only the 39 non-Sunday ones), so the residual difference on every one of the 46 rows is season alone -- covered by C1, the Jan 6-13 boundary, with no separate entry needed any more. *) 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 Subject = Colitur_kernel.Subject module Citation = Colitur_kernel.Citation module V = Rite_ef.Vocab_ef (* Same relative paths test_rite_ef.ml/test_sanctoral_ef.ml use: 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/lectio-ef-2005-2050.txt" let allow_list_path = "../data/ef/expected-divergences.sexp" (* The fixture's own provenance note (test/fixtures/lectio-ef-2005-2050.provenance) records this same digest, so it is discoverable by a reader who never runs the suite. Recorded here too, and ASSERTED (fix round 1, finding 4): a provenance note is enough to REGENERATE the fixture but says nothing about whether the committed bytes still match the commit they claim to come from -- an unnoticed hand-edit or partial re-copy would silently turn the whole oracle into an unlabelled snapshot of whatever someone last ran. Regenerate this constant (and the provenance note's copy) together, deliberately, after re-running the exact command the provenance note names -- never by copying the actual value back in to make a mismatch pass, which would defeat the point of pinning it at all. *) let fixture_sha256 = "8c0b9d9fc284138e69dbbbd87e9153a17f1abb69a39928464d83d248a819f376" (* Same technique tools/bootstrap_sanctoral.ml already uses for this exact purpose (that file's own comment: shelling out to the system's [sha256sum], not an OCaml crypto library -- Task 15's deps are frozen). Unlike that tool, this avoids even the (already-permitted, per that file's own comment, "already in the switch") [unix] library: [Sys.command] plus a redirected-to-file capture needs nothing beyond the Stdlib every dune executable already links. Depends on [sha256sum] being on PATH, which every environment this suite has actually run in (Debian, per CLAUDE.md) provides via coreutils; a machine without it fails this check with a command-not-found exit code rather than silently skipping it. *) let sha256_of_file path = let tmp = Filename.temp_file "colitur_differential_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 (* [Rite_ef.context] takes [~lectionary] (fix round 1, coordinator review) -- caller-supplied, same as [real_layer] above. *) let real_lectionary () = match Colitur_kernel.Lectionary.load "../data/ef/lectionary.sexp" with | Ok l -> l | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e (* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam as the lectionary above, and [~commons] is required rather than defaulted so that no caller can silently run with none -- nothing in layers 3-5 compares reading citations, so a rite quietly missing its Commons would be invisible. Loaded here even where this file asserts nothing about readings, so that the rite under test is the same one bin/main.ml assembles. *) let real_commons () = match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with | Ok c -> c | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e (* --- The seven-column row both streams share (commemorations excluded, --- *) (* limit 1 above). *) type row = { date : string; weekday : string; season : string; week : string; slug : string; rank : string; colour : string; (* Fix round 1 (coordinator finding F3): ONLY ever populated for colitur's own row (see [colitur_rows_2005_2050] below) -- lectio's own fixture format has no subject column at all, so [row_of_line] (the lectio-side parser) sets this to "-", a placeholder never read on that side. Every Layer C predicate that reads [subject] must therefore read it off the COLITUR row [c], never [l] -- the same asymmetry [row]'s other fields do not have, called out here rather than left implicit. *) subject : string; (* Task 8: the day's resolved Epistle/Lesson and Gospel citations, ALREADY DECODED (lectio's own "_"-for-space convention reversed at parse time, [decode_field] below; colitur's own [Citation.reference] never contains an underscore in the first place, so decoding it is a no-op) -- both sides therefore carry the same "-" sentinel for "no reading resolved" that the fixture's own name/week columns already use, so a plain [String.equal] is the whole comparison, matching every other field in this record. *) first : string; gospel : string; } 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 [] (* Reverses the fixture's own "_" -for-space, "-" -for-absent encoding (shared by the name_en/name_pl columns and, since Task 7's lectio-ef-dump refresh, by first/gospel too -- see the fixture's own .provenance note). Verified against the real file, not assumed: a citation like ["Rom_13:11-14"] round-trips to ["Rom 13:11-14"], and the literal sentinel ["-"] (no underscore in it) passes through unchanged, so one function serves both the "real value" and the "absent" case without a special-cased branch. *) let decode_field s = String.map (fun c -> if c = '_' then ' ' else c) s let row_of_line line = match String.split_on_char ' ' line with | date :: weekday :: season :: week :: slug :: rank :: colour :: _name_en :: _name_pl :: first :: gospel :: _others -> { date; weekday; season; week; slug; rank; colour; subject = "-"; first = decode_field first; gospel = decode_field gospel } | _ -> Alcotest.failf "malformed fixture line (fewer than 11 fields): %S" line let lectio_rows () = List.map row_of_line (read_lines fixture_path) (* Recomputes colitur's day-by-day output straight from the library -- exactly [Colitur_kernel.Calendar.year] + [Rite_ef.context] over the real committed data, the same pipeline bin/main.ml's `colitur day` runs (that file's own [day_report]/[day_line] comments explain the two-liturgical- year-per-civil-year indexing this mirrors). Duplicated here rather than shared with bin/main.ml (an executable, not a library) -- the same choice test_rite_ef.ml already made for [real_layer] above. *) let colitur_rows_2005_2050 () = let layer = real_layer () in let rite = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) in let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 20000 in for y = 2004 to 2050 do let days = Cal.year rite 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 for y = 2005 to 2050 do let d = ref (mk y 1 1) in let stop = mk y 12 31 in while Date.compare !d stop <= 0 do (match Hashtbl.find_opt by_rata (Date.to_rata !d) with | Some day -> let t = day.LD.temporal in let cel = day.LD.observed in let week = match t.Colitur_kernel.Temporal.week with Some n -> string_of_int n | None -> "-" in (* [Rite.readings] already resolved the day's citations (calendar.ml's own [build_day]); this just projects the ONE reading of each PART lectio's own dumper prints ("first"/"gospel" -- neither engine's EF stream carries Psalm/Second/Tract/etc, task-7-report.md's own note on why). "-" for a part with no resolved reading, the same sentinel [row_of_line]'s [decode_field] leaves an absent lectio field as, so the two sides compare with a plain [String.equal] like every other field here. *) let citation_ref part = match List.find_opt (fun (c : Citation.t) -> c.Citation.part = part) day.LD.citations with | Some c -> c.Citation.reference | None -> "-" in rows := { date = Date.to_iso8601 day.LD.date; weekday = Date.weekday_to_string t.Colitur_kernel.Temporal.weekday; season = V.season_to_string t.Colitur_kernel.Temporal.season; week; slug = Slug.to_string cel.Cel.slug; rank = V.rank_to_string cel.Cel.rank; colour = Colour.to_string cel.Cel.colour; subject = Subject.to_string cel.Cel.subject; first = citation_ref Citation.First; gospel = citation_ref Citation.Gospel } :: !rows | None -> Alcotest.failf "internal error: no resolved day for %s" (Date.to_iso8601 !d)); d := Date.add_days !d 1 done done; List.rev !rows (* ---------------------------------------------------------------------- *) (* Layer A: vocabulary. Explicit, closed tables; no wildcards, no pattern *) (* matching against arbitrary content -- every case below is a literal *) (* string compared against a literal string. *) (* ---------------------------------------------------------------------- *) (* lectio's season word -> colitur's season word. The ONLY two seasons that are spelled differently; every other season word is shared verbatim (both call Advent "advent", Lent "lent", etc.). *) let norm_season = function | "easter" -> "paschaltide" | "christmas" -> "christmastide" | s -> s let weekdays = [ "monday"; "tuesday"; "wednesday"; "thursday"; "friday"; "saturday" ] (* lectio's slug -> the colitur slug for the SAME office, where the two projects simply chose different names. [month]/[lectio_rank] disambiguate the two cases where lectio reuses one slug for what colitur names two different ways (see each branch's own comment). *) let rec norm_slug ~month ~lectio_rank slug = if String.equal slug "vigil-of-christmas" then "ef-nativity-vigil" (* register §6: lectio names its vigils "vigil-of-X" (prefix); colitur's own convention is "X-vigil" (suffix). Same celebration -- confirmed a genuine duplicate in Task 11, where colitur's overlay suppresses lectio's key entirely from the sanctoral layer. *) else if (* "ef-christmas-sunday-0"/"ef-christmas-0-" mean TWO different things in lectio depending on which window they fall in: the Sunday within Holy Name week (2-5 January, colitur's own named feast) or an ordinary day within the Nativity Octave (26-31 December, where colitur uses its own ef-nativity-octave-day-N naming instead, Layer C's C6 -- that window keeps a genuine RANK difference too, so it must not be absorbed here). Gating on [month = 1] keeps this table from ever touching the December occurrence. *) month = 1 then if String.equal slug "ef-christmas-sunday-0" then "ef-holy-name-sunday" else match List.find_opt (fun wd -> String.equal slug ("ef-christmas-0-" ^ wd)) weekdays with | Some wd -> "ef-christmas-1-" ^ wd | None -> norm_slug_rest ~lectio_rank slug else norm_slug_rest ~lectio_rank slug and norm_slug_rest ~lectio_rank slug = (* Passiontide: lectio never distinguishes Passion week (colitur's week 1, class-3 ferias) from Holy week (colitur's week 2, class-1 ferias) in the slug -- both print "ef-passiontide-0-". Rank, independently and strictly compared elsewhere, is what actually tells the two weeks apart (RG 91 entries 22 vs 2/7), so using it here to pick the expected colitur digit does not launder away a genuine identity bug: a colitur bug that mixed up the two weeks would, on the evidence available in this stream, also very likely show up as a rank mismatch of its own. *) match List.find_opt (fun wd -> String.equal slug ("ef-passiontide-0-" ^ wd)) weekdays with | Some wd -> ( match lectio_rank with | "class-3" -> "ef-passiontide-1-" ^ wd | "class-1" -> "ef-passiontide-2-" ^ wd | _ -> slug) | None -> ( match slug with | "ef-easter-8-wednesday" -> "ef-pentecost-ember-wed" | "ef-easter-8-friday" -> "ef-pentecost-ember-fri" | "ef-easter-8-saturday" -> "ef-pentecost-ember-sat" | s -> s) (* ---------------------------------------------------------------------- *) (* Layer B: numbering (register §3c item 5). The Time-after-Epiphany *) (* week-index embedded in a slug is stripped to a common form on BOTH *) (* sides before comparing -- see limit 3 in this file's header comment *) (* for why a table (Layer A's tool) cannot do this instead. *) (* ---------------------------------------------------------------------- *) let starts_with ~prefix s = let lp = String.length prefix in String.length s >= lp && String.equal (String.sub s 0 lp) prefix let is_digit_string s = s <> "" && String.for_all (fun c -> c >= '0' && c <= '9') s let strip_epiphany_index slug = let prefix = "ef-time-after-epiphany-" in if not (starts_with ~prefix slug) then slug else let rest = String.sub slug (String.length prefix) (String.length slug - String.length prefix) in match String.index_opt rest '-' with | None -> slug | Some i -> let left = String.sub rest 0 i in let right = String.sub rest (i + 1) (String.length rest - i - 1) in if is_digit_string left && List.mem right weekdays then prefix ^ right else if String.equal left "sunday" && is_digit_string right then prefix ^ "sunday" else slug (* ---------------------------------------------------------------------- *) (* Field-diff computation: applies Layers A and B, then reports exactly *) (* which of the SEVEN substantive columns still differ (week is never *) (* inspected at all -- limit 3). weekday is included defensively: dates *) (* are checked 1:1 aligned before this runs, so it should never fire, and *) (* if it ever does that is real signal, not noise to normalise away. *) (* *) (* Task 8: First_f/Gospel_f join the SAME diff set the other five fields *) (* share -- deliberately, not a separate pass gated on "only when the day *) (* otherwise matches". A citation difference on a day whose identity *) (* ALSO differs is not a second, independent fact needing its own *) (* citation -- it is the same divergence Layer C already explains *) (* (different office observed, therefore a different Mass read); each *) (* Layer C predicate below is widened to accept First_f/Gospel_f *) (* alongside its own existing field set wherever that is what the real *) (* fixture data shows, never blanket-permitted. *) (* ---------------------------------------------------------------------- *) type field = Weekday | Season | Slug_f | Rank | Colour_f | First_f | Gospel_f let field_name = function | Weekday -> "weekday" | Season -> "season" | Slug_f -> "slug" | Rank -> "rank" | Colour_f -> "colour" | First_f -> "first" | Gospel_f -> "gospel" let month_of_date date = int_of_string (String.sub date 5 2) let diff_fields (l : row) (c : row) = let m = month_of_date l.date in let l_season = norm_season l.season in let l_slug = strip_epiphany_index (norm_slug ~month:m ~lectio_rank:l.rank l.slug) in let c_slug = strip_epiphany_index c.slug in List.filter_map (fun x -> x) [ (if String.equal l.weekday c.weekday then None else Some Weekday); (if String.equal l_season c.season then None else Some Season); (if String.equal l_slug c_slug then None else Some Slug_f); (if String.equal l.rank c.rank then None else Some Rank); (if String.equal l.first c.first then None else Some First_f); (if String.equal l.gospel c.gospel then None else Some Gospel_f); (if String.equal l.colour c.colour then None else Some Colour_f) ] (* ---------------------------------------------------------------------- *) (* Layer C: the cited allow-list (data/ef/expected-divergences.sexp). *) (* Each predicate below names the [id] it matches; the sexp file carries *) (* that id's citation, verdict and expected row count. A predicate fires *) (* only on an EXACT, narrow field-diff set -- never "any difference at *) (* all" -- so it cannot silently absorb a difference outside what its own *) (* citation actually explains. *) (* ---------------------------------------------------------------------- *) let subset xs ys = List.for_all (fun x -> List.mem x ys) xs let day_of_date date = int_of_string (String.sub date 8 2) (* ef-rebootstrap fixture refresh (2026-08-12): C2, C3, C4, C5, C7, C10, C11, C12 and C13's own predicates/helper bindings (sunday_iclass_slugs, rose_sunday_slugs, advent_feria_slug, fixed_iii_class_reclassified_slugs, jan13_lord_sunday_dates_2005_2050, and the inline C7/C10/C11 date/slug checks) are REMOVED here, not left dead -- each matched zero rows against the refreshed fixture (lectio's own fix wave, 2386a45 -> 3b32c00, independently fixed the exact defects these entries were about), so their citations moved to docs/research/rules-register.md (closure record, with the RG paragraph preserved) and their sexp rows to nothing -- data/ef/expected-divergences.sexp no longer declares those ids at all. Removing a predicate strengthens this harness the same way removing its sexp row does: if any of these nine shapes ever reappears (a regression in colitur, or lectio moving again), it now surfaces as UNEXPLAINED instead of being silently re-absorbed by a citation whose own divergence no longer exists. *) (* Fix round 1, finding 1: C1 and C6 (below) originally gated on calendar date alone, with no slug/slug-family check -- unlike every other entry here. The reviewer constructed the failure this leaves open: if a future sanctoral regeneration made some OTHER 29-31 December celebration win the day (colliding coincidentally with C6's own [Slug_f; Rank] diff shape), it would be silently absorbed under "RG 91 entry 17, Nativity Octave" -- a citation that has nothing to do with the real cause. [subset diffs [...]] alone was never enough; the SLUG that actually won must also be the one each citation is about. Both lists below are exact literals (the Nativity Octave's three colitur-only day slugs; the closed set of offices that can legitimately observe C1's Jan 6-13 window), not patterns -- a slug outside them fails through to [None] instead of being absorbed. *) let jan_6_13_slug slug = String.equal slug "ef-epiphany" || String.equal slug "commemoration-of-the-baptism-of-the-lord" || List.exists (fun wd -> String.equal slug ("ef-christmas-2-" ^ wd)) weekdays let nativity_octave_day_slugs = [ "ef-nativity-octave-day-5"; "ef-nativity-octave-day-6"; "ef-nativity-octave-day-7" ] (* C18 (ef-sanctoral-audit, 2026-08-14): the closed set of colitur [status = Feast] sanctoral slugs whose bootstrapped [colour] was corrected against RG 124 (data/ef/adjustments.sexp's own audit-block comment has the full citation and per-slug reasoning); every one of these still carries lectio's OLD, uncorrected colour, so a row where this is the ONLY colitur candidate observed for its own date diffs on [Colour_f] alone whenever it wins the day (most years -- these are all ordinary universal feasts, rarely impeded). Deliberately a literal list, not a shape predicate, the same discipline C6/C14/C15/C16 already use: a colour diff on any OTHER slug must still surface as unexplained. Eight further audit corrections (prisca/peter/vitus/margaret/agapitus/liborii/ mark-i, all Commemoration_only, plus the new `barbara` Add) are NOT listed here and need no predicate: [colitur_rows_2005_2050] emits only the OBSERVED day's own record, and a [Commemoration_only] candidate can never be observed (Precedence.resolve's own design), so their corrected colours have no row in this comparison to explain. *) let audit_colour_corrected_slugs = [ "conversion-of-st-paul"; "chair-of-st-peter"; "john-of-san-fecundo"; "ephrem-of-syria"; "julia-of-falconieri"; "john-gualbert"; "camillus-de-lellis"; "jerome-emiliani"; "apollinaris"; "martha"; "alphonsus-liguori"; "augustine"; "rose-of-lima"; "josaphat" ] (* C14 (ef-rebootstrap fixture refresh, 2026-08-12; replaces the closed C9, see data/ef/expected-divergences.sexp's own C14 note for the full RG citation): the exact 3 civil days, across the whole 2005-2050 window, where St Joseph's (19 March, I class) RG 96 transfer walk is congested enough by Passiontide/Holy Week/the Easter octave to cross Easter AND collide with the Annunciation's own transferred "sedes propria" (RG 96 Attamen (a), Monday after Low Sunday) -- independently re-derived from this fixture and colitur's own output (`grep joseph-spouse` both sides, filtered to rows with a real post-Layer-A/B diff), not copied from the register's prior "2008, 2035, 2046" prose. A LITERAL date list, not a slug predicate, per the SAME discipline C12/C13 already used (a plain "involves this slug" predicate is exactly what let the old C9 silently cover two textually-unrelated divergences at once -- see C14's own sexp note for the full account of why that was wrong). *) let joseph_annunciation_collision_dates_2005_2050 = [ "2008-04-01"; "2035-04-03"; "2046-04-03" ] (* C15 -- ef-rg112-rg110 task: SPLIT OUT of C1's own blanket 6-13 January window, not a new kind of divergence -- the same discipline C13 (now closed, see the header above) already established for this exact date, and the SAME check the task brief asked for explicitly ("if C1 now absorbs a different observed celebration, split it out"). Before this task, colitur had no Holy Family office at all: on these seven dates (13 January, the one civil day it falls on a Sunday in the 2005-2050 window -- 2008, 2013, 2019, 2030, 2036, 2041, 2047), colitur's own generic Sunday fallback happened to lose outright to the fixed Commemoration of the Baptism of the Lord (RG16(a)'s existing "festum Domini beats an ordinary Sunday" mechanism, subject Lord already present in the REBOOTSTRAPPED data), producing the SAME slug lectio's own tridentine- calendar.ini shows for that date ("commemoration-of-the-baptism-of-the- lord") -- only [Season] differed (RG 72-73 vs lectio's own 6 January boundary), squarely inside C1's own shape. Now that Holy Family is built (RG 17(b)) and correctly outranks the fixed Baptism (RG 91 entry 14, "primum mobilia, deinde fixa"; RG 112(a) excludes the Baptism as a commemoration too, both this task's own precedence_ef.ml changes), colitur's own slug on these seven dates changes to "ef-time-after-epiphany-sunday-1" (Holy Family's own, unchanged from the ordinary-Sunday key it always carried, temporal_ef.ml's own comment on why) -- a GENUINE identity divergence against lectio, which has no Holy Family at all and still shows the fixed Baptism observed there. Gated on the literal 7-date list AND colitur's own slug (the same guard C1/C6/C8/ C14 already apply, fix round 1's own finding 1: a predicate must pin WHICH celebration it is about, not only the date). *) let holy_family_baptism_collision_dates_2005_2050 = [ "2008-01-13"; "2013-01-13"; "2019-01-13"; "2030-01-13"; "2036-01-13"; "2041-01-13"; "2047-01-13" ] (* C16 -- ef-holyname-rg110 task: RG 17(a)'s own fallback (2 January, "secus die 2 ianuarii"), for every civil year 2005-2050 with no Sunday 2-5 January -- independently re-derived against `date -d -01-0{2..5} +%u`, not transcribed from the register's domain-wide figure (see data/ef/expected-divergences.sexp's own C16 note for the full citation and the derivation). lectio has no equivalent fallback at all (checked directly against tridentine-calendar.ini, which carries no 01-02 entry), so it shows the plain Christmastide ferial slug on every one of these dates, class-4 -- colitur's own `ef-holy-name`, class-2, is a genuine [Slug_f; Rank] divergence, not a naming synonym Layer A could absorb. *) let holy_name_fallback_dates_2005_2050 = [ "2006-01-02"; "2007-01-02"; "2008-01-02"; "2012-01-02"; "2013-01-02"; "2017-01-02"; "2018-01-02"; "2019-01-02"; "2023-01-02"; "2024-01-02"; "2029-01-02"; "2030-01-02"; "2034-01-02"; "2035-01-02"; "2036-01-02"; "2040-01-02"; "2041-01-02"; "2045-01-02"; "2046-01-02"; "2047-01-02" ] (* C17 -- ef-bvm-saturday task: RG 91 entry 27 ("Officium sanctae Mariae in sabbato" -- see data/ef/expected-divergences.sexp's own C17 note for the full RG 78/RG 120(b) citation). lectio builds no equivalent office at all (checked directly against its own tridentine-calendar.ini/generator, the same "genuine upstream gap, not a colitur bootstrap miss" shape C15/C16 already document for their own gaps) -- it shows the ordinary season colour on every otherwise-unoccupied Class4 Saturday where colitur's own RG 78 fix now shows white. Season, slug and rank all still agree -- temporal_ef.ml's own [bvm_saturday_names] citation ("Slug" paragraph) deliberately REUSES the ordinary ferial slug rather than minting a new one, precisely so this predicate's diff set stays [Colour_f] alone, never [Slug_f] too. Gated on colitur's own [rank]/[weekday]/[colour] rather than a date list, the same shape M2 (data/ef/expected-divergences- missalemeum.sexp) already uses for the identical reason: this population is large (a large fraction of all Saturdays domain-wide) and entirely formulaic (RG 78's own condition, "otherwise unoccupied IV-class Saturday", reduces exactly to this triple), not a short, individually-interesting list of dates the way C14/C15/C16 above are. *) let is_bvm_saturday_row (c : row) diffs = (* Fix round 1 (coordinator finding F3): [c.subject] added -- the ONLY field in this predicate that pins WHICH celebration is observed, not merely its shape. Every other Layer C entry pins a colitur slug (C1's own [jan_6_13_slug], C6's [nativity_octave_day_slugs], C8/C14/C15/C16's own literal slug checks); this entry could not, because the office deliberately REUSES the ordinary ferial slug (Rite_ef.Temporal_ef's own [bvm_saturday_names] citation, "Slug" paragraph) -- there is no fixed slug string to pin. [subject = "bvm"] is the field that DOES uniquely identify the office (set nowhere else the differential's own [colitur_rows_2005_2050] can produce a Saturday/Class4/white combination for), closing the gap a shape-only predicate left open: without this conjunct, a FUTURE bug that made some OTHER white, Class4, Saturday candidate exist (Christmastide/Paschaltide, where [season_colour] is already white, so a real bug there could slip through unnoticed by colour alone) would be silently absorbed here too. *) diffs = [ Colour_f ] && String.equal c.weekday "saturday" && String.equal c.rank "class-4" && String.equal c.colour "white" && String.equal c.subject "bvm" (* C19 (task 8, branch ef-lectionary): the Time-after-Epiphany WEEK-INDEX offset (limit 3 in this file's own header comment, [strip_epiphany_index] above) is not merely a slug-digit cosmetic difference -- it is a real week-COUNT divergence between the two engines for the REST of the season whenever Holy Family (RG 17(b)) has just displaced the fixed Baptism commemoration (the same 7 years C15 above names): colitur's own week numbering runs one week "behind" lectio's until Time after Epiphany ends at Septuagesima (which re-synchronises both engines' counts from zero), so the SAME calendar day's Sunday-derived reading (whichever Sunday's Mass a feria repeats, or a Sunday's own Mass) is drawn from two adjacent-but-different weeks on each side -- a citation divergence with NO slug divergence at all (Layer B's own [strip_epiphany_index] already normalises the embedded digit away, correctly: the office FAMILY is the same, only which week's content is read differs). Confirmed empirically, not assumed: every occurrence across the whole 2005-2050 fixture falls in exactly these 7 years, and only on a [c.slug] in the [ef-time-after-epiphany-] family -- checked, not merely gated, so a real bug on an unrelated slug in one of these years still surfaces as unexplained. [Colour_f] joins the accepted set for the Saturdays that ALSO happen to carry C17's own BVM-Saturday office that week -- a second, independent, already-cited cause layering onto the SAME row, not something this predicate explains on its own; C17's own 416-row count is untouched, since its own guard still requires diffs = EXACTLY [Colour_f], which these rows (carrying [First_f]/[Gospel_f] too) never satisfy. *) let epiphany_week_shift_years_2005_2050 = [ 2008; 2013; 2019; 2030; 2036; 2041; 2047 ] let year_of_date date = int_of_string (String.sub date 0 4) (* C20 (task 8): lectio genuinely computes NO reading at all -- "-"/"-" on both parts -- for 14 civil days across 2005-2050, all seven of the readingless class-3 saints Task 6 gave a proper or a Common (data/ef/commons.sexp's own "OBSERVABILITY" note, task-6-report.md §1): colitur is MORE COMPLETE here, not wrong -- every one of these 14 answers is a direct Missal citation (data/ef/adjustments.sexp's own propers for thomas-aquinas/john-of-god/francis-of-paola, or data/ef/commons.sexp's own Common assignment for the other four), each read from three independent witnesses (task-6-report.md's own account of its own cross-check discipline), not invented. Gated on the literal 7-slug list (the same identity-pinning discipline every other entry here uses) AND on lectio's own field literally reading "-"/"-" -- never "any first/gospel mismatch on these slugs", which would also swallow a genuine future content disagreement on one of these same seven saints. *) let lectio_no_reading_slugs = [ "vincent-ferrer"; "francis-of-paola"; "isidore-of-seville"; "thomas-aquinas"; "john-of-god"; "sts-felicitas-perpetua"; "frances-rome" ] (* C21 -- RETIRED, task 8 fix round 1 (coordinator review). Previously a [verdict open] entry (107 rows) for "colitur's step 3 cannot reach a correct answer for the fixed Christmastide slugs". That diagnosis has been SUPERSEDED, not merely fixed around: Important 3(a)'s own direct Missal formulary (tools/bootstrap_lectionary.ml's own [nativity_octave_entries]) gives [ef-nativity-octave-day-{5,6,7}] a real, correct answer via STEP 2, not step 3 at all, so "step 3 cannot reach it" is no longer even true. What C21 used to cover has split cleanly into two DIFFERENT, more precisely diagnosed populations: colitur's own [ef-nativity-octave-day-N] rows now fold into C6 above (widened, [verdict] re-opened -- see its own note); the [ef-christmas-1-] rows that resume THAT slug's own (partly wrong, per RG 69) answer one hop later via step 3 are C25 below, a genuinely different id because the mechanism is now precisely RG 69 propagating through step 3, not "step 3 has no answer". No predicate or helper of this file's own still names "C21" -- removing the code alongside the id is the same discipline this file's own header already applies to C2-C5/C7/C9-C13 (closed, cited, never left as dead code). *) (* C22 (task 8, fix round 1, coordinator review, Critical 2): the Lenten Ember days (RG 91 entry 18, colitur's own ef-lent-ember-{wed,fri,sat}) are reachable for the first time this fix round -- tools/ bootstrap_lectionary.ml's own [colitur_keys] comment has the full account of the dead-key bug this closes. lectio's own SLUG for these three days already matched colitur's (both call it "ef-lent-ember-wed" etc, confirmed by this row's own [c.slug]/[l.slug] agreement -- no [Slug_f] in this entry's own diff set), but lectio's own READING resolution has the IDENTICAL internal mismatch colitur's used to have: its own ini keys this data under "ef-lent-1-", and its own [caldata.Readings] never reaches it either, so it still falls back to Lent I Sunday's own Mass (e.g. "2 Cor. 6:1-10"/"Matt 4:1-11") on every one of these dates. That is a genuine LECTIO bug, reported here for upstream, NOT fixed in this task (the brief's own triage rule: record, do not patch the sibling project) -- and it is exactly the "Holy Thursday was violet in both" shape this project's own CLAUDE.md already names: layer 3 was STRUCTURALLY BLIND to this the whole time colitur also had the bug, because both engines independently produced the same wrong answer. Gated on colitur's own slug family (a literal 3-member list, not a pattern) and the diff set staying [First_f; Gospel_f] alone (slug/rank/colour already agree). Derived directly from the OCaml comparator's own failure output, not hand-counted first and cross- checked after: 128 (43 + 43 + 42 -- a few of the 46-year population are impeded by a competing sanctoral feast each year, so the Ember office itself is not always the observed day). *) let lent_ember_slugs = [ "ef-lent-ember-wed"; "ef-lent-ember-fri"; "ef-lent-ember-sat" ] (* C23/C24 (task 8, fix round 1, coordinator review, Critical 1): Holy Week's own citations (tools/bootstrap_lectionary.ml's own [holy_week_entries] comment has the full Missal citations and the two- scan corroboration for each). lectio has no Holy Week propers at all -- every "ef-passiontide-0-" ini section is Passion week's own Mass (or, for Tuesday specifically, Holy Tuesday's own Passion Gospel riding along on Passion week's own Epistle -- a genuine internal inconsistency in lectio's own source, not this project's to fix), so lectio shows THAT reused/conflated citation on every Holy Week weekday, every year, regardless of what the real Missal prints there. C23 covers the four days colitur now answers correctly and completely (Monday, Tuesday, Thursday, Saturday) -- verdict colitur, cited directly. C24 covers the two days colitur deliberately answers with [] instead of a guess (Wednesday, Good Friday -- both genuinely multi-lesson liturgies with no single Epistle slot this schema could honestly fill; [holy_week_entries]'s own comment has the full argument for why forcing a pair would be an uncited editorial choice, not a Missal fact). Both entries: gated on colitur's own literal slug list and [diffs] staying exactly [First_f; Gospel_f] (identity already agrees on both). Every occurrence across the whole 2005-2050 fixture falls in all 46 years without exception, on every one of these six slugs -- Holy Week recurs every year by construction, so this is not a coincidental count the way a collision-driven entry's would be. Derived directly from the OCaml comparator's own failure output, not hand-counted first and cross-checked after: C23 184 (46 x 4), C24 92 (46 x 2). *) let holy_week_answered_slugs = [ "ef-passiontide-2-monday"; "ef-passiontide-2-tuesday"; "ef-passiontide-2-thursday"; "ef-passiontide-2-saturday" ] let holy_week_unanswered_slugs = [ "ef-passiontide-2-wednesday"; "ef-passiontide-2-friday" ] (* C25 (task 8, fix round 1, coordinator review, Important 3(b)) -- [verdict open], the SAME RG 69 gap C6 above re-opened, one hop removed. RG 69 (docs/research/scan1.txt:625-631, word for word: "De dominica infra octavam Nativitatis Domini... semper fit Officium cum commemoratione festi forte occurrentis... nisi dominica incidat in festum I classis") is unconditional: a Sunday landing 26-31 December should keep ITS OWN Office (with the day's feast merely commemorated), not the fixed weekday placeholder -- but colitur's [Temporal_ef] gives both the SAME slug, undifferentiated by weekday (C6's own note, and data/ef/expected-divergences.sexp's own C6 entry, have the full account). Confirmed, not merely inferred, that THIS entry is the direct, one-hop-removed consequence of that same gap: every single one of these rows' colitur-side citation is "Tit. 3, 4-7" (the fixed weekday formulary), because [Lectionary_ef.readings]' step 3 resumes the SAME wrongly-undifferentiated Sunday C6 already names, not a second, independent cause. A genuine, precisely-diagnosed [Temporal_ef] defect -- out of this task's own scope (a behaviour change to a shared, multi-round-reviewed kernel-adjacent function) -- recorded here per the coordinator's own instruction ("record it prominently... for a follow-up task"), not fixed and not left silently failing. Gated on the literal 4-slug family (the only members of [ef-christmas-1-] this mechanism can ever reach -- Monday and Tuesday of that same week are always claimed first by the Circumcision or Holy Name's own DIRECT entries, so they can never fall through to this specific step-3 chain) and [diffs] staying exactly [First_f; Gospel_f] (identity already agrees -- this is a pure content consequence). Derived directly from the OCaml comparator's own failure output, not hand-counted first and cross-checked after: 57 (19 + 19 + 13 + 6, Wednesday/Thursday/Friday/Saturday respectively). *) let rg69_one_hop_slugs = [ "ef-christmas-1-wednesday"; "ef-christmas-1-thursday"; "ef-christmas-1-friday"; "ef-christmas-1-saturday" ] (* [layer_c_reason l c diffs] returns the [data/ef/expected-divergences.sexp] [id] this row-pair's remaining (post Layer A/B) diff set belongs to, or [None] if nothing here explains it (a genuine, uncovered failure). *) let layer_c_reason (l : row) (c : row) diffs = let m = month_of_date l.date and d = day_of_date l.date in if diffs = [] then None else if m = 1 && d >= 6 && d <= 13 && subset diffs [ Season; Colour_f; Slug_f ] && (not (List.mem Slug_f diffs) || jan_6_13_slug c.slug) then Some "C1" else if m = 12 && (d = 29 || d = 30 || d = 31) (* NARROWED, fixture-refresh review: this was [subset diffs [ Slug_f; Rank ]]. Since lectio's own Christmas-octave rank was corrected (3b32c00) all 138 rows diff on [Slug_f] ALONE, and the note below says so -- but leaving [Rank] in the accepted set did not merely over-permit, it made the entry BLIND: every row already carries a Slug_f diff, so an added Rank diff changed neither the diff-set membership nor the count. Demonstrated by the reviewer: dropping the RG 91 entry 17 elevation in temporal_ef (Class2 -> Class4 on octave days 5-7) left BOTH differential tests green with C6 still reporting 138. The slug guard below already pins identity; [Rank] only removed the ability to notice a rank regression. WIDENED, task 8 fix round 1 (coordinator review, Important 3(a)/(b)): [First_f; Gospel_f] joined the accepted set. Important 3(a)'s own fix (tools/bootstrap_lectionary.ml's [nativity_octave_entries]) gives these dates a real, Missal-verified citation for the first time -- and it does NOT converge with lectio's own (differently incomplete) Sunday-walkback answer, so ALL 138 of this entry's rows now carry a citation diff too, not merely the slug one. See data/ef/expected- divergences.sexp's own C6 entry for why this entry's [verdict] is now [open], not [colitur] -- Important 3(b)'s own RG 69 finding. *) && subset diffs [ Slug_f; First_f; Gospel_f ] && List.mem c.slug nativity_octave_day_slugs then Some "C6" else if (String.equal c.slug "ef-rogation-monday" || String.equal c.slug "ef-rogation-tuesday") && subset diffs [ Season; Slug_f; Colour_f ] then Some "C8" else if List.mem l.date joseph_annunciation_collision_dates_2005_2050 (* Task 8: [First_f; Gospel_f] joined the accepted set -- Joseph's own transferred office (Ecclus 45:1-6/Matt 1:18-21, his own proper) now genuinely differs from the plain feria lectio shows for the same civil date, exactly BECAUSE the identity already differs (this predicate's own pre-existing [Slug_f; Rank] pair); not a second, independent citation-only divergence. *) && subset diffs [ Slug_f; Rank; First_f; Gospel_f ] (* Colitur-slug guard, added by the fixture-refresh review. Fix round 1's own finding 1 established that a Layer C predicate must pin WHICH celebration the citation is about, not merely the date -- C1, C6 and C8 all carry that guard; C14 was written after that ruling and omitted it. Demonstrated live: with [Calendar.compare_deferred] switched to RG 98 origin order, the observed celebration on all three dates flips to the Annunciation, and C14 still matched exactly 3 rows with its count pin green. The suite only reddened because the SIBLING dates fell outside every predicate -- geometry, not this pin. *) && String.equal c.slug "joseph-spouse-of-the-bl-virgin-mary" then Some "C14" else if List.mem l.date holy_family_baptism_collision_dates_2005_2050 (* Task 8: [First_f; Gospel_f] joined the accepted set for the same reason as C14 above -- Holy Family's own citations (Col 3:12-17/Luke 2:42-52, its own proper) differ from the fixed Baptism's (which lectio still shows there) exactly BECAUSE the office identity already differs; the same single cause, not two. *) && subset diffs [ Season; Slug_f; First_f; Gospel_f ] && String.equal c.slug "ef-time-after-epiphany-sunday-1" then Some "C15" else if List.mem l.date holy_name_fallback_dates_2005_2050 (* Task 8: [First_f; Gospel_f] joined the accepted set. Now that [ef-holy-name] carries its own citations (tools/bootstrap_lectionary.ml's own [derived_entries], sourced from RG 17(a)'s single Mass heading covering both shapes), most of these 20 rows already match lectio's own citation by coincidence -- lectio's OWN plain-ferial fallback, on years with no Sunday 2-5 January, usually lands on a December Sunday whose slug collapses to the SAME "ef-christmas-sunday-0" key this generator translated -- but on the 7 years where 1 January ITSELF is a Sunday, lectio's own fallback instead finds the Circumcision's own real Mass (Titus 2:11-15/Luke 2:21), a genuine divergence from colitur's RG 17(a) office, which reads the SAME Mass on 2 January regardless of what happened the day before. Both shapes are the identical underlying cause (colitur builds RG 17(a); lectio has no equivalent office at all), so both belong to this one id, not a split entry. *) && subset diffs [ Slug_f; Rank; First_f; Gospel_f ] && String.equal c.slug "ef-holy-name" then Some "C16" else if is_bvm_saturday_row c diffs then Some "C17" else if diffs = [ Colour_f ] && List.mem c.slug audit_colour_corrected_slugs then Some "C18" else if List.mem (year_of_date l.date) epiphany_week_shift_years_2005_2050 && starts_with ~prefix:"ef-time-after-epiphany-" c.slug && subset diffs [ Colour_f; First_f; Gospel_f ] then Some "C19" else if diffs = [ First_f; Gospel_f ] && String.equal l.first "-" && String.equal l.gospel "-" && List.mem c.slug lectio_no_reading_slugs then Some "C20" else if List.mem c.slug rg69_one_hop_slugs && diffs = [ First_f; Gospel_f ] then Some "C25" else if List.mem c.slug lent_ember_slugs && diffs = [ First_f; Gospel_f ] then Some "C22" else if List.mem c.slug holy_week_answered_slugs && diffs = [ First_f; Gospel_f ] then Some "C23" else if List.mem c.slug holy_week_unanswered_slugs && diffs = [ First_f; Gospel_f ] then Some "C24" else None (* ---------------------------------------------------------------------- *) (* data/ef/expected-divergences.sexp loading -- a plain sequence of *) (* top-level records (not one wrapping list: see the file's own header). *) (* ---------------------------------------------------------------------- *) 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, run once and shared by every test case below *) (* (Alcotest test cases are independent processes-in-a-list, not free to *) (* share mutable state across a suite, so this recomputes per call -- *) (* acceptable: colitur's own 1583-9999 property sweep runs orders of *) (* magnitude more days in tens of seconds, and this is 16801 civil days, *) (* once per test case, twice total). *) (* ---------------------------------------------------------------------- *) type outcome = Matched | Explained of string | Unexplained of field list let compare_streams () = let lectio = lectio_rows () in let colitur = colitur_rows_2005_2050 () in (lectio, colitur) let classify lectio colitur = List.map2 (fun (l : row) (c : row) -> if not (String.equal l.date c.date) then Alcotest.failf "streams misaligned: lectio %s vs colitur %s" l.date c.date; let diffs = diff_fields l c in if diffs = [] then (l, c, Matched) else match layer_c_reason l c diffs with | Some id -> (l, c, Explained id) | None -> (l, c, Unexplained diffs)) lectio colitur (* Task 8: [first]/[gospel] joined the row dump on both sides -- naming the date, the slug and the PART (via [diffs]' own field name, "first" or "gospel") and each side's reference is exactly what the task brief asks a citation mismatch to report, never a bare count. *) let describe_unexplained (l : row) (c : row) diffs = Printf.sprintf "%s: %s differ -- lectio=(%s %s %s %s %s first=%s gospel=%s) colitur=(%s %s %s %s %s first=%s gospel=%s)" l.date (String.concat "," (List.map field_name diffs)) l.weekday l.season l.slug l.rank l.colour l.first l.gospel c.weekday c.season c.slug c.rank c.colour c.first c.gospel (* Fix round 1, finding 4: the fixture's own byte content must still match the SHA-256 its provenance note claims (lectio commit 2386a45, recorded both here and in test/fixtures/lectio-ef-2005-2050.provenance). Checked before anything else reads the fixture -- a drifted fixture makes every other assertion in this suite a statement about an unlabelled snapshot, not about the pinned lectio commit it claims to be. *) let test_fixture_checksum () = Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256 (sha256_of_file fixture_path) (* Dates align 1:1 in the same order on both streams (both are one line per civil day, 2005-01-01..2050-12-31 -- see the fixture's own provenance note and [colitur_rows_2005_2050]'s construction). A silent misalignment would make every subsequent comparison meaningless -- checked first, on its own, rather than trusted. *) let test_dates_align () = let lectio, colitur = compare_streams () in Alcotest.(check int) "both streams have 16801 rows (46*365 + 11 leap days)" 16801 (List.length lectio); Alcotest.(check int) "colitur recomputed the same number of rows" (List.length lectio) (List.length colitur); let mismatched = List.filter_map (fun (l, c) -> if String.equal l.date c.date then None else Some (l.date, c.date)) (List.combine lectio colitur) in Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched (* The core assertion: every one of the 4975 raw differences is either normalised away (Layers A/B) or named in the cited allow-list (Layer C). Nothing else is permitted to pass silently. *) let test_no_unexplained_differences () = let lectio, colitur = compare_streams () in let classified = classify lectio colitur in let unexplained = List.filter_map (fun (l, c, outcome) -> match outcome with Unexplained diffs -> Some (describe_unexplained l c diffs) | _ -> None) classified in Alcotest.(check (list string)) "no differences outside Layers A/B/C" [] unexplained (* Teeth, not just green: EVERY Layer C entry's actual row count over this fixture must equal what data/ef/expected-divergences.sexp declares, in BOTH directions -- an id used by [layer_c_reason] that is missing from the sexp file, an id declared but never matched, or a count that has drifted either up or down, all fail loudly. A silent drift here is exactly the "allow-list absorbs a new bug" failure mode this task was warned about. *) let test_layer_c_counts_match_citations () = let lectio, colitur = compare_streams () in let classified = classify lectio 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 = ( "differential (lectio, EF, 2005-2050)", [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum; Alcotest.test_case "streams are 16801 rows each, dates aligned 1:1" `Quick test_dates_align; Alcotest.test_case "every difference is normalised (A/B) or cited (C) -- none unexplained" `Quick test_no_unexplained_differences; Alcotest.test_case "Layer C counts match data/ef/expected-divergences.sexp exactly" `Quick test_layer_c_counts_match_citations ] )