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
|
(** The rite-parameterised resolver: RG 91 says who wins, RG 92-95 says what
happens to the loser, RG 108-111 says how many commemorations are admitted.
Three separate rite-supplied functions, because the loser's fate depends on
the loser's own rank, not the winner's -- conflating them would resist
extension to a second rite. *)
(** Which of the day's two office streams a candidate came from. *)
type origin = Temporal | Sanctoral [@@deriving sexp]
(** RG 111: an admitted commemoration's own standing, distinct from its rank. *)
type privilege = Privileged | Ordinary [@@deriving sexp]
(** What becomes of a losing candidate. *)
type disposition =
| Omit (** yields with no trace in the day's celebration *)
| Commemorate of privilege (** kept as a commemoration of the observed day *)
| Transfer (** moved to the next free day (RG 92-95) *)
| Repose (** kept only in a votive/private sense; not commemorated today *)
[@@deriving sexp]
(** A celebration together with the office stream it was drawn from. Parameterised
by the rite's rank type only, matching {!Celebration.t}. *)
type 'r candidate = { cel : 'r Celebration.t; origin : origin } [@@deriving sexp]
(** The day a resolution is computed for. Parameterised by the rite's season
type only -- a context has no rank of its own. *)
type 's context = { date : Date.t; season : 's; weekday : Date.weekday }
(** The rite's three resolution functions. *)
type ('s, 'r) rules = {
band : 's context -> 'r candidate -> int;
(** RG 91: orders candidates for the day; lower wins. *)
disposition : winner:'r candidate -> loser:'r candidate -> disposition;
(** RG 92-95: the loser's fate, which depends on the loser's own rank. *)
admit :
observed:'r candidate ->
temporal:'r candidate ->
('r candidate * privilege * int) list ->
('r candidate * privilege) list;
(** RG 108-111: how many commemorations are admitted, and in what order;
anything filtered out here is recorded in {!resolution.omitted}, not
dropped.
Each input triple's trailing [int] is that candidate's own {!band}
value, computed once by {!resolve} (RG 113: "in admittendis et
ordinandis aliis commemorationibus, servetur ordo tabellae
praecedentiae" -- ADMITTING and ORDERING commemorations is governed
by the same table-of-precedence order {!band} already supplies for
picking the day's own winner; docs/research/rules-register.md §4
"Commemorations"). Supplied rather than left for [admit] to compute
itself because [admit] has no [context] (date/season/weekday) of
its own -- {!resolve} already holds one and calls {!band} with it
for every candidate regardless. A rite's [admit] is free to ignore
the value entirely (e.g. fall back to [Vocab.rank] alone), the same
as it may ignore [temporal] below.
[temporal] is {!resolve}'s own [~temporal] argument, passed through
unchanged -- the day's temporal-cycle candidate, regardless of
whether it won. Fix round 1 (RG16(a) task): before this, a rite's
[admit] could only infer properties of the CIVIL DAY (chiefly "is
this a Sunday", RG 111(b)'s own two-tier admission rule) from
[observed]'s own fields -- a proxy that breaks the moment something
OTHER than the day's own temporal candidate can be [observed], the
exact shape RG 16(a) introduces (a Feast of the Lord standing in
the impeded Sunday's place "cum omnibus iuribus et privilegiis",
RG 91 entry 14). This is NOT a kernel definition of "Sunday" --
the kernel does not gain any rite-specific knowledge by this
parameter, it only threads through a value {!resolve} already
holds; a rite's own [admit] is free to ignore it entirely, the
same as [observed].
OBLIGATION ON THE IMPLEMENTATION, not enforced by this type: every
candidate this function returns must be a value taken UNCHANGED
from its input list, never rebuilt (e.g. via a [{ c with ... }]
record update, even one that copies every field back unchanged).
{!resolve}'s own [omitted] accounting distinguishes an admitted
candidate from a dropped one by PHYSICAL equality ([==]) on the
candidate value, not structural equality -- a rebuilt record is
[=] to the original but not [==], so {!resolve} cannot match the
rebuilt copy against the original it was given. The celebration
then surfaces TWICE in the same day's result -- once in
{!resolution.commemorations} (the rebuilt copy, admitted) and once
in {!resolution.omitted} (the original, which nothing in the
admitted set matches). One admission, double-reported, silently
rather than raising. This obligation previously lived only in one rite's
own module documentation (Rite_ef.Precedence_ef.admit); stated
here because this signature -- not any one rite's implementation
of it -- is what an author of the next rite reads. *)
vigil_feast : 'r candidate -> Slug.t option;
(** The feast this candidate is a VIGIL OF, when the rite subjects that
vigil to omission because its feast did not keep its own day;
[None] for every other candidate, which is what a rite with no such
rule returns unconditionally.
Exists for RG 33's third omission trigger -- "vel si festum cui
praemittitur in alium diem transferri aut ad commemorationem reduci
contingat", "or if the feast it precedes happens to be transferred
to another day or reduced to a commemoration". Both halves of that
clause reduce to ONE observable question, which is why this hook
returns a slug rather than a verdict: is the named feast the
OBSERVED office on the following day? {!Calendar} asks it and
suppresses the vigil when the answer is no. A feast transferred away
(RG 96) and a feast outranked into a bare commemoration (RG 94) both
fail that test; so does a feast omitted outright, which RG 33 does
not enumerate but which is strictly the stronger case.
WHY THE RITE NAMES THE FEAST. The kernel could not infer it. RG 34
fixes the vigil on the day BEFORE its feast, so the date is known,
but nothing in {!Celebration.t} links the two and the slugs do not
reliably derive from one another -- in the EF's own shipped data
only two of five vigils ("ef-ascension-vigil"/"ef-ascension",
"vigil-of-sts-peter-paul"/"sts-peter-paul") share a stem, while
"vigil-of-st-lawrence" precedes "lawrence" and
"vigil-of-the-assumption" precedes
"assumption-of-the-blessed-virgin-mary". Deriving the feast by
string surgery would be wrong for three of the five. Asking "is a
Class1 sanctoral office observed tomorrow?" would be a PROXY, and
would fire on a day where some UNRELATED I-class feast had
transferred in on top of the real one -- the vigil's feast would be
absent and the vigil wrongly kept.
CALLED ONCE PER CANDIDATE PER DAY, and the resolution of the
following day that {!Calendar} performs to answer it does NOT
itself apply this rule. That is not an approximation: a vigil is a
candidate only on its own day, never on its feast's, so suppressing
it cannot change what is observed the day after. The check is
therefore a single pass with no fixed point and no recursion --
unlike RG 96's transfers, which genuinely need one. *)
}
(** The outcome of resolving one day's candidates. *)
type 'r resolution = {
observed : 'r candidate;
commemorations : ('r candidate * privilege) list;
deferred : 'r candidate list;
omitted : ('r candidate * string) list; (** each with a reason *)
}
(** Total: the temporal candidate is passed separately, so there is no
empty-candidate case. Ties break on slug, so the result never depends on
input order. A [Commemoration_only] celebration is held out of the contest
and can never be [observed]. Every input candidate appears exactly once in
[observed], [commemorations], [deferred] or [omitted] — nothing is dropped
silently. *)
val resolve :
('s, 'r) rules -> 's context -> temporal:'r candidate ->
sanctoral:'r candidate list -> 'r resolution
(** RG 111(a) (EF; docs/research/rules-register.md; LT.txt, grep "Ratio
admittendi commemorationes"): {i "in diebus liturgicis I classis et in
Missis in cantu non conventualibus, nulla admittitur commemoratio,
praeter unam privilegiatam"} -- on a liturgical day of the first class,
AND at any non-conventual SUNG Mass regardless of the day's own class,
at most ONE commemoration is admitted, and only if it is privileged.
(b)/(c)/(d), immediately following in the same rubric, give the wider
caps -- two ordinary/privileged commemorations depending on class --
that apply everywhere else; {!rules.admit} already computes exactly
that wider set, which is why this function's own INPUT is the admitted
LOW-MASS list, not a fresh resolution: (a) is not a distinct admission
RULE with its own candidate-ranking logic, it is a narrower CAP applied
afterwards to the identical admitted, precedence-ordered list -- "at
most one, and it must be privileged" is exactly "keep the first
admitted entry, if any, that is privileged", nothing else in the list
can ever outrank it (RG 113: admission order already follows the
rite's own table of precedence, {!rules.admit}'s own citation).
Deliberately a pure post-hoc DERIVATION over
{!Liturgical_day.t.commemorations}, not a second stored field: the
input list is already validated (privilege-tagged, admission-capped);
this function adds no new information and can regress in no way the
input list itself could not already regress, so a second field would
only create a second place for the same fact to drift out of sync with
the first -- the identical reasoning {!Liturgical_day.t.temporal}'s own
"embedded, not flattened" comment gives for a different field. Exposed
here, at the kernel level, rather than left for an output layer to
reimplement: the filter is small but the RULE it encodes (RG 111(a))
is not obvious from the type alone, and a caller (the [rubrics] CLI
column, a future template, a differential test) should name the
rubric, not re-derive "privileged commemorations, capped at one" for
itself.
SCOPE: "non conventualibus" is read literally -- this models the
ordinary (non-conventual) sung Mass only. RG 111(a)'s own text implies
a CONVENTUAL sung Mass keeps the wider (b)/(c)/(d) caps even when sung
(a choir-obligation distinction), but this engine has no concept of
"conventual" at all (no community/choir dimension anywhere in
{!Celebration.t} or {!Liturgical_day.t}), so this function's result
should be read as "what a normal parish/private Low OR sung Mass
admits", never "what every sung Mass, everywhere, admits" -- a
documented scope limit, not an oversight. *)
val sung_mass_commemorations : ('a * privilege) list -> ('a * privilege) list
|