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
|
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 []] 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 })))
let find t saint =
match List.assoc_opt saint t.assigned with
| None -> None
| Some common -> List.assoc_opt common t.commons
(* 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
let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at =
match observed.Celebration.citations with
| _ :: _ as cs -> 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 cs -> cs
| None -> (
match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with
| Some cs -> 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 []
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 -> cs
| None -> [])))
|