From 90584d87e763789808329847c46dbaa8e22dca47 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 23:53:49 +0200 Subject: fix(citation): represent chapter-crossing verse ranges (W4) Parse.verse_range's [last] endpoint gains an optional chapter (Parse.verse_end: { chapter : int option; verse : verse_num }), so a hyphen range whose two endpoints lie in different chapters ("1 John 1:5-2:2") can be represented at all. Rejected: a bare [int option] living alongside [last] as a second field on verse_range -- that would let 'a chapter with no verse' exist as a constructible value. parse_part now splits a part's leading "chapter:" at the FIRST colon only (not every colon), so the verses side can itself carry a second colon from a crossing range. parse_range detects a crossing by checking whether the range's right-hand side contains ':', and skips the same-chapter descending-range guard for that case (a later chapter is always "ahead", whatever its own verse numbers are). Handles the compound shape too -- a crossing range followed by further, same-chapter verse references in the same comma list ("Matthew 9:35-10:1,5a,6-8") -- since those trailing pieces parse as ordinary bare verses/ranges, unaffected by the preceding crossing. Render's one_range renders a crossing [last] through the same chapter_verse template one_part already uses for the part's own leading "chapter:verses", so a style that reconfigures the chapter/verse separator renders a crossing endpoint in that same convention rather than a hardcoded ':'. This closes the W4 known-wrong: 41 (now 49, after an intervening Second-reading extraction) of the OF lectionary's citations printed unconverted, every one this exact shape. test_citation_coverage_of.ml's pinned residual is now empty and asserted exactly, over the full 1725-field data/of/lectionary.sexp population, including the round-trip check (parse -> render -> parse structural equality). test_citation.ml gains direct parse-suite cases for the basic crossing, the compound shape, a mid-list crossing, a crossing with a sub-verse letter, and a malformed-crossing rejection. data/of/lectionary.sexp is regenerated via its own generator (tools/bootstrap_lectionary_of.ml, whose own embedded header text is updated to match); only comment lines change, confirmed by diff -- no lectionary entry differs. test_lectionary_of.ml's whole-file SHA-256 pin is updated to match. EF is unaffected: data/ef/ is untouched since v1.0.0, and a direct byte comparison of `colitur day`/`colitur readings` for 2026, 1583 and 9999 against a git-worktree build of 1c0137d is identical on all six outputs. --- data/of/lectionary.sexp | 80 ++++++---------------------- lib/citation/parse.ml | 76 ++++++++++++++++++++------ lib/citation/parse.mli | 14 ++++- lib/citation/render.ml | 19 ++++++- test/test_citation.ml | 32 ++++++++++- test/test_citation_coverage_of.ml | 109 +++++++++++++++++--------------------- test/test_lectionary_of.ml | 12 ++++- tools/bootstrap_lectionary_of.ml | 51 +++++++++--------- 8 files changed, 223 insertions(+), 170 deletions(-) diff --git a/data/of/lectionary.sexp b/data/of/lectionary.sexp index 7fe5b24..693b768 100644 --- a/data/of/lectionary.sexp +++ b/data/of/lectionary.sexp @@ -89,20 +89,21 @@ ; ferial (Lectionary_of.readings' own step 2 -> step 3) -- this is ; OLM norms working as designed for a saint with no proper of ; their own, not a gap in this data. -; (4) Citation-siglum conversion (fix wave I2, 2026-08-26 review): of -; 1725 emitted (First, Second, Gospel) citation fields, 49 distinct -; references (out of the full 1725) do not parse -- every one a -; hyphen range that crosses a chapter boundary ("2:29-3:6"), a -; {!Colitur_citation.Parse.t} shape this parser's [part]/ -; [verse_range] types do not represent, deliberately not built -; this task (see book.ml's own [is_single_chapter] neighbourhood -; for what WAS fixed: 345 previously-unregistered book names and -; verse sub-letter markers, both closed at the parser/book-table -; level, not here). Such a reference still prints, verbatim, -; never a crash or a dropped citation -- {!Colitur_citation.Sigla -; .format}'s own documented contract on a parse miss. Pinned -; exactly, both directions, by test/test_citation_coverage_of.ml. -; Named below. +; (4) Citation-siglum conversion (fix wave I2, 2026-08-26 review; +; CLOSED by W4): of 1725 emitted (First, Second, Gospel) citation +; fields, 0 distinct references (out of the full 1725) do not +; parse. I2/I7 left 49 unconverted here, every one a hyphen +; range crossing a chapter boundary ("2:29-3:6"); W4 gave +; {!Colitur_citation.Parse.t} a [verse_end] so a range's [last] +; can name its own, later chapter (see book.ml's own +; [is_single_chapter] neighbourhood for the other, earlier fix: +; 345 previously-unregistered book names and verse sub-letter +; markers, closed at the parser/book-table level). A reference +; that still fails to parse would still print verbatim, never a +; crash or a dropped citation -- {!Colitur_citation.Sigla +; .format}'s own documented contract on a parse miss -- but none +; does today. Pinned exactly, both directions, by +; test/test_citation_coverage_of.ml. Named below (if any). ; (5) Second-reading coverage (fix wave I7, 2026-08-26): 183 of the ; 1725 emitted citation fields above are a Second reading -- every ; one from a SUNDAY-CYCLE (-A/-B/-C, i.e. Sunday/solemnity) @@ -119,56 +120,7 @@ ; Regenerate with: ; eval $(opam env) && dune exec tools/bootstrap_lectionary_of.exe -- ../lectio/internal/caldata/of-lectionary.ini data/of/lectionary.sexp ; Unmapped lectio bases (0): -; Citations that do not parse (49 distinct, all chapter-crossing ranges): -; 1 Corinthians 10:31-11:1 -; 1 Corinthians 12:31-13:13 -; 1 John 1:5-2:2 -; 1 John 2:29-3:6 -; 1 John 3:22-4:6 -; 1 John 4:19-5:4 -; 1 Thessalonians 3:12-4:2 -; 2 Corinthians 3:15-4:1,3-6 -; 2 Corinthians 4:13-5:1 -; 2 Samuel 18:9-10,14b,24-25a,30-19:3 -; 2 Thessalonians 1:11-2:2 -; 2 Thessalonians 2:16-3:5 -; 2 Timothy 3:14-4:2 -; Colossians 1:24-2:3 -; Ecclesiastes 11:9-12:8 -; Ephesians 4:30-5:2 -; Ephesians 4:32-5:8 -; Exodus 11:10-12:14 -; Exodus 14:21-15:1 -; Ezekiel 2:8-3:4 -; Galatians 4:22-24,26-27,31-5:1 -; Genesis 1:1-2:2 -; Genesis 1:20-2:4a -; Habakkuk 1:12-2:4 -; Hebrews 7:25-8:6 -; Isaiah 52:13-53:12 -; Isaiah 8:23b-9:3 -; James 3:16-4:3 -; John 15:26-16:4a -; John 18:1-19:42 -; Jonah 1:1-2:2,11 -; Luke 7:36-8:3 -; Malachi 1:14b-2:2b,8-10 -; Mark 2:23-3:6 -; Mark 8:34-9:1 -; Matthew 10:34-11:1 -; Matthew 18:21-19:1 -; Matthew 9:35-10:1,5a,6-8 -; Matthew 9:36-10:8 -; Numbers 13:1-2,25-14:1,26a-29a,34-35 -; Philippians 3:17-4:1 -; Revelation 20:1-4,11-21:2 -; Sirach 27:30-28:7 -; The Acts 12:24-13:5a -; The Acts 17:15,22-18:1 -; The Acts 7:51-8:1a -; Wisdom 11:22-12:2 -; Wisdom 2:23-3:9 -; Wisdom 7:22b-8:1 +; Citations that do not parse (0 distinct): ; Temporal_of slugs (2004-2051) with no lectionary entry (0, informational -- ; most are the Christmas-season/late-Advent date-vs-weekday gap named above): ((all-saints diff --git a/lib/citation/parse.ml b/lib/citation/parse.ml index 2c73600..811019e 100644 --- a/lib/citation/parse.ml +++ b/lib/citation/parse.ml @@ -13,12 +13,30 @@ it would silently lose real precision a reader can see in the source text, trading "wrong format, complete" for "right format, incomplete". *) type verse_num = { n : int; suffix : string } -type verse_range = { first : verse_num; last : verse_num option } + +(* See parse.mli's own citation for [verse_end]'s rationale. *) +type verse_end = { chapter : int option; verse : verse_num } +type verse_range = { first : verse_num; last : verse_end option } type part = { chapter : int; verses : verse_range list } type t = { book : Book.id; parts : part list } let split_on c s = String.split_on_char c s |> List.map String.trim +(* Split at the FIRST ':' only, unlike [split_on ':'] which splits at every + one. [parse_part] needs this: a "chapter:verses" part whose verses + themselves contain a chapter-crossing hyphen range ("1:5-2:2") carries a + SECOND colon that belongs to the verses side, not to a second chapter + marker -- splitting on every ':' would see three pieces there and fail + to parse at all (the exact shape [parse_range]'s own citation above + restores). *) +let split_first_colon s = + match String.index_opt s ':' with + | None -> None + | Some i -> + Some + ( String.trim (String.sub s 0 i), + String.trim (String.sub s (i + 1) (String.length s - i - 1)) ) + (* The book is the longest leading run of non-digit words, allowing one leading ordinal ("1 Cor", "3 Kings"). Everything after it is the reference tail. *) @@ -109,18 +127,44 @@ let num_opt s : verse_num option = | None -> None | Some (digits, suffix) -> ( match int_opt digits with Some n -> Some { n; suffix } | None -> None) -(* "20-32" -> {first=20; last=Some 32}; "21" -> {first=21; last=None} *) +(* "20-32" -> {first=20; last=Some{chapter=None; verse=32}}; + "21" -> {first=21; last=None}; + "35-10:1" -> {first=35; last=Some{chapter=Some 10; verse=1}} -- the + RIGHT side of a hyphen range may itself carry an explicit "chapter:verse" + (a range whose hyphen crosses a chapter boundary, [verse_end]'s own + citation in the .mli). The LEFT side never does: it is always the + chapter already in effect at that point in the part (parse_part's own + [c], or the chapter a PRECEDING range in the same part most recently + crossed into -- an ambiguity the source text itself never resolves and + this function does not try to, since nothing downstream needs the first + side's chapter: {!Colitur_citation.Render} only ever prints a part's + OWN [chapter] once, at the front, from [parse_part]'s [c]). *) let parse_range s = match split_on '-' s with | [ a ] -> ( match num_opt a with Some f -> Some { first = f; last = None } | None -> None) | [ a; b ] -> ( - match (num_opt a, num_opt b) with - (* A descending range ("1:20-10") is always a transcription error; - accepting it would render back out as a citation nobody can follow. - Compared on the NUMBER only -- "11a-11b" is a real, ascending - sub-verse range even though nothing here orders letters. *) - | Some f, Some l when l.n >= f.n -> Some { first = f; last = Some l } - | _ -> None) + match num_opt a with + | None -> None + | Some f -> + if String.contains b ':' then + (* The right side names its own chapter explicitly: no verse- + number ordering constraint applies (a later chapter is + always "ahead", whatever its own verse numbers are). *) + match split_on ':' b with + | [ cs; vs ] -> ( + match (int_opt cs, num_opt vs) with + | Some c, Some v -> Some { first = f; last = Some { chapter = Some c; verse = v } } + | _ -> None) + | _ -> None + else + match num_opt b with + (* A descending range ("1:20-10") is always a transcription + error; accepting it would render back out as a citation + nobody can follow. Compared on the NUMBER only -- "11a-11b" + is a real, ascending sub-verse range even though nothing + here orders letters. *) + | Some l when l.n >= f.n -> Some { first = f; last = Some { chapter = None; verse = l } } + | _ -> None) | _ -> None let parse_ranges s = @@ -140,13 +184,16 @@ let parse_ranges s = always a VERSE, never a chapter introduction -- see book.ml's own citation for the real, wrongly-parsed example this was found on. *) let parse_part ~single_chapter ~inherited s = - match split_on ':' s with - | [ c; v ] -> ( - (* explicit "chapter:verses" *) + match split_first_colon s with + | Some (c, v) -> ( + (* explicit "chapter:verses" -- [v] may itself carry further colons, + from a chapter-crossing hyphen range inside it; [parse_ranges] / + [parse_range] read those, this function does not need to. *) match (int_opt c, parse_ranges v) with | Some ch, Some vs -> Some { chapter = ch; verses = vs } | _ -> None) - | [ only ] -> + | None -> ( + let only = s in if single_chapter then match parse_ranges only with | Some vs -> Some { chapter = Option.value inherited ~default:1; verses = vs } @@ -171,8 +218,7 @@ let parse_part ~single_chapter ~inherited s = match parse_ranges only with | Some vs -> Some { chapter = ch; verses = vs } | None -> None) - | _, None -> None) - | _ -> None + | _, None -> None)) let parse s = let s = String.trim s in diff --git a/lib/citation/parse.mli b/lib/citation/parse.mli index ea38d24..bc08728 100644 --- a/lib/citation/parse.mli +++ b/lib/citation/parse.mli @@ -15,7 +15,19 @@ actually carried. *) type verse_num = { n : int; suffix : string } -type verse_range = { first : verse_num; last : verse_num option } +(** The end of a verse range, when it needs to say more than a bare verse + number. [chapter = None] is the overwhelming common case (the range + ends in the same chapter its [part] already names); [chapter = Some c] + is a range whose hyphen crosses into a LATER chapter -- the shipped OF + lectionary really does cite this shape ([1 John 1:5-2:2]: the range + starts in chapter 1, the part's own [chapter], and ends at 2:2). A + dedicated record, rather than a bare [int option] living alongside + [verse_range.last] as a second field, so "a chapter with no verse" is + not a state this type can even represent -- see parse.ml's own + [parse_range] for how a hyphen range decides which shape it is. *) +type verse_end = { chapter : int option; verse : verse_num } + +type verse_range = { first : verse_num; last : verse_end option } type part = { chapter : int; verses : verse_range list } type t = { book : Book.id; parts : part list } diff --git a/lib/citation/render.ml b/lib/citation/render.ml index 2b40ab3..d85b050 100644 --- a/lib/citation/render.ml +++ b/lib/citation/render.ml @@ -81,10 +81,27 @@ let subst tmpl pairs = let render st ~names (t : Parse.t) = let show_num (v : Parse.verse_num) = string_of_int v.Parse.n ^ v.Parse.suffix in + (* A range's [last] normally renders as a bare verse number. When it + names its own, later chapter ([verse_end.chapter], parse.mli's own + citation), it is rendered through the SAME [chapter_verse] template + [one_part] below uses for the part's own leading "chapter:verses" -- + so a style that reconfigures the chapter/verse separator (the "missal + convention" comma, {!Colitur_citation.Render}'s own doc comment) + renders a cross-chapter jump in that identical convention, not a + hardcoded ":". *) let one_range (r : Parse.verse_range) = match r.Parse.last with | None -> show_num r.Parse.first - | Some l -> subst st.range [ ("first", show_num r.Parse.first); ("last", show_num l) ] + | Some (l : Parse.verse_end) -> + let last_str = + match l.Parse.chapter with + | None -> show_num l.Parse.verse + | Some c -> + subst st.chapter_verse + [ ("chapter", string_of_int c); ("chapter_roman", roman_numeral c); + ("verses", show_num l.Parse.verse) ] + in + subst st.range [ ("first", show_num r.Parse.first); ("last", last_str) ] in let one_part (p : Parse.part) = let verses = String.concat st.verse_sep (List.map one_range p.Parse.verses) in diff --git a/test/test_citation.ml b/test/test_citation.ml index 3cbc8c6..ffbefe8 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -265,13 +265,18 @@ let suite = module P = Colitur_citation.Parse (* Render a parse back to a debug string so a test can assert shape - compactly: "book|chapter:v-v,v-v|chapter:v". *) + compactly: "book|chapter:v-v,v-v|chapter:v". A [last] that names its own, + later chapter (a chapter-crossing range) shows as "v-C:v", the same + "chapter:verse" shape a plain part already uses -- so a crossing is + visible in the debug string exactly where the source text put it. *) let show (t : P.t) = let num (v : P.verse_num) = string_of_int v.P.n ^ v.P.suffix in let range (r : P.verse_range) = match r.P.last with | None -> num r.P.first - | Some l -> Printf.sprintf "%s-%s" (num r.P.first) (num l) + | Some { P.chapter = None; verse } -> Printf.sprintf "%s-%s" (num r.P.first) (num verse) + | Some { P.chapter = Some c; verse } -> + Printf.sprintf "%s-%d:%s" (num r.P.first) c (num verse) in let part (p : P.part) = Printf.sprintf "%d:%s" p.P.chapter @@ -319,6 +324,29 @@ let parse_suite = ("four parts", `Quick, parses "Eccli 24:5; 14:7; 14:9-11; 24:30-31" "ecclesiasticus|24:5|14:7|14:9-11|24:30-31"); ("modern name, vulgate id", `Quick, parses "Rev 12:1" "apocalypse|12:1"); + (* Chapter-crossing ranges (W4): a hyphen range whose two endpoints lie + in different chapters -- real shapes from data/of/lectionary.sexp, + previously unparseable in their entirety (parse.mli's own + [verse_end]). *) + ("cross-chapter range", `Quick, parses "1 John 1:5-2:2" "john_1|1:5-2:2"); + (* The compound shape: a chapter-crossing range FOLLOWED by further, + same-chapter verse references in the same comma list -- "5a" and + "6-8" belong to chapter 10, the chapter the preceding range just + crossed into, never chapter 9. A naive fix that only special-cased + "the whole part is one crossing range" breaks exactly here. *) + ("cross-chapter then more verses (compound)", `Quick, + parses "Matthew 9:35-10:1,5a,6-8" "matthew|9:35-10:1,5a,6-8"); + (* The crossing is not the first comma-piece, and a normal same-chapter + range ("24-25a") sits between two others -- proves the crossing is + detected per-piece, not merely at a fixed position in the list. *) + ("cross-chapter mid-list", `Quick, + parses "2 Samuel 18:9-10,14b,24-25a,30-19:3" "samuel_2|18:9-10,14b,24-25a,30-19:3"); + (* Verse sub-letters survive on a chapter-crossing endpoint too. *) + ("cross-chapter with sub-verse letter", `Quick, + parses "Genesis 1:20-2:4a" "genesis|1:20-2:4a"); + (* A malformed crossing (no verse named after the second chapter) is + rejected, not silently misread. *) + ("rejects malformed cross-chapter", `Quick, rejects "Luke 1:5-2:"); (* Ordinals 3 and 4 must survive [split_book]'s leading-digit scan. Narrowing its '1'..'4' range to '1'..'3' passes every OTHER case in this suite silently, while seven real citations depend on it. A diff --git a/test/test_citation_coverage_of.ml b/test/test_citation_coverage_of.ml index 7badd96..af8d161 100644 --- a/test/test_citation_coverage_of.ml +++ b/test/test_citation_coverage_of.ml @@ -3,48 +3,46 @@ (* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the OF counterpart of test_citation_coverage.ml. That file's own header states the rule this one follows: "every citation the engine can emit must - parse". EF reaches zero unconverted citations; OF does not, and this - file is the honest record of exactly where it falls short, mirroring the - review's own explicit instruction -- "enumerate them rather than - lowering the bar to fit". + parse". At the time this file was first written, EF reached zero + unconverted citations and OF did not; this file was the honest record of + exactly where it fell short. - MEASURED, not estimated: before this fix wave, 259 of 730 citation - fields in `colitur readings --rite of 2026` (35%) printed unconverted -- - 345 distinct book spellings unregistered ({!Colitur_citation.Book}), - plus verse sub-letter markers ("11a") and a handful of single-chapter- - book misreads ({!Colitur_citation.Book.is_single_chapter}) the parser - could not read at all. All of that is now fixed at the source (book.ml's - own [of_lectionary_table] and parse.ml's own [verse_num]/[single_chapter] - additions), closing every genuinely fixable shape. What remains, walking - the FULL data/of/lectionary.sexp (not merely what one civil year happens - to observe -- a stricter check than EF's own year-walk, and the same one - tools/bootstrap_lectionary_of.ml's own generated header now runs and - discloses): 41 distinct references, out of 1542 total, EVERY ONE a - hyphenated range crossing a chapter boundary ("2:29-3:6", "1 John") -- - a {!Colitur_citation.Parse.t} shape [part]/[verse_range] do not - represent (a [part] is one chapter and a list of within-chapter ranges; - representing a cross-chapter range would need restructuring that type - and {!Colitur_citation.Render}'s own consumption of it), deliberately - not built this task. + MEASURED, not estimated: before fix wave I2, 259 of 730 citation fields + in `colitur readings --rite of 2026` (35%) printed unconverted -- 345 + distinct book spellings unregistered ({!Colitur_citation.Book}), plus + verse sub-letter markers ("11a") and a handful of single-chapter-book + misreads ({!Colitur_citation.Book.is_single_chapter}) the parser could + not read at all. All of that was fixed at the source (book.ml's own + [of_lectionary_table] and parse.ml's own [verse_num]/[single_chapter] + additions). What remained, walking the FULL data/of/lectionary.sexp (not + merely what one civil year happens to observe -- a stricter check than + EF's own year-walk, and the same one tools/bootstrap_lectionary_of.ml's + own generated header runs and discloses): a hyphenated verse range whose + two endpoints lie in different chapters ("2:29-3:6", "1 John") -- a + {!Colitur_citation.Parse.t} shape [part]/[verse_range] did not + represent. Fix wave I7 (2026-08-26) grew the population from 1542 to + 1725 citation fields (a new Second-reading extraction) and the residual + from 41 to 49 references, same shape, no new failure mode. - UPDATED, fix wave I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5, - 2026-08-26): {!Rite.t.citation_shapes} (kernel change) plus - tools/bootstrap_lectionary_of.ml's own [cite] now also extract a Second - reading on every Sunday-cycle (Sunday/solemnity) entry, so the total grew - 1542 -> 1725 (+183, exactly the count data/of/lectionary.sexp's own - header COVERAGE (5) now discloses) and the unparseable residual grew - 41 -> 49: the same chapter-crossing shape, now also hit by 8 of the 183 - new Second-reading references. Not a new failure MODE, just a bigger - population drawing from the same, already-understood one -- confirmed - below by [looks_chapter_crossing], unchanged, still holding for all 49. + CLOSED (W4, this task): {!Colitur_citation.Parse.verse_end} now lets a + range's [last] endpoint name its own, later chapter, and + {!Colitur_citation.Render}'s [one_range] renders it back through the + part's own [chapter_verse] template. Every one of the 49 pinned + references now parses and round-trips -- including the COMPOUND shape + ("Matthew 9:35-10:1,5a,6-8": a crossing range followed by further, + same-chapter verse references in the same comma list) and a crossing + that is not the list's first or only piece ("2 Samuel + 18:9-10,14b,24-25a,30-19:3"). The residual below is therefore now EMPTY, + over the full 1725-field population -- measured, not merely no longer + observed on a sample. - This file pins that residual EXACTLY -- not merely "at least these fail" - -- so it fails loudly in BOTH directions: a NEW unconverted reference - appearing (a regression) and one of these 49 starting to convert (this - list going stale, e.g. because a future task builds cross-chapter - support) are both caught, the same discipline every allow-list in this - project's suite already follows (`expected_rows`, `expected_bands`, the - litcal/missalemeum comparators). *) + The pinning discipline is kept rather than deleted: this file still + fails LOUDLY, in both directions, if the unconverted set ever stops + being empty -- a real regression (a genuinely new unparseable shape) and + a silently corrupted lectionary file are both caught by the same + mechanism every allow-list in this project's suite already follows + (`expected_rows`, `expected_bands`, the litcal/missalemeum + comparators). *) let lectionary_path = "../data/of/lectionary.sexp" @@ -53,25 +51,12 @@ let lectionary () = | Ok l -> l | Error e -> Alcotest.failf "%s: %s" lectionary_path e -(* The exact, pinned residual -- copied from data/of/lectionary.sexp's own - generated "Citations that do not parse" listing, itself produced by the - SAME {!Colitur_citation.Parse.parse} call this test makes, so the two - can never silently drift apart without one of them visibly failing to - regenerate/re-test clean. *) -let known_cross_chapter_residual = - [ "1 Corinthians 10:31-11:1"; "1 Corinthians 12:31-13:13"; "1 John 1:5-2:2"; "1 John 2:29-3:6"; - "1 John 3:22-4:6"; "1 John 4:19-5:4"; "1 Thessalonians 3:12-4:2"; "2 Corinthians 3:15-4:1,3-6"; - "2 Corinthians 4:13-5:1"; "2 Samuel 18:9-10,14b,24-25a,30-19:3"; "2 Thessalonians 1:11-2:2"; - "2 Thessalonians 2:16-3:5"; "2 Timothy 3:14-4:2"; "Colossians 1:24-2:3"; "Ecclesiastes 11:9-12:8"; - "Ephesians 4:30-5:2"; "Ephesians 4:32-5:8"; "Exodus 11:10-12:14"; "Exodus 14:21-15:1"; - "Ezekiel 2:8-3:4"; "Galatians 4:22-24,26-27,31-5:1"; "Genesis 1:1-2:2"; "Genesis 1:20-2:4a"; - "Habakkuk 1:12-2:4"; "Hebrews 7:25-8:6"; "Isaiah 52:13-53:12"; "Isaiah 8:23b-9:3"; - "James 3:16-4:3"; "John 15:26-16:4a"; "John 18:1-19:42"; "Jonah 1:1-2:2,11"; "Luke 7:36-8:3"; - "Malachi 1:14b-2:2b,8-10"; "Mark 2:23-3:6"; "Mark 8:34-9:1"; "Matthew 10:34-11:1"; - "Matthew 18:21-19:1"; "Matthew 9:35-10:1,5a,6-8"; "Matthew 9:36-10:8"; - "Numbers 13:1-2,25-14:1,26a-29a,34-35"; "Philippians 3:17-4:1"; "Revelation 20:1-4,11-21:2"; - "Sirach 27:30-28:7"; "The Acts 12:24-13:5a"; "The Acts 17:15,22-18:1"; "The Acts 7:51-8:1a"; - "Wisdom 11:22-12:2"; "Wisdom 2:23-3:9"; "Wisdom 7:22b-8:1" ] +(* The exact, pinned residual -- EMPTY since W4 closed the cross-chapter gap + (see this file's own header). Kept as a named binding, not inlined as + [[]] at each use site, so a future partial regression has one place to + list what it could not fix, exactly as the pre-W4 version of this file + did for the 49 references it once named here. *) +let known_cross_chapter_residual = [] (* Every failure this walk finds really is a chapter-crossing hyphen range (a "-" whose right side itself contains a ":") -- checked mechanically, @@ -107,8 +92,8 @@ let test_of_citations_convert_or_are_the_known_cross_chapter_residual () = Alcotest.(check int) "1725 citation fields in data/of/lectionary.sexp today -- if this changes, the \ pinned residual below may need updating too, not just this count" 1725 !total; - Alcotest.(check (list string)) "the unconverted set is EXACTLY the known cross-chapter residual, no \ - more and no fewer" + Alcotest.(check (list string)) "the unconverted set is EXACTLY the known cross-chapter residual (empty, \ + since W4) -- no more and no fewer" (List.sort compare known_cross_chapter_residual) bad; List.iter (fun r -> @@ -119,7 +104,9 @@ let test_of_citations_convert_or_are_the_known_cross_chapter_residual () = (* Everything OUTSIDE the pinned residual round-trips through Sigla, the same structural (not textual) comparison test_citation_coverage.ml's own - EF version makes, and for the identical reason its own header gives. *) + EF version makes, and for the identical reason its own header gives. + Since the residual is now empty, this exercises the FULL 1725-field + population, cross-chapter shapes included. *) let test_round_trip_outside_the_residual () = let sg = Colitur_citation.Sigla.make ~style:Colitur_citation.Render.default_style diff --git a/test/test_lectionary_of.ml b/test/test_lectionary_of.ml index 706ee15..dc02176 100644 --- a/test/test_lectionary_of.ml +++ b/test/test_lectionary_of.ml @@ -178,7 +178,15 @@ let sha256_of_file path = don't transcribe it from generator stdout" discipline test_calendar_of_data.ml's own [test_sha256] already follows. *) let test_sha256_pinned () = - (* Fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the + (* W4 (this task): header-only change, same discipline as I2 below -- + {!Colitur_citation.Parse.verse_end} closed the chapter-crossing gap, + so the residual the header's own COVERAGE (4) and the trailing + "Citations that do not parse" listing disclose went from 49 to 0. No + lectionary ENTRY changed (`git diff --stat` on this file shows only + comment lines). Re-derived with `sha256sum data/of/lectionary.sexp`, + same discipline. + + Fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the hash changed again -- ONE genuine new entry this time ("of-nativity", the Christmas Day Mass hand-authored from OLM 1981; see test_coverage_2026's own citation), not header-only as the I2 change @@ -193,7 +201,7 @@ let test_sha256_pinned () = and test_citation_coverage_of.ml), and the header's "Epistle" wording is fixed to "First". Re-derived the same way, same discipline. *) Alcotest.(check string) "data/of/lectionary.sexp SHA-256" - "0d82069e879d8312943dee543aabc23208265534fcf7edd85c3d9137c2f4581d" (sha256_of_file lectionary_path) + "788ddc6d2d783286b6e64882a5372ae213cd5462c887e68aa2bea22388cdb4f7" (sha256_of_file lectionary_path) (* lectio's OWN of-lectionary.ini SHA-256, pinned inside data/of/ lectionary.sexp's own provenance header (tools/bootstrap_lectionary_of diff --git a/tools/bootstrap_lectionary_of.ml b/tools/bootstrap_lectionary_of.ml index 83019b1..3ba54df 100644 --- a/tools/bootstrap_lectionary_of.ml +++ b/tools/bootstrap_lectionary_of.ml @@ -904,14 +904,17 @@ let temporal_day_gap ~lectionary ~year_start = -> {!Colitur_citation.Parse.parse} on a miss). Disclosed here, at generation time, rather than left for a reader to discover as a silent "- | -"-shaped surprise or an unconverted English fragment sitting next - to Latin ones -- this is what I2's own review finding asked for as the - minimum acceptable fix if full conversion could not be reached, and it - was not: see test/test_citation_coverage_of.ml for the pinned, exact - residual and book.ml's own citation for why it remains (chapter-crossing - hyphen ranges -- "2:29-3:6" -- a {!Colitur_citation.Parse.t} shape this - parser does not represent, deliberately not built this task; every OTHER - shape this file's data used to expose, including 345 previously- - unregistered book names, is now fixed at the source). *) + to Latin ones. + + W4 (this task): the residual I2/I7 found here -- a hyphenated verse + range whose two endpoints lie in different chapters ("2:29-3:6") -- is + now CLOSED. {!Colitur_citation.Parse.verse_end} lets a range's [last] + name its own, later chapter, and {!Colitur_citation.Render} renders it + back through the part's own [chapter_verse] template; the compound + shape (a crossing range followed by further same-chapter verses in the + same comma list, "Matthew 9:35-10:1,5a,6-8") is handled too. See + test/test_citation_coverage_of.ml for the (now empty) pinned residual, + still asserted exactly rather than merely "at least these fail". *) let citation_conversion_census lect = let total = ref 0 and bad = ref [] in List.iter @@ -1055,20 +1058,21 @@ pairs -> %d colitur slugs mapped, %d ini bases genuinely excluded (structurally\ ; ferial (Lectionary_of.readings' own step 2 -> step 3) -- this is\n\ ; OLM norms working as designed for a saint with no proper of\n\ ; their own, not a gap in this data.\n\ - ; (4) Citation-siglum conversion (fix wave I2, 2026-08-26 review): of\n\ - ; %d emitted (First, Second, Gospel) citation fields, %d distinct\n\ - ; references (out of the full %d) do not parse -- every one a\n\ - ; hyphen range that crosses a chapter boundary (\"2:29-3:6\"), a\n\ - ; {!Colitur_citation.Parse.t} shape this parser's [part]/\n\ - ; [verse_range] types do not represent, deliberately not built\n\ - ; this task (see book.ml's own [is_single_chapter] neighbourhood\n\ - ; for what WAS fixed: 345 previously-unregistered book names and\n\ - ; verse sub-letter markers, both closed at the parser/book-table\n\ - ; level, not here). Such a reference still prints, verbatim,\n\ - ; never a crash or a dropped citation -- {!Colitur_citation.Sigla\n\ - ; .format}'s own documented contract on a parse miss. Pinned\n\ - ; exactly, both directions, by test/test_citation_coverage_of.ml.\n\ - ; Named below.\n\ + ; (4) Citation-siglum conversion (fix wave I2, 2026-08-26 review;\n\ + ; CLOSED by W4): of %d emitted (First, Second, Gospel) citation\n\ + ; fields, %d distinct references (out of the full %d) do not\n\ + ; parse. I2/I7 left 49 unconverted here, every one a hyphen\n\ + ; range crossing a chapter boundary (\"2:29-3:6\"); W4 gave\n\ + ; {!Colitur_citation.Parse.t} a [verse_end] so a range's [last]\n\ + ; can name its own, later chapter (see book.ml's own\n\ + ; [is_single_chapter] neighbourhood for the other, earlier fix:\n\ + ; 345 previously-unregistered book names and verse sub-letter\n\ + ; markers, closed at the parser/book-table level). A reference\n\ + ; that still fails to parse would still print verbatim, never a\n\ + ; crash or a dropped citation -- {!Colitur_citation.Sigla\n\ + ; .format}'s own documented contract on a parse miss -- but none\n\ + ; does today. Pinned exactly, both directions, by\n\ + ; test/test_citation_coverage_of.ml. Named below (if any).\n\ ; (5) Second-reading coverage (fix wave I7, 2026-08-26): %d of the\n\ ; %d emitted citation fields above are a Second reading -- every\n\ ; one from a SUNDAY-CYCLE (-A/-B/-C, i.e. Sunday/solemnity)\n\ @@ -1092,8 +1096,7 @@ pairs -> %d colitur slugs mapped, %d ini bases genuinely excluded (structurally\ cit_total (List.length cit_bad) cit_total second_count cit_total src dst; Printf.fprintf oc "; Unmapped lectio bases (%d):\n" (List.length report.unmapped); List.iter (fun b -> Printf.fprintf oc "; %s\n" b) report.unmapped; - Printf.fprintf oc "; Citations that do not parse (%d distinct, all chapter-crossing ranges):\n" - (List.length cit_bad); + Printf.fprintf oc "; Citations that do not parse (%d distinct):\n" (List.length cit_bad); List.iter (fun r -> Printf.fprintf oc "; %s\n" r) cit_bad; Printf.fprintf oc "; Temporal_of slugs (2004-2051) with no lectionary entry (%d, informational --\n\ ; most are the Christmas-season/late-Advent date-vs-weekday gap named above):\n" -- cgit v1.3