aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/specs/2026-07-27-lectio-calendar-engine-design.md296
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.