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.ml461
1 files changed, 416 insertions, 45 deletions
diff --git a/test/test_oracle.ml b/test/test_oracle.ml
index ac8d95d..2624664 100644
--- a/test/test_oracle.ml
+++ b/test/test_oracle.ml
@@ -14,11 +14,10 @@
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).
+ file compares axes the lectio differential cannot touch at all: rank,
+ colour (as SET membership, see below), commemoration presence, count,
+ and (CORRECTED 2026-08-12, Task B, branch ef-rg16a -- see below) IDENTITY
+ -- WHICH commemoration is admitted, not only how many.
*** THE FIXTURE'S OWN SHAPE (fixtures/missalemeum-ef-2026-2027.txt,
fixtures/missalemeum-ef-2026-2027.provenance, tools/
@@ -29,7 +28,59 @@
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 for the DAY's own rank/colour, never parses anything out of
+ info.id for that purpose.
+
+ *** COMMEMORATION IDENTITY (added 2026-08-12, Task B, branch ef-rg16a)
+ -- CORRECTING the previous header's own "deliberately NOT compared"
+ claim ***
+
+ The gap this correction closes: layer 4 used to compare commemoration
+ PRESENCE and List.length ONLY -- cardinality, never identity. A reviewer
+ proved this was vacuous by reversing admit's own dignity/precedence sort
+ (Rite_ef.Precedence_ef, so the engine admits the WORST candidate, an
+ outright RG 111 violation) and finding every assertion in this file
+ still green across all 730 days (see the perturbation record in the task
+ report, reproduced and reverted, not left in the tree). This file now
+ also compares WHICH commemoration is admitted, using two independent
+ signals from missalemeum's own JSON: the commemoration's title text
+ (English, exact string) and, for a sancti-origin commemoration, its own
+ [id]'s embedded rank digit (used only as a corroborating check, never as
+ the primary signal -- see [Comm_identity] below).
+
+ THE MAPPING, AND EXACTLY WHAT IT CANNOT RESOLVE: missalemeum identifies
+ a commemoration by an English TITLE string; colitur identifies one by a
+ SLUG. There is no hand-built slug<->title lookup table here -- colitur's
+ own {!Celebration.t}.names already carries an English name for every
+ SANCTORAL-origin celebration (bootstrapped from lectio's own English
+ titles, which verifiably match missalemeum's titles character-for-
+ character on every checked row), so the comparison reads that name
+ directly off colitur's own resolved output, the same field
+ test_differential.ml already treats as authoritative for OF/EF display
+ text. This resolves identity for every SANCTORAL-origin commemoration.
+ It CANNOT resolve a TEMPORAL-origin commemoration (an impeded feria, a
+ privileged Sunday, an Ember/Rogation day, etc.): {!Rite_ef.Temporal_ef}'s
+ own [build] never sets a celebration's [names] field (rite_ef/
+ temporal_ef.ml has no [en "..."] string anywhere in it), so every such
+ candidate's own title is empty on colitur's side -- there is nothing to
+ compare against missalemeum's title text, and no principled way to
+ invent one without duplicating missalemeum's own English-prose
+ convention ("Feria III after the I Sunday of Lent") as a second,
+ unverified source of truth. THIS CASE IS NEVER SILENTLY SKIPPED: see
+ [Comm_identity_unresolved] below -- it is counted, reported, and must be
+ named in the allow-list like any other difference, exactly the discipline
+ the brief demands ("a day whose commemoration cannot be matched must be
+ visibly counted and reported, not quietly passed").
+
+ Identity is only even ATTEMPTED once presence and count already agree
+ (both non-empty, same length) -- a cardinality mismatch is already
+ [Comm_presence]/[Comm_count]'s own business (see [diff_fields] below),
+ and comparing SETS of different size would just restate that mismatch
+ under a third name, not add information. Compared as a SORTED MULTISET
+ of titles, not a sequence: missalemeum's own [commemorations] array order
+ is not independently verified against RG 113's own "commemoratio de
+ Tempore fit primo loco" ordering rule here, so this axis proves WHICH
+ commemorations are admitted, not what order they are listed in.
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+
@@ -56,14 +107,26 @@
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. TWO entries (M11 and
- M13) are [verdict open] -- CORRECTED, final fix wave, item 7: this
- comment previously said "one entry (M13)", missing M11, whose own
- verdict was changed from [colitur] to [open] in fix round 1 (see M11's
- own entry below for why) but this summary was never updated to match.
- Both are 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
+ silently absorbed as if colitur were correct. TWO entries (M11 and M13)
+ are [verdict open] -- CORRECTED, final fix wave, item 7: this comment
+ previously said "one entry (M13)", missing M11, whose own verdict was
+ changed from [colitur] to [open] in fix round 1 (see M11's own entry
+ below for why) but this summary was never updated to match. Task B
+ (branch ef-rg16a) briefly added a THIRD, M17 (the same-band tie-break
+ between Maurice and Thomas of Villanova, 22 September) -- CORRECTED,
+ fix round 1 of that same task: M17 was itself wrong. The "tie" was
+ manufactured by {!Precedence_ef.band} lending a [Commemoration_only]
+ candidate the same table entry as a genuine [Feast] of its own rank
+ (RG 91's table has no row for a bare commemoration at all -- fixed in
+ [band] itself, not here); once fixed, 22 September resolves cleanly on
+ both sides and M17 was deleted, not merely re-adjudicated. ONE entry
+ (M15) carries its own fourth verdict, [unresolvable] -- not a rubric
+ dispute or a data gap either engine is wrong about, but a LIMIT of this
+ comparator itself (see M15's own entry). All remaining OPEN entries are
+ adjudicated as unresolved/unresolvable, 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
@@ -74,6 +137,8 @@ module Slug = Colitur_kernel.Slug
module Date = Colitur_kernel.Date
module Cel = Colitur_kernel.Celebration
module Colour = Colitur_kernel.Colour
+module Names = Colitur_kernel.Names
+module Lang = Colitur_kernel.Lang
module V = Rite_ef.Vocab_ef
(* Same relative paths test_differential.ml uses: dune test runs from
@@ -88,7 +153,16 @@ let allow_list_path = "../data/ef/expected-divergences-missalemeum.sexp"
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"
+(* CORRECTED 2026-08-12, Task B (branch ef-rg16a): field 9
+ (commemoration_ids) appended to the fixture (tools/
+ extract_missalemeum_oracle.py's own header) so this file can compare
+ commemoration IDENTITY, not merely presence/count -- see this file's own
+ header comment, rewritten below. Regenerated from the SAME underlying
+ snapshot (fixtures/missalemeum-ef-2026-2027.provenance: still lectio
+ commit d7da4b0, re-verified, first 8 fields byte-identical) -- only field
+ 9 is new content, so this digest changes but the fixture's own data does
+ not. *)
+let fixture_sha256 = "4162e79d7a0bc03b81a34ce6cd3b074e2d32570217759bc3b4058ada75ea8781"
(* Identical technique to test_differential.ml's own [sha256_of_file]
(that file's own comment explains why: shelling out to [sha256sum]
@@ -143,6 +217,7 @@ type oracle_row = {
o_colours : char list;
o_title : string;
o_commemorations : string list;
+ o_commemoration_ids : string list; (** parallel to [o_commemorations], see field 9's own doc above *)
o_displaced : string list;
}
@@ -150,16 +225,27 @@ 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
+(* A commemoration id's own trailing "rank" field, e.g. "sancti:01-05:4:r" ->
+ [Some 4] -- the SECOND, corroborating identity signal this file's own
+ header describes (test_identity_rank_corroboration, near the bottom of
+ this file). [None] for any id that does not have exactly 4 ':'-separated
+ parts (defensive; every id checked so far does). *)
+let id_rank id =
+ match String.split_on_char ':' id with [ _; _; rank; _ ] -> int_of_string_opt rank | _ -> None
+
+let is_sancti_id id = String.length id >= 7 && String.sub id 0 7 = "sancti:"
+
let oracle_row_of_line line =
match String.split_on_char '|' line with
- | [ date; rank; colours; title; _tempora; commemorations; displaced; _n_masses ] ->
+ | [ date; rank; colours; title; _tempora; commemorations; displaced; _n_masses; commemoration_ids ] ->
{ o_date = date;
o_rank = int_of_string rank;
o_colours = explode colours;
o_title = title;
o_commemorations = split_list_field commemorations;
+ o_commemoration_ids = split_list_field commemoration_ids;
o_displaced = split_list_field displaced }
- | _ -> Alcotest.failf "malformed fixture line (expected 8 '|'-separated fields): %S" line
+ | _ -> Alcotest.failf "malformed fixture line (expected 9 '|'-separated fields): %S" line
let read_lines path =
let ic = open_in path in
@@ -183,7 +269,20 @@ let oracle_rows () = List.map oracle_row_of_line (read_lines fixture_path)
(* year, which straddles two civil years). *)
(* ---------------------------------------------------------------------- *)
-type colitur_row = { c_date : string; c_rank : int; c_colour : char; c_commemorations : string list }
+(* [c_commemorations]: one entry per admitted commemoration, carrying its
+ own slug, RG 8 rank, STATUS, AND its English name if colitur's own
+ {!Celebration.t}.names has one -- [None] for a TEMPORAL-origin
+ candidate, which [Rite_ef.Temporal_ef] never names (this file's own
+ header, "THE MAPPING, AND EXACTLY WHAT IT CANNOT RESOLVE"). The [None]
+ case is what [Comm_identity_unresolved] below reads; [status] is read by
+ [test_identity_rank_corroboration]'s own scope guard, see its comment. *)
+type colitur_row = {
+ c_date : string;
+ c_rank : int;
+ c_colour : char;
+ c_commemorations : (string * int * Cel.status * string option) list;
+ (** slug, rank, status, English name *)
+}
let rank_to_int = function V.Class1 -> 1 | V.Class2 -> 2 | V.Class3 -> 3 | V.Class4 -> 4
@@ -195,6 +294,8 @@ let colour_to_char = function
| Colour.Rose -> 'p'
| Colour.Black -> 'b'
+let en = Lang.of_string_exn "en"
+
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
@@ -210,14 +311,17 @@ let colitur_rows_2026_2027 () =
(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
+ let commemorations =
+ List.map
+ (fun (c, _) ->
+ (Slug.to_string c.Cel.slug, rank_to_int c.Cel.rank, c.Cel.status, Names.find c.Cel.names en))
+ 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
+ c_commemorations = commemorations
}
:: !rows
| None -> Alcotest.failf "internal error: no resolved day for %s" (Date.to_iso8601 !d));
@@ -226,18 +330,39 @@ let colitur_rows_2026_2027 () =
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). *)
+(* Field-diff computation: the axes the oracle supports, RANK/COLOUR/ *)
+(* PRESENCE/COUNT as before, plus (Task B) IDENTITY -- see this file's own *)
+(* header for the mapping and its exact limits. *)
(* ---------------------------------------------------------------------- *)
-type field = Rank | Colour_f | Comm_presence | Comm_count
+type field = Rank | Colour_f | Comm_presence | Comm_count | Comm_identity_mismatch | Comm_identity_unresolved
let field_name = function
| Rank -> "rank"
| Colour_f -> "colour"
| Comm_presence -> "commemoration-presence"
| Comm_count -> "commemoration-count"
+ | Comm_identity_mismatch -> "commemoration-identity-mismatch"
+ | Comm_identity_unresolved -> "commemoration-identity-unresolved"
+
+(* Identity (Task B): only even attempted once presence AND count already
+ agree (both non-empty, same length) -- see this file's own header,
+ "Identity is only even ATTEMPTED...". Two outcomes beyond a clean match:
+ [Comm_identity_unresolved] when ANY of colitur's own admitted
+ commemorations carries no English name to compare (a TEMPORAL-origin
+ candidate -- counted and reported, never silently skipped, per the
+ brief); [Comm_identity_mismatch] when every name IS resolvable but the
+ SORTED MULTISET of titles still disagrees with missalemeum's own. *)
+let identity_diff (c : colitur_row) (o : oracle_row) =
+ let c_has = c.c_commemorations <> [] and o_has = o.o_commemorations <> [] in
+ if not (c_has && o_has) || List.length c.c_commemorations <> List.length o.o_commemorations then None
+ else
+ let unresolved = List.exists (fun (_, _, _, name) -> name = None) c.c_commemorations in
+ if unresolved then Some Comm_identity_unresolved
+ else
+ let c_titles = List.sort compare (List.filter_map (fun (_, _, _, n) -> n) c.c_commemorations) in
+ let o_titles = List.sort compare o.o_commemorations in
+ if c_titles = o_titles then None else Some Comm_identity_mismatch
let diff_fields (c : colitur_row) (o : oracle_row) =
let c_has = c.c_commemorations <> [] and o_has = o.o_commemorations <> [] in
@@ -252,7 +377,8 @@ let diff_fields (c : colitur_row) (o : oracle_row) =
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)
+ else None);
+ identity_diff c o
]
(* ---------------------------------------------------------------------- *)
@@ -345,19 +471,31 @@ let m4_dates = [ "2026-01-28"; "2027-01-28" ]
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. *)
+(* M6 -- CORRECTED 2026-08-12, transcription-audit task (branch ef-rg16a):
+ the previous version of this comment read the ELECTRONIC TRANSCRIPTION
+ (1962-06-23,_SS_Ioannes_XXIII,_Missale_Romanum,_LT.pdf), which silently
+ drops calendarium commemorations, and mistook its silence for the
+ calendarium's own -- "reads, in full, 'Vigilia, II classis.' -- nothing
+ else" was true of the TRANSCRIPTION only. A photographic scan
+ (missale-romanum-1962.pdf), 14 August row: "Vigilia, II classis,
+ Commemoratio S. Eusebii Conf.", with its own proper immediately
+ following ("Eodem die ... Et fit commemoratio S. Eusebii Conf.") --
+ independently confirmed in the second scan, Missale Romanum 1962_text
+ .pdf. The same shape as M4/M7/M9/M14, NOT their reverse: a genuine
+ commemoration is present in the calendarium and in missalemeum's own
+ data, but absent from data/ef/sanctoral.sexp and lectio's own
+ tridentine-calendar.ini (no 08-14 entry beyond [vigil-of-the-assumption]).
+ This Eusebius Confessor (14 Aug) is a genuinely DIFFERENT saint from
+ Eusebius Bishop and Martyr (16 Dec, already correctly in data/ef/
+ sanctoral.sexp, matching lectio's own [eusebius] entry at THAT date) --
+ both exist, at their own separate dates. Verdict missalemeum (flipped
+ from colitur): colitur is missing this commemoration, a genuine data gap
+ not fixable without touching ~/git/projects/lectio -- see
+ docs/research/rules-register.md, Decisions item 4 and the
+ transcription-audit record in section 6, for the full account and the
+ wider calendarium sweep this correction came from. The diff SHAPE this
+ predicate matches (Comm_presence, both dates) is unchanged by the
+ verdict flip -- only which side is right, and why, changed. *)
let m6_dates = [ "2026-08-14"; "2027-08-14" ]
(* M7 -- a genuine DATA GAP, primary-source-confirmed, the same shape as
@@ -479,6 +617,89 @@ let m12_dates = [ "2027-02-22" ]
item. *)
let m13_dates = [ "2027-03-19" ]
+(* M15 -- Task B (branch ef-rg16a), NOT a rubric dispute and NOT a data gap
+ either engine is wrong about: a genuine LIMIT of this comparator, honestly
+ counted rather than silently passed (the brief's own explicit
+ instruction: "a day whose commemoration cannot be matched must be
+ visibly counted and reported, not quietly passed"). On each of these 19
+ rows, colitur's own admitted commemoration is TEMPORAL-origin (an
+ impeded Advent/Lent feria) -- Rite_ef.Temporal_ef's own [build]
+ (temporal_ef.ml) never sets a celebration's [names] field, so there is no
+ English string on colitur's side to compare against missalemeum's title
+ text at all (this file's own header, "THE MAPPING, AND EXACTLY WHAT IT
+ CANNOT RESOLVE"). Every one of these 19 oracle titles reads as a plain
+ prose description of the SAME temporal ferial slot colitur independently
+ computes for that date (e.g. 2 Dec 2026: oracle "Feria IV after I Sunday
+ of Advent" against colitur's own [ef-advent-1-wednesday]; 4 Dec 2027:
+ oracle "Sabbato after I Sunday of Advent" against [ef-advent-1-saturday])
+ -- suggestive corroboration, recorded here for a human reader, but NOT
+ proof: this comparator has no mechanism to verify English prose against
+ an opaque slug, so it does not claim these as verified matches, only as
+ unresolved. Fixing this at the ROOT (giving every temporal-cycle
+ candidate an English name) is a data/lectionary-bootstrap task, not
+ something this comparator can do for itself -- register §6, "RG 113
+ tie-break" section's own neighbour, records it as an open item. *)
+let m15_dates =
+ [ "2026-02-24"; "2026-03-19"; "2026-03-25"; "2026-12-02"; "2026-12-03"; "2026-12-04";
+ "2026-12-07"; "2026-12-11"; "2026-12-21"; "2027-02-24"; "2027-12-02"; "2027-12-03";
+ "2027-12-04"; "2027-12-06"; "2027-12-07"; "2027-12-11"; "2027-12-13"; "2027-12-16";
+ "2027-12-21" ]
+
+(* M16 -- Task B: a genuine IDENTITY mismatch, both sides otherwise agreeing
+ on rank/colour/count/presence. 27 March 2026 (Friday of Passion Week):
+ colitur admits "St. John Damascene" (a real Class3 universal feast that
+ genuinely falls that day); missalemeum shows "For Our Lady of the Seven
+ Sorrows" instead. The SAME standing gap M13 above already names ("the
+ 1962 calendarium's own March table carries... 'Feria VI post dominicam I
+ Passionis: Commemoratio septem Dolorum B. Mariae Virg.'... a fixed
+ commemoration... EVERY year") -- confirmed real, and confirmed
+ UNIMPLEMENTED in this codebase (no candidate for it is ever constructed
+ in temporal_ef.ml), so colitur has nothing to admit in the Seven
+ Sorrows' place and falls back to whichever ordinary Class3 saint
+ actually wins that Friday instead. Verdict missalemeum: colitur is
+ missing a real, primary-cited office, not differently opinioned.
+ Register §6 open item (same one M13/M16's own primary-source note
+ already tracks). Only 2026 shows here -- 2027's Friday of Passion Week
+ IS 19 March, M13's own date, where the identity axis cannot even be
+ reached (M13's own rank/colour mismatch already excludes that day from
+ count-matched identity comparison).
+
+ NOTE for whoever builds the office (fix round 1, coordinator finding 7):
+ 27 March 2026 is a III-class day, where RG 111(d) admits TWO
+ commemorations, not one -- yet missalemeum admits only the Seven Sorrows
+ and DISPLACES John Damascene entirely (its own "displaced" list carries
+ his title that day), not merely drops him to second place. Implementing
+ the Seven Sorrows candidate naively (as one more ordinary III-class
+ commemoration competing for the day's two slots) will not reproduce
+ this: John Damascene would still win one of the two admitted slots by
+ dignity/band, giving colitur TWO commemorations where missalemeum shows
+ one. Whatever privilege or precedence the Seven Sorrows commemoration
+ carries must itself explain the exclusion, not just the admission --
+ register §6's own open item for this office should carry this caveat
+ forward. *)
+let m16_dates = [ "2026-03-27" ]
+
+(* M17 was DELETED, fix round 1 (Task B): the "genuine tie" it adjudicated
+ as [open] was itself wrong. 22 September (any year the September Ember
+ Wednesday falls on the 22nd -- 2027 in this window): colitur used to
+ admit "St. Maurice and Companions, Martyrs" (Commemoration_only, Class3)
+ where missalemeum shows "St. Thomas of Villanova" (Feast, Class3) --
+ NOT because RG 113 runs out of instruction between two same-rank
+ candidates (the framing this entry used to carry), but because
+ {!Precedence_ef.band} used to lend a [Commemoration_only] candidate the
+ SAME table entry (24) as a genuine [Feast] of its own rank, manufacturing
+ a tie the primary text never creates: RG 91's own table enumerates only
+ "dies liturgici" (entry 24: "Festa III classis..." -- FEASTS), and the
+ calendarium's own 22 September row confirms it in its own notation --
+ "S. Thomae de Villanova Ep. et Conf., III classis. / Commemoratio Ss.
+ Mauritii et Soc. Mm." -- Thomas carries a class number, Maurice carries
+ none. Fixed at the source ([band] itself now returns [Precedence_ef
+ .unclassified] for any [Commemoration_only] candidate, docs/research/
+ rules-register.md §6.1's own corrected account) rather than here: 22
+ September now resolves identically on both sides with no allow-list
+ entry needed at all -- removed, not re-adjudicated to a different
+ verdict, since there is no longer a divergence to name. *)
+
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"
@@ -494,7 +715,15 @@ let layer_m_reason (c : colitur_row) (o : oracle_row) diffs =
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"
+ (* M13's own subset widened (Task B): the same underlying gap (the Seven
+ Sorrows commemoration is never constructed at all) now ALSO surfaces
+ as [Comm_identity_unresolved] on this date, since colitur's own
+ admitted commemoration there is temporal-origin (no English name) --
+ one root cause, one id, both axes it touches. *)
+ else if List.mem c.c_date m13_dates && subset diffs [ Rank; Colour_f; Comm_identity_unresolved ] then
+ Some "M13"
+ else if List.mem c.c_date m15_dates && diffs = [ Comm_identity_unresolved ] then Some "M15"
+ else if List.mem c.c_date m16_dates && diffs = [ Comm_identity_mismatch ] then Some "M16"
else None
(* ---------------------------------------------------------------------- *)
@@ -538,17 +767,24 @@ let classify oracle colitur =
| None -> (c, o, Unexplained diffs))
oracle colitur
+let status_to_string = function Cel.Feast -> "Feast" | Cel.Commemoration_only -> "Commemoration_only"
+
+let describe_comm (slug, rank, status, name) =
+ Printf.sprintf "%s(rank=%d,status=%s,name=%s)" slug rank (status_to_string status)
+ (match name with Some n -> Printf.sprintf "%S" n | None -> "NONE")
+
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])"
+ title=%S comms=[%s] comm_ids=[%s] displaced=[%s])"
c.c_date
(String.concat "," (List.map field_name diffs))
c.c_rank c.c_colour
- (String.concat ";" c.c_commemorations)
+ (String.concat ";" (List.map describe_comm 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_commemoration_ids)
(String.concat ";" o.o_displaced)
let test_fixture_checksum () =
@@ -566,9 +802,11 @@ let test_dates_align () =
in
Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched
-(* The core assertion: every one of the 42 raw differences across all four
+(* The core assertion: every one of the 63 raw differences across all six
axes (rank, colour-membership, commemoration-presence, commemoration-
- count) is named in the cited allow-list. Nothing else passes silently. *)
+ count, and, since Task B/ef-rg16a, commemoration-identity-mismatch and
+ commemoration-identity-unresolved) 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
@@ -604,6 +842,136 @@ let test_layer_m_counts_match_citations () =
undeclared"
expected actual
+(* Corroboration (Task B): for every day where the identity axis is CLEAN
+ (no [Comm_identity_mismatch]/[Comm_identity_unresolved] -- titles matched
+ as a set), independently cross-check missalemeum's own id-embedded rank
+ digit against colitur's OWN candidate rank for that SAME title -- a
+ SECOND, independent signal beyond string equality (this file's own
+ header: "used only as a corroborating check, never as the primary
+ signal"). Restricted to "sancti:"-origin ids (a TEMPORAL-origin oracle
+ commemoration has no colitur-side name to pair it with in the first
+ place, see [Comm_identity_unresolved]'s own days, which never reach here
+ since their identity axis is not clean).
+
+ SPLIT BY STATUS -- a genuine finding from running this check, not the
+ originally-planned single assertion: EVERY ONE of colitur's 114
+ [Commemoration_only] sanctoral entries carries a bootstrap-assigned
+ [rank Class3] (verified: `grep -c "status Commemoration_only"` and
+ `grep -B3 ... | grep -oP "rank Class\d" | sort | uniq -c` both give
+ 114/114 Class3, data/ef/sanctoral.sexp) -- but missalemeum's OWN id-rank
+ for a [Commemoration_only] match is NOT itself uniform: of the 175
+ examined, 17 genuinely AGREE (oracle_rank = colitur_rank = 3, real
+ corroboration) and 158 show oracle_rank 4 against colitur's bootstrapped
+ 3 -- CORRECTED after the first version of this comment overclaimed "id
+ consistently encodes 4 for every entry" from the 158-strong failure list
+ alone, before the 17 agreeing rows (then hidden inside a too-strict
+ assertion) were found. Two acceptable shapes, then, not one: either the
+ ranks genuinely agree, or the specific oracle=4/colitur=3 gap holds. This
+ is not evidence of a title-matching error, and not new: it independently
+ corroborates the register's own already-open item (§6, "Commemoration-
+ only entries' inferred STATUS, not only rank" -- Task 16's own data
+ audit already flagged these ranks as bootstrap inferences, never
+ individually Missal-verified). Auditing which of the 114 are genuinely
+ Class3 and which were silently defaulted to it is that item's own scope,
+ not this task's -- so rather than either asserting a false universal
+ equality (which fails on the 158 KNOWN, tracked, out-of-scope rows) or
+ silently dropping the check, this pins the discovered SHAPE itself:
+ every [Commemoration_only] match is EITHER a real agreement OR the known
+ gap, with no THIRD pattern -- a real, falsifiable claim (any other
+ combination would break it) that stays honest about what remains
+ unverified.
+
+ WHAT THIS DOES NOT PIN (coordinator finding 8, fix round 1, honestly
+ named rather than left implicit): the [oracle_rank = colitur_rank]
+ branch accepts ANY genuine agreement, including one this check cannot
+ independently verify is the CORRECT rank -- a regression that silently
+ flipped some entry's [rank] from [Class3] to [Class4] would land in the
+ agreement branch and pass cleanly if missalemeum's own id happened to
+ read 4 for that entry too (data drift on one side coinciding with data
+ drift on the other is not ruled out by this check, only coincidence
+ independent of any real cause is). This test pins "no third pattern
+ appears", not "every individual rank is correct" -- a narrower, still
+ genuinely useful claim (see the [> 50] population guard below, which
+ confirms the pinned shape is actually exercised at scale, not vacuously
+ true over an empty or trivial set), and this comment says so rather than
+ letting the assertion's own name imply more than it checks.
+
+ [Feast]-status candidates get the ORIGINAL, unrestricted check (any
+ disagreement at all is unexpected). CORRECTED (fix round 1, coordinator
+ finding 1): this comment previously claimed this branch was unreachable
+ in the 2026-2027 window (checked: 0) because its one candidate, Thomas
+ of Villanova on 22 September, was M17's own mismatch -- WRONG, once
+ traced further: M17 itself was wrong (see {!Precedence_ef.band}'s own
+ fidelity fix, register §6.1), and fixing it made 22 September resolve
+ cleanly on both sides, reachable here after all. Measured, not assumed:
+ 22 Feast-status commemorations are now examined by this branch (not
+ only Thomas of Villanova -- every OTHER genuinely clean Feast-status
+ match in the window reaches it too, which the earlier version of this
+ comment did not check for before asserting "0"). All 22 agree. *)
+let test_identity_rank_corroboration () =
+ let oracle, colitur = compare_streams () in
+ let feast_checked = ref 0 in
+ let feast_mismatches = ref [] in
+ let commemoration_only_checked = ref 0 in
+ let commemoration_only_surprises = ref [] in
+ List.iter2
+ (fun (o : oracle_row) (c : colitur_row) ->
+ if
+ identity_diff c o = None && c.c_commemorations <> [] && o.o_commemorations <> []
+ && List.length c.c_commemorations = List.length o.o_commemorations
+ then
+ List.iter2
+ (fun title id ->
+ if is_sancti_id id then
+ match id_rank id with
+ | None -> ()
+ | Some oracle_rank -> (
+ match List.find_opt (fun (_, _, _, n) -> n = Some title) c.c_commemorations with
+ | Some (slug, colitur_rank, Cel.Feast, _) ->
+ incr feast_checked;
+ if colitur_rank <> oracle_rank then
+ feast_mismatches :=
+ Printf.sprintf "%s: %s oracle-id-rank=%d colitur-rank=%d" c.c_date slug
+ oracle_rank colitur_rank
+ :: !feast_mismatches
+ | Some (slug, colitur_rank, Cel.Commemoration_only, _) ->
+ incr commemoration_only_checked;
+ (* Two ACCEPTABLE shapes, not one -- corrected after first running this
+ check: most Commemoration_only entries genuinely agree
+ (oracle_rank = colitur_rank, real corroboration, 17 of the
+ population checked), and the rest show the KNOWN
+ oracle=4/colitur=3 convention gap (this comment's own header).
+ Anything OUTSIDE those two shapes (e.g. oracle=2 or a THIRD
+ colitur rank paired with a non-4 oracle rank) is a genuine
+ surprise. *)
+ if not (oracle_rank = colitur_rank || (oracle_rank = 4 && colitur_rank = 3)) then
+ commemoration_only_surprises :=
+ Printf.sprintf "%s: %s oracle-id-rank=%d colitur-rank=%d (neither agrees nor \
+ fits the known oracle=4/colitur=3 pattern)"
+ c.c_date slug oracle_rank colitur_rank
+ :: !commemoration_only_surprises
+ | None -> ()))
+ o.o_commemorations o.o_commemoration_ids)
+ oracle colitur;
+ Alcotest.(check (list string))
+ "Feast-status matches: oracle id-rank agrees with colitur's own rank" [] (List.rev !feast_mismatches);
+ Alcotest.(check (list string))
+ "Commemoration_only-status matches: every one fits the KNOWN oracle=4/colitur=3 convention gap -- any \
+ other combination would be a genuine, new surprise"
+ [] (List.rev !commemoration_only_surprises);
+ (* Vacuity guard: BOTH branches must actually run now. CORRECTED (fix
+ round 1, coordinator finding 1): the [Feast] branch used to be
+ unreachable in this window (checked: 0) because its one candidate,
+ Thomas of Villanova on 22 September, was M17's own mismatch (excluded
+ by the [identity_diff = None] guard above). Fixing {!Precedence_ef
+ .band}'s Commemoration_only fidelity (register §6.1) made that day
+ resolve cleanly, so it is reachable here too -- this guard now expects
+ at least 1, not merely documents the branch as dormant (measured: 22
+ Feast-status commemorations now examined, up from 0). *)
+ Alcotest.(check bool) "the Feast-status population actually examined is non-trivial" true (!feast_checked > 0);
+ Alcotest.(check bool) "the Commemoration_only population actually examined is non-trivial" true
+ (!commemoration_only_checked > 50)
+
let suite =
( "oracle (missalemeum, EF, 2026-2027)",
[ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum;
@@ -611,5 +979,8 @@ let suite =
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
+ `Quick test_layer_m_counts_match_citations;
+ Alcotest.test_case
+ "identity corroboration: matched titles' oracle id-rank agrees with colitur's own rank" `Quick
+ test_identity_rank_corroboration
] )