aboutsummaryrefslogtreecommitdiff
path: root/test
Commit message (Collapse)AuthorAgeFilesLines
* test(precedence-ef): witness the RG26 Class4 gate and correct a vacuous ↵Lukasz Kasprzak2026-08-121-6/+65
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | guard claim Item 2(a): the RG 26 omission gate (rank = Class4) is extensionally identical, over every candidate this engine actually constructs, to the refuted "privilege_of loser = Ordinary" predicate it replaced -- confirmed by enumerating the whole candidate space over 1583-9999, substituting the old predicate back leaves 257/257 green and byte-identical output. Adds a synthetic row: a temporal-origin, Ordinary-privilege Class3 loser, which the two gates disagree on (RG26: Commemorate; the refuted gate: Omit, since it never looked at rank). Reverting the gate to the old predicate turns exactly this row red: Expected: "Commemorate(Ordinary)" Received: "Omit" Item 2(b): the existing "RG33 boundary" row's comment claimed it proved the RG26 branch's own (not (is_vigil ...)) guard. It does not: the vigil sourced there is Class2, already excluded by the branch's rank = Class4 test alone, with or without the vigil guard -- verified by deleting the guard, which leaves 257/257 green. Corrected the comment to say what the row actually shows (RG31's "if impeded, commemorated" vigil mandate, a real and separate fact worth keeping) and added a genuinely discriminating synthetic row: a temporal-origin Class4 loser whose slug is ALSO vigil-shaped, a combination RG 91 does not describe but Precedence.resolve can still be asked to construct. Deleting the guard turns this row red: Expected: "Commemorate(Ordinary)" Received: "Omit" Both reversions were applied locally to precedence_ef.ml, confirmed, and reverted; the committed .ml is unchanged. Test count 257 -> 259, both new rows green under the current, correct code.
* test: golden pins for the known-tricky yearsLukasz Kasprzak2026-08-122-1/+512
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Validation layer 5: fourteen hand-verified pins in test/test_golden.ml, wired into the suite via test/test_colitur.ml. Each pinned day was computed and checked against its RG citation before being written down, never transcribed from `colitur day` output: - Easter extremes 1598 (earliest, 22 Mar), 1666 (latest, 25 Apr) and 2038 (late-modern instance of the same latest date) -- all three independently computed by hand via the Gauss/Meeus Gregorian Easter algorithm, not read off Computus.gregorian_easter. extreme_years in test_validate.ml already established 1598/1666 as the true 1583-2500 extremes (correcting a stale 1818/2038 comment); this file pins the resolved DAY there, not just the date. - Annunciation transfer, 25 March inside Holy Week (2016: 25 March is literally Good Friday) and the double transfer with St Joseph (2008: RG96's Attamen(a) claims Easter+8 for the Annunciation first, Joseph's own RG96 walk continues past it to the next day). - The RG96 Attamen(a) exception's own CONDITION pinned on both sides: 2057, 2007, 2012 (general walk suffices, lands before Easter, no exception) vs 2016/2024 (walk would cross Easter, Easter+8 fires). - 2011-07-04, the Precious Blood transfer (Sacred Heart outranks it outright on 1 July; the RG96 walk skips Visitation and a Sunday before landing on 4 July). - All Souls falling on a Sunday (2025, RG96 Attamen(b)) and Christmas falling on a Sunday (2022, RG91 entry 1 -- no contest, since 25 December is never an ordinary Sunday candidate in the EF temporal cycle). - Holy Thursday's white amid violet Passiontide (2026, RG128(b)/RG122) -- the case colitur and lectio previously agreed was violet, so the differential could never have caught it; only a golden pin or the missalemeum oracle can. - Advent/Lent Ember ferias commemorated when impeded (1900, 1902, RG24 + RG109(e)) contrasted with IV-class ferias never commemorated (2026, RG26) and RG111(b)'s Sunday rank floor (2009, 2026) -- all three assert the actual displaced/excluded candidate is present in `omitted`, not just that `commemorations` is empty, so a day with no losing candidate at all could not pass vacuously. 2038 is deliberately NOT pinned day-by-day: register item F4 confirms 2038-03-06/08/09 are wrong (a lectio bootstrap-generator defect, not fixable here). Only the Easter-week days, untouched by that bug, are pinned; the exclusion is stated in the test's own comment, not silent. Every weekday asserted was independently cross-checked against `date -d <iso> +%A` (glibc, wholly outside this codebase) before being written down. Precedence outcomes were traced against precedence_ef.ml's own band/ disposition/admit/transfer_target, not merely observed to look plausible; where the primary text alone doesn't fully settle an outcome (the 2008 Annunciation/Joseph tie-break, both landing at RG91 table entry 11), the test's own comment says so rather than overclaiming a citation. Perturbation-tested: temporarily broke Holy Thursday's white-colour special case in temporal_ef.ml, confirmed the golden test failed with a clear day-and-field diff, reverted.
* test(oracle): correct M11's citation and verdict (fix round 1, F3)Lukasz Kasprzak2026-08-121-15/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | M11 (the Nativity Octave's 26/27/28 December commemoration) previously read "No RG paragraph found to support this" with verdict colitur. Wrong on both counts, found by fix-round-1 review. RG 67 ("dies infra octavam sunt II classis") and RG 109(c) ("de diebus infra octavam Nativitatis Domini") both name this commemoration; the 1962 calendarium's own text confirms it directly, verbatim, under all three dates: "S. STEPHANI PROTOMARTYRIS, II classis. / Com. octavae Nativitatis." (same pattern for John the Evangelist and the Holy Innocents). It is also a genuine regression the previous commit's own commemoration-eligibility fix introduced: before that fix, colitur happened to commemorate the day's generic Christmastide loser unconditionally, which coincidentally kept presence agreeing with the oracle even though the reasoning was wrong; the fix correctly stopped that, but 26-28 December's own temporal candidate falls through to [ferial_rank]'s Christmastide catch-all, which gives it Class4 -- the wrong ferial rank for a day RG 67 says is II class. A naive fix would also get RG 69 backwards: when this window falls on a Sunday, RG 69 names the SUNDAY as observed with a commemoration of THE FEAST ITSELF (Stephen/John/the Innocents), never the generic octave placeholder -- so this needs a commemoration that attaches to whichever named feast is observed, not one more competing RG 92-95 candidate the way everything else in this engine is modelled. Out of this fix round's own scope. Verdict changed from colitur to open: neither side is confidently right without that fix, and the previous "colitur is correct, nothing to implement" framing was itself the risk under review.
* kernel(precedence): fix round 1 -- RG23-26 govern ferial commemoration, not ↵Lukasz Kasprzak2026-08-121-28/+68
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | RG109 Review round 1 (F1, Critical): the commemoration-eligibility fix from the previous commit silently dropped II-class Advent and Lent Ember ferias when impeded, while ordinary (lower-solemnity) ferias of the same seasons kept their commemoration -- backwards on any reading. Reproduced: 1900-12-21 (an Advent Ember Friday) lost its commemoration entirely; 2026-12-21 (an ordinary Advent feria the same week) kept its. F2 (Important, the direct cause of F1): the branch's own justification mis-stated RG 109 as an exhaustive list of the only temporal circumstances that generate a commemoration. RG 109 is headed "Commemorationes privilegiatae sunt commemorationes" and closes "Omnes aliae commemorationes sunt commemorationes ordinariae" -- it sorts commemorations that already exist into two HONOUR classes (RG 108's differing liturgical hours), and says nothing about which offices have the right to be commemorated at all. That right belongs to Caput IV, "De feriis" (RG 21-27), never opened by the original pass: RG 24 (II-class ferias): "si vero impediuntur, commemorari debent" -- if impeded, MUST be commemorated. Not optional. RG 25 (III-class ferias): same mandate. RG 26: every feria not named in 23-25 is IV class, and IV-class ferias are NEVER commemorated -- the one exclusion RG 21-27 actually states. The omission branch is now gated on `rank = Class4` (RG 26) directly, not on `privilege_of = Ordinary` -- the old gate happened to reach the right answer for IV-class ferias (RG 26 also excludes them, for a reason the old citation did not give) and the wrong one for II/III- class Ember ferias RG 109(e) did not name by letter. RG 109(e) itself is also corrected: its bare "feriis Adventus, Quadragesimae et Passionis" previously excluded the Advent and Lent Ember sub-days by analogy with (d)'s separate September carve-out. RG 91's own TABLE needs an explicit "exceptis feriis Quatuor Temporum" at entries 22 and 25 to keep Ember days from being double- listed against their own entry 18 -- an exception that would be unnecessary drafting if "feriae Adventus"/"feriae Quadragesimae" did not already include their Ember sub-days by default. RG 109(e) carries no such exception, so it is read at that same default, inclusive scope: the Advent and Lent Ember ferias are privileged under (e), not merely ordinary-but-commemorable. (d)'s own existence is unaffected -- September Ember days sit outside Advent/Lent/Passiontide under any reading, so (d) remains necessary regardless. Verified: 1900-12-21 and 1902-02-22 now correctly commemorate their Ember ferias; 2026-12-21 and 1902-02-24 (ordinary ferias) unchanged. Blast radius re-measured against the prior commit, 1900-2100: 102 civil days changed, every one an Advent/Lent Ember (or Ember-vs- Joseph-collision) day regaining its commemoration, nothing else. Validate re-swept exhaustively, 1583-9998: 0 failures. F6: two privilege-boundary test rows that had been bent to expect the bug's own output (Commemorate -> Omit) are restored to what RG 24/25 actually require, now Commemorate(Privileged) given the RG 109(e) correction above -- these are the rows that should have caught F1. F7: admit_cases had no witness for RG 111(b)'s rank floor that wasn't already Class2, so reverting that filter only reddened the oracle suite, never this file. Added a row (II-class Sunday, sole candidate an ordinary Class3) that fails without the filter and passes with it. F9: RG 122 cited alongside RG 128(b) for Holy Thursday's white -- states the same fact affirmatively ("Demum adhibetur color albus, feria V Hebdomadae sanctae...") rather than as an exception to violet.
* test: oracle vs missalemeum 2026-2027; audit the sanctoralLukasz Kasprzak2026-08-125-2/+1380
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Validation layer 4: an oracle harness against missalemeum (Divinum Officium data), independent of the lectio bootstrap chain colitur's own sanctoral data comes from -- the only layer that can catch an error inherited from that bootstrap, and the only one that can validate commemorations at all (the lectio differential explicitly excludes them, per its own header comment). tools/extract_missalemeum_oracle.py shapes the fixture from lectio's sources/snapshot.tar.gz outside the test (no JSON library in this project's frozen deps, same reasoning test_differential.ml's own fixture already documents). test/fixtures/missalemeum-ef-2026-2027 .txt (730 days, SHA-256 pinned and asserted) + its own .provenance note record exactly how to regenerate it. test_oracle.ml compares three axes the oracle actually supports: rank, colour (SET MEMBERSHIP -- 14 of 730 days carry two colours, e.g. rose+violet on Gaudete/Laetare, which independently vindicates this project's own rose reading against lectio's violet-only one, recorded in the register), and commemoration presence/count. Slug identity is deliberately out of scope (needs a title->slug mapping, the data audit's own business, not the automated comparator's). Of 730 days, 688 matched cleanly outright. The remaining 42 are all named in data/ef/expected-divergences-missalemeum.sexp (14 cited entries, M1-M14): most are genuine primary-source-confirmed findings this task adjudicated and fixed in the two preceding commits (RG 33, RG 109/111, Holy Thursday's colour); the rest are real, cited, deferred feature/data gaps (RG 91 entry 27's BVM-Saturday office, RG 110's inseparable Peter/Paul commemoration, four sanctoral entries missing from lectio's own source) or genuine oracle-side artifacts -- honestly verdicted against whichever side this task's own primary-source research actually backs, never defaulted to colitur. One entry (M13, St Joseph vs the Friday of Passion Week 2027) is verdict open: adjudicated as unresolved after real search effort, not guessed past. The data audit: every sanctoral entry the comparison flagged was hand-checked against the 1962 calendarium, plus a 20-entry deterministic random control sample (seed 20260812) drawn independently of the flagged set. The control sample caught two entries (benedict, frances-rome) marked Commemoration_only in the bootstrapped data when the primary calendarium lists them as plain III-class feasts with their own Office -- traced to lectio's own source, not fixable here, and reported as a signal (10% of a random sample) rather than a blanket claim. Coverage recorded honestly in docs/research/rules-register.md's own three buckets: confirmed by oracle (200/322), confirmed by hand (23/322, 2 of them wrong), unverified (115/322) -- the unverified bucket stated explicitly rather than left implicit. Harness teeth demonstrated and reverted (not committed): a fixture rank/colour edit on a previously-clean day fails both the checksum pin and the no-unexplained-differences assertion independently; an expected_rows drift on the allow-list fails the citation-count assertion. Both captured with their exact failure messages, both reverted before this commit.
* kernel(temporal): Holy Thursday is white, per RG128(b)'s named exceptionLukasz Kasprzak2026-08-121-7/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | RG 128(b) excepts "Missa sive Chrismatis sive in Cena Domini feria V Hebdomadae sanctae" from the Septuagesima-to-Easter-Vigil violet span as a whole-Mass exception (unlike Palm Sunday's blessing/procession, which the same sentence carves out as only part of that day) -- so Holy Thursday's Mass is white, not violet. temporal_ef.ml fell through to the generic Passiontide ferial path (season_colour Passiontide = violet) for this date, since [named] has no entry for the Triduum at all (rank was already correct via [privileged_feria]; only colour was wrong). Found via Task 16's missalemeum oracle comparison: every other Triduum day's oracle colour set includes violet as an option (Good Friday "bv", Holy Saturday "vw" -- already-flagged, deliberately deferred per-action gaps, register §3b), but Holy Thursday's is white alone. This is a genuine NEW divergence from lectio too (lectio has no such exception and still prints violet for Holy Thursday) -- added as Layer C11 to the differential harness and data/ef/expected-divergences .sexp, 46 rows (2005-2050), rather than silently changing what that harness accepts. Also corrected a stale comment on season_colour claiming white's RG paragraph was never pinned -- RG 119 was found and cited in the register on 2026-08-11 but the correction was never copied into this file's own comment.
* kernel(precedence): three RG-verified EF precedence bugs, found via oracleLukasz Kasprzak2026-08-121-17/+76
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Task 16's missalemeum oracle comparison (2026-2027) surfaced three distinct precedence-engine bugs, each confirmed against the primary 1962 Missale Romanum text and cross-checked against the oracle: 1. RG 33 (vigil omission) was transcribed backwards. The primary text reads "Vigilia II AUT III classis penitus omittitur" (a II OR III class vigil is entirely omitted on any Sunday or I-class feast) -- not "I aut II" as the register and this module's own rank check (Class1 || Class2) previously had it. I-class vigils (Nativity, Pentecost) can never actually lose in this engine (band always ranks them above anything that could coincide with their dates), so the Class1 half was dead code; the real bug was that Class3 (St Lawrence's vigil, the sole III-class vigil) was MISSING, so it fell through to an ordinary commemoration instead of RG 33's mandatory omission whenever 9 August landed on a Sunday. 2. RG 93/95/109/113 read together: an ordinary, non-privileged TEMPORAL-cycle office (a bog-standard green-season feria, a plain Advent/Lent Ember day, a Minor Rogation day) has no standing to be commemorated at all when impeded -- RG 109's six-item list is a CLOSED set of the only temporal circumstances that generate a commemoration, not a floor under which anything ordinary still gets one. The engine previously commemorated the losing feria itself in this situation; confirmed wrong against ~190 independent missalemeum days (2026-2027) showing zero commemorations for the exact shape. Vigils are explicitly excluded from this new rule -- RG 31/32 give them their own "if impeded, commemorated" mandate, independent of RG 109's list. 3. RG 111(b)'s Sunday admission slot is restricted to "de festo II classis" -- a rank restriction, not merely the best available ordinary candidate. A III/IV-class ordinary saint competing for a II-class Sunday's single commemoration slot had no such restriction applied before; confirmed wrong via St Hyginus (11 Jan, Class3) losing to Holy Family, which missalemeum shows entirely displaced, never commemorated. All three fixes are pure disposition/admission changes -- they never touch band, so the observed day (season/slug/rank/colour) is unaffected in every case; the lectio differential (which never compares commemorations) stays green untouched. test_precedence_ef.ml updated throughout: two previously-wrong test expectations corrected (the RG33 III-class-vigil boundary row, the IV-class-feria totality row), three privilege boundary rows sharpened from "commemorated but unprivileged" to "omitted entirely" now that temporal+ordinary means omission, and a new contrast row added to keep the SANCTORAL side of rule 2 covered separately from the TEMPORAL side.
* test(differential): gate C1/C6 on slug family, pin the fixture's SHA-256Lukasz Kasprzak2026-08-122-10/+107
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Fix round 1 on Task 15's review, four findings closed. C1 and C6 previously gated on calendar date alone -- unlike every other Layer C entry, which also checks the actual colitur slug that won. A future sanctoral change producing an unrelated celebration with the same diff shape (e.g. a promoted 29-31 December saint landing on C6's own [Slug_f; Rank]) would have been silently absorbed under a citation that has nothing to do with the real cause. Both entries now require, whenever Slug_f is in the diff set, that colitur's own slug be a member of an explicit closed list (jan_6_13_slug, nativity_octave_day_slugs) -- the same style advent_feria_slug already used for C4. Constructed the exact substitution via a temporary (unshipped) overlay Replace directive promoting thomas-becket to Class1 Feast White; confirmed the tightened predicate refuses to absorb it (46 unexplained rows, one per year); reverted. C10 gained the diff-field-subset restriction every other entry already had (it previously fired on the two pinned dates regardless of which fields differed). Fixed a stale doc comment ("SIX" columns; field has five constructors). The fixture's provenance note now records its SHA-256, and the suite asserts it as its own first test case (test_fixture_checksum), so a hand-edited or partially re-copied fixture fails loudly instead of silently becoming an unlabelled snapshot. Computed via the system sha256sum binary through Sys.command plus a redirected-output temp file -- no new library dependency, not even unix. Constructed a one-byte fixture edit and confirmed the checksum test fails independently of the row-comparison test; reverted. 237/237 tests green, clean-build verified.
* test: differential vs lectio 2005-2050 with a cited allow-listLukasz Kasprzak2026-08-125-2/+17323
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Validation layer 3 (design spec's five): compares colitur's real day-by-day EF output against lectio (sibling project, Go), 2005-2050, one line per civil day. Of 16801 day-pairs, 11206 already agree on the seven leading columns; the 5595 that don't resolve into exactly 25 distinct field-diff signatures, all triaged. Three strictly separate layers, per the controller's ruling (the brief's single flat allow-list assumed a handful of differences, not 5595): - Layer A (test_differential.ml, norm_season/norm_slug): vocabulary. An explicit, closed table of naming synonyms with no liturgical substance (lectio's easter/christmas vs colitur's paschaltide/christmastide; a handful of slugs that are two names for the identical office). No wildcards -- every entry is a literal string pair. - Layer B (strip_epiphany_index): numbering. The one slug family whose lectio/colitur index offset is not a constant (Time-after-Epiphany week numbering, register 3c#5) has its embedded digit stripped to a common form on both sides before comparing; rank and colour stay fully compared. - Layer C (data/ef/expected-divergences.sexp): the cited allow-list. Ten genuine liturgical disagreements, each citing its RG paragraph and naming which engine is right (always colitur, verified against the Missal/register, never against lectio's own behaviour). This is the only layer permitted to cover a difference in rank, colour, or which celebration is observed. Five extend or restate register 3c's already- documented divergences (season boundary, Sunday I-class, Advent Ember ferias, Rogations); five are new, found and adjudicated in this task (Lent Ember days, the Nativity Octave, Ember-day-vs-saint precedence, the St Joseph transfer off a Lent Sunday, and the 2011 Sacred Heart / Precious Blood / Visitation collision). expected_rows on each entry is an exact regression pin, asserted by the test, not documentation. 13 January (register 6's long-open "Baptism of the Lord" item) is confirmed empirically fixed already -- Task 11's sanctoral wiring closed it before this task started -- so it is not allow-listed; the only residual difference there is the season boundary already covered by C1. Two stated limits carried from the brief (commemorations are not comparable; lectio's own EF oracle asserts season only, 2025-2026 only, so a rank/colour difference is not presumptive evidence against colitur) plus a third found during this task (the week column is a display convention on both sides, not a liturgical fact, and is not compared at all) are documented in the test file's own doc comment. Fixture: test/fixtures/lectio-ef-2005-2050.txt, committed as plain text (1.4 MB), generated by lectio commit 2386a45; provenance recorded in the sibling .provenance file. Colitur's side is recomputed fresh from the library on every run, through the same Calendar/Rite_ef pipeline `colitur day` uses, not the compiled binary. Proved the harness has teeth by two reverted perturbations: a genuine colour difference injected into a fully-covered fixture row fails the "no unexplained differences" check with the exact mismatched row printed; a one-row drift in an allow-list entry's expected_rows fails the count check independently, showing it is not merely a duplicate of the first assertion. 236/236 tests green, clean-build verified, deterministic across OCAMLRUNPARAM=R.
* kernel(validate): fold in Plan 2's carried guardsLukasz Kasprzak2026-08-122-33/+129
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three carried items from Plan 2's parked rulings, closed: 1. Slug uniqueness moves from a 200-sample QCheck property scoped to one rite (test_temporal_ef.ml) into Validate's own "slugs" check, so every consumer gets it. The resumed-Sunday exemption that property carried is dropped, not weakened elsewhere: Plan 2 verified zero duplicate slugs domain-wide (all 8 416 years), and by construction a resumed Sunday only ever backfills a week number Septuagesima cut short that same liturgical year, so it can never repeat a number that year's own January Sundays already used. The now-redundant property and its is_resumable_sunday_slug helper are removed from test_temporal_ef.ml; test_validate.ml's own domain-wide property covers the same ground for every consumer. 2. The anchors-erosion guard (Plan 2: deleting entries from a rite's anchors list left the whole suite green) is implemented, but not in Validate. Which of a rite's named days are Easter-derived is knowledge only the rite's own `named` function has; Rite.t deliberately exposes only `temporal` and `anchors`, never `named`, so a rite-agnostic Validate has no ground truth to check anchors' completeness against. Hardcoding an Easter offset, or even Easter itself, would smuggle Western/Gregorian-specific knowledge into code meant to also serve a future Julian-reckoning rite; rediscovering "named-ness" structurally from `temporal` alone is unsound for EF, since most ordinary Sunday/feria slugs from Septuagesima onward are also constant-offset-from-Easter by construction. The guard is therefore EF-specific and lives in test_temporal_ef.ml, discovering the Easter-derived slug set mechanically (scanning a window around Easter and keeping whatever `named` answers Some for) rather than hand-copying either named's or anchors' own offset list, then asserting completeness against the real anchors for the domain's Easter extremes (1598, 1666) plus an ordinary year. A negative fixture proves the guard has teeth, matching Plan 2's exact regression (anchors missing "ef-ascension" reports it, and only it, as missing). 3. test_validate.ml's extreme_years comment claimed 1818/2038; verified against Computus.gregorian_easter directly, the domain's actual Easter extremes (1583..2500) are 1598/1666. Corrected. Verification: the full 1583..9999 domain sweep (233 tests via dune test's 200-sample default, plus a manual full sweep) reports exactly one failure -- the known, already-pinned year-9999 season-truncation case -- and zero occurrences of the new "slugs" check anywhere in the domain. Deleting "ef-ascension" from the real anchors list (reproducing Plan 2's regression directly) is caught immediately by the new EF test and, confirmed empirically, invisible to Validate's own full property sweep -- direct evidence for why item 2 cannot live in Validate.
* test(rite_ef): pin no transfer ever lands inside the Easter octaveLukasz Kasprzak2026-08-121-1/+139
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Task 11's re-review found that Precedence_ef.transfer_target's Annunciation/Easter condition -- and RG 96's general walk more broadly -- is correct only because Temporal_ef happens to make every day from Easter Sunday through Low Sunday (Easter+0..+7) blocking (Class1/2): Easter and Low Sunday via named, every day between via privileged_feria. Nothing enforces that; a future edit narrowing the octave's blocking range could let a translated feast land inside it, silently. Add a test for the consequence, not the mechanism: over 2005-2050 (real data, the project's own differential window), no day in [Easter, Easter+7] is ever a transfer's target, checked against both transferred_in and transferred_out's own recorded target dates. Plus a direct, deliberately-constructed case that starts the RG 96 search inside Holy Week itself (Holy Thursday 2026, real occupant), pinning that the search lands on Easter+8 -- and a companion test that reads the real occupant's own rank across the whole [origin+1, Easter+7] span to confirm the search genuinely walked through the window rather than stopping early for an unrelated reason. Mutation-verified: narrowing privileged_feria's post-Easter range (6 to 4 days) makes all three new tests fail, including a real natural occurrence the fixture did not anticipate (St Joseph, 19 March, transferring into Easter+5 in 2008/2035/2046) -- not just the deliberately constructed Holy Week case.
* kernel(validate): resolution invariantsLukasz Kasprzak2026-08-121-23/+284
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Widen Validate.run to take the rite's sanctoral layer alongside the rite itself (Calendar.year needs both), and add five checks over the fully resolved liturgical year, on top of the existing temporal-only pass: - observed: a day's observed celebration never also appears among that same day's own commemorations/omissions. - lost: no sanctoral entry is silently dropped. Per slug, the number of times it is actually sighted (observed + commemorations + omitted, summed over the year) must never fall below the number of times its own Date_spec resolves within the year's span -- also fires if resolving the year raises at all, the most total form of loss. - duplicated: the same per-slug count must never exceed the number of Date_spec resolutions either. Deliberately NOT "no slug appears twice": a fixed date can legitimately resolve twice in the ~20% of liturgical years whose 371-day span reaches it on both ends (30 November/St Andrew is the worked example in validate.mli). - unconverged: no day's omitted reason indicates Calendar's placement pass hit its round guard before reaching a fixed point. - admission: the rite's own rules.admit is a fixed point on what it already admitted -- the rite-agnostic form of "the admission limit was not exceeded" available without embedding a rite's own numeric caps (RG 111's, for EF) into kernel code. Each check has a dedicated negative fixture in the synthetic rite (test_validate.ml), hand-traced against Calendar's actual resolution mechanics before writing the assertion, and verified to fail for the right reason against the code before this change. One pair (unconverged/duplicated) is not fully independent: hitting the round guard genuinely also trips duplicated, a real consequence of Calendar's own accounting once a candidate is simultaneously sighted at its permanent natural date and wherever the last placement round left it -- documented in guard_rules's own comment, not papered over. test_validate.ml's ef_rite/run now use the real Rite_ef.context and the real bootstrapped data/ef layer (Precedence_ef and the sanctoral bootstrap did not exist when this scaffolding was first written) rather than the earlier placeholder rules. Validate is clean across the whole 1583..9999 domain against real EF data except the one already-documented year-9999 truncation case (test_year_9999_does_not_raise).
* rite(ef): the Annunciation's RG96 exception is conditional, not unconditionalLukasz Kasprzak2026-08-122-15/+72
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The register was transcribed faithfully but was itself wrong: RG 96's Attamen (a), primary-source-verified from the scans and now corrected in the register, reads 'festum Annuntiationis B. Mariae Virg., quando est transferendum post Pascha, transfertur ... in feriam II post dominicam in albis' -- the Monday-after-Low-Sunday seat applies ONLY 'quando est transferendum post Pascha', when the feast is to be transferred PAST EASTER. The unconditional transcription made the exception fire on every impeded Annunciation regardless of cause. transfer_target now computes the general RG 96 target first, for every candidate, and overrides to the Monday after Low Sunday only when that general target itself falls after Easter Sunday -- testing the rubric's own condition directly rather than re-deriving a date-proximity rule from first principles. Confirmed against three real years the review named: 2007, 2012 and 2057 all previously sent the Annunciation to Easter + 8 (16 April, 16 April, 30 April respectively) when the correct, now-produced target is the next free day before Easter (26 March in each case -- Passion Sunday in 2007/2012, Lent III Sunday in 2057). Verified with actual CLI output for all three, before and after. Also cites RG 96 Attamen (b), the same primary-source passage, as the direct authority for All Souls' own move to the following Monday when impeded by a Sunday -- previously inferred from RG 91 entry 8's parenthetical plus the general walk, which happened to produce the right date; now stated directly. Rewrote the two existing Annunciation unit tests, whose synthetic occupants no longer trigger the (now correctly conditional) exception, and added a 2057 regression test using the real Temporal_ef.temporal as occupant plus a real-data cram pin -- both mutation-verified against the unconditional reading.
* test(ef): pin the overlay suppression's own observable effectLukasz Kasprzak2026-08-121-2/+48
| | | | | | | | | | | | | | | | | With data/ef/adjustments.sexp's one Suppress directive replaced by an empty directive list, colitur day 2026's stdout was byte-identical for all 365 days and the whole suite still passed: ef-nativity-vigil already outranks vigil-of-christmas in the RG91 contest regardless of whether the overlay ever ran, so the suppression's only observable effect is keeping vigil-of-christmas out of 24 December's Liturgical_day.omitted -- a field no cram assertion reads. test/dune's own dep on adjustments.sexp pinned the file's existence, never its content. Confirmed the gap two ways: reverting the overlay to (directives ()) makes this new test fail (24 December's omitted gains vigil-of-christmas), and confirmed test/cli.t's whole cram suite still passes under that same mutation -- cram is structurally blind to this, not merely undertested.
* rite(ef): clamp the RG96 search at the domain ceilingLukasz Kasprzak2026-08-124-3/+114
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | search_from could walk up to 400 days past origin before Calendar's own ~start ~stop clamp is ever consulted, and nothing stopped it probing occupant on a date past 31 December 9999 -- occupant chains through the real EF rite's temporal, which calls Computus.gregorian_easter, not total outside 1583..9999 (it Date.makes and failwiths on Error). Not reachable with the shipped sanctoral data alone, but reachable through the project's own primary extension path: an overlay adding an I-class feast on 25 December leaves nothing but Class2 Nativity-octave days for the rest of civil year 9999, so the unguarded search reached 1 January of year 10000 and crashed there with 'computus: year 10000 out of range 1583..9999'. 9999 is an in-range year and the kernel's contract is 'never raises on in-range input'. search_from now also stops, without probing occupant again, once it passes Date's own domain ceiling -- the same 'return a finite date, let Calendar's own out-of-range handling record it, never pretend to have found something admissible' contract the existing step-count guard already follows. Two new tests, both mutation-verified to actually reproduce the crash when the guard is removed (see the task report): a precedence_ef.ml unit test using the real Temporal_ef.temporal as occupant (a synthetic occupant can never discriminate this, since it never calls Computus itself), and a Calendar-level integration test reproducing the exact overlay-based scenario the review found.
* cli: colitur day <year> dumps resolved liturgical daysLukasz Kasprzak2026-08-123-3/+196
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Assembles Rite_ef.context (lib/rites/rite_ef/rite_ef.ml[i]): temporal, anchors and vocab from Temporal_ef; rules from Precedence_ef's band, disposition and admit; season_runs = Vocab_ef.seasons; transfer_target newly implemented here. transfer_target (RG 96): the next following day that is not I or II class, with the Annunciation's own exception (Monday after Low Sunday). Terminates by a structural step bound on its internal search, independent of Calendar's own round guard, which bounds rounds across a year, not one call's walk; documented as an obligation on rite.mli's transfer_target field, which did not previously state it. Fixes the vigil-naming mismatch Task 7's review predicted: the sanctoral bootstrap names its vigils with a vigil-of-X prefix (lectio's own convention), while Precedence_ef's is_vigil only recognised the temporal cycle's own X-vigil suffix. Both are now recognised, fixing RG 91 entries 21/26 and RG 33's vigil omission for the four affected celebrations. Verified by unit test and by mutation-testing the fix (reverting it fails exactly the new rows) and against real output across several years. Suppresses data/ef/sanctoral.sexp's vigil-of-christmas via a new overlay, data/ef/adjustments.sexp: it is the same celebration as the temporal cycle's own ef-nativity-vigil, both dated 24 December. colitur day <year>: one line per civil-year day, temporal and sanctoral fully resolved through Layer, Overlay, Precedence_ef and Calendar -- the first CLI path exercising the whole Plan 3 pipeline against real data. Verified the All Souls transfer chain (Tasks 7-8-11) end to end against real output for both a Sunday year (2025, lands on 3 Nov) and a non-Sunday year (2026, observed directly on 2 Nov).
* data(ef): bootstrap the 1962 sanctoral from lectioLukasz Kasprzak2026-08-123-1/+117
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Convert lectio's tridentine-calendar.ini (322 entries) into data/ef/sanctoral.sexp via a validating OCaml converter, tools/ bootstrap_sanctoral.ml, rather than a hand-written script: every field is built through Slug.of_string, Colour.of_string and Vocab_ef.rank_of_string, so the emitted sexp is valid by construction. Two conversion decisions, both documented rather than buried: - subject defaults to Subject.Saint, overriding Celebration.make's kernel default of Subject.Temporal, for the 316 entries with no explicit class; - rank = commemoration maps to status = Commemoration_only with an inferred Class3 (not a citation -- it is what the 1960 reform reduced most simple feasts from), recorded as an open item in the rules register for the oracle to adjudicate. Every celebration is tagged layer = Precedence_ef.universal_layer, the provenance id RG 91's band classifier reads to tell the universal calendar from proper/indult data. The generated file carries a provenance header: source path, its SHA-256, and the UTC conversion date, so re-bootstrapping against a newer lectio is reproducible and diffable. Output is byte-identical across runs. test/test_sanctoral_ef.ml loads the file through Layer.load and checks the counts independently derived from the source INI (322 entries, 114 Commemoration_only, 12 Class1, no Subject.Temporal, every date resolves in a leap year), plus two named spot-checks against the INI's own text -- one entry with an explicit class field, one commemoration -- so a passing count cannot hide the wrong 322 entries having been converted.
* test(ef): prove the II-class-Sunday privilege override end-to-endLukasz Kasprzak2026-08-111-1/+67
| | | | | | | | | | | | | | | | | | | | | | | | | Adds test_ii_class_sunday_privileged_witness_admitted_end_to_end, proving RG 111's "II-class Sundays: one, dropped if a privileged one is due" clause through the real pipeline (band, disposition, privilege_of, admit together), not only at the isolated admit level. RG 109(b) is the one privileged category that is sanctoral and rank-based rather than temporal-origin: a Commemoration_only, Class1 sanctoral candidate (the same shape disposition_cases already uses as (b)'s witness) is held out of the band contest entirely by Precedence.resolve, so it always loses regardless of what wins the day. Paired with an ordinary sanctoral saint, both losing to an ordinary Class2 Sunday, gives observed_is_sunday = true with one privileged and one ordinary loser due at once, with no new fixture. Documents, and mutation-confirms, what this fixture does and does not prove: RG 109(b)'s only reachable witness is Class1 (the highest dignity), so the privileged loser here is unavoidably also the higher-dignity one, and the admission outcome alone cannot distinguish "privilege overrides dignity" from "dignity alone picked the same winner" -- that distinction remains admit_cases's job. What this test does prove, and what a category-(b) regression in privilege_of breaks, is the privilege tag itself surviving the real pipeline end-to-end.
* rite(ef): an impeded I-class Sunday commemorates, not transfersLukasz Kasprzak2026-08-111-0/+22
| | | | | | | | | | | | | | | | | | | | | | | | | disposition's Class1 branch was unconditional on rank, so an impeded I-class Sunday (Advent/Lent/Passiontide/Low Sunday) currently transferred like a feast. RG 95 (register lines 323, 363) restricts the right of translation to I-class FEASTS -- RG 91's own table lists Sundays as a separate row (entry 6, line 332) from feasts (entries 11-13, lines 337-339) -- and RG 109(a) (line 374) lists "of a Sunday" as a privileged commemoration category, which presupposes an impeded Sunday stays put rather than moving to another day. Excludes is_sunday_slug losers from the Transfer branch so they fall through to the existing Commemorate (privilege_of loser) branch, which already tags them Privileged via RG 109(a) with no further change. Fires on real dates in the 2005-2050 differential window: 24 December on Advent IV in 2023, 2028, 2034, 2045; 8 December on an Advent Sunday in 2024, 2030, 2041. Verified no previously-passing Transfer assertion used a Sunday-shaped loser (grepped test_precedence_ef.ml, test_calendar.ml -- which uses its own synthetic rite unrelated to Precedence_ef.disposition -- test_colitur.ml, test_validate.ml); confirmed by mutation-reverting the fix and observing exactly the one new test fail, nothing else.
* rite(ef): commemoration privilege and admission (RG 108-111)Lukasz Kasprzak2026-08-111-4/+306
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Precedence_ef.privilege_of classifies a commemoration candidate against RG 109's closed list of privileged commemorations (a Sunday; a I-class day; a day within the Octave of the Nativity; a September Ember day; a feria of Advent, Lent or Passiontide; the Major Rogations in Mass), read entirely off the candidate's own rank/slug/origin, no context needed. Major Rogations (f) is left unimplemented rather than guessed: no producer for the Major Litanies exists anywhere in this codebase yet. disposition's two Commemorate sites now call privilege_of instead of Task 8's interim_privilege placeholder, which is removed entirely (binding and .mli export both gone). Precedence_ef.admit applies RG 111's four admission counts, keyed on the observed day's own class and Sunday-ness: a I-class day admits none except one privileged commemoration; a II-class Sunday admits one, but a privileged commemoration due displaces any ordinary one regardless of dignity; any other II-class day admits one by dignity alone, with no such override; III/IV-class days admit at most two by dignity. Ties break on slug, matching Precedence.compare_by, so the admitted set never depends on input order. Every admitted candidate is a value taken unchanged from the input list, never rebuilt, so Precedence.resolve's physical-equality-based dropped/omitted accounting keeps working -- closing a note left open since Task 2. Tests: RG 109 category rows (one per reachable category, plus boundary rows proving Advent/Lent Ember days and Minor Rogations are correctly excluded), RG 111 admission rows checked on slug identity rather than count, an order-independence check, and three end-to-end integration tests proving the admission limit's drop lands in resolution.omitted rather than vanishing.
* rite(ef): occurrence dispositions (RG 92-95, 33, 94)Lukasz Kasprzak2026-08-111-4/+144
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Precedence_ef.disposition decides the loser's fate in an occurrence: a Commemoration_only celebration is always commemorated (it can never win or transfer); a I- or II-class vigil impeded by any Sunday or a I-class feast is entirely omitted (RG 33), checked before the generic rule below or the Nativity/Pentecost Vigil could wrongly transfer; any other I-class loser transfers (RG 95 -- only I class has the right of translation); everything else is commemorated, with the admit-or-omit decision left to RG 108-111's admission count (Task 9). RG 94 needs no code: resolve always compares a loser against the day's actual winner, never against a departed sibling, so no commemoration can ride along with a transferred feast in this design. This is the branch that completes Task 7's carried All Souls fix: once it loses to an occurring Sunday, its untouched Class1 rank routes it to Transfer via the generic rule, not a special case. Landing on 3 November is Rite.transfer_target's job, not wired up yet. Commemorate carries an interim Precedence.Ordinary privilege pending Task 9's RG 109 implementation, exposed as interim_privilege for that task to replace. Table-driven tests cover each rule, including RG 33's boundary from both sides and a Commemoration_only loser that is also Class1 and vigil-shaped to pin the branch ordering. Mutation-tested: disabling RG 33, either direction of RG 95's rank condition, or the Commemoration_only priority check each fail exactly the rows built to catch them.
* rite(ef): fix entry 8's Sunday exception and entry 14's over-restrictionLukasz Kasprzak2026-08-111-6/+96
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review of 436ba75 found two calendar defects and one coupling risk. Entry 8 (All Souls) dropped register line 334's own qualifier, "(yields to an occurring Sunday)" -- it returned 8 unconditionally, so on 2 November falling on a Sunday (2025, 2031, 2036, 2042 in the 2005-2050 differential window), All Souls incorrectly outranked and observed over the Sunday. 2 November is always Time_after_pentecost and never coincides with any other entry's own date, so the only rival this exception ever has is an ordinary entry-15 II-class Sunday; on such a Sunday, band now returns one worse than entry 15's own value rather than the literal 8 -- strictly worse, not merely different, since an exact tie would fall to Precedence.resolve's slug tie-break, which for "ef-all-souls" against a Sunday slug would make All Souls win the tie anyway. Entry 8's own rank is untouched, so Task 8's disposition still sees a true I-class candidate to transfer. Entry 14 (Feasts of the Lord, II class) added a universal-layer restriction the register's line 341 does not carry -- contrast entry 16 at line 342, which explicitly says "not of the Lord"; RG 37c (register line 393) also speaks of "II-class feasts of the Lord" with no universal qualifier. Dropped the layer test: a proper or indult feast of the Lord now bands 14, not 19/20. Also exposes vigil_suffix and ember_prefixes from precedence_ef.mli, matching universal_layer/indult_prefix's existing "colitur convention, not an RG citation" treatment -- both were previously private literals duplicated in the test file, so a rename of temporal_ef.ml's slug format could have drifted silently past both sides agreeing with each other. Two test rows now build their candidate from Temporal_ef.temporal's own real output (entry 18's Lent Ember day, entry 21's Ascension Vigil) instead of a hand-typed slug, closing that specific coupling. Adds the three cheap rows review named as closing the remaining unpinned guards (a temporal-origin Class1 candidate on an unnamed date; a universal Class2 vigil of the Lord; a non-universal Class3 vigil), plus a resolve()-level test proving the Sunday is actually observed over All Souls, not just that band returns the right integer in isolation.
* rite(ef): RG 91 Table of PrecedenceLukasz Kasprzak2026-08-112-1/+203
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Precedence_ef.band transcribes RG 91's 28-entry Table of Precedence (rules-register.md §4) for the EF rite: given a day's context and a candidate celebration, returns the table's own entry number, 1-28 (I class 1-13, II class 14-21, III class 22-26, IV class 27-28); lower wins. Every branch carries its entry number and register citation in a comment, checked in the table's own numeric order. Two entries are transcribed as the register states them even though they invert the pattern the rest of the table follows: at III class, 23 (particular calendars) outranks 24 (universal), the reverse of how 11/12 and 14/16/19/20 rank a universal feast ahead of a proper one at I and II class. Sanctoral-origin, layer-decided entries (11-13, 14/16/19/20, 23/24) follow the brief's structural insight: a celebration whose layer is not the universal base is an overlay -- proper, or indult if its layer id also carries the indult prefix. Neither the universal-layer id nor the indult prefix is an RG citation; both are colitur's own data-modelling convention, exposed from the module so whichever task loads the real EF sanctoral overlays can align to them. Vigils (21, 26) are read off the temporal cycle's own -vigil slug suffix rather than gated on origin, since a II/III-class vigil can be either temporal-origin (Ascension, already produced by temporal_ef) or sanctoral-origin (a saint's vigil, no task has loaded yet); Ember days (part of entry 18) are read off temporal_ef's own ember slug prefixes rather than re-derived, since the September anchor is independently flagged there as one of the more contested dates in the calendar. A candidate shape the table has no row for (e.g. a Class1 vigil that is not Nativity or Pentecost, or a Class4 candidate marked as a vigil -- RG 91 has no IV-class vigil either) returns a dedicated unclassified sentinel (max_int) rather than being folded into a same-rank entry it does not belong to. test_precedence_ef.ml is table-driven: one Alcotest.test_case per RG 91 entry (55 rows total, several entries covered by more than one named day so a single missed offset cannot hide behind a passing sibling), each date computed from Computus.gregorian_easter rather than hand-typed, so an arithmetic slip cannot pass by accident.
* kernel(calendar): fix multi-departure loss, band-order gap, off-array targetsLukasz Kasprzak2026-08-111-65/+142
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four findings from Task 6 review, addressed on top of f15e44d. 1. transferred_out was a single Date.t option, so when RG 97-98 collides three or more feasts on one date (more than one loser), only the last one Hashtbl.iter happened to visit survived -- a genuinely lost move, and which one survived depended on OCaml's hash seed (OCAMLRUNPARAM=R), an environment read a kernel invariant forbids. RG 97-98 says coinciding feasts transfer "in order" -- plural -- so the type was wrong, not the fixture: transferred_out is now (Celebration.t * Date.t) list. transferred_in stays a single option, deliberately: a day receives at most one arrival (RG 96 sends each departure to the next non-I/II-class day, and the first to arrive occupies it). The per-day list is canonicalised (sorted by target date, then slug) after accumulation, the same fix layer.ml already applies to its own date-bucket index and for the same reason. Verified clean across 15 runs under OCAMLRUNPARAM=R; disabling the canonicalisation step showed the raw order genuinely flip between seeds, confirming the fix is load-bearing. 2. Every deferred candidate in the fixture was the same rank, so compare_deferred's band branch was unreachable and reversing it broke nothing -- the RG 97-98 test was pinning slug order, not band order. The fixture now has three ranks (Hi1 outranks Hi2, both transfer, both outrank Lo), with slugs chosen so band order and slug order disagree. Reversing the band comparison now fails the test on "higher-band loser claims 2 Feb first", received the wrong slug instead. 3. A transfer_target free to name any date could place a candidate outside the liturgical year's own start/stop bounds: invisible to year/build_day, so it would be observed nowhere and, since its origin's re-resolution would report it as settled, omitted nowhere either -- genuinely gone, contradicting calendar.mli's "never silently dropped". place_transfers now checks the range on every placement and routes an out-of-range one to a permanent-exclusion table instead of assignment, with its own cited omitted reason. 4. Precedence.resolve folds Transfer and Repose into one deferred case, and place_transfers routed all of it through transfer_target (RG 96's search), which is only correct for Transfer. Repose is RG 100-102's repositio, a distinct rubric this module does not implement. Documented rather than split into a second mechanism: nothing in the EF ruleset returns Repose (design spec section 1.3, "declared, not exercised"), so the gap is latent, not a live bug. Two new tests (origin records every departure; transfer target outside year is recorded not lost); the RG 97-98 test's fixture and assertions rewritten for finding 2.
* kernel(calendar): place transferred celebrations (RG 96-98)Lukasz Kasprzak2026-08-112-23/+167
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Calendar.year now runs a placement pass after resolving every day: each deferred candidate (RG 95's I-class-only right of translation, via Precedence's Transfer disposition) is placed on the next day the rite's new Rite.t.transfer_target names as admissible, transferred_in/out are set on the two ends of the move, and the whole year is re-resolved to a fixed point, bounded by a hard max_transfer_rounds = 64 guard. transfer_target is rite-supplied rather than a generic search Calendar drives itself: RG 96's 'not I or II class' is not derivable from band or disposition alone (RG 91's own table lets a universal I-class feast outrank an ordinary Sunday in a raw contest, yet RG 96 forbids landing a translation there regardless), and the search's starting point is rite-specific too (the Annunciation exception). It takes an occupant callback exposing what Calendar currently resolves as observed on any date, so the rite never has to re-implement occurrence resolution. Two correctness properties drove most of the design: - A candidate's permanent natural loss at its own origin (the layer entry never moves) is rediscovered every round; left unfiltered this oscillates a placed candidate between two dates forever, since its own rank makes it look 'occupied' to a fresh search from its origin. Both the round loop's gather and the final per-day omitted accounting filter this out, keeping only sightings that are either brand new or losing at a candidate's *current* target (a fresh RG 97-98 bump). - RG 97-98's sort has to actually decide something, not just happen to agree with Precedence.resolve's own tie-break next round: a claimed-this-round overlay lets earlier-processed candidates in one round block later ones in the same pass, so two coinciding I-class feasts land on consecutive admissible days in the one round they collide, in band order. Also folds in Task 5's review finding: year_bounds clamps y to [1582, 9999] once, up front, rather than guarding start and stop independently (each guard only ever covered one of the two rite.year_start calls, leaving year 999 and year 100000 each able to call it out of domain through the other branch).
* kernel: carry omitted celebrations on Liturgical_day.t, reason and allLukasz Kasprzak2026-08-111-3/+60
| | | | | | | | | | | | | | | | | | | | | | | Precedence.resolution already tracked what happened to every losing candidate -- commemorated, deferred, or omitted with a reason -- but Liturgical_day.t had nowhere for the deferred and omitted buckets to land, so Calendar dropped them at the door. Task 12's no-celebration-lost invariant needs to read that accounting off the day result itself, not re-resolve every day to reconstruct it, so a reason recorded nowhere is not recorded. Add Liturgical_day.omitted : ('r Celebration.t * string) list, after transferred_out and before citations. Calendar.resolve_day now folds resolution.omitted (Precedence's own native omissions, reasons intact) and resolution.deferred (mapped to "deferred: transfer placement not yet implemented (Task 6)") into it. Adds a full-day accounting test against the whole Calendar pipeline: four colliding sanctoral entries plus the day's feria, checked as a slug set (matching test_precedence.ml's own nothing-silently-lost test) so a candidate silently dropped or duplicated into two buckets would fail it, plus an identity check that the deferred and admission-limit reasons don't get swapped.
* kernel(calendar): the year is the primitive, the day is derivedLukasz Kasprzak2026-08-112-1/+174
| | | | | | | | | | | Transfers make per-date resolution impossible to do correctly: resolving 25 March can push a feast onto 26 March, and RG 97-98 has coinciding I-class feasts transfer in table order, which needs global knowledge. So year computes a whole liturgical year in one pass and day indexes into it. Pure, no cache, no mutable state. This commit resolves each day but does not yet place deferred transfers; they are recorded with a reason. Task 6 adds the placement pass.
* kernel(rite): bundle what a rite supplies; make season runs rite-suppliedLukasz Kasprzak2026-08-111-6/+73
| | | | | | | | | | | Validate took four loose arguments that had to come from the same rite with nothing enforcing it, and Calendar is about to add more. Bundling makes a mismatched assembly unrepresentable through the normal path. season_runs replaces the hardcoded assumption that every season occupies exactly one unbroken run. That holds for the 1962 rite but is false for the modern form's Ordinary Time, which is one season in two runs -- as written the check would have reported a false failure every year for the second rite.
* test(precedence): strengthen accounting test, cover order and empty sanctoralLukasz Kasprzak2026-08-111-7/+39
| | | | | | | | | | | | The accounting test only checked bucket lengths, which a mutant satisfies by duplicating a candidate across two buckets while dropping another entirely. Replace it with a sorted slug-set comparison (Alcotest.slist), which a duplicate-or-missing slug both fail. Add two cases the brief's three properties call for but nothing exercised: input-order independence (permuting the sanctoral list must not change the outcome) and a temporal-only day (empty sanctoral list), the case the temporal/sanctoral split exists to make safe.
* kernel(precedence): rite-parameterised resolverLukasz Kasprzak2026-08-112-1/+77
| | | | | | | | | | | | Three rite-supplied functions, not one: band (who wins, RG 91), disposition (what happens to the loser, RG 92-95) and admit (how many commemorations are admitted, RG 111). The loser's fate depends on the loser's own rank, so conflating them would resist extension. resolve takes the temporal candidate separately from the sanctoral list, which makes it total by construction. Every candidate lands in exactly one of observed, commemorations, deferred or omitted -- nothing is dropped silently, which is what makes the no-celebration-lost invariant checkable.
* kernel(celebration): distinguish a feast from a commemoration-only entryLukasz Kasprzak2026-08-111-0/+15
| | | | | | | The 1960 reform reduced many feasts to a bare commemoration. They keep a rank, because RG 111 orders admitted commemorations by dignity, but they can never be the observed day. Modelled as a separate status rather than a fifth rank: RG 8 fixes the classes at four.
* cli: emit "-" for an absent week, keeping output column-safeLukasz Kasprzak2026-08-111-3/+3
| | | | | | | | | | | | | | | | | | | | | | | | | week is "" for roughly 30 days a year (any day outside a numbered week). Printed as-is among seven space-separated fields, that collapses two fields into a double space, so naive field-position parsing (e.g. awk '{print $4}') silently reads the wrong column on those days -- a real defect for a project whose stated design is Unix-composable CLIs. Emit "-" instead, so every line always has exactly seven single-space-separated fields. Record.headers/to_row exist for exactly this kind of column-safe encoding but use a different column set and order than this CLI's documented date/weekday/season/week/slug/rank/colour format (design spec §6); adopting them here would be a larger, unrequested format change, so this keeps the CLI's own field list and only substitutes the empty value. The cram test's pinned lines are re-verified against liturgical anchors before promoting, not blind-promoted: 1 Jan 2026 (Circumcision, class-1, white) and 2-3 Jan (ordinary class-4 Christmastide ferias, the finding-1 slug fix already re-pinned separately) are correct; 2026-04-05 remains the sole Easter Sunday line, Paschaltide week 1, class-1, white, matching the independently-computed Easter anchors printed by `colitur easter 2026` immediately above in the same file.
* kernel(validate): never raise at 9999; add anchor, determinism, vocab checksLukasz Kasprzak2026-08-111-8/+93
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Validate.run ~year:9999 raised (year_start (year + 1) asked year_start for civil year 10000, out of the kernel's 1583..9999 domain), even though 9999 is itself in range and kernel computation must never raise on in-range input; ~year:9998 already returned zero failures. run now clamps its scan to 31 December 9999 instead of computing year_start (year + 1) when year is the domain maximum, and validates the resulting truncated final liturgical year rather than not being able to run it at all. The design spec's validation §5 lists eight checks; only five were implemented (coverage, seasons, weeks, weekday, closure). The two missing were a real gap, not just a documentation slip: - §5.7 anchor agreement. All of an EF year's Easter-derived and fixed named days were pinned only by point assertions for 2026. run now takes an ~anchors:(int -> (string * Date.t) list) parameter -- the rite's own independent restatement of those dates, paired with the slug each should carry, not derived from temporal itself -- and checks that temporal agrees on every one of them. Temporal_ef.anchors supplies EF's list. Kept rite-agnostic: the anchor list comes from the rite argument, not the kernel. - §5.8 determinism. run now calls temporal a second time for every date and checks the result is structurally equal to the first. Also, finding 8: the rank/season closure checks compare vocab entries via their _to_string images, which is only sound if those images are injective. run now checks List.map rank_to_string ranks and List.map season_to_string seasons for duplicates up front and reports a "vocab" failure if either collapses two distinct values to the same string, rather than relying on that injectivity unasserted. Test-quality fixes to the existing synthetic fixture, found while adding coverage for the above: the fixture's own comment claimed its mutation target (2026-03-15) was "not a Sunday" and "sits safely mid-run" -- it is a Sunday, which made the coverage/week mutations cascade further than documented even though the assertions still target specific check labels. Moved to a genuine mid-week day (2026-03-17) and the comment corrected. extreme_years's own test required only "found at least one" of the two Easter-extreme years; tightened to require both, since both genuinely exist in 1583..2500. Covering tests: test_year_9999_does_not_raise (would error under the old code; the fix is pinned by calling run 9999 directly with no try, plus asserting the truncated year is reported via an ordinary "seasons" failure, not silently or via coverage); anchor-clean and anchor-fires cases on the synthetic rite; a determinism-fires case using a target date whose temporal alternates what it returns across successive calls; two vocab-injectivity-fires cases (collapsed rank strings, collapsed season strings).
* rite(ef): fix duplicate slugs and omitted weeks in the temporal cycleLukasz Kasprzak2026-08-112-36/+107
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two correctness bugs in Temporal_ef, both only visible across many years, not a single point assertion: 1. Duplicate slugs within one liturgical year. Christmastide has no numbered weeks, so the ferial fallback's <season>-<week>-<weekday> scheme collapsed every feria in it to literal week "0". Since colitur's Christmastide runs 25 Dec - 13 Jan (RG 72-73, a deliberate divergence from lectio), the same weekday recurs across that span and the keys collided (e.g. ef-christmas-0-saturday on three different dates). Fixed with a dedicated christmastide_feria_slug that splits the span into four sub-stretches, prioritising compatibility with lectio's own keys wherever lectio has one: - 26-28 Dec keeps lectio's existing ef-christmas-0-<weekday>. - 2-5 Jan becomes ef-christmas-1-<weekday> -- lectio collapses this indistinguishably into the same key as the stretch above, so there is nothing to preserve; a colitur-only lectionary gap. - 7-13 Jan splits at the actual first-Sunday-after-Epiphany origin: on/after it, this is genuinely week 1 of Time after Epiphany and takes lectio's own ef-time-after-epiphany-1-<weekday> (which also can't collide with that season's own later week-1 ferias, since it's the same computation). Before it (0-6 days, whenever Epiphany doesn't fall on a Saturday), a first attempt at labelling this "week 1" too, matching a literal reading of lectio's behaviour, was verified empirically (a throwaway sweep of the full 1583..9998 domain) to reproduce duplicates in most years -- so this remainder is its own ef-christmas-2-<weekday>, a further colitur-only gap. Verified with the same throwaway sweep: zero duplicate slugs across 1583..9998 after the fix. 2. named's week field was set by hand on some branches (Passion/Palm Sunday, Easter, Low Sunday, Pentecost and its Vigil, Christ the King) and left at None on others (Ascension and its Vigil, Corpus Christi, Sacred Heart) even though all of them sit inside a numbered season run. named no longer carries a week at all -- temporal now calls week itself for every day, named or not, so "a named day inside a run carries that run's week" holds by construction rather than by remembering to set it on each branch. temporal.mli's week field doc is reworded to state the actual rule. Covering tests: point assertions for all four Christmastide sub-stretches; a QCheck property scanning random years for any duplicate slug within a liturgical year (excluding the deliberate resumed-Sunday reuse); point assertions for the four previously-None days now carrying their week (first to fail without the fix: Ascension Vigil, expected Some 6, got None); a QCheck property asserting temporal's week equals week for every day of the year, replacing a prior property whose fallback made it structurally incapable of detecting an omitted week. Also: cite RG 91 e18 for the September/Advent Ember days matching lectio and flag the Lent/Whitsun Ember and Rogation slugs as colitur-only lectionary gaps inline, matching the existing Nativity-vigil/octave-day convention; cite RG 117/123/127/128/131 for season colours and Gaudete/Laetare rose; drop the unreachable Passiontide arm from the Sunday-rank match (Passiontide has only two Sundays and both are already named above, so no Passiontide Sunday ever reaches that fallback).
* kernel: loaders never escape as an exception, date-spec validates on loadLukasz Kasprzak2026-08-112-3/+46
| | | | | | | | | | | | | | | | | | | | | | Layer.load narrowed its catch to Sexplib0.Sexp_conv_error.Of_sexp_error, but rank_of_sexp is caller-supplied and may raise anything -- e.g. a hand-written rank parser that calls invalid_arg. Overlay.load already catches every exception from the equivalent call; mirror that here so layer.mli's "never as an exception" promise actually holds. Date_spec.t derived its sexp converters with plain ppx_sexp_conv, unlike Slug and Lang, which hand-write validating parsers specifically so malformed data is rejected at load. (Fixed (month 13) (day 40)) used to deserialise cleanly into a spec that simply never resolves -- a saint quietly vanishing with no diagnostic. t_of_sexp now re-runs the value through the existing fixed validator, the same shape Slug and Lang already use. Covering tests: a Layer.load case where rank_of_sexp raises Invalid_argument instead of Of_sexp_error (would have escaped uncaught before this fix); two Date_spec.t_of_sexp cases (month 13, 31 April) that must raise Of_sexp_error rather than silently constructing an unresolvable spec.
* cli: colitur temporal <year> dumps the EF temporal cycleLukasz Kasprzak2026-08-111-1/+23
| | | | | | | One line per day -- date, weekday, season, week, slug, rank, colour -- built through Record, the canonical output view. Until Plan 3 brings the differential and oracle layers, this dump is how a human checks EF temporal against missalemeum by eye.
* kernel(validate): synthetic negative-path fixture; drop vacuous slug checkLukasz Kasprzak2026-08-111-1/+154
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Task 14 review, findings 1 and 2. Finding 1: the only tests against Validate exercised the clean path against real EF data, so the evidence that each check can actually fire lived in a scratch mutation probe that was never committed. A future edit that quietly weakened a check would leave the suite green, since a weaker check only makes more inputs pass. Added a small synthetic two-season, two-rank rite fixture in test_validate.ml -- not EF -- letting each test violate exactly one invariant directly: a temporal that raises (coverage), a season that recurs (seasons), a week that decreases mid-run (week), a weekday that disagrees with Date.weekday (weekday), a rank absent from the declared vocab (rank), and a colour outside Colour.all (colour, via a same-representation Obj.magic value, safe here because the check compares by structural equality rather than pattern match). A clean-baseline test confirms the fixture itself reports zero failures before any mutation is applied. Each new test was verified non-vacuous by temporarily weakening its corresponding check in validate.ml, confirming the matching test fails, then reverting -- the same trap one level up, checked explicitly rather than assumed. Finding 2: removed the slug well-formedness check. Slug.t is a private string validated on every construction path, and to_string is the identity, so round-tripping an existing Slug.t can never fail -- the check was structurally incapable of firing. Folded the explanation into the comment block that already covers why slug uniqueness isn't checked, since it's the same kind of fact: a property the type system delivers, not one Validate needs to assert. The colour check stays: unlike slug, Colour.all is a hand-maintained list that can drift from the type, so it is only practically (not structurally) tautological, the same class as the rank closure check.
* kernel(validate): invariant harness over liturgical yearsLukasz Kasprzak2026-08-112-1/+46
| | | | | | | | | | | Coverage, season contiguity and completeness, Sunday-aligned week numbering, slug well-formedness, weekday agreement and vocabulary closure. Checks run over a liturgical year rather than a civil one, since Christmastide straddles January and would otherwise appear to recur. Run against EF temporal for landmark years, both Easter extremes, and 200 random years across 1583..9998 -- the property layer is how confidence reaches past the oracle horizon.
* rite(ef): give Pentecost, its Vigil and Christ the King their weekLukasz Kasprzak2026-08-111-1/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | named hardcoded week = None for ef-pentecost-vigil, ef-pentecost and ef-christ-the-king, unlike the five sibling named Sundays (Easter, Low Sunday, Passion Sunday, Palm Sunday, Trinity), which already carry the explicit week number that week computes for the same date. Because these three sit inside a season run rather than at a run boundary, the omission let the week number jump on the following Monday without an intervening Sunday marking the change -- breaking week continuity in every single year in the domain. This was found by the new Validate invariant harness (Task 14), which flagged exactly two failures in every one of the 8416 years 1583..9998, both on the Monday after one of these days. Season contiguity itself was clean across the whole domain; only week numbering was affected. Pentecost and its Vigil are fixed Easter offsets, so they take the literal values (7 and 8) that week already computes for them. Christ the King's date varies by year, so the same expression week evaluates is inlined instead, since week is defined later in the file and cannot be called from named. Added a general property test, prop_named_week_agrees_with_week, checking across 200 random years that wherever named gives an explicit week, it matches what week independently computes for that date -- covering all sibling entries, not just the three fixed here, so the same drift cannot silently reappear in a future addition to named.
* rite(ef): ferias, Ember days, Rogations and the temporal entry pointLukasz Kasprzak2026-08-111-1/+101
| | | | | | | | | | | | | | | | | | | | Ferial ranks per RG 91: I class in Holy Week and the privileged octaves (entries 7, 10), II class for Advent 17-23 and the Ember days (entry 18), III class for Advent to 16 December and Lent/Passiontide (entries 22, 25), IV class per annum (entry 28). Two further divergences from lectio, both from the table: Advent 17-23 are II class, and the Lenten Ember days are II class rather than ordinary Lenten ferias. Rogations (RG 80/87) exist at all, which lectio does not compute. Gaudete and Laetare are rose. Two corrections against the primary source beyond the task brief's draft. Entry 7 (verified against the Latin: "feria IV cinerum et II, III et IV Hebdomadae sanctae") covers only Ash Wednesday and Monday-Wednesday of Holy Week; Thursday-Saturday are the Sacred Triduum, entry 2, cited separately. Rogation Monday and Tuesday keep the ordinary ferial rank of their season (RG 88: "de Litaniis minoribus nihil fit in Officio") rather than a fixed class, since no RG 91 entry ranks them specially. A module-type constraint proves Temporal_ef satisfies the kernel RITE contract.
* rite(ef): pin down floor_div and week/sunday_slug agreementLukasz Kasprzak2026-08-111-1/+25
| | | | | | | | | Task 12 review: add a permanent regression test for floor_div's negative branch (Ash Wednesday, 4 days before the Lent I origin -- the only date in the system where it fires), and stop sunday_slug from independently recomputing the Pentecost-relative week number that week already computes. A cross-check test pins the two together before the refactor and continues to guard against future duplication.
* rite(ef): Sunday slugs, week numbering and the resumed-Sunday tailLukasz Kasprzak2026-08-111-1/+55
| | | | | | | | Week origins are Sundays, so week numbers are constant Sunday-to-Saturday; the origin is season-specific, which is why Validate must not assume a run starts at 1. When Easter is early the surplus Sundays after Pentecost resume the Sundays after Epiphany that Septuagesima cut short, and the last Sunday before Advent always keeps the 24th (Last) Mass.
* rite(ef): named temporal days, vigils and the Nativity octaveLukasz Kasprzak2026-08-111-3/+40
| | | | | | | | | | The I-class feasts of the Lord (RG 91 entries 1-3), the Nativity, Ascension and Pentecost vigils (RG 28-34), and the days within the Octave of the Nativity (RG 64-70). Christ the King is the last Sunday of October per the 1960 calendar, not the OF's last Sunday before Advent. Rogation Wednesday coincides with the Ascension vigil; with no precedence framework until Plan 3, temporal emits the higher-ranked vigil.
* rite(ef): season boundaries per RG 71-77Lukasz Kasprzak2026-08-111-1/+38
| | | | | | | | Christmas Time runs to 13 January inclusive (RG 72-73) and time after Epiphany opens on 14 January (RG 77), diverging from lectio, which starts time-after-epiphany at 6 January. Holy Saturday stays in Passiontide: the Easter Vigil is a night Mass, and a per-day calendar assigns a date by the season governing its day-hours.
* rite(ef): season and rank vocabulary per RG 71-77 and RG 8Lukasz Kasprzak2026-08-113-2/+30
| | | | | | | Eight seasons in canonical liturgical-year order, each carrying its RG citation, and the four classes. season_slug_word is deliberately distinct from season_to_string: slugs are lectionary keys adopted verbatim from lectio, which calls Paschaltide 'easter' and Christmastide 'christmas'.
* fix(record): derive headers and to_row from a single columns listLukasz Kasprzak2026-08-111-1/+7
| | | | | | | | | Make alignment impossible: both headers and to_row are derived from a single list of (name, extractor) pairs, so reordering one without the other is impossible. CSV schema cannot silently mislabel columns. Add a meaningful test that pins the header names in order, not just length. This catches column reordering regressions.
* kernel(record): flat all-string canonical output viewLukasz Kasprzak2026-08-111-1/+42
| | | | | | | The boundary where rite-parametric types stop. CSV, JSON and the template engine all render from this one schema, so they never see a type variable. headers/to_row cover the scalar columns; names and citations are variable-arity and wait for the richer encoders in Plan 5.
* test(overlay): close review gaps in the merge-algebra test suiteLukasz Kasprzak2026-08-111-6/+97
| | | | | | | | | | | | | | | | | | | | | | | | | Five review findings, all in the test suite the task designates as the algebra's specification: - test_diagnostics's headline assertion was vacuous: it checked slug presence after an add-over-existing, but hilary was already present before the Add ran, so a broken implementation that recorded the diagnostic and skipped applying the entry would still pass. Add now carries a rank that genuinely differs from base()'s default, and the assertion checks the field actually changed. - Set_subject, Set_name, Remove_name, Set_citation and Remove_citation were implemented but never exercised. Added coverage for all five, including the case that matters most for Set_citation: setting a part that already exists must replace it, not append a duplicate. - Replace had zero coverage. Added tests for both its existing-slug path (entry swapped, no diagnostic) and its absent-slug path (diagnostic recorded, entry still added). - merge's diagnostic accumulation was untested; test_merge_order discarded the diagnostics it got back. Added a test asserting two overlays' each producing one diagnostic come back in application order. Extended the cel/entry test helpers with optional subject/names/citations so the new tests can build fixtures with those fields populated.
* kernel(overlay): ordered layer-merge algebra with diagnosticsLukasz Kasprzak2026-08-111-1/+100
| | | | | | | add/suppress/replace/field-edit folded in order, last writer wins per field, empty the identity. A directive naming an unknown slug, or adding one that already exists, yields a diagnostic rather than silence or a hard failure: overlays must survive a shifted base while still surfacing authoring errors.
* test(layer): cover load's failure modes and same-date index orderingLukasz Kasprzak2026-08-111-1/+80
| | | | | | | | | | | | | load previously had zero coverage despite being the module's only fallible- I/O function and the one place the brief's literal code would have let an exception escape on this sexplib version (Failure instead of Parse_error). Add cases for a missing file, a malformed/unterminated sexp, an invalid slug embedded in an otherwise well-formed layer, and a success round-trip. Also add a same-date index case with three entries inserted out of slug order, pinning that the by-date index accumulates rather than overwrites and returns buckets in canonical order regardless of insertion order -- the previous two-entry fixture only ever touched bucket sizes 0 and 1.