summaryrefslogtreecommitdiff
path: root/lib
Commit message (Collapse)AuthorAgeFilesLines
* feat(ef): RG 497, the Conversion of St Paul takes the Apostles prefaceLukasz Kasprzak2026-08-232-11/+62
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | RG 497 ("in Missis festivis et votivis Apostolorum et Evangelistarum") carries no natalicium restriction, unlike RG 475(e)'s Creed clause -- preface_apostle_slugs already includes one non-natalicium sibling (In Commemoratione S. Pauli, 30 June) on this exact reasoning. conversion-of-st-paul (25 January) was left off pending a witness: the FIUV Ordo's own single window has 25 January impeded by a Sunday. Now witnessed four times, two independent publishers, four civil years: the Latin Mass Society Ordo's 2023-2024 and 2024-2025 editions both read "Pr of the Apostles" on their own unimpeded 25 January (2024, 2025); extraordinaryform.org's 2024-2025Ordo.pdf and 2026-2027Ordo.pdf independently agree on their own unimpeded years (2025, 2027, spot-checked, not wired in as a fixture). Every source's 2026 edition agrees 25 January is impeded that year and produces no data point at all. No source disagreed with the textual reading. Surveyed every other Apostle/Evangelist-named slug in data/ef/sanctoral.sexp before concluding the list needed exactly this one addition: the Dedication of the Basilicas of Sts Peter & Paul (18 November) is confirmed NOT an RG 497 trigger (all three LMS editions read "Pr of the Dedication of a Church", not "Apostles" -- it is about the buildings, not the Apostles in person); the Vigil of Sts Peter & Paul (28 June) is confirmed "Common Pr", corroborating the pre-existing "no vigil ever reaches this branch" comment. Blast radius, measured exhaustively (1583-9999, new permanent sweep in test_rubrics_ef.ml): 7217 domain years have 25 January unimpeded and observed; all 7217 now read Apostles, zero exceptions. Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9
* feat(ef): the Mass preface, RG 482-499Lukasz Kasprzak2026-08-2211-1/+676
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Item 2 of Phase 3 (celebrant-rubrics-phase1), the bulk of this phase and the last EF Mass rubric this project scoped: which preface is said, deferring to seasonal/proper-title rules for the fourteen named prefaces, then RG 498's Common residual. RG 482 gives the resolution chain: the Mass's own proper preface, failing that the seasonal one, failing that Common. Read literally, RG 484-497 look like fourteen separate rules, but each numbered rubric's own propria/de-Tempore pair produces the SAME preface identity either way, so the whole chain collapses into one priority- ordered decision: title/mystery triggers (Holy Cross, Sacred Heart, Christ the King, Trinity, St Joseph, BVM, the Nativity octave, the Apostles, Epiphany), each independent of season, then six seasonal windows (Nativity, Epiphany, Lent, Holy Cross/Passiontide, Easter, Ascension, Holy Spirit, Trinity-for-ordinary-Sundays), then Common. The "one genuinely unproven piece" the design spec worried about -- per-feast proper prefaces extracted from the Missal's propers at scale -- turned out not to be a large-scale extraction problem at all: every one of the fourteen propers is a closed, small, subject/slug-keyed trigger (mirroring Precedence_ef.band's own RG 91 table), not thousands of individual saints' pages. The one genuine open question (RG 488, the Chrism Mass) is N/A: this engine resolves one Mass per civil day and has no separate Chrism-Mass dimension. Preface is a new kernel type (lib/kernel/preface.ml[i]), not an EF-specific one, the same placement as Colour/Subject/Mass_formulary: Liturgical_day.t is parameterised only over season/rank, so any field it carries generically must live in the kernel even though only EF constructs a value of it today. Wired exactly as creed/gloria were (Rite.t.preface, Calendar.ml, Rite_ef.context), but Preface.t option, not a bare bool: unlike creed/gloria, a preface is said only at a Mass, and Good Friday (1955-restored Holy Week) resolves an observed celebration but has no Mass at all -- None is the honest answer there, and also the neutral value an unimplemented rite returns. Two priority-order findings only the oracle settled, not derivable from the Latin text in isolation, both cross-checked against 358 individually classified entries in the FIUV Ordo's own praef column (test/fixtures/fiuv-ordo-2025-2026.sexp, already captured, wired up here as a new comparison axis): RG 484(b)'s own "except Masses with a proper of the divine mysteries or Persons" is narrower than every other window's implicit exception (an Apostle inside the Nativity octave is overridden to Nativity; outside it, keeps his own preface even inside another window); and RG 495's "et votivis" half is live for the one office this engine models without a votive-Mass dimension (the Saturday Office of the BVM), which also proved a vigil is not a "festum" for this purpose (the Assumption's own vigil takes Common, not BVM, correcting an initial reuse of Precedence_ef.marian_slugs that had no reason to make that distinction for its own, different rubric). A third, RG16(a)-shaped fix landed the same way: RG 494(b)'s own Trinity grant must read the day's TEMPORAL season, not the celebration that actually won it, or a Class1 feast with no preference of its own (All Saints) wrongly falls to Common on a Sunday it merely commemorates. colitur rubrics gains a sixth TAB-separated column. Domain-wide 1583-9999 exhaustive sweep confirms every Christmastide day resolves Nativity/Epiphany/Bvm, every Paschaltide day one of its own three windows or a season-independent title, every Lent day Lent or a title, and Passiontide legitimately produces Easter exactly once a year (the Vigil Mass) -- 8416 of the domain's 8417 years, the one short year being the domain's own upper boundary (the liturgical year opening in 9999 cannot construct dates in year 10000, a pre-existing edge this sweep re-confirms rather than a new one). day/readings verified byte-identical to the branch's own state before this phase (commit 9c96e0a) across a 455-year sample spanning the whole domain -- not literally to the v0.10.1 tag, which 22 earlier commits on this same branch (Phase 1/2, the bissextile shift, the Rogation colour fix) had already moved past before this phase began.
* feat(ef): RG 111(a), the sung-Mass commemoration capLukasz Kasprzak2026-08-223-0/+68
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Item 1 of Phase 3 (celebrant-rubrics-phase1): the "at Low Mass" commemoration-placement rule the design spec recorded as unread. It is not a new rule -- it is the sung/low axis of RG 111, which colitur already implements. RG 111(a) (LT.txt, "Ratio admittendi commemorationes"): a liturgical day of the first class, AND any non-conventual sung Mass regardless of the day's own class, admits at most one commemoration, and only if it is privileged. (b)/(c)/(d), the same rubric's remaining clauses, give the wider caps colitur's admit already computes -- which is exactly the LOW MASS answer. Exposed as Precedence.sung_mass_commemorations, a pure derivation over the existing Low-Mass admitted list (filter to Privileged, keep the first), not a new stored field on Liturgical_day.t: the input list is already validated and privilege-tagged, so a second field would only create a second place for the same fact to drift out of sync with the first, with no new information gained. Liturgical_day.t.commemorations is now documented as the Low Mass set explicitly, removing the ambiguity its .mli previously left unstated. Tested against synthetic Low-Mass sets (none/one/two privileged, already-first, empty) and two real calendar days resolved through the normal Cal.day pipeline: 2026-08-14 (Vigil of the Assumption, an ordinary-only commemoration, dropped at Sung Mass) and 2026-04-25 (the Major Litanies, RG 80/109(f), privileged, kept at both Masses).
* fix(ef): Rogation Monday/Tuesday are white, not violet (RG 119)Lukasz Kasprzak2026-08-221-2/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Temporal_ef.temporal's Rogation Monday/Tuesday branch hardcoded Colour.Violet with no citation (data/ef/expected-divergences-lms.sexp's L6, surfaced by the LMS Ordo). RG 119 assigns white to the de-Tempore Office and Mass from the Paschal Vigil Mass to the Pentecost Vigil Mass exclusive; Easter+36/+37 sit inside that window. RG 128's own exhaustive four-case violet list never names Rogation days, and its vigil clause (d) is doubly inapplicable: these are not vigils, and they are inside Paschaltide, not outside it. RG 88 explains the violet actually seen in print sources: "de Litaniis minoribus nihil fit in Officio, sed tantum in Missa" -- the violet belongs to the Rogation Mass/procession, a distinct votive text this engine does not model, not to the day's own Office. Witnessed directly: LMS Ordo 2023-2024, 2024-05-06, "FERIA IV Cl W" with "Gl", the Office and Mass printed separately from the optional violet votive Mass of the Rogations. Cascade verified, not assumed: RG 432(b) omits the Gloria on every violet Mass, so the wrong colour was also silencing the Gloria on these two weekdays even though RG 237(e) (Breviary, ordinary Paschaltide ferias say Te Deum) already made it true underneath. White removes the 432(b) block, and the Gloria now follows Te Deum via 431(a), matching the Ordo's own "Gl" -- confirmed with dedicated golden pins in both test_golden.ml (colour) and test_rubrics_ef.ml (the Gloria flip), 2028-05-22/23 (neither day impeded that year). Closed L6 in data/ef/expected-divergences-lms.sexp. Reopened C8 in data/ef/expected-divergences.sexp (26 of 16801 rows, 2005-2050): lectio independently computes these two days violet too, so the two engines used to agree by sharing the same uncited assumption; colitur's colour is now correct and lectio's is not, so the comparator disagrees again, for the opposite reason. While reopening C8, found and corrected a genuine misattribution in its own prior closure note, which claimed lectio's violet was "RG 128(d)" -- 128(d) governs vigils outside Paschaltide and has nothing to do with Rogation days. Checked docs/research/rules-register.md for the same claim: not present there, but a related conflation was found and corrected at its O'Connell section 4(d) note, which had used a Rogation MASS/procession citation to "confirm" the day's own OFFICE colour. Blast radius measured in-process across the full 1583-9999 domain (throwaway probe, not committed): 4722 days total (2368 Rogation Mondays + 2354 Rogation Tuesdays actually observed outright, i.e. not impeded by a stronger sanctoral feast that year), all Violet->White, all with the Gloria flipping false->true alongside, zero anomalies. Mutation-tested: reverting the branch to Colour.Violet reddens 16 tests across Rubrics_ef, the lectio differential, the LMS Ordo suite and both golden pin files; restored and reconfirmed green (680 tests).
* feat(ef): the Gloria in excelsis, RG 431-432, deferring to Breviary 237-238Lukasz Kasprzak2026-08-229-1/+429
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phase 2 of celebrant-rubrics-phase1: colitur rubrics gains a fifth column, whether the Gloria is said. Follows the Creed's own seam exactly -- Rite.t.gloria, Liturgical_day.t.gloria, wired through calendar.ml the same way. RG 431(a)/432(a) defer the Gloria to the Breviary's own Te Deum rule (nn. 237-238), so te_deum is implemented as its own named predicate, cited clause by clause, not collapsed into a colour heuristic. 431(c) (Holy Thursday, the Easter Vigil Mass) and 432(b)/(d) (violet; a Requiem) are independent overrides checked ahead of the Te Deum-derived answer. Every clause this engine has no dimension to model (votive Mass classes, the wider n.302 "Missa festiva" categories) is stated as N/A with its own reasoning, not silently dropped. Validated against the FIUV universal Ordo (Gloria and Te Deum) and all three LMS editions (Gloria). A first pass over-trusted a clean-looking 15-for-15 FIUV contradiction of 237(b)'s own Septuagesima exception and replaced it with a blanket "every Sunday" rule; the evidence was itself corrupted -- the FIUV extractor recognised only one of the source's two Te Deum negations ("non dicitur", not "sine"), so every "sine Te Deum" Sunday read wrongly true. Fixed in tools/extract_fiuv_ordo.ml, fixture re-extracted, and the literal 237(b) reading restored once the corrected data confirmed it. A second bug surfaced alongside it (Palm/ Passion Sunday wrongly reading true via Temporal_ef.named's own table membership, then Christ the King wrongly reading false from an over-broad fix) is closed with an explicit two-slug exclusion. Domain-wide 1583-9999: every violet or Rose day is gloria=false except the Easter Vigil (RG 431(c) lex specialis), every Requiem is gloria=false, both measured exhaustively, zero exceptions. Mutation- proved: disabling 431(c) reddens 8 tests including all four oracle comparisons; disabling 238(c)'s feria-I-classis exclusion reddens exactly the dedicated Ash Wednesday unit test, a genuine blind spot in both oracle layers, reported rather than hidden. Two open, cited findings, neither fixed here (out of this task's "follow creed's exact seam" scope): a privileged Lenten/Passiontide feria carrying one commemoration reads Gloria=true in the LMS Ordo but Te-Deum=true/Gloria=false in FIUV -- the two oracles disagree with each other, not merely with colitur (data/ef/expected-divergences-lms.sexp L5, expected-divergences-fiuv.sexp F3); and a pre-existing, uncited Colour.Violet bug on Rogation Monday/Tuesday in Temporal_ef.temporal, surfaced by this comparison but root-caused as a separate defect (L6). day/readings verified byte-identical against a build from the branch tip before this task (v0.10.1's own tag predates an already-landed bissextile fix that legitimately changed both, so it is not the right baseline). 671 tests green (dune test); 678 with the exhaustive sweep (COLITUR_EXHAUSTIVE_SWEEP=1 dune test --force, ~104s).
* feat(ef): implement the calendarium's bissextile February shiftLukasz Kasprzak2026-08-2210-8/+134
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Missale Romanum's calendarium footnote for February (LT.txt:5011-5014, scan-corroborated scan2.txt:3050-3058): in a leap year the sixth kalends of March (24 February) is doubled rather than a 29th day appended, so St Matthias moves 24->25 February and St Gabriel of Our Lady of Sorrows 27->28, with 24 February itself carrying no fixed office that year. data/ef/expected-divergences-lms.sexp entry L3 tracked this as an open gap. Implemented as the rubric's own general mechanism (every fixed entry from 24 through 28 February shifts one civil day later in a leap year), not as "move these two named saints": the two readings are indistinguishable on shipped data (nothing else is fixed in that window) and diverge only for a future --overlay entry in the same range, which the mechanism reading covers correctly and a two-saints special case would not. This project has already been bitten once (RG 16(a)) by a rule implemented against shipped data's coincidental shape rather than the rubric itself. Kernel stays rite-agnostic: Rite.t gains a fixed_key field (Date.t -> (int * int) option), the same seam easter already establishes, read only by Layer.on_date's FIXED half -- the movable half (Easter_offset/Nth_weekday) is untouched. Default is the identity mapping, an optional argument on on_date, so every existing caller and every rite that supplies nothing is byte-identical to before this field existed. Date.is_leap is exposed in date.mli (already existed in date.ml) so the rite reads the kernel's own single-sourced leap-year definition. The EF implementation lives in Rite_ef.Temporal_ef.bissextile_fixed_key, fully cited, wired into Rite_ef.context. Blast radius, measured over the full 1583-9999 domain (Calendar.year run twice per civil year, real fixed_key vs identity, every day diffed): all 2,041 leap years in the domain show a visible change for each saint; 6,983 individual liturgical days change total, zero unclassified, across four shapes (Matthias vacates 24 Feb in 1,803 years / occupies 25 Feb in 1,811; Gabriel vacates 27 Feb in 1,699 / occupies 28 Feb in 1,670). L3's own prior estimate (1,650 years, observed-outright only) is confirmed close on the same definition (1,677 measured); the broader observed-or- commemorated count is higher, not lower, showing the narrow estimate undercounted visible impact rather than overcounting it. The lectio differential (2005-2050 fixture) gains a new cited entry, C40 in data/ef/expected-divergences.sexp: lectio implements no such shift, so every leap year in its window now diverges on the two shifted days (21 rows, not the naive 44 -- the comparator never compares commemorations, so a side where the shifted saint has zero comparator-visible footprint on both engines produces no row). The LMS Ordo's 2023-2024 fixture -- the window L3 was originally found in -- now matches on the Creed comparison; its formulary-override bucket count is corrected 182->181 (24 February leaves the counted Proper population, becoming Own_slug-sourced). The missalemeum oracle fixtures (2026-2027, 2038, 2035) cover no leap year and are unaffected. L3 is closed: converted from an active allow-list record to a prose closure paragraph (L1's own precedent), citation preserved verbatim, fix and measured blast radius recorded. The id-list assertion narrows to [L4] alone, and the now-dead "2024-02-24" -> "L3" date mapping is removed. Six golden values pinned across four new test cases: Matthias in a leap year (both the vacated and occupied sides) and a common-year control; Gabriel likewise, deliberately choosing a leap year where he is admitted only as an ordinary commemoration rather than winning outright, a different shape from Matthias's. Mutation-tested: forcing fixed_key back to identity reddens exactly five test cases -- the differential's Layer C count-pin (C40 drops to 0 actual vs 21 expected), the LMS Ordo's Creed and formulary-bucket checks, and both new golden pins -- confirming the tests actually exercise the fix. Reverted; dune test, the exhaustive sweep, and make check are all green.
* fix(kernel): Mass_formulary.t.said is honestly optional -- was false for VotiveLukasz Kasprzak2026-08-224-22/+55
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The .mli promised said is "the slug whose Mass is said". For Votive (RG 78/309(a), the Saturday votive Mass of Our Lady) it was set to the day's own ferial slug -- whose Mass is exactly the one NOT said. A consumer joining rubrics to readings on that slug would silently get the wrong Mass: 2026-01-03 reports ef-christmas-1-saturday, which has zero entries in data/ef/lectionary.sexp, because the citations actually come from bvm_saturday_citations, a season-keyed function with no slug of its own anywhere in the shipped data. Chose the type-honest fix over the interim documentation one: said is now Slug.t option, None exactly for Votive, because there is genuinely no slug in the shipped data this field could report for that one source. Adding real ids for the five seasonal BVM Masses (the reviewer's first option) is out of scope -- a data restructuring this round explicitly does not carry. Threading the office slug through a second field was considered and rejected as redundant: the day's own office is already available on the same Liturgical_day.t via observed.slug, which every caller already has in scope regardless of via, so said does not need to duplicate it. colitur rubrics stays byte-identical: rubrics_line already has d.observed in scope and falls back to its slug when said is None, printing the exact value it always printed for a Votive row (verified directly, diffed against pre-fix output across four years). colitur day/readings are unaffected (neither reads Mass_formulary at all). colitur emit --format sexp's pretty-printed line count for 2027 moved 9011 -> 9025: every day's formulary record widened by said's own extra option wrapping, and to_string_hum wraps by column width. Cosmetic only, diffed line by line to confirm every change is this shape or a consequent wrap shift; recorded in test/cli.t alongside the 476(f) note it now sits next to.
* fix(ef): RG 476(f) -- the Creed is not said at a Requiem MassLukasz Kasprzak2026-08-222-7/+56
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | colitur said the Creed IS said on All Souls' Day and every other Requiem-shaped day: RG 475(b)'s "in festis I classis" branch fired on rank alone, with nothing checking RG 476(f), "Non dicitur symbolum ... f) in Missis defunctorum". Allow-listed as L1 in expected-divergences-lms.sexp on the argument that [creed] has no notion of a Requiem Mass in its inputs and could not express the rule without a signature change. That argument does not hold. Colour.Black is used by exactly two celebrations in the whole engine, verified by grepping every occurrence in lib/ and data/: commemoration-of-all-souls (data/ef/sanctoral.sexp) and Good Friday (temporal_ef.ml, already false via the earlier RG 23(b) feria exclusion and has no Mass at all in the 1955-restored Holy Week). So on every day this engine can construct, colour = Black if and only if the Mass is a Requiem -- a citable implication (RG 117 assigns black to Masses of the dead) on this data, not a heuristic. Rite_ef.Rubrics_ef.creed now reads observed.colour as RG 476(f)'s own guard, positioned ahead of 475(b)'s rank branch. A new test, test_colour_black_population_is_exactly_two, sweeps both the shipped data and Temporal_ef's own code across a century and fails loudly the day a third Colour.Black celebration is ever introduced, so the proxy cannot silently rot. L1 is closed in expected-divergences-lms.sexp, citation preserved. Blast radius, measured (full 1583-9999 sweep, grouped by observed slug): 8 416 days change from true to false, every one commemoration-of-all-souls (missing from exactly 1 of the 8 417 domain years, a boundary-year transfer effect at 1583/9999). A further 8 416 Colour.Black days are Good Friday, unaffected (already false before this fix). colitur day/readings stay byte-identical to v0.10.1 (checked directly against a v0.10.1 worktree build, 9 domain-spanning years); colitur rubrics changes on exactly the 8 416 days above. colitur emit --format sexp's pretty-printed line count for 2027 moved 9010 -> 9011 as a side effect: Sexplib.Sexp.to_string_hum wraps by column width, and All Souls' Day 2027's record grew one wrapped line because "false" is one character longer than "true". Cosmetic only -- no field's data changed -- and pinned in test/cli.t with the measurement recorded.
* fix(ef): check RG 24/25's ferial invariant instead of asserting itLukasz Kasprzak2026-08-221-9/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | rubrics_ef.ml's Creed check excluded feriae from 475(b)/(c)/(e) via an explicit test for feria I classis (Ash Wednesday, Holy Week) and vigils only. RG 24/25's feriae II and III classis (Advent 17-23 December, the Advent/Lent/September Ember sets, the numbered Lenten/Passiontide ferias) were excluded by no check at all, correct today only because of an unstated Temporal_ef property (no ferial-origin office it builds carries subject Lord/Bvm or an apostle-list slug). A comment read as if this were established "by construction of the two exclusions immediately above", which overclaimed: those two exclusions cover feria I classis only. Corrected the comments to name RG 24/25 explicitly, state what is and is not checked, and cite where the real guarantee comes from. Added a test that sweeps Temporal_ef.temporal across a century (1583-1682), asserting the invariant directly against every ferial-origin day found, with its own coverage counters confirming the span actually reaches Advent 17-23, all three non-Whitsun Ember sets, and Lenten/Passiontide ferias. The first run of the test found a real gap in its own population filter (RG 17(a)'s 2 January Holy Name fallback, a genuine feast sitting outside Temporal_ef.named for an architectural reason), fixed by naming that date explicitly rather than narrowing the span. Proved the test has teeth by mutation: forcing the ferial builder's default subject to Lord reddened it (and 24 other tests, expected collateral from an intentionally broad mutation) before being reverted. No production logic changed -- only comments in rubrics_ef.ml. day, readings and rubrics output confirmed byte-identical against the pre-fix binary for four spot years across the domain. Also corrects a stale test-case count in a prior task report (18 in the array, not the 19 recorded there); the array now genuinely holds 19.
* feat(ef): the Creed, RG 475-476Lukasz Kasprzak2026-08-2210-1/+308
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Whether the Creed is said at Mass. New Rubrics_ef.creed, reached through a new Rite.t.creed field and a new Liturgical_day.t.creed bool (false, not an option, for a rite that has not implemented the rule); colitur rubrics gains a fourth TAB-separated column. 475(a) reads the TEMPORAL cycle's own weekday, not the observed day: a Sunday impeded by a Feast of the Lord (RG 16a) still says the Creed. 475(d)'s three octaves (Nativity, Easter, Pentecost) are pure date/ Easter-offset windows, checked first, since a saint's feast winning the day inside one of them still says the Creed ("etiam in festis occurrentibus") -- St Stephen, 26 December, is the live witness. RG 23 classifies Ash Wednesday and every feria of Holy Week (including the Sacred Triduum) as feriae, not festa, however high their rank, so 475(b)/(c)'s "in festis" never reaches them; this single check subsumes 476(a)'s own naming of the Chrism/Lord's-Supper Mass and the Easter Vigil. RG 28-34 vigils are a third liturgical-day category, also excluded from "in festis" regardless of rank -- reuses the already- exported Precedence_ef.is_vigil rather than a new list. 475(e)'s Apostle/Evangelist natalicia list (creed_apostle_slugs) was derived by grepping data/ef/sanctoral.sexp directly and checking each candidate's own date against whether it is that saint's dies natalis -- not copied from any list supplied with the task. The Conversion of St Paul and the 30 June Commemoration of St Paul are excluded (neither is a natalicium); the Chair of St Peter and St Barnabas are included only because the clause names them explicitly, which is exactly why it has to: neither is a natalicium either. 475(c)'s BVM half reuses Precedence_ef.marian_slugs (newly exported) rather than the subject field alone: checked against the shipped data, almost every Marian sanctoral entry ships subject=Saint, not Bvm. man/colitur.1's rubrics section is updated to match the new column; test/cli.t repinned via dune promote for the same reason. Verified day/readings output byte-identical to v0.10.1 across the whole 1583-9999 domain (both binaries' concatenated day+readings output, 6,148,492 lines each, zero diff). Domain-wide: 882,996 days say the Creed, 2,191,250 do not; every one of the domain's 439,178 Sundays says it, zero exceptions.
* docs(ef): retract the false LMS Ordo witness claim for Mass_formulary.VotiveLukasz Kasprzak2026-08-222-19/+51
| | | | | | | | | | | | | | | | | | | | | | | | | Fix round 1's own comment claimed the Latin Mass Society Ordo witnesses the Votive tag directly, reading its "V Mass of BVM" as an abbreviation for "votive". That was wrong: the Ordo's I-V are roman numerals naming WHICH of the Missal's five seasonal "Missae de sancta Maria in sabbato" (RG 309(a)'s own "iuxta temporum diversitatem") is said on a given Saturday, not a marker of the Mass's kind. All five numerals occur through the Ordo (I x1, II x4, III x3, IV x3, V x8); the false reading came from "V Mass of BVM" also being a substring of "IV Mass of BVM". Votive itself is unaffected and stays correct on RG 431(e) alone, quoted verbatim in its place: "e) in Missis votivis IV classis de Angelis, quocumque die, et de B. Maria Virg. quae in sabbato celebrantur" -- votive Masses of the IV class, of the BVM, celebrated on Saturday. The retraction is recorded, not softened, in lectionary_ef.ml/.mli and the test pin's own comment, together with what the Ordo's numeral actually means so the mistake is not repeated, and the consequence it leaves behind: because the numeral names WHICH seasonal Mass is said, a future comparison against the Ordo (Task 6) can validate bvm_saturday_citations' own season-keyed selection, not merely that some BVM Mass was chosen. Comments and docs only -- no behaviour change. Both suites re-run green and colitur day re-confirmed byte-identical.
* fix(kernel,ef): a votive Mass is not the day's own -- add Mass_formulary.VotiveLukasz Kasprzak2026-08-214-21/+50
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Mass_formulary.source was missing a case for a Mass said IN PLACE of the day's own office's Mass while that office is itself kept unchanged -- RG 309(a) and RG 431(e) both classify the RG 78 Saturday Mass of Our Lady this way, in the Missal's own words, as a "Missa votiva IV classis... de B. Maria Virg.", not as the day's own office's Mass. The Latin Mass Society Ordo (docs/research/ordo/lms-ordo-2024-2025.pdf) witnesses it directly, printing that day as "V Mass of BVM". The BVM-Saturday branch in Lectionary_ef.readings was tagged Own_slug for lack of a better constructor when Task 2 landed, flagged there as a judgement call rather than a specified answer. That call was wrong: left as Own_slug, a future comparison against the LMS Ordo (a later task in this plan) would read every BVM Saturday as a manufactured divergence between colitur's "own" Mass and the Ordo's votive one. Retagged to Votive; said is unchanged (still the day's own, reused ferial, temporal slug) since the office itself is unaffected, only the Mass said for it. Added the new constructor's source_to_string case ("votive") and its own test row, and pinned the branch itself in test_lectionary_ef.ml's formulary cases at 1 August 2026, verified directly against the resolver rather than assumed. Both this session's own drifted pins that the earlier commit inherited from the task brief are unaffected by this change, and both are re-confirmed independently correct in this round: dune test and the exhaustive sweep are green, and colitur day stays byte-identical against the pre-fix-round binary.
* feat(kernel,ef): the lectionary reports which Mass it saidLukasz Kasprzak2026-08-218-29/+129
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Rite.readings now returns (Mass_formulary.t option * Citation.t list) instead of a bare citation list, and Liturgical_day.t carries the result as a new formulary field. Validate holds a rite that resolves a formulary at all to resolving one on every day, the same discipline it already applies to citations; the EF lectionary chain resolves Some on every day of every year 1583-9999, confirmed by a direct sweep over 2005-2050 as well as through Validate itself. Plan Tasks 2 and 3 are merged into this one commit on the coordinator's own instruction: Rite.readings' signature and the field that consumes it are one atomic edit, and the intermediate state does not compile on its own. Each of the four lectionary steps now builds its own Mass_formulary.t at the point it decides, not by re-deriving it afterwards from the citations it returns: step 1 tags Proper with the observed slug, step 2 tags Own_slug with the day's own temporal slug, step 3 tags Preceding_sunday with the resumed Sunday's temporal slug, and step 4 tags Common with the Common's own id -- Commons.find now returns that id alongside its citations rather than discarding it, since it is only ever in scope at the point the assignment is looked up. The RG 309(a) Saturday votive Mass of Our Lady, which answers between steps 4 and 2 rather than as one of the four numbered steps, is tagged Own_slug too: Mass_formulary.source has no dedicated constructor for it, and its own guard only ever fires when the observed celebration already is the day's own (reused ferial) temporal slug, which is exactly what Own_slug documents. Recorded as a judgement call in the task report, not a specified answer. test/cli.t's `emit --format sexp` line count is repinned (8472 to 8881): that command serializes Liturgical_day.t whole, so the new field grows its output. `colitur day` itself is untouched -- verified byte-identical against the pre-change binary across 1583, 1900, 2026, 2038 and 9999.
* feat(kernel): a type for which Mass a day saysLukasz Kasprzak2026-08-212-0/+34
| | | | | | | The lectionary's four-step chain already decides whether a day says its own proper, its own slug's entry, the preceding Sunday's Mass or a Common, and then discards that decision once the citations are out. An ordo needs to print it.
* feat(ef): implement RG 33's third omission triggerLukasz Kasprzak2026-08-216-6/+266
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | RG 33 omits a II/III-class vigil in three cases: it falls on a Sunday, it falls on a I-class feast, "vel si festum cui praemittitur in alium diem transferri aut ad commemorationem reduci contingat". Only the first two were built; the third was recorded in precedence_ef.ml as unimplemented on the grounds that no witness existed in the shipped data. That reasoning was wrong, and the rule fires on 1 744 days across 1583-9999. Both halves of the clause reduce to one observable question -- is the feast the OBSERVED office on the following day (RG 34 puts it there) -- so the kernel asks it once per candidate, after place_transfers has settled the year. No fixed point is needed: a vigil is a candidate only on its own day, never on its feast's, so suppressing it cannot change what the next day observes. Precedence.rules gains vigil_feast, which returns the slug of the feast a vigil precedes; the kernel cannot infer that itself, because only two of the five vigil/feast pairs share a slug stem. Blast radius, measured pre-change binary vs HEAD over the whole domain and classified: 1 744 days, three shapes, zero unexplained. 1 199 are the feast reduced to a commemoration (10 August on a Sunday, St Lawrence); 478 and 67 are the feast transferred under RG 96 after the Sacred Heart or Corpus Christi takes its day. The Assumption's and the Ascension's vigils never qualify -- their I-class feasts always keep their own day. Independently witnessed, which is unusual here. The published Ordo -- the only witness outside the Divinum Officium -> missalemeum -> lectio lineage -- omits St Lawrence's vigil on 2025-08-09, agreeing with colitur against both engines. That date had been read earlier as an Ordo gap; the Ordo was right, and correcting the misreading is what surfaced this clause. On 2027-08-09 the feast does keep its day and the Ordo omits a vigil colitur correctly keeps, which is a genuine Ordo gap. Allow-lists: C39 (lectio, 10 rows) and a 2038 oracle class citing the register, the 2026-2027 window having no instance. The golden pin asserting St Lawrence's vigil is violet moved 2025 -> 2027; its own comment had reasoned about the vigil's weekday and missed that RG 33 also looks at the feast's. Two new pins cover both shapes of the clause. The vigil/feast table is built with Slug.of_string_exn: mutation testing showed that of_string plus Result.to_option turns a typo into None, which this hook's contract reads as "not a vigil", switching the rule off in silence. Two tests assert the table against the shipped data in both directions.
* feat(config): flavour is settable, and an INI overlay is diagnosedLukasz Kasprzak2026-08-212-1/+14
| | | | | | | | | | | The escaping flavour was the one rendering setting with no config key, so a template whose extension says nothing needed the flag every run. Unset still means infer, which config --show reports as (infer) rather than (none). Backfills the 0.8.0 and 0.9.0 changelog entries. Both were tagged by hand rather than through 'make release', which is precisely the guard that would have refused a release with no changelog line.
* fix(render): a month answers to both spellings of its own nameLukasz Kasprzak2026-08-201-1/+13
| | | | | | | | | | | | | | | | A week object carries month_name/month_num/month_abbr, because at that level a bare `name` would be ambiguous. An author who learned those names inside {{#weeks}} reaches for them one level up inside {{#months}} too -- where they resolved to nothing, and an unknown key renders as the empty string by design, so the result was a silently blank month heading rather than any error. Found by writing a template from scratch rather than copying a shipped one; every shipped template sidesteps it, which is why nothing caught it. The aliases are additive, so no existing template or golden changes. colitur-templates(5) now documents both spellings and says why they exist.
* fix: audit findings — parser strictness, name ambiguity, and errorsLukasz Kasprzak2026-08-207-73/+317
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Found by auditing the shipped program rather than the diff. The parser accepted OCaml integer-literal syntax, so "Luke 1_1:5" read as chapter ELEVEN and "+5" as 5 -- a typo silently becoming a different chapter, reachable through any user overlay. Numbers are now plain digits and positive, and a descending range is rejected: 1:20-10 is always a transcription error. No shipped citation changed. FOUR PAIRS OF DIFFERENT BOOKS SHARED A FULL TITLE. 1 and 2 Corinthians both rendered "Epistola ad Corinthios", as did Thessalonians, Timothy and Peter -- 108 citations in 2027 alone that a reader cannot resolve to a book. This is the Kings defect fixed earlier and not generalised. The titles now carry their volume numeral, marked CONSTRUCTED, and a test asserts no two books share a name -- while allowing the case where two ids ARE the same book under different numbering, which a tradition relates. Spec section 8.5 is now delivered rather than merely recorded. Shipped styles did not re-parse their own output: 32 of 52 Latin abbreviations and 49 of 52 full titles failed, so a citation copied from colitur's own output into an overlay was passed through untouched and printed in the wrong language, silently. Every shipped name is registered as a spelling and split_book learned multi-word titles by longest-token match. Now 0 of 52 fail beyond the same-book aliases. Overlay errors were written for a compiler author: they named an OCaml source file the reader does not have and buried the useful token. The existing five-path rewriter is replaced by a generic one, applied to every load path rather than one, so "rank: is not one of the allowed values (at Class9)" replaces the raw Of_sexp_error dump. Also: the new-overlay scaffold documented citations and layer without showing them, and its comment implied the wrong nesting -- the single easiest thing to get wrong; error messages echoed whole file lines, copying an unrelated file's contents into stderr when a flag pointed at one; and config --show validated partway down its table, exiting 2 after writing five rows to stdout.
* fix(citation): close the final review's blocking findingsLukasz Kasprzak2026-08-202-0/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The branch was RED and reported green. `dune test` exited 1: test/cli.t pinned the pre-fix output `kings_1 19:3-8`, which the previous commit had already fixed to `1 Reg 19:3-8`. The gate command piped dune through `tail`, so it reported tail's exit status, and cram prints its diff BEFORE the alcotest summary, so the two lines shown were the passing ones. Verify with `dune test; echo $?`, never through a pipe. A style file's own `book` key was unreachable. sigla_book resolved against a hardcoded "abbr" and the result was applied unconditionally, so the documented `[sigla] book = full` could never win. Render gains book_string, and the style's own value is now the default that a flag or config overrides. The unit test pinned style_of_fields correctly while the wiring defeated it. `lang --check` filtered the reference set to the celebration prefix, so a file with no [bible] section at all reported a clean bill of health -- contradicting both the reason the keys change was made and lang.ml's own comment. It now reports missing book names too. The token test missed a FOURTH citation-bearing file: adjustments.sexp writes citations as `Set_citation`, not `(reference ...)`. Its 16 citations all parse, so nothing was broken, but nothing was checking. The first attempt at this fix read the file and extracted NOTHING -- the marker stopped before the opening quote, so every payload was the part label -- which is recorded in the code rather than left as a trap. Also: colitur-config(5) claimed a trailing period the data does not carry, and two la.ini scan quotes silently corrected OCR damage ("Ionae 3, I - I O", "Epistolse") while presenting themselves as verbatim. Both are now marked as corrections.
* feat(render): citations render through Sigla at both call sitesLukasz Kasprzak2026-08-203-11/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | View.citation_ref and readings' part_ref are the only two places a citation reaches output; every emitter goes through View. --raw passes Sigla.verbatim rather than a style built over Lang.raw: an identity name table would still reformat punctuation and renumber. Loosen reject_sigla_for: --sigla-style/--sigla-book/--sigla-tradition now actually render on readings/table/render/emit/publish (each builds its own Sigla.t via the new load_sigla), and stay refused only on commands that render no citation (day, easter, temporal, ...). names_of degrades a Lang.bible miss to Book.default_spelling rather than printing the lookup key itself ("luke.abbr"); pinned in cli.t against a language file with no [bible] section at all, independent of la.ini/en.ini's own eventual [bible] section. Regenerate the 11 golden templates for 2027: every changed line is one canonical citation replacing a stored variant, verified against the full readings 2027 diff (raw vs default) cell by cell -- 86 distinct (raw, rendered) pairs account for the entire diff across all 11 files, with zero unexplained residue. Two families: the seven duplicate book spellings collapsing onto one canonical form (Isa./Isa, 3 Kgs./3 Kings, ...), and citations reconstructed from parsed structure dropping stray punctuation the parser already treats as noise (a trailing period or semicolon, a comma chapter/verse separator, an elided inherited chapter) -- each of the latter already named and tested in test_citation.ml's parse_suite before this task.
* feat(config): sigla_style, sigla_book and sigla_traditionLukasz Kasprzak2026-08-202-2/+35
| | | | | | | | | Same flag > config > default precedence as --lang, and each reported by config --show with its source, so an override is visible rather than mysterious. A language file's [sigla] section IS a style; a config key SELECTS one and may override settings within it -- the two are not a duplicate setting.
* feat(lang): read [bible] and [sigla] sectionsLukasz Kasprzak2026-08-202-3/+48
| | | | | | | | | | | | | | | | | | Lang reads a hardcoded list of section names and ignores anything else silently, so without this a [bible] section would appear to work and do nothing -- confirmed directly with a scratch executable: before this change, of_string on text containing a [bible] section parsed without error and Lang.keys came back empty, no trace of the section anywhere. Lang.bible keeps the total-lookup contract every other lookup in this module has: a miss returns the key itself, never the empty string. Lang.sigla_fields returns the [sigla] section's raw fields, values trimmed but still quoted, for Render.style_of_fields to unquote. [bible] joins the keys reference set so lang --check reports missing book names; [sigla] deliberately does not, being settings with working defaults rather than translatable names -- adding it would make --check demand five settings from every language file.
* feat(citation): the Sigla facade, total by constructionLukasz Kasprzak2026-08-202-0/+52
| | | | | | | | | | | | | | | | | | format never raises and returns an unparseable citation unchanged, so a gap degrades to today's behaviour rather than to a crash. The coverage test asserts separately that no shipped citation takes that path. verbatim is what --raw uses: an identity name table would still reformat punctuation and renumber, which would break byte-exact diffing against lectio. The existing citation-coverage walk (1970-2070) now also drives a round-trip check in the same pass: parse -> render -> parse must reach the same structure, using Book.default_spelling for names (Book.to_string returns the internal id, which is not a registered token and cannot be read back). Proved with a mutation: changing the default style's part_sep to a separator the parser does not accept reddened the round-trip case on 31 multi-part citations and nothing else; reverted.
* feat(citation): default_spelling, and close two test gapsLukasz Kasprzak2026-08-202-0/+24
| | | | | | | | | | | | | | | | | | | | | | | | Book.default_spelling returns the first registered spelling for an id. It is the fallback display name, and it exists because the alternative is worse: a language file's [bible] lookup is total and returns THE KEY on a miss, so a book with no entry would render as "luke.abbr 5:12-14". Falling back to the data's own spelling makes it render as "Luke 5:12-14" instead -- what colitur printed before this feature existed. The degraded case is the old behaviour, the same principle Lang states for its own key-returning misses. Two test gaps closed, both found by mutation rather than by reading: Parse's split_book scans a leading ordinal digit over '1'..'4', and no case in the suite used an ordinal above 1. Narrowing the range to '1'..'3' passed every test while seven real citations depend on it ("3 Kings 17:8-16", "4 Kings 5:1-15"). Parse-layer cases added; the first attempt at this test asserted through Book.of_token, which is a table lookup and never reaches split_book at all. default_spelling is asserted to round-trip: every cited id's fallback spelling must itself resolve back to that id, or Render and Parse disagree the moment a book goes unnamed.
* feat(citation): render a parsed citation in a configurable styleLukasz Kasprzak2026-08-202-0/+155
| | | | | | | | | A style is a set of format strings, so punctuation convention is data. Values are unquoted here rather than in Overlay_ini: that parser trims every value and is shared with overlays and [defaults], so teaching it about quotes would change behaviour this feature has no business changing.
* feat(citation): parse citations into structureLukasz Kasprzak2026-08-202-0/+125
| | | | | | | | | The parsed form is a book and a LIST of chapter-parts: the data cites across chapters and lists disjoint verse ranges within one. Two rules the shipped data forces and that are not obvious: a semicolon-separated part may inherit the previous chapter rather than restate it, and a chapter may be separated from its verses by a comma.
* fix(citation): survey all three citation-bearing files, not just the lectionaryLukasz Kasprzak2026-08-202-16/+52
| | | | | | | | | | | | | | | | | | | | | The book table was built against data/ef/lectionary.sexp alone. That undercounts: sanctoral.sexp carries more citations than the lectionary and, together with commons.sexp, cites 21 book tokens the table was missing entirely (62 distinct tokens across all three files, not 42), several of them common (2 Tim appears 8+ times). Add the missing spellings to existing ids (2 Cor, Col., Exod, Ezek, James, Sir, Eccli) and ten new ids for books not cited before (1-2 Timothy, 2 Peter, Apocalypse, Judith, Malachi, Proverbs, Song of Songs, Tobit, Wisdom). Sir and Rev are modern spellings sitting inside Vulgate data, so both resolve to their Vulgate ids (ecclesiasticus, apocalypse) rather than to the sirach/revelation tradition targets -- mapping them to a second id would double-map the same book. Add a duplicate-spelling invariant test (List.assoc_opt would silently prefer the first match on a collision) and a test that re-derives the token set from all three data files at test time and asserts every token resolves, rather than trusting a survey performed once by hand.
* feat(citation): canonical book ids and tradition mappingLukasz Kasprzak2026-08-203-0/+125
| | | | | | | | | Seven books arrive in two spellings, inherited from lectio's ini and ultimately from Divinum Officium. Collapse them onto one id here rather than editing generated data. Naming and renumbering are kept apart: a tradition decides which book an id denotes, a language file decides what it is called.
* feat(render): Roman week numbers and date spans, as dataLukasz Kasprzak2026-08-203-11/+94
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The ordo booklet's week header repeated the month name on every week even though the heading above already established it -- pure noise. Replace it with a Roman week number plus the span of dates the week covers, e.g. "Hebdomada I (Ian 1-2)", following the project's own rule that a presentation choice is data, not code. lang/{la,en}.ini gain a [month_abbr] section (three-letter month abbreviations); Lang.month_abbr follows Lang.month's exact shape, including the out-of-range and miss-returns-the-key contracts. The coverage test now fails loudly if an abbreviation goes missing, the same as [month] already does. Every week object in the view gains num_roman (Roman numeral, num stays as the arabic original -- Roman is a presentation choice, not an engine change), first_dom/last_dom (the day-of-month of the week's first and last IN-MONTH days, padding excluded), month_abbr (resolved through Lang.month_abbr), and single_day (true when the week holds exactly one in-month day). single_day is a flag, not a preformatted span string: the engine is logic-less and cannot itself decide between "Ian 1" and "Ian 1-2", so a template makes that call from the flag instead -- the same "shape the data, not the template" discipline in_month and last already follow. Weeks are built per month with padding only at the two ends, so a week's in-month days never cross a month boundary -- verified, not assumed: every week always has at least one real day since no month is shorter than a single week. Covered by three new View tests, including a real single-day-week witness (January 2027's own trailing week is a lone Sunday, the 31st).
* feat(templates): the ordo booklet flows continuously, not one week per pageLukasz Kasprzak2026-08-201-0/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One week per page filled roughly the top third of every A5 page and cost 65 pages for a year that needed far fewer -- the original requirement, reversed here: a full 7-day week already occupies most of an A5 page even at reduced type, so the real saving was always in the year's many SHORT boundary weeks (a month's first/last week is rarely a full seven days), not in packing two full weeks together. lib/render/view.ml gains a [first] field on every week object (true on a month's own first week, the same 'cheap flag beats invented template logic' call [last] already made for grid rows) -- there is still no {{../}} parent-path syntax for a template to test this any other way. ordo.tex: the per-week \clearpage is gone. Every week keeps its existing small header line; the month's own first week additionally prints a large, ruled banner. \needspace (a template-local LaTeX dependency, not one of the frozen OCaml deps) keeps a header from being stranded alone at a page's bottom -- and a real, separate bug surfaced building it: \label placed BEFORE \needspace captured the OLD page number when \needspace went on to force a break, producing a step-stale \pageref in the TOC (fixed by moving \label after both needspace checks). The day box itself is tightened (rank/colour folded onto the same line as the citations -- an A5 box is far wider than tall, so this fits even Holy Week's longest citations with room to spare) so a second week routinely shares a page with the first. Measured result: 65 -> 53 pages, two pdflatex passes still settle the TOC, zero orphaned headers, zero overfull boxes. ordo.typ: the per-week #pagebreak is gone; #show heading's own block gains sticky:true, Typst's built-in answer to what ordo.tex needs \needspace for. The month banner needed its OWN sticky:true, found live: without it a banner could sit alone at a page's bottom with the entire month pushed to the next page -- not caught by a check for an orphaned HEADING, since the banner is plain markup, not one. Measured result: 65 -> 27 pages (Typst's own page-fill packs tighter than ordo.tex's needspace-driven approach), one compile pass, zero orphans. The five non-paginated flavours (ms/html/md/adoc/txt) never had a forced page break to remove -- they mainly needed the same header treatment for structural consistency: a week-level header nested under each month's existing one, walking months>weeks>days (the week's own padded, in-month-guarded day list) in place of the old months>days flat loop. Verified against groff, asciidoctor and an HTML parser -- zero warnings. Goldens regenerated through the Test_view.view_of path test_render_golden.ml itself uses, not the CLI (whose --lang default would have pinned output the tests never produce).
* fix(naming): the rite prints its display name, not the internal id efLukasz Kasprzak2026-08-203-2/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The booklet's title page and running header read "Ordo 2027 . ef" -- an internal identifier reaching a reader, the same defect class the sanctoral/temporal slugs were already fixed for. lang/la.ini and lang/en.ini each gain a [rite] section mapping the rite id to a reader-facing name, sourced from the Missal's own title page (docs/research/LT.txt:6 "MISSALE ROMANUM", :15 "EDITIO TYPICA") plus the 1962 dating this project uses throughout (rules-register.md, CLAUDE.md) -- both cited in the ini comments, marked PATTERN since the phrase combines two title-page elements rather than quoting one verbatim heading. Lang.t gains a rite table/lookup (same total, miss-echoes-the-key contract as every other lookup here); View.of_days exposes rite_name alongside the existing rite field, which stays the stable key exactly as slug is kept beside name. Every shipped template that printed {{rite}} now prints {{rite_name}} instead. test_lang_coverage.ml gains a coverage assertion over every rite id the engine can emit -- one today -- so a second rite module (OF) landing without a matching [rite] entry fails loudly instead of printing its own bare id. Goldens regenerated through the Test_view.view_of path test_render_golden.ml itself uses (English with a Latin fallback), not the CLI (whose --lang default is plain Latin and would have pinned output the tests never produce). test/cli.t's own JSON prefix assertion updated to match: rite_name is a real new key in that generic view dump, sitting right after rite.
* feat(render): Typst as a seventh escaping flavourLukasz Kasprzak2026-08-202-9/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adds Escape.Typst: type, all, to_string ("typst"), of_string, of_extension (.typ), and a per-character expand function, structured identically to the existing latex escaper (one pass, no re-scan, so double-escaping stays impossible by construction). The metacharacter set was verified against the installed typst 0.14.2 binary, not assumed: a probe document escaping each of #, *, _, $, @, <, >, `, \, ~ and - was compiled and pdftotext'd back to confirm the literal character survives, and each was separately confirmed to do something else when left bare (# opens code mode, */_ toggle strong/emph, $ opens math, @ opens a reference -- a bare unresolved @word is a hard compile error, not merely mangled output -- </> can close around a bare word into label syntax that swallows it whole, ` opens raw, ~ is a non-breaking space, and a run of two or three '-' becomes an en/em dash). All ten are backslash-escapable; none needed a non-backslash workaround. '-' is escaped unconditionally rather than only inside a detected run, since this escaper has no lookahead -- a probe confirmed escaping every hyphen independently still typesets as literal hyphens for a run of any length, so the single per-character rule is sufficient. test_escape.ml's new Typst cases were written first and shown to fail against a stubbed identity apply before the real escaper landed, per this project's own regression-test discipline. Comments in both files mark which new tests are genuine regression tests (the per-character escaping, including a real shipped citation and a synthetic markdown-habit overlay name) versus characterisation (to_string/ of_string/of_extension are flat table lookups with no logic to have been wrong).
* feat(cli): --lang and --raw, colitur lang, colitur configLukasz Kasprzak2026-08-201-2/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Names are Latin by default now; --raw restores the old byte-exact output, verified against a pre-branch binary built from a clean HEAD checkout, not merely asserted. day and readings gain a trailing display-name field (day's own name suffix, readings' own " | name" suffix) rather than a substitution in the slug's existing position: a name contains spaces and would otherwise break the space-separated columns, the same reasoning that already keeps readings a separate command. The field is present only when it differs from the slug already shown, which is a direct consequence of Lang.raw being the identity table (no lookup ever misses), not a special case threaded through day_line/readings_line -- under --raw, or any language with no entry for a given day, the trailing field is simply absent, byte for byte what this project printed before naming existed. An unknown language is an error naming what is available, never a silent fallback to Latin: a booklet quietly printed in the wrong language is worse than one that refuses to print. A declared fallback still chains, so a partial translation shows its fallback language rather than slugs. lang_dir mirrors data_dir's probe order exactly -- installed prefix, then build tree -- because an installed binary that finds calendar data but no language files is the failure this would otherwise ship. Config (lang/overlay/template/format) is loaded once, unconditionally, before dispatch: a config file that exists but fails to parse is fatal on every command, not only the ones that consult it, because a config the user wrote and colitur cannot honour is not something to silently carry on past. An unknown key or unknown section is reported and ignored, worded differently so a misspelled section reads differently from a misspelled key inside a recognised one. overlay's config default applies only when zero --overlay flags were given, never merged with a partial CLI list, so precedence stays flag > config > default in one direction throughout. colitur lang --list/--dump/--check and colitur config --show are what make "anyone can write a language file" true rather than merely permitted, the same pattern the overlay system already established with new-overlay and check. --check reports what is missing AND rejects an unknown slug (exit 1), because a key matching nothing is silently dead and its author would otherwise never learn why their own name does not appear. --dump emits sections in a fixed order (Lang.keys' own global sort keeps every section's keys contiguous), so two dumps of the same table are byte-identical. config --show resolves through the same Config.resolve every other command uses -- deliberately no separate provenance function, so the two cannot disagree -- and previews exactly what flags given on the same command line would resolve to anywhere else. One authorized change outside this task's own file list: emit_ics.ml's SUMMARY line was reading the unlocalised rank/colour pair ("class-1, white") beside an already-localised name, where the JSON schema right next to it correctly carries rank_name/colour_name ("II classis"/"albus"). Found while reviewing the published site; fixed at the source.
* feat(render): names reach the view and every emitterLukasz Kasprzak2026-08-196-62/+124
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The view's name is now the RESOLVED display string and slug is untouched, so machine formats carry both -- a script keeps the stable key, a human reads the name. name is a plain string, not a lang-keyed object. That removes the shadowing hazard outright: a dotted {{name.la}} used to fall back WHOLESALE to the enclosing month's name.la and print Ianuarius on every unnamed day, which is how a printed booklet came to show the month where the feast belonged. weekday, season, rank and colour all gain localised companions, because a calendar in a language needs more than feast names, and templates gain a term vocabulary so fixed strings need no template edit to translate. Asserted over a whole year: no day renders its slug as its name. Beyond the brief's own code sample: - comm_value's own name is now resolved through the same lang.celebration table too (not only the observed day's), because Task 8's own ordo template interpolates a plain {{name}} inside {{#comms}} -- an Obj there would render silently blank. A commemoration slug without Latin coverage still degrades to the slug, same as everywhere else in this system; that is a lang/la.ini DATA gap (113 of 327 sanctoral slugs, measured), not a regression this task introduced. - bin/main.ml's emit/table/publish call sites needed ~lang to compile at all, which is collateral from the of_days signature change, not this task's own file list. Rather than pass Lang.raw and ship the very slug-as-name defect this branch exists to fix, they load the shipped Latin table by the same probe order data_dir() already uses -- a deliberate, commented BRIDGE that Task 6 replaces wholesale with real --lang/--raw/config resolution. bin/dune gained colitur_naming accordingly. - test/cli.t needed two related fixes to stay green: the CSV header/row example, and a table/LaTeX escaping demonstration that relied on the kernel's own English name for Sts Peter & Paul -- gone from the view now that name resolves through lang tables only, and the Missal's own Latin spells the feast with et, never an ampersand. Escaping itself is still proved live on 2035 data in test_emit.ml. - Both schemas gained the new day/week/top-level keys (season_name, weekday, rank_name, colour_name, term, weekday_headings, month_num, month_name), not only the name shape change; schema/colitur-v1.xsd verified against real emitted XML via xmllint (make check-schema). Render/golden's 9 cases (the shipped ordo/grid templates, all six flavours) now fail as expected: their old {{name.la}} / {{#name}}... idiom finds nothing on a plain string. That is Tasks 8/9's own scope to rewrite, per the plan's own pre-flight conflict scan -- not fixed here, and not silently pinned by regenerating goldens off broken output. 495 tests run (490 + 5 new), 486 pass; the 9 failures are exactly Render/golden's ordo/grid cases.
* fix(naming): config.ml merges every [defaults] block, like lang.mlLukasz Kasprzak2026-08-192-20/+43
| | | | | | | | | | | | | | | | | | | | | config.ml and lang.ml both parse the INI format through the same reader, Colitur_kernel.Overlay_ini.parse_sections, but resolved a repeated [section] header oppositely: lang.ml folds over every section sharing a name, while config.ml used List.find_opt and silently discarded every [defaults] block after the first. Two modules parsing one file format must not disagree about what a duplicate section header means. of_string now folds a single accumulator across every section named [defaults], in file order, matching lang.ml's of_string shape. A scalar key (lang/template/format) repeated across two blocks resolves to the later value, consistent with the existing within-section last-wins rule; overlay keeps accumulating across every block, not only the first; and unknown_sections still excludes every [defaults] block, merged or not, since merging it is the point. config.mli's lang doc comment is extended to say the last-wins rule holds across block boundaries too, cross-referencing lang.ml's own duplicate-section policy so the two do not drift again unnoticed.
* fix(naming): config fix round 1 -- unknown sections, O(n) accumulateLukasz Kasprzak2026-08-192-21/+56
| | | | | | | | | | | | | | | | | | | | | F1: test_unknown_key_is_reported_not_fatal never asserted unknown_keys itself, only that parsing survives -- a no-op accumulator passed it. Now asserts the key is actually collected. F2: a misspelled section name, e.g. [deafults], was silently discarded -- Ok empty, lang and everything else gone, nothing reported. That is the highest-value typo this feature exists to catch. Any section other than [defaults] is now collected into a new Config.unknown_sections, kept separate from unknown_keys so the CLI can word the two warnings differently. Still non-fatal: a newer colitur's added section must not break an older binary. F3: overlays and unknown_keys accumulated with '@ [v]' per line, O(n^2) over the field count. Cons during the fold, List.rev once at the end. F4: documented that lang/template/format are last-wins on a repeated key, the opposite direction from Overlay_ini.get's first-wins over the same section type.
* fix(naming): merge duplicate [section] blocks in the language tableLukasz Kasprzak2026-08-192-4/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1 (review round 1): of_string's find took only the FIRST section of a given name (List.find_opt), so a second [celebration] block anywhere in the file was silently dropped in its entirety -- reproduced with two blocks (a in the first, b in the second): b resolved to the slug fallback "b", not its real value. This is a data-loss footgun aimed squarely at what happens next: Tasks 3/4 write a 595-entry, hand-edited la.ini, and appending a second [celebration] block is the natural way to paste in a new batch of names. Worse, the failure surfaces nowhere near its cause -- a coverage check reports the dropped slugs as missing a Latin name, with nothing pointing at the parser. find now folds over every section sharing the name, in file order, so all blocks merge. This also settles which value wins when the same key appears in two different blocks: later in the file wins, consistent with the existing within-one-block behaviour (unchanged, still last SM.add wins) and with what a reader expects when appending to an INI file. lang.mli now documents both duplicate policies explicitly, and notes they run OPPOSITE to Overlay_ini.get's first-match (List.assoc_opt) over the same section.fields shape -- undocumented before, and a latent trap since the two modules read the same section type but resolve a duplicate key in opposite directions. Three tests added: two [celebration] blocks both resolve (the F1 regression), a key repeated across two blocks resolves to the later block, and a key repeated within one block still resolves to the later line (confirms unchanged behaviour). Confirmed the regression test fails against the pre-fix code (b resolves to "b", the slug fallback) and passes after.
* feat(naming): the config fileLukasz Kasprzak2026-08-192-0/+71
| | | | | | | | | | | | | | | Owns precedence and provenance and nothing else, and never reads the filesystem, so it is as testable as the language table. resolve returns the value AND its source, because a setting that silently comes from a file the user forgot about is worse than no setting at all -- config --show can then say where each effective value came from. overlay accumulates rather than last-wins: a user has more than one. An unknown key is reported, never fatal. A config written for a newer colitur must still work on an older one, but silently dropping a line the user wrote is how a typo becomes invisible.
* feat(naming): the language tableLukasz Kasprzak2026-08-194-0/+152
| | | | | | | | | | | | | | | | | | | | | | | | | | | Maps strings to strings and nothing else -- no calendars, no dates, no filesystem. That is what lets every command use it without the kernel learning about presentation. Every lookup is total, and a miss returns THE KEY rather than the empty string. A partial translation is therefore usable from its first line, and the fully-degraded case is exactly today's output (bare slugs) rather than a blank page. --raw is a real identity table, not a special case threaded through every call site: one value the whole program passes around. Reuses Overlay_ini's INI reader rather than growing a second one that would drift in its comment, quoting and trimming rules; parse_sections is exposed in the .mli for that, with no behaviour change. Fixes one defect found while running the brief's own tests rather than transcribing them blind: weekday's internal lookup key is an English day-name word (month's is already the numeral string), so on a miss it echoed that word instead of the documented numeral, breaking both the 0=Sunday convention and Lang.raw's own identity contract for weekday. weekday/month now fall back to string_of_int n directly on a miss instead of through get's generic echo-the-search-key path; month is byte-identical since its key already equals string_of_int n.
* fix(render): omit DTEND at the domain's own last day, 9999-12-31Lukasz Kasprzak2026-08-191-2/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | F1: Date.add_days is UNBOUNDED (date.mli) -- only Date.make enforces 1583..9999 -- and Date.to_iso8601 pads but never truncates, so 9999-12-31's naive successor formatted as "10000-01-01", and compact turned that into a 9-digit, non-conformant DATE on the last VEVENT of year 9999. Confirmed at the source before fixing, and reproduced against real `colitur emit --format ics --from 9999 --to 9999` output (DTEND;VALUE=DATE:100000101) before touching any code. RFC 5545 section 3.6.1: a VEVENT with a DATE-valued DTSTART and neither DTEND nor DURATION has an implicit one-day duration, so omitting DTEND for that one event is the standard's own correct answer, not a workaround. dtend_of re-derives the successor's year/month/day and re-validates them through Date.make -- the one function that actually enforces the domain -- before trusting the string; None means the caller omits the DTEND line entirely. F2 (minor, same function): documented next_day's own Error branch as dead-but-silent on shipped data (event's iso <> "" guard is the only caller and always parses) -- behaviour unchanged, comment only. Two new tests: the domain's last VEVENT (DTSTART 99991231) has no DTEND line at all; every DTEND anywhere in a 9999 feed is exactly 8 digits (the general form of the bug, catches a regression anywhere else in the domain too). Existing 2027/2028 DTEND-arithmetic assertions untouched and still pass. Mutation-proved: both new tests fail against the pre-fix code (9-digit DTEND value caught verbatim), pass after.
* feat(render): iCalendar emitter, RFC 5545Lukasz Kasprzak2026-08-192-0/+82
| | | | | | | | | | | | | | | | | | | | | | | | | | | Not a template job: folding, escaping, exclusive DTEND and stable UIDs are rules a logic-less template cannot enforce, and each fails silently in a subscriber's client rather than loudly at generation. DTEND is EXCLUSIVE for an all-day event (section 3.6.1). Wrong here shows every event a day short, everywhere. UIDs are YYYYMMDD-<rite>@colitur and stable across regenerations (section 3.8.4.7). Wrong here duplicates the whole year in every subscriber's phone, months later. Every line is CRLF-terminated and folded at 75 octets (section 3.1). No RRULE: a liturgical calendar is not a recurrence rule. Asserted, so nobody optimises it later. DTSTAMP is a parameter, not a clock read. RFC 5545 requires it and the obvious implementation reads the wall clock -- which violates the kernel's determinism rule and would make two feeds from identical data differ byte-for-byte, defeating reproducible builds and any reviewable diff on a published tree. Corrected one test literal against real engine output: DTSTAMP is a per-VEVENT property (section 3.8.7.2), not calendar-level, so the default-value line count is 365 (every event), not 1. Mutation-tested: a non-exclusive DTEND reddens the suite.
* feat(render): XML emitter and schemaLukasz Kasprzak2026-08-192-0/+56
| | | | | | | | | | | | | Element-per-field; attributes carry identity only and there is no mixed content, so a consumer's XPath never has to distinguish the two. Schema validation is an opt-in make check-schema via xmllint, not an in-suite assertion: validating XSD needs an XML library and the dependency list is frozen. It prints SKIPPED loudly when xmllint is absent, because a silent skip reads as a pass. The suite asserts well-formedness properties directly instead. This corrects the design spec, which claimed in-test validation.
* fix(render): remove rank_label -- it duplicated name verbatimLukasz Kasprzak2026-08-191-2/+6
| | | | | | | | | | | | | | | | | | view.ml's rank_label field was a byte-for-byte copy of the celebration's name (names_value cel.Celebration.names), not a localized rank label at all -- the kernel has no per-language rank names to draw one from, so there was no honest value to put there. Nothing consumed it: no template in the plan, no test, no other code referenced it. Removed from both day_value and padding_cell so the two key sets stay identical (23 keys each, verified). schema/day-v1.json already described 23 keys and needed no change -- it now matches the emitted output exactly. schema/day-v1.json is a published contract: once a phone subscribes or a site fetches this, removing a field is a breaking /v2/ change. The time to remove a field that lies about its own contents is before anyone can depend on it, not after.
* feat(render): CSV and JSON emitters, and the published contractLukasz Kasprzak2026-08-194-0/+110
| | | | | | | | | | | | | | | | | | | | | Both consume the VIEW, not the kernel, so every emitter and every template describe exactly the same fields -- there is one vocabulary, not five. CSV is RFC 4180: a field with a comma is quoted. That is live on real data, not hypothetical -- 'St. Joseph, Spouse of the Bl. Virgin Mary' would otherwise split into two columns. JSON is hand-rolled because the dependency list is frozen and escaping is the only subtlety. Control characters below 0x20 are \u-escaped per RFC 8259 section 7. There are no numbers in the view, deliberately: a consumer never has to guess whether week is 2 or "2". schema/day-v1.json pins the shape. Once a phone subscribes or a site fetches this, it is a promise to strangers -- adding a field is minor, renaming one means /v2/.
* feat(render): the view modelLukasz Kasprzak2026-08-192-0/+169
| | | | | | | | | | | | | | | | | | Shapes a civil year of resolved days into the value a template renders against. This layer is why the engine can stay logic-less: a month grid needs leading blank cells, week bucketing and an in-month test, and a logic-less template can compute none of it. Both weeks and days are offered at every level -- the booklet walks days, the grid walks weeks -- so the two artefacts cannot drift. Colours are six booleans, not hex: hex bakes a presentation policy into the engine, and LaTeX, groff and HTML each want a different colour expression. Asserted: exactly one of the six is true on every day of a whole year, so a template keying off them can never get none or two. Padding cells carry every field a real day carries, empty, so a template never hits a missing key mid-grid.
* feat(render): template renderer with mandatory escapingLukasz Kasprzak2026-08-192-0/+64
| | | | | | | | | | | | | | | | Every interpolated value is escaped for the template's flavour; the template's own literal text never is, because that is the author's markup. There is no raw form, so a template cannot opt out. Scope is a stack with outward fallback, so a grid template can reach the year number from inside a week without the view duplicating it into every cell. A missing key renders empty -- the one deliberate silence, so a template survives a rite that does not set every optional field. Mutation-tested: dropping the Escape.apply call reddens the data-cannot-escape-flavour case.
* fix(render): reject empty tag paths, sharpen the raw-form testLukasz Kasprzak2026-08-191-4/+14
| | | | | | | | | | | | | | | | | | F1: test_no_raw_or_partial_form's first assertion only excluded one literal shape (Ok [Var ["{name"]]), so it could not actually catch a future raw/unescaped constructor under a different name. Replace it with an assertion of the real parse result for {{{name}}} (Ok [Var ["{name"]; Text "}"]), documented behaviour rather than a guarantee this test cannot check -- the real guarantee is structural: node has exactly four constructors and none of them is raw. F2: {{.}}, {{#}}, {{^}} and {{/}} used to parse to a Var/Section/ Inverted with an empty path, reachable but never designed. This engine has no "current context" for a bare dot to mean, so a bare-dot or empty-sigil path is now a parse error at lex time, covering all four sigil forms via one path helper. The existing "empty tag {{}}" branch is unchanged and still reachable (a fully empty body is a distinct case from a sigil with an empty path).
* feat(render): logic-less template parserLukasz Kasprzak2026-08-192-0/+114
| | | | | | | | | | | Placeholders, sections, inverted sections, comments. Nothing else: no partials, no lambdas, no expression evaluation, no raw form. A template is data, never a program, which is what keeps an untrusted template safe. Errors rather than silence on a malformed template: an unterminated tag, an unclosed section, a mismatched close and a partial all return Error. Swallowing '{{name' as text is how a typo becomes invisible missing output in a printed booklet.
* fix(render): make fold_ics total on arbitrary octet stringsLukasz Kasprzak2026-08-191-0/+8
| | | | | | | | | | | | | | | | | | | fold_ics's UTF-8 backoff loop could back `cut` all the way down to `pos` on 74+ consecutive continuation bytes (0x80-0xBF), producing a zero-length chunk and recursing on the identical position forever -- not producible by valid UTF-8, whose longest continuation run is 3, but the kernel's own totality requirement covers arbitrary octet strings, not only valid ones. When backoff finds no boundary inside the window, cut hard at the limit instead, so forward progress is unconditional. test_fold_never_splits_utf8 previously asserted only that unfolding reproduced the original bytes, a property folding preserves at any cut position and therefore blind to a boundary violation. It now also asserts the named property directly: no continuation chunk may start with a UTF-8 continuation byte. A new regression test feeds fold_ics 100 consecutive continuation bytes and asserts it terminates with every line at or under 75 octets.
* feat(render): per-flavour escaping and RFC 5545 line foldingLukasz Kasprzak2026-08-193-0/+120
| | | | | | | | | | | | | | | Six flavours: latex, groff, html, xml, ics, none. Markdown, AsciiDoc and plain text map to none deliberately -- their metacharacters are context-dependent and escaping them aggressively produces worse output than not escaping. An unrecognised extension returns None rather than falling back to none: guessing the flavour wrong produces malformed output that looks fine until it does not. Folding backs off to a non-continuation byte, so a fold never splits a UTF-8 sequence -- the failure mode that would corrupt Polish and Latin names in a published feed.