diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:00:28 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:00:28 +0200 |
| commit | 3a46ec31b4b20fe58f1b24f0bb5b47af67ae99fa (patch) | |
| tree | fe9cfd83c621ff9c54748afcc8530798d612b6d7 /docs/superpowers/specs | |
| parent | 1b4134d883544beba3fc51704322cef2a875a7d2 (diff) | |
| download | lectio-3a46ec31b4b20fe58f1b24f0bb5b47af67ae99fa.tar.gz lectio-3a46ec31b4b20fe58f1b24f0bb5b47af67ae99fa.zip | |
docs: design for self-contained OF liturgical calendar engine (sub-project #1)
Offline, pure-Go, stdlib-only engine computing the Ordinary Form liturgical
day (season/rank/colour/observed + inline propers) from Computus + embedded
General Roman Calendar + Universal Norms precedence, replacing the scrape.
Locks the epic's formats (INI config + INI-section celebration records,
TSV corpora, JSON/iCal API) and six-layer architecture; 25-50yr longevity
mandate. Part of the self-contained-calendar epic.
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md b/docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md new file mode 100644 index 0000000..d7fd9e9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md @@ -0,0 +1,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. |
