summaryrefslogtreecommitdiff
path: root/lib/rites/rite_ef/lectionary_ef.ml
blob: 1fba3dea989406718f0cfef7ba5bb9b10791d604 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
open Colitur_kernel

(* The Commons of the 1962 Missal, plus the per-saint assignments that route
   a readingless class-3 feast to one. Data only -- every value in the
   shipped file is transcribed from the Missal and carries its own source
   citation there (data/ef/commons.sexp). This module knows the SHAPE and
   the invariants, never the values.

   Caller-supplied, exactly as [lectionary] is, and for the same reason
   (see this module's own .mli): a rite module that reads the filesystem as
   a side effect of being linked breaks every caller that touches none of
   its data. *)
module Commons = struct
  open Sexplib0.Sexp_conv

  (* [commons]: a Common's id -> the Epistle and Gospel PRINTED WITH the
     named Mass formulary. [assigned]: a saint's slug -> the Common his own
     day sends him to. Two tables, not one, because the same Common serves
     several saints and the two facts have different warrants -- the
     formulary is read from the Commune Sanctorum, the assignment from the
     saint's own date in the Proprium Sanctorum. *)
  type t = {
    commons : (Slug.t * Citation.t list) list;
    assigned : (Slug.t * Slug.t) list;
  }
  [@@deriving sexp]

  let empty = { commons = []; assigned = [] }
  let formularies t = t.commons
  let assignments t = t.assigned

  (* Same discipline as [Colitur_kernel.Lectionary.of_entries]: canonically
     sorted, and a duplicate key is an [Error] naming it rather than a
     silently-shadowed second answer. *)
  let sorted_by_slug xs = List.stable_sort (fun (a, _) (b, _) -> Slug.compare a b) xs

  let first_dup xs =
    let rec go = function
      | (a, _) :: ((b, _) :: _ as rest) -> if Slug.equal a b then Some a else go rest
      | _ -> None
    in
    go xs

  let of_tables ~commons ~assigned =
    let commons = sorted_by_slug commons and assigned = sorted_by_slug assigned in
    match first_dup commons with
    | Some s -> Error (Printf.sprintf "commons: duplicate common %S" (Slug.to_string s))
    | None -> (
        match first_dup assigned with
        | Some s -> Error (Printf.sprintf "commons: duplicate assignment for %S" (Slug.to_string s))
        | None -> (
            (* A formulary with no citations is indistinguishable at the call
               site from "this saint has no Common" -- [commons_for] would
               return [Some (common, [])] and [readings] would emit [] either
               way. That is exactly the silent hole this project does not
               allow, so it is rejected here where it is still nameable. *)
            match List.find_opt (fun (_, cs) -> cs = []) commons with
            | Some (s, _) ->
                Error (Printf.sprintf "commons: common %S has no citations" (Slug.to_string s))
            | None -> (
                (* An assignment pointing at a Common that does not exist
                   would otherwise degrade to [None] -- i.e. to "this saint
                   has no Common", the same answer as no assignment at all --
                   so a typo in the data file would be invisible. Named
                   loudly instead. *)
                match
                  List.find_opt (fun (_, common) -> not (List.mem_assoc common commons)) assigned
                with
                | Some (saint, common) ->
                    Error
                      (Printf.sprintf "commons: %S is assigned to unknown common %S"
                         (Slug.to_string saint) (Slug.to_string common))
                | None -> Ok { commons; assigned })))

  (* Returns the Common's own id ALONGSIDE its citations, not the citations
     alone: [readings]' step 4 needs the id to build the day's
     {!Colitur_kernel.Mass_formulary.t} ("the Common's own id as its slug"),
     and the id is only ever in scope here, at the point [common] is looked
     up -- re-deriving it afterwards would mean a second [assoc_opt] search
     over [t.assigned] for a value this function already held. *)
  let find t saint =
    match List.assoc_opt saint t.assigned with
    | None -> None
    | Some common -> (
        match List.assoc_opt common t.commons with
        | None -> None
        | Some cs -> Some (common, cs))

  (* Byte-for-byte the failure discipline of [Lectionary.load] (see its own
     comments for why each catch-all is placed where it is): every parse and
     validation failure comes back as [Error], never as an exception, and
     never at module-initialisation time. *)
  let load path =
    match Sexplib.Sexp.load_sexp path with
    | exception Sexplib.Sexp.Parse_error e ->
        Error (Printf.sprintf "commons: %s: %s" path e.err_msg)
    | exception Sys_error e -> Error (Printf.sprintf "commons: %s" e)
    | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
    | sexp -> (
        match t_of_sexp sexp with
        | exception Sexplib0.Sexp_conv_error.Of_sexp_error (exn, _) ->
            Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
        | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
        | parsed -> of_tables ~commons:parsed.commons ~assigned:parsed.assigned)
end

let commons_for ~commons saint = Commons.find commons saint

(* Step 1: the observed celebration's own proper.
   Step 4: the observed SAINT's assigned Common -- see the branch comment in
   [readings] for why it sits here, second, and not last.
   Step 2: the day's own temporal slug.
   Step 3: a weekday whose own slug has no entry says the preceding Sunday's
   Mass -- see the implementation comment on that branch in [readings] for
   the termination argument and why it is the Sunday's TEMPORAL, not
   observed, identity.

   The steps keep their original NUMBERS (the plan's, and every existing
   test's and comment's) even though step 4 now runs second: renumbering
   would silently invalidate every "step 3" reference already written down.
   Execution order is 1, 4, 2, 3.

   Nothing here encodes "Lent has daily propers": the presence of an entry in
   [lectionary] is the sole discriminator -- this function does not branch on
   season, rank, or any other field to decide whether a temporal slug "ought"
   to have its own Mass.

   The WARRANT for that shape is lectio's own observed behaviour only, not a
   confirmed Missal citation: Lent 1 Monday returns its own Ezech 34:11-16,
   while Advent, Christmas and post-Pentecost Mondays return their Sunday's
   Mass, in both streams. docs/research/rules-register.md records this
   openly as unconfirmed against the primary source ("EF reading-selection
   rules ... Have lectio's behaviour; confirm against the Missal's
   ferial-Mass rubrics when coding") -- that confirmation has not been done;
   do not read this comment as citing RG/the Missal for the SELECTION rule
   itself, only [Lectionary.find]'s presence-or-absence as the mechanism.
   Step 4 is the one step of the four that does NOT rest on that shape: it
   is step 1's own rule continued (the observed office's readings win over
   the day's temporal ones, wherever colitur happens to store them), and it
   is separately corroborated by a direct Missal citation. Its own comment
   gives both. *)
(* Days from a given weekday back to the preceding Sunday. Sunday itself
   yields 0, which is why step 3 must guard on it -- see [readings] below. *)
let days_since_sunday : Date.weekday -> int = function
  | Date.Sun -> 0
  | Date.Mon -> 1
  | Date.Tue -> 2
  | Date.Wed -> 3
  | Date.Thu -> 4
  | Date.Fri -> 5
  | Date.Sat -> 6

(* ---- The votive Mass of Our Lady on Saturday (RG 309(a)) ----

   RG 78 gives the OFFICE: "In sabbatis, in quibus occurrit Officium de feria
   IV classis, fit de sancta Maria in sabbato" -- and Temporal_ef has built
   that since the ef-bvm-saturday task, white and IV class. What it did NOT
   build is WHICH MASS is said, so the day observed Our Lady and then read the
   feria's own Epistle and Gospel: white, IV class, de sancta Maria, and
   Colossians on the parable of the weeds. That is test_oracle.ml's own M26
   shape 1.

   RG 309(a) (LT.txt:2445-2447) is the rule: "in Ecclesia universa, Missae quae
   pro sancta Maria in sabbato, IUXTA TEMPORUM DIVERSITATEM, in Missali
   assignantur" -- the Masses the Missal assigns according to the diversity of
   SEASONS. It prints five, each under its own season heading (the "Missae de
   sancta Maria in sabbato" section, scan1.txt:42982-43290):

     I    TEMPORE ADVENTUS
     II   A NATIVITATE DOMINI USQUE AD PURIFICATIONEM
     III  A DIE 3 FEBRUARII USQUE AD FERIAM IV HEBDOMADAE SANCTAE
     IV   TEMPORE PASCHALI
     V    A FESTO SS. TRINITATIS USQUE AD SABBATUM ANTE DOMINICAM I ADVENTUS

   THE MISSAL'S RANGES ARE NOT THIS RITE'S SEASONS, and that looked like the
   hard part: II ends at the Purification, mid-season; III runs from a civil
   date to a movable one, spanning four of the eight seasons. But measured
   against what a IV-class Saturday can actually BE, it collapses to a
   seasonal mapping plus one date test:

   - Mass II vs III splits at 2/3 February, inside Time_after_epiphany. That
     is the ONLY boundary needing a date, and it is a fixed civil one.
   - III's end (Feria IV of Holy Week) and IV's start (Paschaltide) leave the
     Triduum unassigned -- UNREACHABLE: Holy Saturday is I class, so no
     IV-class Saturday exists there.
   - IV's end and V's start (Trinity) leave Pentecost week unassigned --
     ALSO UNREACHABLE, and verified rather than assumed: colitur puts
     Pentecost week in Paschaltide and its Saturday is [ef-pentecost-ember-sat],
     an Ember Saturday, never IV class. Time_after_pentecost then begins
     exactly at Trinity Sunday, which is exactly where Mass V begins.

   So no Easter arithmetic is needed here at all. Both gaps are covered by
   dedicated tests rather than left to inference.

   CITATION WITNESSES, stated because they differ: Masses II-V were each
   confirmed twice, against the scan AND against a live missalemeum capture of
   2038 which names them ("II Mass of the B. V. M.", "III Mass...", and so
   on). Mass I rests on the SCAN ALONE -- no oracle year to hand has an Advent
   Saturday carrying this office, so its two citations have one witness where
   the others have two. *)
let bvm_saturday_citations season ~month ~day =
  let cite first gospel =
    [ { Citation.part = Citation.First; reference = first };
      { Citation.part = Citation.Gospel; reference = gospel } ]
  in
  match (season : Vocab_ef.season) with
  (* I -- Tempore Adventus. Isai. 7, 10-15 (scan1.txt:43008-43009) /
     Luc. 1, 26-38 (scan1.txt:42998-42999).

     STRUCTURALLY UNREACHABLE as the Saturday office, measured not assumed:
     zero IV-class Advent Saturdays across 2000-2100, because Advent has no
     IV-class ferias at all. [ferial_rank] gives Class2 from 17 December
     (RG 91 entry 18) and Class3 before it (entry 25), and RG 78's own
     protasis is "Officium de feria IV CLASSIS". So this branch can never
     fire on the universal calendar.

     It is kept, and deliberately: the Missal does print this Mass under
     "Tempore Adventus", because RG 309's own subject is Masses that "de
     beata Maria Virgine celebrari POSSUNT, TAMQUAM VOTIVAE" -- a votive Mass
     in Advent is a real thing, even though the Saturday OFFICE never falls
     there. Removing the branch would make the season mapping incomplete
     against its own source, and a diocesan overlay that ever produced a
     IV-class Advent Saturday would then silently fall through.

     This is also why Mass I has ONE witness where II-V have two: no oracle
     year can corroborate it, not because none was looked for, but because no
     such day exists to query. *)
  | Vocab_ef.Advent -> cite "Isa 7:10-15" "Luke 1:26-38"
  (* II -- A Nativitate Domini usque ad Purificationem. Tit. 3, 4-7
     (scan1.txt:43056-43057) / Luc. 2, 15-20 (scan1.txt:43096-43097). *)
  | Vocab_ef.Christmastide -> cite "Titus 3:4-7" "Luke 2:15-20"
  | Vocab_ef.Time_after_epiphany when month = 1 || (month = 2 && day <= 2) ->
      cite "Titus 3:4-7" "Luke 2:15-20"
  (* III -- A die 3 februarii usque ad feriam IV Hebdomadae Sanctae.
     Eccli. 24, 14-16 (scan1.txt:43121-43122) / Luc. 11, 27-28
     (scan1.txt:43165-43166). *)
  | Vocab_ef.Time_after_epiphany | Vocab_ef.Septuagesima | Vocab_ef.Lent | Vocab_ef.Passiontide ->
      cite "Ecclus 24:14-16" "Luke 11:27-28"
  (* IV -- Tempore paschali. Eccli. 24, 14-16 (scan1.txt:43177-43178) /
     Io. 19, 25-27 (scan1.txt:43202-43203). *)
  | Vocab_ef.Paschaltide -> cite "Ecclus 24:14-16" "John 19:25-27"
  (* V -- A festo Ss. Trinitatis usque ad sabbatum ante dominicam I Adventus.
     Eccli. 24, 14-16 (scan1.txt:43224-43226) / Luc. 11, 27-28
     (scan1.txt:43250-43252). *)
  | Vocab_ef.Time_after_pentecost -> cite "Ecclus 24:14-16" "Luke 11:27-28"

(* The office is identified STRUCTURALLY, never off the slug -- it deliberately
   reuses the ordinary ferial slug (ef-bvm-saturday task: a bespoke one would
   have broken Validate's own slug-uniqueness-per-year invariant, since the
   office recurs many times a year). Three conditions, all necessary: [Bvm]
   subject, IV class, and a Saturday. A Marian FEAST on a Saturday carries
   [Bvm] too, but has its own citations and so is answered by step 1 long
   before reaching here; the rank and weekday tests close the gap anyway. *)
let is_bvm_saturday_office (observed : Vocab_ef.rank Celebration.t)
    (temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) =
  observed.Celebration.subject = Subject.Bvm
  && observed.Celebration.rank = Vocab_ef.Class4
  && temporal.Temporal.weekday = Date.Sat

let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at =
  match observed.Celebration.citations with
  | _ :: _ as cs ->
      (Some { Mass_formulary.said = observed.Celebration.slug; via = Mass_formulary.Proper }, cs)
  | [] -> (
      (* Step 4: a saint who is the day's observed office and has no proper
         says his assigned Common. The assignment is explicit, never
         inferred -- see data/ef/commons.sexp.

         ORDER. This runs SECOND, before the temporal fallbacks, not last as
         the task brief sketched. The brief's ordering was tried first and is
         provably dead code: measured against the real resolver over
         1950-2200, EVERY day on which one of the fifteen readingless
         class-3 saints is actually the observed office also has a
         non-empty step-2 or step-3 answer waiting (a Septuagesima or
         Paschaltide feria resolves through its own slug or its preceding
         Sunday's), so a step 4 placed after them is never reached on any
         date in the domain. It would also be WRONG where it did fire: on
         2038-03-06 the observed office is Sts Perpetua and Felicity, a
         III-class feast that beat the feria, and the Mass said that day is
         theirs -- not Septuagesima II Saturday's 2 Cor 11:19-33 / Luke
         8:4-15, which is what the brief's ordering emits.

         WARRANT (1) -- INTERNAL, and it is the decisive one: step 4 is
         STEP 1'S CONTINUATION, not a fifth thing bolted after the temporal
         fallbacks. Step 1 already runs the observed office's own proper
         ahead of steps 2 and 3; step 4 is that same rule for the saints
         whose readings the Missal keeps in a Common instead of printing on
         the celebration. Placing it last would have made the chain
         internally inconsistent with code that already existed -- one saint
         (St Joseph, test_step1_wins_over_a_competing_step2_entry) beating a
         competing temporal entry because his readings happen to sit on his
         Celebration.t, and another (St Vincent Ferrer) losing to one
         because his sit one indirection away. Nothing in the rubrics draws
         that distinction; it is an artefact of where colitur stores the
         data. So the plan's ordering was not merely wrong on the data
         (below) -- it contradicted step 1.

         WARRANT (2) -- EXTERNAL, corroborating: the Missal itself, at each
         of these saints' own dates, names the Mass to be said --
         "Missa Cognovi, de Communi non Virginum II loco, praeter orationem
         sequentem" (9 March), "Missa Os iusti, de Communi Abbatum"
         (21 March), and so on. That is a direct instruction about what is
         read when the feast is the office of the day, quoted per saint in
         data/ef/commons.sexp. Steps 2 and 3, by contrast, rest only on
         lectio's observed behaviour (above). So the one step with a primary
         source outranks the two without -- which is also simply what the
         steps MEAN: steps 2 and 3 answer "what does this day's TEMPORAL
         office read", a question that only governs when the temporal office
         is the one being celebrated.

         THE GUARD makes that precondition structural rather than a property
         of the data file: the Commons are consulted only when the observed
         celebration is not itself the day's temporal office. Without it, a
         future overlay that assigned a Common to a temporal slug by mistake
         would silently replace a feria's Mass; with it, ferias, Sundays, the
         Triduum and the RG 78 Saturday Office of the BVM (whose observed
         celebration IS its temporal office, deliberately sharing the ferial
         slug) can never be diverted here at all.

         The guard is a NO-OP on the shipped data -- no assignment in
         data/ef/commons.sexp names a temporal slug -- which is exactly what
         makes it defensive rather than load-bearing, and exactly why
         nothing in the real data can exercise it. It is nevertheless
         tested, against a SYNTHETIC [Commons.t] built by [Commons.of_tables]
         that deliberately assigns a Common to a really-observed ferial slug:
         test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug. That
         test fails with this guard removed; without it the guard had no
         coverage at all (fix round 1, coordinator review -- the reviewer
         forced [sanctoral_office = true] and the whole suite stayed green).

         COULD THE GUARD WRONGLY EXCLUDE A REAL SAINT? Only if a sanctoral
         slug ever equalled the day's own temporal slug. It cannot today --
         every temporal slug [Temporal_ef] builds carries the "ef-" prefix
         and 0 of the 327 entries in data/ef/sanctoral.sexp do -- but that
         is an ASSUMPTION this codebase leans on, NOT an asserted invariant,
         and it should not be dressed up as one. (CORRECTED, fix round 1:
         this comment previously cited [Validate]'s slug-uniqueness check as
         the authority. That check compares [Temporal.office] slugs to each
         other across the days of one liturgical year -- it says nothing
         about collision BETWEEN the temporal and sanctoral streams.)
         [Validate] is candid about the same assumption where it makes it,
         for a different purpose -- recovering a commemoration's origin,
         validate.ml's own note: "This is exact whenever slugs cannot
         collide across the two streams (Task 12's own 'observed' check
         already assumes this for a different purpose), which is the same
         assumption the rest of this codebase already leans on."

         The failure mode if that assumption ever broke is benign and
         one-directional: a colliding saint would be denied his Common and
         fall through to steps 2/3, i.e. to the temporal Mass -- which is
         precisely the answer the unguarded chain would have given him
         anyway. No day gains a reading it should not have. *)
      let sanctoral_office =
        not (Slug.equal observed.Celebration.slug temporal.Temporal.office.Celebration.slug)
      in
      match
        if sanctoral_office then commons_for ~commons observed.Celebration.slug else None
      with
      | Some (common_id, cs) ->
          (Some { Mass_formulary.said = common_id; via = Mass_formulary.Common }, cs)
      | None -> (
          (* The votive Mass of Our Lady on Saturday (RG 309(a)) runs HERE:
             after the proper (step 1) and the Common (step 4), which answer
             for a saint who beat the feria, and BEFORE the temporal slug
             lookup -- which is exactly what used to answer, with the feria's
             own Mass, on a day whose office is Our Lady's. Placing it later
             would be dead code; placing it earlier would let it outrank a
             real saint's proper.

             FORMULARY PROVENANCE: [Votive], not [Own_slug] (CORRECTED, fix
             round 1, coordinator review -- the first pass tagged this
             [Own_slug] for lack of a better constructor and flagged it as a
             judgement call; [Mass_formulary.source] has grown a [Votive]
             case since, precisely for this branch). RG 431(e)
             (docs/research/LT.txt, verbatim): "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
             Angels on any day, and of the BVM WHICH ARE CELEBRATED ON
             SATURDAY: the Missal's own words classify this exact Mass as a
             "Missa votiva", not as the day's own office's Mass, corroborated
             by RG 309(a) (this branch's own header comment above, "iuxta
             temporum diversitatem") naming the FIVE seasonal Masses RG 431(e)
             is speaking of. Together: a votive Mass said IN PLACE of the
             day's own office's Mass, the office (RG 78, Officium sanctae
             Mariae in sabbato) being kept unchanged.

             CORRECTED, fix round 2 (coordinator review): fix round 1's own
             comment here claimed the Latin Mass Society Ordo witnesses
             [Votive] directly, printing "V Mass of BVM" with "V" read as an
             abbreviation for "votive". That was the coordinator's
             misreading, not this codebase's, and it was wrong: the Ordo's
             "I"-"V" are ROMAN NUMERALS naming WHICH of the Missal's five
             seasonal "Missae de sancta Maria in sabbato" (this branch's own
             header comment; RG 309(a)'s "iuxta temporum diversitatem") is
             said on a given Saturday -- not an abbreviation of anything, and
             not a marker of the Mass's class or kind. All five numerals
             occur through the Ordo (I x1, II x4, III x3, IV x3, V x8,
             counted across the whole document); "V Mass of BVM" merely also
             happens to be substring-matched by a naive search for "V Mass of
             BVM" inside "IV Mass of BVM", which is what produced the
             original false reading. [Votive] itself is UNAFFECTED by this
             correction and stays right, on RG 431(e) alone -- only the
             Ordo-witness claim is retracted, not softened, removed. A
             genuinely useful consequence survives the mistake: because the
             Ordo's own numeral names WHICH seasonal Mass is said, a future
             comparison against it (Task 6 of this plan) can validate
             [bvm_saturday_citations]'s own season-keyed SELECTION, not
             merely that some BVM Mass was chosen.

             [said] is UNCHANGED by this correction and stays the day's own
             temporal slug: [is_bvm_saturday_office] only ever fires when
             [sanctoral_office] above is false, i.e. the observed celebration
             already IS the day's own temporal office (the office
             deliberately reuses the ordinary ferial slug, [Temporal_ef]'s
             own [bvm_saturday_names]) -- only [via] needed correcting, the
             office/Mass split [Votive] exists to name. *)
          if is_bvm_saturday_office observed temporal then
            let said = temporal.Temporal.office.Celebration.slug in
            ( Some { Mass_formulary.said; via = Mass_formulary.Votive },
              bvm_saturday_citations temporal.Temporal.season ~month:(Date.month date)
                ~day:(Date.day date) )
          else
          match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with
          | Some cs ->
              ( Some
                  { Mass_formulary.said = temporal.Temporal.office.Celebration.slug;
                    via = Mass_formulary.Own_slug },
                cs )
          | None -> (
              (* Step 3: a feria with no proper of its own says the preceding
                 Sunday's Mass. WARRANT is the same as step 2's -- lectio's own
                 observed behaviour, not a confirmed Missal citation: this is the
                 rule lectio hard-codes as data on the four Advent ferias
                 (Advent II's readings copied verbatim onto the following
                 Monday-Saturday) and leaves absent on the other slugs this step
                 now also reaches; docs/research/rules-register.md already
                 records the ferial-Mass selection rule itself as unconfirmed
                 against the primary source.

                 Guarded on weekday, but NOT because a Sunday reaching this
                 branch would loop (fix round 1, coordinator review: the
                 original comment here claimed exactly that, and it was wrong).
                 [readings] is not recursive -- step 3's fallback is one flat
                 [Lectionary.find], never a re-entrant call into [readings] --
                 so without the guard, [days_since_sunday Sun = 0] would just
                 repeat the SAME [Lectionary.find] step 2 already ran and
                 already got [None] from (same pure inputs, same date), and
                 return [] once, normally. The chain as a whole terminates
                 because every step either consults data (a lookup) or, here,
                 a strictly EARLIER date via [temporal_at] -- no step ever calls
                 back into [readings] itself, so there is no recursion anywhere
                 in this function for a cycle to form in the first place. The
                 real reason for the guard is simpler: a Sunday has no
                 PRECEDING Sunday to resume -- consulting itself would be
                 meaningless (it would re-ask the question step 2 just
                 answered), not dangerous, so the guard exists to make that
                 intent explicit rather than to prevent a runaway loop that was
                 never actually possible.

                 The preceding Sunday's TEMPORAL slug, never its observed one:
                 the rubric is the preceding Sunday's Mass even in a year when a
                 feast displaced that Sunday from being observed (see
                 test_step3_uses_temporal_not_observed). [temporal_at] gives the
                 temporal identity of any date, so the Sunday is reached by date
                 arithmetic and a fresh call to the temporal cycle -- never by
                 string surgery on [own_slug]: the slug shapes are genuinely
                 inconsistent across seasons (e.g. [ef-advent-sunday-1] versus
                 [ef-advent-1-monday], the week number on opposite sides of the
                 season name), so deriving one from the other textually would be
                 a latent bug the moment a season's naming convention differs. *)
              let offset = days_since_sunday temporal.Temporal.weekday in
              if offset = 0 then (None, [])
              else
                let sunday = Date.add_days date (-offset) in
                let sunday_temporal = temporal_at sunday in
                match
                  Lectionary.find lectionary sunday_temporal.Temporal.office.Celebration.slug
                with
                | Some cs ->
                    let said = sunday_temporal.Temporal.office.Celebration.slug in
                    (Some { Mass_formulary.said; via = Mass_formulary.Preceding_sunday }, cs)
                | None -> (None, []))))