# 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.` 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.