summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md
blob: d7fd9e9e88acfbda0325fba7e58bda3f97b38cbc (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
# lectio Liturgical Calendar Engine — Design (Sub-project #1)

**Status:** design, awaiting review
**Date:** 2026-07-27
**Supersedes scraping for calendar computation; part of the self-contained-calendar epic.**

## Goal

Make lectio compute the liturgical day **offline, from first principles**, instead of
scraping niedziela.pl / missalemeum. This document specifies **sub-project #1: the
Ordinary Form (OF) calendar engine** — given a Gregorian date, produce the day's
liturgical identity (season, celebration(s), rank, colour, the observed celebration)
plus any *inline* proper readings. It also fixes the **data formats** and **package
architecture** that the whole epic builds on.

## Longevity mandate (global constraints)

Every decision below serves a 25–50 year lifespan. These are binding:

- **Language:** Go. The core engine uses **stdlib only, zero third-party dependencies.**
- **No network in the core.** The engine is pure and total: `(date, selection, layers) → day`.
- **Durable, hand-editable formats only:** **INI** (config + all calendar/celebration data),
  **TSV** (scripture corpora only), **JSON + iCalendar** (API output only, later phase).
  No TOML, no YAML.
- **Unix philosophy:** one concern per unit; the engine emits data, never renders; front-ends
  and API are thin, disposable consumers; compose via text.
- **Data is the crown jewel:** authoritative, owned, plain-text, outlives any code.

## Where #1 fits (the epic)

Four linked sub-projects, one-way dependency flow:

```
INPUTS →  DATA (embedded base calendar, corpora)   [#1 base, #3 lectionary]
          CONFIG (INI, app behavior)               [#1]
          CUSTOMIZATION (INI layer files, stacked)  [#2]
              │
CORE     →  LOGIC / ENGINE (pure Go, stdlib)         [#1 calendar, #3 readings]
              │  emits LiturgicalDay
CONSUMERS→  UI (cli/tui/web)   API (json/ical)        [migration, #4]
```

1. **Calendar engine** (this doc) — day identity. FOUNDATION.
2. **Customization** — load user INI layer files, ordered-stack merge (`use = …`), authoring CLI.
3. **Lectionary** — the temporal reading cycle (Sundays A/B/C, weekdays I/II) + full
   reading resolution.
4. **API** — expose calendars as JSON + iCal.

## Sub-project #1 scope

**In:**
- The pure **calendar engine**: Computus → temporal cycle → sanctoral → precedence → `LiturgicalDay`.
- The **embedded universal General Roman Calendar** data (the base layer).
- The **INI data formats**: config, and the celebration-record schema (incl. reading fields).
- The **layer-merge mechanism** in the engine (accepts an ordered `[]Layer`), unit-tested with
  synthetic layers; #1 ships only the embedded universal layer.
- **Config layer**: TOML→INI migration + calendar-selection keys.
- A **CLI surface** to compute and print a day: `lectio calendar [DATE]`.
- **Validation**: regression oracle + unit tests proving correctness before we trust it offline.

**Out (deferred, but interfaces designed so they slot in):**
- Loading user layer files + `use =` composition + `calendar new`/`calendar check` → **#2**.
- Temporal reading cycle (Sundays A/B/C, weekday cycle I/II) + reading-vs-ferial resolution → **#3**.
- JSON / iCal API → **#4**.
- Migrating the TUI/web daily view off the scraper (the scraper stays, quarantined, until then).
- The Extraordinary Form (1962) engine — same shape, later; `selection.form` reserves the axis.
- Copyrighted `bt` text — skipped; readings render from embedded public-domain corpora.

## Data formats

### Config — INI (`internal/config`, migrated from TOML)

Flat `key = value`, `[sections]` only where grouping helps. New section:

```ini
[calendar]
lectionary = new          ; new (OF) | old (EF, later)
epiphany   = fixed         ; fixed (Jan 6) | sunday   — national placement knob
ascension  = thursday      ; thursday | sunday
corpus_christi = thursday   ; thursday | sunday
```

Existing keys (ui_language, sigla_style, versions, display, web_*, width, pager, offline,
default_version, …) migrate 1:1 into INI. **Migration:** on startup, if `config.ini` is absent
but `config.toml` exists, auto-convert once (read the old TOML, write `config.ini`); thereafter
INI is authoritative. The TOML reader is retained only for this one-shot conversion and removed in
a later cleanup. (`use =`, the customization stack, is introduced in #2 — not in #1's schema.)

### Celebration records — INI sections (the base calendar AND, later, override layers)

One `[slug]` per celebration; `[slug/variant]` for extra Masses. Used by the embedded base
calendar now and by user override files in #2 — same format so overrides layer cleanly and
dioceses copy universal entries as templates.

```ini
[layer]                    ; layer header (metadata for the file)
id   = universal
name = General Roman Calendar
type = universal

[assumption]
date    = 08-15            ; fixed MM-DD, or a movable expression (see DateSpec)
rank    = solemnity        ; solemnity | feast | memorial | optional | (ferial is implicit)
class   = bvm              ; lord | bvm | saint  — drives precedence within a rank
colour  = white            ; white | red | green | violet | rose | black
name.en = Assumption of the Blessed Virgin Mary
name.pl = Wniebowzięcie Najświętszej Maryi Panny
name.la = In Assumptione Beatae Mariae Virginis
reading.first  = Ap 11,19; 12,1-6.10   ; proper readings — citations only (see Readings)
reading.psalm  = Ps 45,10-16
reading.second = 1 Kor 15,20-26
reading.gospel = Łk 1,39-56

[assumption/vigil]         ; a second Mass formulary
reading.first  = 1 Krn 15,3-4.15-16; 16,1-2
reading.gospel = Łk 11,27-28
```

Override operations (semantics defined here; **loading is #2**): a `[slug]` in a higher layer
**merges over** the same slug below (field-level; unlisted fields inherit); a new slug **adds**;
`suppress = true` removes; `date =` moves; `rank =`/`class =` re-rank. Movable-relative
sanctoral (e.g. a diocesan patron on a Sunday) use a DateSpec.

**DateSpec grammar** (minimal for #1): `MM-DD` (fixed); `easter±N`; `advent-sunday-N`;
`sunday-after MM-DD`; `christmas±N`. Most base sanctoral are `MM-DD`; the movable **temporal**
solemnities (Easter, Ascension, Pentecost, Trinity, Corpus Christi, Sacred Heart, Christ the King,
Baptism, Holy Family) are computed in `temporal.go`, not stored as data.

### Readings — citations, not text

`reading.<part>` values are **scripture citations** in the config sigla dialect. They are resolved
against the **embedded corpora** via the existing `bible.BookTable.ParseRef` → `bible.Verses`
machinery (the `--ref` path). Citations are references (not copyrightable); text renders from
public-domain Wujek/Vulgate/Douay/Greek. Parts: `first, psalm, second, acclamation, gospel`
(reusing the established PartID vocabulary). **#1 surfaces inline propers when present**; it does
not compute ferial/Sunday readings (that's #3). Note: romcal supplies the *calendar* but not
reading citations, so populating universal proper readings is incremental — major solemnities in
#1, the rest with the lectionary in #3.

### Corpora — TSV (unchanged)

Scripture stays TSV (`internal/bible`). Genuinely tabular, already embedded, already proven.

## The engine (LOGIC)

Pure, stdlib-only, total. Package `internal/calendar` holds both the vocabulary types and the
computation; `internal/caldata` embeds+parses the base data into `calendar.Layer`.

### Types (the boundary contract — UI/API depend only on these)

```
type Rank int        // ferial < optional < memorial < feast < solemnity
type Class int       // saint < bvm < lord   (tiebreak within a rank)
type Colour string   // white,red,green,violet,rose,black
type Season string   // advent,christmas,ordinary,lent,triduum,easter

type Reading struct { Part, Citation string }
type Mass    struct { Variant string; Readings []Reading }

type Celebration struct {
    Slug   string
    Name   map[string]string   // lang → name
    Rank   Rank
    Class  Class
    Colour Colour
    Date   DateSpec
    Masses []Mass              // proper readings, if any
    Layer  string             // provenance: which layer contributed it
}

type Layer struct {           // one calendar layer (base or, later, an override file)
    ID, Name, Type string
    Cels map[string]Celebration
}

type Selection struct {       // from config
    Form           string     // "new" (OF). "old" reserved for the EF engine.
    Epiphany       string     // "fixed" | "sunday"
    Ascension      string     // "thursday" | "sunday"
    CorpusChristi  string     // "thursday" | "sunday"
}

type LiturgicalDay struct {
    Date     time.Time
    Season   Season
    Week     int              // week-within-season (for the lectionary phase)
    Weekday  time.Weekday
    Observed Celebration      // the winner after precedence
    Others   []Celebration    // commemorations / optional memorials also available today
    Colour   Colour
    SundayCycle  string       // "A"|"B"|"C"  (computed; used by #3)
    WeekdayCycle string       // "I"|"II"    (computed; used by #3)
}

func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay
```

### Algorithm (`Compute`)

1. **Merge** `layers` in order (later wins, field-level) → the resolved celebration set.
2. **Computus** (`computus.go`): Gregorian Easter via the Anonymous Gregorian algorithm
   (Meeus/Jones/Butcher). Deterministic, ~15 lines.
3. **Temporal** (`temporal.go`): from Easter + the Advent anchor (4th Sunday before Dec 25),
   derive the season, week, weekday, and the movable solemnities/feasts, honoring `Selection`'s
   placement knobs. Produce the day's *temporal* candidate with its own rank.
4. **Sanctoral** (`sanctoral.go`): resolve each celebration's `DateSpec` for this year; collect
   any landing on `date`.
5. **Precedence** (`precedence.go`): map every candidate (temporal + sanctoral) to its position in
   the **Table of Liturgical Days** (Universal Norms, 1969) using (Rank, Class, scope,
   season-context); pick the highest → `Observed`; the rest → `Others` (commemorations / optional).
   Apply **transfer of impeded solemnities** (e.g. Annunciation / St Joseph in Holy Week → after
   the Easter octave; a solemnity on a privileged Sunday → following Monday).
6. **Colour/season** from the observed celebration (falling back to the season colour).

`Compute` returns a fully-populated `LiturgicalDay`. It never errors (any valid Gregorian date
yields a day; pre-1970 applies modern rules retroactively — documented).

## Package layout (the six layers → Go packages)

| Concern | Package | Notes |
|---|---|---|
| shared INI reader | `internal/ini` **(new)** | tiny zero-dep reader/writer; used by config + caldata (+ overrides in #2) |
| **logic** | `internal/calendar` **(new)** | types + `Compute`; **stdlib only** |
| **data** | `internal/caldata` **(new)** | `//go:embed roman-calendar.ini`; parse → `calendar.Layer`; imports `calendar` for types |
| **config** | `internal/config` (modify) | TOML→INI migration; `[calendar]` selection keys |
| corpora | `internal/bible` (reuse) | TSV; citation resolution for inline propers |
| CLI surface | `internal/cli` (add) | `lectio calendar [DATE]` demo/validation command |
| (scraper) | `internal/liturgy`, `internal/tradlit` | **unchanged, quarantined**; still the default readings source until the migration |

Dependency arrows point one way: `caldata → calendar → (stdlib)`; `config → ini`; `cli → calendar,
caldata, config, bible`. No cycles. The pure `calendar` package is the 25–50 year artifact.

## Base calendar data (source)

Bootstrap `roman-calendar.ini` from **romcal's MIT-licensed** General Roman Calendar data
(name/rank/class/colour per celebration), **verify against the official General Roman Calendar**,
and commit the result as **our owned data** (romcal credited in `NOTICE`). The regression oracle
(below) catches conversion errors. Proper-reading citations are added incrementally (major
solemnities in #1).

## CLI surface for #1

```
lectio calendar [DATE]     # print the computed liturgical day for DATE (default: today)
                           #   season, observed celebration (name/rank/colour), commemorations,
                           #   and inline proper readings if the celebration has them
  --lectionary new|old     # override config (old = EF, not yet implemented → clear error)
```

This is the demo + manual-validation surface. The daily-readings view keeps using the scraper
until a later migration, so nothing user-facing breaks.

## Validation & testing

Proving the algorithm is the point of #1 — we must trust it before it replaces the scraper.

- **Unit tests:** known Gregorian Easter dates (published table, ~1990–2050); season boundaries
  (Advent I, Ash Wednesday, Pentecost, Christ the King, Baptism); fixed solemnities; a set of
  hand-picked collision/transfer cases (Annunciation in Holy Week; a memorial on a Lenten weekday
  becoming optional; a solemnity on a Sunday of Ordinary Time).
- **Merge tests:** synthetic layers exercising add / field-override / move / re-rank / suppress.
- **Regression oracle:** compare `Compute` output structurally (season, observed slug↔identity,
  rank, colour — ignoring wording/localization) against an authoritative implementation
  (**calapi.inadiutorium.cz** / calendarium-romanum) across **2020–2040**. A one-time fetch script
  (outside the test run — no network in tests) snapshots the oracle to
  `internal/calendar/testdata/oracle-2020-2040.json`; the test diffs against it.
- **Secondary check:** compare against the user's existing cached scraped `DayInfo` where present.

Target: zero structural diffs against the oracle over the range (documented, explained exceptions
only — e.g. genuine national-vs-universal differences, which the oracle config must match).

## Error handling

- `Compute` is total — no error path for valid dates.
- **Data load** (`caldata`): malformed embedded INI is a programmer error → fail fast at
  init/startup with a precise message; covered by tests so it never ships.
- **Config**: invalid INI → clear message, fall back to documented defaults where safe
  (never silently wrong).
- **Citations** in propers that fail to resolve → surfaced like the existing `--ref` "(no
  reference)" path, never a crash.

## Open questions / risks

- **Transfer rules** are the fiddliest part (impeded solemnities, Advent/Lent interactions). The
  oracle is the safety net; expect iteration here. (Confidence 8/10 overall; ~6/10 that the first
  cut of transfers is complete.)
- **Proper-reading data coverage** in #1 is partial by design (major feasts); full coverage lands
  with the lectionary in #3. User-visible readings from the engine are therefore limited until #3.
- **calapi/romcal national settings** must be matched to the universal base for a clean diff
  (their defaults may assume a nation).

## Non-goals

Restating for clarity: no reading *cycle* (#3), no user layer *loading* (#2), no API (#4), no EF
engine yet, no `bt`, no UI migration in #1. #1 delivers a **trustworthy, offline, pure-Go engine
that computes the universal OF day**, with the formats and interfaces the rest of the epic needs.