From bf42e85ed622da5227a69c26ec801d32c767bb32 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 27 Aug 2026 00:24:03 +0200 Subject: fix(render): derive citation keys from the day, not a hardcoded pair (W5) lib/render/view.ml hardcoded exactly two template keys, "first" and "gospel", regardless of what a day's citations actually contain. The OF now resolves a third reading, Second, on Sundays and solemnities (OLM 1981 Praenotanda n. 69.1/n. 84(b)(c)) via the rite-supplied Rite.t.citation_shapes, which the render layer could not express. Latent today (bin/main.ml's reject_rite_for refuses --rite on every command that reaches View.of_days, so only EF ever reaches this path), but the wrong shape to carry forward. View.citation_fields now derives one key per Citation.part ACTUALLY PRESENT in a day's own citations, in Citation.all_parts order, using Citation.part_to_string for the key name. An absent part contributes no key at all, not a key holding the empty string: Template.render already treats a missing key and a present Str "" identically in Var, Section and Inverted position, so nothing is lost, and Emit_json.year dumps the whole Obj verbatim, so an unconditionally-present-but-empty key for a still-unbuilt part (Psalm/Tract/Alleluia/Sequence) would sit in every EF day's JSON forever. Filtering to present parts alone is what makes EF's output identical byte for byte BY CONSTRUCTION rather than by a rite check: EF's citations are always exactly [First; Gospel], so the derived key set is always {"first"; "gospel"}, in that order. padding_cell keeps its own fixed "first"/"gospel" pair rather than deriving from a (nonexistent) day: it mirrors the closed set every rite currently reachable through this render path (EF alone) always carries, documented as debt for whenever a rite whose citation_shapes includes Second is admitted here -- test_padding_and_real_share_key_set would catch the mismatch immediately. Downstream consumers: Emit_json needed no change (already a verbatim dump). Emit_xml and Emit_ics gained a third, order-preserving element/line (a element; a "Second ..." DESCRIPTION line spliced between Epistle and Gospel) since neither is a fixed-column format. Emit_csv is deliberately left unwidened and commented: RFC 4180's header-row contract makes a variable column count illegal, and OF cannot reach `emit` today regardless, so there is nothing to widen for yet; admitting OF here later needs a disclosed, deliberate format change, not a silent one. term_keys (line ~239) is left unchanged, with a comment recording the judgment call: "epistle" and "lesson" are indeed EF/Roman vocabulary (Epistola vs. a non-apostolic Lectio, e.g. an Ember day's Old Testament reading) sitting in an otherwise rite-agnostic list, but nothing forces a fix yet -- no OF template exists, and every in-repo EF template already reads term.epistle, so renaming it would break 11 shipped templates and any user template for zero present benefit. "lesson" already sits unused, already translated in both lang files, and is the natural neutral term a future OF ordo template would reach for. Tests: test_view.ml gains test_ef_days_never_carry_a_second_key (a whole year, not one day) and test_three_citation_day_exposes_all_three, driving View.of_days directly with a hand-built Liturgical_day.t since OF cannot reach this path through the CLI yet -- the only honest way to exercise a latent path. test_emit.ml/test_ics.ml extend coverage to Emit_xml/Emit_ics (presence, value, and reading order) and pin Emit_csv's deliberate 16-column non-widening. EF byte-identity: zero diff in data/ef/ or lib/rites/rite_ef/ against the pre-change commit. `colitur day`, `readings`, `emit --format csv/xml/json/ ics`, and `table --template templates/ef/ordo.txt` for 2026, all non-empty, all byte-identical (cmp) against a worktree build of the pre-change commit. --- lib/render/view.ml | 88 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 7 deletions(-) (limited to 'lib/render/view.ml') diff --git a/lib/render/view.ml b/lib/render/view.ml index 67dfa70..0b122a7 100644 --- a/lib/render/view.ml +++ b/lib/render/view.ml @@ -54,6 +54,43 @@ let citation_ref ~sigla cits part = | Some c -> Sigla.format sigla c.K.Citation.reference | None -> "" +(* W5: the day's own reading citations, keyed by [Citation.part_to_string] + and restricted to the parts THIS DAY ACTUALLY CARRIES -- generalises what + used to be exactly two hardcoded fields ([first]/[gospel]). A day with + [[First; Second; Gospel]] (OF, a Sunday or solemnity, OLM 1981 + Praenotanda n. 69.1/n. 84(b)(c)) now exposes all three keys; a day with + [[First; Gospel]] (OF ferias/feasts/memorials, n. 66.1; every EF day, + unconditionally, RG-cited lectionary) exposes exactly the two it always + did. [K.Citation.all_parts] order (not [cits]' own order) makes the + result deterministic regardless of how the rite built the list. + + An ABSENT part contributes NO KEY at all, not a present key holding the + empty string -- deliberately, for two independent reasons, not one: + (1) [Template.render]'s own documented contract already makes the two + indistinguishable at every place a template can observe them ([Var], + [Section], [Inverted] -- [Template.truthy] treats [Str ""] and a lookup + miss identically), so nothing is lost expressively; (2) {!Emit_json.year} + dumps this whole [Obj] verbatim, with no field list of its own to filter + through (unlike [Emit_csv]/[Emit_xml]/[Emit_ics], each of which names its + fields explicitly). [Citation.all_parts] also lists [Psalm]/[Tract]/ + [Alleluia]/[Sequence] -- deliberately unbuilt by every rite that exists + today (no source, no oracle, CLAUDE.md) -- and NO rite will ever populate + them, so a present-but-always-empty key for each would sit in every EF + day's JSON forever. Filtering to only the PRESENT parts is what makes EF's + output identical byte for byte across every emitter, [Emit_json] included, + BY CONSTRUCTION rather than by a rite check here: EF's [citations] is + always exactly [[First; Gospel]] ({!Rite_ef}'s own [citation_shapes], + {!Colitur_kernel.Validate}'s "citations" check), so the derived key set + below is always {"first"; "gospel"}, in that order -- letter-for-letter + what the two fields this replaces used to produce. *) +let citation_fields ~sigla cits = + List.filter_map + (fun part -> + match citation_ref ~sigla cits part with + | "" -> None + | s -> Some (K.Citation.part_to_string part, str s)) + K.Citation.all_parts + (* A commemoration's own name resolves through the SAME [lang.celebration] table as the observed day's -- a commemoration's slug is drawn from the identical sanctoral/temporal pool, not a second vocabulary. Templates @@ -74,6 +111,18 @@ let comm_value ~lang (c, priv) = so a template can render it blank. Every field a real day has is present and empty, so a template never hits a missing key on a padding cell -- the same key SET as [day_value], not merely the same shape by coincidence. *) +(* [first]/[gospel] stay a fixed pair here, deliberately NOT derived through + [citation_fields] (there is no day, hence no [citations] list, to derive + from): they mirror the closed set every rite CURRENTLY REACHABLE through + this whole grid/booklet path (EF alone -- [bin/main.ml]'s + [reject_rite_for] refuses [--rite] on every command that calls + [of_days]) always carries. A day with a [Second] reading can only enter a + grid once a rite whose [citation_shapes] includes it is admitted to + these commands -- not built yet, tracked as debt rather than + pre-built, CLAUDE.md's own "one field short of the remedy" discipline -- + at which point this list needs the matching key added, or + [test_padding_and_real_share_key_set] (test_view.ml) will catch the + mismatch immediately. *) let padding_cell dow = T.Obj [ ("iso", str ""); ("dom", str ""); ("dow", str (string_of_int dow)); @@ -100,7 +149,7 @@ let day_value ~lang ~sigla ~vocab (d : ('s, 'r) K.Liturgical_day.t) = let colour_s = K.Colour.to_string colour in let season_s = vocab.K.Vocab.season_to_string tmp.K.Temporal.season in T.Obj - [ ("iso", str (K.Date.to_iso8601 date)); + ([ ("iso", str (K.Date.to_iso8601 date)); ("dom", str (string_of_int (K.Date.day date))); ("dow", str (string_of_int (dow_int (K.Date.weekday date)))); ("in_month", bool true); @@ -138,11 +187,10 @@ let day_value ~lang ~sigla ~vocab (d : ('s, 'r) K.Liturgical_day.t) = T.Obj [ ("slug", str (K.Slug.to_string c.K.Celebration.slug)); ("to", str (K.Date.to_iso8601 dest)) ]) - d.K.Liturgical_day.transferred_out) ); - ("first", str (citation_ref ~sigla d.K.Liturgical_day.citations K.Citation.First)); - ("gospel", str (citation_ref ~sigla d.K.Liturgical_day.citations K.Citation.Gospel)); - (* overwritten per grid row by [set_last]; false in the flat [days] list *) - ("last", bool false) ] + d.K.Liturgical_day.transferred_out) ) ] + @ citation_fields ~sigla d.K.Liturgical_day.citations + @ [ (* overwritten per grid row by [set_last]; false in the flat [days] list *) + ("last", bool false) ]) (* Bucket a month's day values into Sunday-started weeks of exactly seven cells, padding both ends. This is the computation the template cannot do. *) @@ -235,7 +283,33 @@ let weeks_of_month ~first_dow ~month_num ~month_name ~month_abbr day_values = template edit. The key list is the vocabulary's own fixed, closed set (lang/*.ini's own [term] section, Task 1/3) -- not open like [celebration], so it is named here rather than invented a second time from a wildcard - enumeration. *) + enumeration. + + W5 judgment call: ["epistle"] (and, less obviously, ["lesson"] -- the + 1962 Missal's own word for a non-apostolic reading, e.g. an Ember day's + Old Testament reading, as opposed to "Epistola" proper; see + [tools/bootstrap_lectionary.ml]'s "last lesson before the Gospel" + comments) ARE genuinely EF/Roman vocabulary sitting in this otherwise + rite-agnostic list, the same category CLAUDE.md's "carried into Plan 4" + section already tracks for [validate.ml]/[Liturgical_day.transferred_in]/ + [Precedence.privilege]/[Repose]. Left UNCHANGED here rather than made + rite-supplied or renamed, for a reason specific to this key rather than a + blanket "not worth it": nothing FORCES the fix yet. + [bin/main.ml]'s [reject_rite_for] refuses [--rite] on every command that + reaches [term_value] (`table`/`emit`/`render`/`publish`), so OF cannot + reach a template that reads [term.epistle] today, and no OF template + exists under [templates/] to need a different word (only [templates/ef/] + does, and every one of them already reads [term.epistle]). Renaming it + would be a breaking change to those 11 shipped templates AND to any + user's own template, for zero present behavioural difference -- exactly + the trade [Rite.t.citation_shapes]'s own doc comment warns a template-key + rename always is. ["lesson"] already sits unused by any shipped template, + is already translated in both [lang/en.ini] ("Lesson") and [lang/la.ini] + ("Lectio"), and is the natural neutral term a future OF ordo template + would reach for -- so the infrastructure an eventual fix needs already + exists; only the forcing function (OF actually reaching this command) is + missing. Recorded here as the next instance of the pattern rather than + fixed speculatively, matching CLAUDE.md item 8's own discipline. *) let term_keys = [ "ordo"; "contents"; "epistle"; "lesson"; "gospel"; "commemoration"; "week" ] let term_value lang = T.Obj (List.map (fun k -> (k, str (Lang.term lang k))) term_keys) -- cgit v1.3