diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/test_golden.ml | 174 | ||||
| -rw-r--r-- | test/test_validate.ml | 108 |
2 files changed, 276 insertions, 6 deletions
diff --git a/test/test_golden.ml b/test/test_golden.ml index 8d33e29..e9b3ec5 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -55,6 +55,7 @@ module Slug = Colitur_kernel.Slug module Date = Colitur_kernel.Date module Cel = Colitur_kernel.Celebration module Colour = Colitur_kernel.Colour +module Citation = Colitur_kernel.Citation module Temporal = Colitur_kernel.Temporal module Prec = Colitur_kernel.Precedence module V = Rite_ef.Vocab_ef @@ -170,6 +171,42 @@ let describe (day : (V.season, V.rank) LD.t) = let check ~msg y m d expected = Alcotest.(check string) msg expected (describe (fetch y m d)) +(* ---- Task 10: the reading citations ---- + + A SEPARATE formatter rather than widening [describe] itself, and the + reason is this file's own opening rule, not convenience. Widening + [describe] would invalidate all ~30 expected literals above at once, and + the only practical way to restore them would be to run the suite and paste + back whatever colitur printed -- "copied from a `colitur day` run and then + rationalised", the exact thing the header forbids. Restoring them + HONESTLY would mean hand-verifying 30 further Masses against the scans, + which is a task of its own and not this one. + + So the trade is stated rather than hidden: the pins above stay + citation-blind, and the pins below carry citations for one day per chain + step, every literal hand-verified against the photographic scans BEFORE + being typed here (each pin's own comment carries its scan line numbers). + That division is defensible because layer 2 now covers what breadth would + have bought: {!Colitur_kernel.Validate}'s own citation checks assert the + SHAPE of every day's citations in every year 1583-9999 -- far more days + than 30 pins could -- so what layer 5 still owes is specific VALUES on the + distinct routes, which is exactly what these six are. *) +let describe_readings (day : (V.season, V.rank) LD.t) = + let part_ref p = + match + List.find_opt (fun (c : Citation.t) -> c.Citation.part = p) day.LD.citations + with + | Some c -> c.Citation.reference + | None -> "-" + in + Printf.sprintf "%s slug=%s first=%s gospel=%s" + (Date.to_iso8601 day.LD.date) + (slug_s day.LD.observed) + (part_ref Citation.First) (part_ref Citation.Gospel) + +let check_readings ~msg y m d expected = + Alcotest.(check string) msg expected (describe_readings (fetch y m d)) + let omitted_has (day : (V.season, V.rank) LD.t) slug = List.exists (fun (c, _) -> slug_s c = slug) day.LD.omitted @@ -1122,6 +1159,128 @@ let test_major_litanies_transfer_2011_easter_monday () = "2011-04-26 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white \ subject=temporal name_la=- comms=[major-litanies:privileged] in=- out=[]" +(* ---- Task 10: one pin per step of the reading-resolution chain ---- + + The chain has four steps, and a citation regression is far easier to + diagnose when the failing pin names WHICH step broke. Every literal below + was read out of the photographic scans first (line numbers given per pin), + then compared with colitur -- not the other way round. + + Step 4 deserves special note: it is the ONE step with no external oracle + witness of any kind (register ยง6.7 -- the five Common-routed saints are + the observed office on just five days in all of 2005-2050, and not one + falls inside layer 4's own 2026-2027 fixture window; layer 3 sees those + days but lectio resolves the literal "-"/"-" sentinel there, which is what + C18 is gated on, so it confirms only that colitur emits SOMETHING). These + two pins are therefore the first independent confirmation that route has + ever had, which is also why step 4 gets two of the six rather than one. *) + +(* STEP 1 -- a sanctoral proper: the citation lives on the Celebration + itself, no walk, no Common. 8 March 1938^H2038, "S. Ioannis a Deo Conf., + III classis": "Lectio libri Sapientiae. Eccli. 31, 8-11" / "Sequentia + sancti Evangelii secundum Matthaeum. Mt. 22, 34-46" (scan1.txt:27732-27734 + + 27769-27770; scan2.txt:30698-30699 + 30733-30736; page image p. 485). + The Missal prints him a FULL Mass with its own assigned Gospel -- the + Epistle happens to coincide with the Common of a Confessor not a Bishop's, + but the Gospel does not (that Common has Luke 12:35-40), which is what + makes this a proper and not a Common, and so a step-1 case rather than a + step-4 one. NOTE the plan named 2030-01-13 for this step; that date is a + TEMPORAL day (Holy Family, carried on the generic Sunday slug per + RG 17(b)) and would not have exercised step 1 at all. *) +let test_readings_step1_sanctoral_proper () = + check_readings ~msg:"step 1: sanctoral proper (John of God, 8 March)" 2038 3 8 + "2038-03-08 slug=john-of-god first=Ecclus 31:8-11 gospel=Matt 22:34-46" + +(* STEP 2 -- a temporal day with its own direct lectionary entry. 23 February + 2026 is "Feria II post dominicam I in Quadragesima" (heading, scan1.txt: + 8137): "Lectio Ezechielis Prophetae. Ezech. 34, 11-16" (scan1.txt:8138- + 8139) / "Sequentia sancti Evangelii secundum Matthaeum. Mt. 25,31-46" + (scan1.txt:8199). A Lenten feria is the right shape here because Lent's + ferias each have their OWN Mass -- they never walk back, so a regression + in step 3 cannot mask a regression in step 2 on this date. + The book abbreviation is "Ezech", not "Ezek": this pin was first written + from the scan as "Ezek 34:11-16" and failed, and the difference turned out + to be notation alone -- colitur's own spelling is in fact the Missal's own + ("Ezech. 34, 11-16", above). Recorded rather than quietly amended, since a + failing pin whose only fault is an abbreviation is exactly the kind of + thing a later reader will otherwise re-litigate. Cross-engine abbreviation + differences are normalised away in layer 3 (test_differential.ml's own A/B + normalisation) and so are invisible there; this layer sees them. *) +let test_readings_step2_temporal_proper () = + check_readings ~msg:"step 2: temporal proper (Lent I Monday)" 2026 2 23 + "2026-02-23 slug=ef-lent-1-monday first=Ezech 34:11-16 gospel=Matt 25:31-46" + +(* STEP 3 -- the ferial resumption: a day with NO Mass of its own, which + therefore repeats its preceding Sunday's. 1 December 2025 is the Monday + after Advent I, and Advent's ferias (unlike Lent's) have no proper Mass, + so the citation must be Advent I Sunday's own: "ad Romanos. Rom. 13, + 11-14" (scan1.txt:4878) / "dum Lucam. Luc. 21, 25-33" (scan1.txt:4912). + That the pinned value equals the SUNDAY's, not the Monday's own, is the + whole point of the pin. *) +let test_readings_step3_ferial_resumption () = + check_readings ~msg:"step 3: ferial resumption (Advent I Monday reads Advent I Sunday)" 2025 12 1 + "2025-12-01 slug=ef-advent-1-monday first=Rom 13:11-14 gospel=Luke 21:25-33" + +(* STEP 4 (a) -- the Common route. 6 March 2038, "Ss. Perpetuae et + Felicitatis Martyrum, III classis", whose own entry directs: "Missa Me + exspectaverunt, de Communi non Virginum I loco [35]" (scan1.txt:27634- + 27635). That Common -- COMMUNE NON VIRGINUM, "Pro Martyre non Virgine" + (scan1.txt:42560 ff) -- reads "Lectio libri Sapientiae. Eccli. 51, 1-8 et + 12" (scan1.txt:42589-42590) and "Sequentia sancti Evangelii secundum + Matthaeum. Mt. 13, 44-52" (scan1.txt:42606). 2038 is the only year in + 2005-2050 in which she is the OBSERVED office at all. *) +let test_readings_step4_common_felicitas () = + check_readings ~msg:"step 4: Common of Non-Virgins I (Perpetua & Felicitas)" 2038 3 6 + "2038-03-06 slug=sts-felicitas-perpetua first=Ecclus 51:1-8, 12 gospel=Matt 13:44-52" + +(* STEP 4 (b) -- the SECOND Common, deliberately a different one, so the pin + pair distinguishes "the Common route works" from "one Common's data is + right". 9 March 2038, "S. Franciscae Romanae Vid., III classis", directed + to "Missa Cognovi, de Communi non Virginum" -- the second Mass of that + same Common, which reads "Lectio libri Sapientiae. Prov. 31, 10-31" + (scan1.txt:42679) and shares the Gospel "Mt. 13, 44-52" (scan1.txt:42720). + The shared Gospel with (a) is the Missal's own doing, not a colitur + collapse -- which is exactly why both Epistles are pinned too. *) +let test_readings_step4_common_frances () = + check_readings ~msg:"step 4: Common of Non-Virgins II (Frances of Rome)" 2038 3 9 + "2038-03-09 slug=frances-rome first=Prov 31:10-31 gospel=Matt 13:44-52" + +(* THE DISPLACED-SUNDAY FERIA -- the case that separates the temporal slug + from the observed one, and the one the plan singles out. It is pinned + here as KNOWN-DIVERGENT OUTPUT, deliberately and with the divergence + named, NOT blessed as correct: this is test_oracle.ml's own M26 shape 2(b) + (verdict open). 26 October 2026 is a feria of the week whose Sunday was + displaced by Christ the King (I class, movable, "Dominica ultima + octobris"). The general mechanism -- a week's ferias follow their OWN + Sunday's temporal identity even when that Sunday is impeded -- is the + shape RG 69 establishes for the Nativity Octave, so the displaced ordinary + Sunday's Mass is what the ferias should read; colitur's step 3 instead + walks back to the DISPLACING feast and emits Christ the King's own Gospel + (John 18:33-37, "Art thou a king?"), which is the literal in this pin. + No Missal-propers rubric specific to Christ the King's own week was found + (checked: scan1.txt:39140-39163, no rubric precedes that Mass heading, + unlike Holy Name's and Holy Family's own), so M26 stays verdict open + rather than being asserted against a citation that does not exist. + WHEN M26 IS FIXED THIS PIN MUST FAIL -- that is its purpose. Update it + deliberately then; do not regenerate it. + + Two things this pin established that M26's own note did not record. First, + BOTH parts come from the displacing feast, not only the Gospel M26 cites: + the Epistle here is Col 1:12-20, which is Christ the King's own, so step 3 + is transplanting the whole Mass rather than half of it. Second, the + reference carries a TRAILING PERIOD ("Col 1:12-20."), a blemish inherited + through the bootstrap from the source data. It is pinned verbatim rather + than tidied, because this pin's job is to state what colitur actually + emits today; the period is noted here as a separate, cosmetic data defect + and is deliberately not fixed under a task about test coverage. Layer 3 + cannot see it (its A/B normalisation strips punctuation before comparing), + which is why it surfaced here first. *) +let test_readings_displaced_sunday_feria_known_divergent () = + check_readings + ~msg:"displaced-Sunday feria: pins M26 shape 2(b)'s KNOWN-WRONG walkback, not correct output" + 2026 10 26 + "2026-10-26 slug=ef-time-after-pentecost-22-monday first=Col 1:12-20. gospel=John 18:33-37" + let suite = ( "golden pins (known-tricky years)", [ Alcotest.test_case "Easter extreme: 1598 earliest (22 Mar, Gauss-verified)" `Quick @@ -1199,5 +1358,18 @@ let suite = `Quick test_major_litanies_displaces_mark_on_ii_class_sunday; Alcotest.test_case "RG80's second trigger: Easter Monday = 25 April, the Litanies transfer to Easter+2 (2011)" `Quick - test_major_litanies_transfer_2011_easter_monday + test_major_litanies_transfer_2011_easter_monday; + Alcotest.test_case "readings step 1: sanctoral proper (John of God, 2038-03-08)" `Quick + test_readings_step1_sanctoral_proper; + Alcotest.test_case "readings step 2: temporal proper (Lent I Monday, 2026-02-23)" `Quick + test_readings_step2_temporal_proper; + Alcotest.test_case "readings step 3: ferial resumption (Advent I Monday, 2025-12-01)" `Quick + test_readings_step3_ferial_resumption; + Alcotest.test_case "readings step 4: Common of Non-Virgins I (2038-03-06)" `Quick + test_readings_step4_common_felicitas; + Alcotest.test_case "readings step 4: Common of Non-Virgins II (2038-03-09)" `Quick + test_readings_step4_common_frances; + Alcotest.test_case + "readings: displaced-Sunday feria pins M26 shape 2(b)'s KNOWN-WRONG walkback (2026-10-26)" `Quick + test_readings_displaced_sunday_feria_known_divergent ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index 8c96f26..1a960df 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -3,6 +3,7 @@ module Rite = Colitur_kernel.Rite module P = Colitur_kernel.Precedence module Layer = Colitur_kernel.Layer module Overlay = Colitur_kernel.Overlay +module Citation = Colitur_kernel.Citation module V = Rite_ef.Vocab_ef module T = Rite_ef.Temporal_ef @@ -299,12 +300,26 @@ module Synthetic = struct defaults against the default empty [layer], since nothing ever contests the temporal office there) so the resolution fixtures further down can override them without duplicating every other field. *) - (* No fixture here exercises citations -- readings is a harmless constant - [], the same role the other placeholder defaults above play. *) + (* Most fixtures here exercise no citations -- [readings] is a harmless + constant [], the same role the other placeholder defaults above play, + and {!Validate}'s own citation checks are gated on a rite producing SOME + citation somewhere, so a constant [] leaves them entirely dormant. Task + 10 makes it overridable ([?readings] below) so the citation fixtures at + the end of this file can drive those checks directly, exactly as every + other check here is driven -- rather than leaving two kernel checks with + no committed proof that they can fire at all. *) let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = [] + (* The shape {!Validate} accepts: exactly one First and one Gospel. The + references are deliberately nonsense -- these fixtures assert SHAPE, + never content. *) + let well_formed_citations = + [ { Citation.part = Citation.First; reference = "Synth 1:1" }; + { Citation.part = Citation.Gospel; reference = "Synth 2:2" } ] + let rite ?(vocab = vocab) ?(anchors = fun _ -> []) ?(season_runs = [ A; B ]) ?(rules = rules) - ?(transfer_target = fun _ origin _ -> origin) temporal : (season, rank) Rite.t = + ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) temporal : + (season, rank) Rite.t = { Rite.id = "synthetic"; vocab; year_start; temporal; anchors; rules; season_runs; transfer_target; readings } @@ -315,8 +330,11 @@ module Synthetic = struct unchallenged). Task 12's own resolution fixtures pass their own. *) let empty_layer = Layer.empty ~id:"synthetic-empty" ~name:"empty" - let run ?vocab ?anchors ?season_runs ?rules ?transfer_target ?(layer = empty_layer) temporal = - Val.run (rite ?vocab ?anchors ?season_runs ?rules ?transfer_target temporal) layer ~year:2026 + let run ?vocab ?anchors ?season_runs ?rules ?transfer_target ?readings ?(layer = empty_layer) + temporal = + Val.run + (rite ?vocab ?anchors ?season_runs ?rules ?transfer_target ?readings temporal) + layer ~year:2026 let has_check check (fs : Val.failure list) = List.exists (fun f -> f.Val.check = check) fs @@ -708,6 +726,76 @@ let test_resolution_checks_clean_on_a_well_behaved_layer () = Alcotest.(check (list string)) "none of the five checks fire" [] (fired_task12_checks (run ~layer:clean_sanctoral_layer ~rules:clean_sanctoral_rules good)) +(* ---- Task 10: the citation invariants ---- + + Two new kernel checks, and on real EF data NEITHER can fire: every day of + every liturgical year 1583-9999 resolves exactly one Epistle and one + Gospel (measured, not assumed -- the exhaustive sweep is clean, and the + check was mutation-proved live rather than merely silent). That is the + good outcome and precisely why these fixtures are needed: a check with no + live witness and no negative-path test is indistinguishable from a check + that does nothing, which is the trap this whole section of the file exists + to avoid. + + All four drive the checks through [?readings], the same way every fixture + above drives its own check through [?vocab]/[temporal]/[?rules]. *) + +(* The gate itself, and the most load-bearing of the four: a rite that + computes no readings AT ALL (the default constant [], every other fixture + in this file, and any rite whose lectionary is simply not built yet) must + report neither check -- not "usually", not "on this year". Without this, + the natural implementation ("a day with no citations is a failure") would + turn every unrelated fixture here red and, worse, would make [Validate] + demand a lectionary of any rite that has none. *) +let test_citations_silent_without_a_lectionary () = + let fs = run good in + Alcotest.(check bool) "no citation check fires for a rite with no readings at all" false + (has_check "citations" fs || has_check "citations-unresolved" fs) + +(* The positive: well-formed citations on every day report nothing. *) +let test_citations_clean_when_well_formed () = + let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = well_formed_citations in + let fs = run ~readings good in + Alcotest.(check bool) "neither citation check fires when every day carries First + Gospel" false + (has_check "citations" fs || has_check "citations-unresolved" fs) + +(* Zero or two, never one: a lone Epistle is a malformed Mass. This is the + invariant the plan names first, and the one a bootstrap bug would most + plausibly produce -- half a lookup succeeding. *) +let test_citations_fires_on_a_lone_epistle () = + let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = + [ { Citation.part = Citation.First; reference = "Synth 1:1" } ] + in + Alcotest.(check bool) "citations check fires when a day carries an Epistle but no Gospel" true + (has_check "citations" (run ~readings good)) + +(* A part outside this plan's scope. The chants (Psalm/Second/Tract/Alleluia/ + Sequence) are deliberately unbuilt -- no source, no oracle -- so one + appearing is a defect, not a feature arriving early, and must be caught + even though the day is otherwise a well-formed pair. *) +let test_citations_fires_on_an_out_of_scope_part () = + let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = + { Citation.part = Citation.Tract; reference = "Synth 3:3" } :: well_formed_citations + in + Alcotest.(check bool) "citations check fires when a part outside First/Gospel appears" true + (has_check "citations" (run ~readings good)) + +(* The coverage half, kept a SEPARATE check name from the three above: a rite + that resolves readings on most days but falls through on one. On real EF + data this has no witness at all, so this fixture is the only thing that + holds it honest. [target] is the same mid-run day every other fixture in + this file singles out. *) +let test_citations_unresolved_fires_on_a_gap () = + let readings ~observed:_ ~temporal:_ ~date ~temporal_at:_ = + if D.compare date target = 0 then [] else well_formed_citations + in + Alcotest.(check bool) "citations-unresolved fires when one day of the year resolves nothing" true + (has_check "citations-unresolved" (run ~readings good)); + (* ...and the well-formedness check must stay silent on that same run: the + two are different faults and must not be reported as one. *) + Alcotest.(check bool) "the well-formedness check stays silent on a pure coverage gap" false + (has_check "citations" (run ~readings good)) + let suite = ( "Validate", [ Alcotest.test_case "landmark years" `Quick test_landmark_years; @@ -727,6 +815,16 @@ let suite = Alcotest.test_case "vocab rank injectivity fires" `Quick test_vocab_rank_injectivity_fires; Alcotest.test_case "vocab season injectivity fires" `Quick test_vocab_season_injectivity_fires; Alcotest.test_case "slugs fires" `Quick test_slugs_fires; + Alcotest.test_case "citations silent without a lectionary" `Quick + test_citations_silent_without_a_lectionary; + Alcotest.test_case "citations clean when well formed" `Quick + test_citations_clean_when_well_formed; + Alcotest.test_case "citations fires on a lone epistle" `Quick + test_citations_fires_on_a_lone_epistle; + Alcotest.test_case "citations fires on an out-of-scope part" `Quick + test_citations_fires_on_an_out_of_scope_part; + Alcotest.test_case "citations-unresolved fires on a gap" `Quick + test_citations_unresolved_fires_on_a_gap; Alcotest.test_case "lost fires on resolution exception" `Quick test_lost_fires_on_resolution_exception; Alcotest.test_case "duplicated fires" `Quick test_duplicated_fires; Alcotest.test_case "unconverged fires" `Quick test_unconverged_fires; |
