aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-27-lectio-ef-calendar-engine-design.md
blob: 8736a90b28fa121a9c028523191e0a365e974583 (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
# lectio EF (1962) Liturgical Calendar Engine — Design

**Status:** design, awaiting review
**Date:** 2026-07-27
**Part of the self-contained-calendar epic. Follows #1 (OF engine) and #2 (custom layers); prioritized ahead of the OF-readings and API sub-projects because the tool must be universal (both forms).**

## Goal

Make lectio compute the **1962 (Extraordinary Form / traditional) liturgical day** offline, so the traditional calendar no longer depends on the missalemeum scrape. Done **form-aware inside the existing engine**: `Compute(date, sel, layers)` branches on `sel.Form` — `"new"` → OF (shipped), `"old"` → EF (new). This is a universal tool; it must support both forms.

Scope of this sub-project = EF **day-identity** (season, celebration, rank, colour, observed + commemorations). EF **propers/readings** (the 1-year lectionary) are a later phase, mirroring how OF split #1 (calendar) from #3-readings; the trad scraper keeps serving EF readings until then.

## Longevity mandate (global constraints)

- `internal/calendar` stays **stdlib-only**; `Compute` is pure and total.
- No network in the core; offline computation.
- INI data + TSV corpora only; JSON/iCal for API output (later).
- Data is owned plain text; reuse existing rules/data (Divinum Officium), don't reinvent.

## Architecture — one form-aware engine (option A, chosen)

`Compute` selects the temporal + precedence path by `sel.Form`:

```
Compute(date, sel, layers):
  merged := mergeLayers(layers)          # SHARED
  if sel.Form == "old":
     td := temporalEF(date)              # NEW: 1962 temporal cycle
     ... EF candidates + precedenceEF()  # NEW: 1960 rubrics
  else:
     td := temporal(date, sel)           # existing OF
     ... precedence()                    # existing OF
  -> LiturgicalDay                        # SHARED result type
```

**Shared (unchanged):** Computus (`Easter`), `mergeLayers`, `DateSpec`/`resolveDate`, the INI data format, `LiturgicalDay`, and the whole custom-layer stack (#2) — so a diocese/order layer works for EF exactly as for OF.

**New, EF-specific:** `temporalEF`, `precedenceEF`, the EF rank vocabulary, and the embedded EF sanctoral data.

## Type model

### Rank: int enum → unified string token (prerequisite refactor)

`Rank` becomes `type Rank string`. Both vocabularies coexist; each form's precedence interprets its own:

- **OF:** `solemnity`, `feast`, `memorial`, `optional`, `ferial` (unchanged values, now strings).
- **EF (1960 Code of Rubrics):** `class-1`, `class-2`, `class-3`, `class-4`, `commemoration`, `ferial`. (I–IV class + commemoration — the 1962 scheme; matches the missalemeum oracle's numeric `rank: 1–4`. NOT the pre-1960 Double/Semidouble/Simple system.)

Ordered comparisons that today use `<` (e.g. `cel.Rank < RankSolemnity` in OF Compute filters) become an explicit **per-form ordering helper** `ofRankOrder(Rank) int` / `efRankOrder(Rank) int`. `ParseRank` becomes a passthrough/validator; `rankLabel` (CLI display) becomes form-aware.

This refactors shipped OF code at ~5 comparison sites + `precedence.go` + `temporal.go` constructors + `caldata` parsing + tests. It is **guarded by re-running the OF oracle** (0 season mismatches must hold).

### Season: superset string (no refactor)

`Season` is already `type Season string`. Add EF constants, sharing advent/christmas/lent/easter/triduum:
`septuagesima`, `passiontide`, `time-after-epiphany`, `time-after-pentecost`, plus octave handling. OF code emits OF seasons; EF code emits EF (+shared) seasons.

### Colour / Class

`Colour` (white/red/green/violet/rose/black) already covers EF. `Class` stays OF-only; EF precedence reads its own nature tags (feria/vigil/octave/feast-of-lord…) from the data + temporal.

## The EF (1962) temporal cycle (`temporalEF`)

Derived from Computus + the Advent anchor (reuses `adventStart`). Seasons and their movable structure:

- **Advent** — 4 Sundays (shared start).
- **Christmastide** — Christmas + octave (Dec 25–Jan 1), Circumcision (Jan 1), Most Holy Name (Sun Jan 2–5, else Jan 2), Epiphany (Jan 6) + octave (to Jan 13), Holy Family (Sun in the octave).
- **Time after Epiphany** — up to 6 Sundays; the surplus are *resumed* before the last Sunday after Pentecost.
- **Pre-Lent** — Septuagesima (`easter-63`), Sexagesima, Quinquagesima (violet, no Alleluia).
- **Lent** — Ash Wednesday (`easter-46`) → Sundays I–IV.
- **Passiontide** — Passion Sunday (`easter-14`), Palm Sunday (`easter-7`).
- **Holy Week / Triduum**.
- **Paschaltide** — Easter + octave, Low Sunday (`easter+7`), Sundays II–V after Easter, Rogation days, Ascension (`easter+39`) + octave, Sunday after the Ascension, Pentecost (`easter+49`) + octave (Ember days).
- **Time after Pentecost** — Trinity (`easter+56`), Corpus Christi (Thu, `easter+60`), Sacred Heart (Fri, `easter+68`); Sundays 2nd–24th; **resumed Time-after-Epiphany Sundays** inserted before the Last Sunday when the year has >24 such weeks. **Christ the King = last Sunday of October** (EF rule — *different* from OF's last-Sunday-before-Advent).

Ferias, vigils, and octave days are temporal celebrations with their own EF rank (I–IV class feria, etc.).

## The 1960 precedence (`precedenceEF`)

Implements the *Rubricarum instructum* (1960 Code of Rubrics) **occurrence** rules: when two celebrations fall on one day, the higher class is observed; the other is commemorated, omitted, or transferred per the table (I-class feasts transfer when impeded; a III-class feast on a privileged feria is commemorated; etc.). Concurrence (Vespers ranking) is out of scope — we compute the **Mass day identity**. The engine returns the observed celebration + the commemorations (`LiturgicalDay.Others`).

## Data — embedded 1962 sanctoral

`internal/caldata` gains `//go:embed tridentine-calendar.ini` and `Tridentine() calendar.Layer` (the EF base). Bootstrapped from the 1962 General Roman Calendar via **Divinum Officium** data (through the **Missal1962** / **tridentine_calendar** ports); credited in NOTICE. Entries carry EF ranks (`class-1…4`), fixed dates, colours, names — the EF sanctoral differs from OF in dates, ranks, and membership. Initial subset ships; full population is oracle-gated (like #1).

`caldata.Stack` becomes form-aware: `Stack(form, dir, use)` returns `[Base(form)] + user layers`, where `Base("old") = Tridentine()`, else `Universal()`. Custom user layers (#2) stack over whichever base the form selects.

## CLI

The `--liturgy` EF guard (added earlier) is **removed**: `sel.Form == "old"` now computes the EF day. `rankLabel` shows EF ranks. Everything else (`--cal-new`/`--cal-check`, the `use` stack) already works for both forms.

## Validation — the missalemeum oracle

`scripts/build-oracle-ef.sh` snapshots missalemeum's per-date API
(`https://www.missalemeum.com/en/api/v5/proper/{date}`) over a range (e.g. 2024–2028) into `internal/calendar/testdata/oracle-ef.json` as `{date: {season, rank, colour}}`, mapping its fields:
- `tempora` string (e.g. "Feria VI after IX Sunday after Pentecost") → our EF season + week.
- `rank` (1–4) → `class-1…4`; `colors` (`w/r/v/g/…`) → our colours.
- top `info`/celebration → the observed.

`oracle_ef_test.go` computes each day and asserts **season strictly** (data-independent, validates `temporalEF`), reporting rank/colour diffs informationally (partial sanctoral). Same offline-snapshot pattern as #1. **Not run in `go test` without the snapshot** (network-gated generator).

Also: **re-run the OF oracle** after the Rank refactor — it must still show 0 season mismatches.

## Error handling

`Compute` stays total for both forms. Malformed embedded EF data → fail fast at init (test-caught). Unknown `sel.Form` → default to OF (documented).

## Package/file layout

| Concern | File | Change |
|---|---|---|
| Rank refactor | `internal/calendar/types.go`, `precedence.go`, `temporal.go`, `calendar.go` | int enum → string + ordering helpers |
| EF temporal | `internal/calendar/temporal_ef.go` **(new)** | `temporalEF` + EF seasons |
| EF precedence | `internal/calendar/precedence_ef.go` **(new)** | 1960 rubrics |
| Form branch | `internal/calendar/calendar.go` | `Compute` selects OF/EF path |
| EF data | `internal/caldata/tridentine-calendar.ini` **(new)**, `caldata.go` | `Tridentine()`, form-aware `Stack` |
| CLI | `internal/cli/liturgy.go` | drop the EF guard; form-aware rank labels |
| Oracle | `internal/calendar/oracle_ef_test.go`, `testdata/oracle-ef.json`, `scripts/build-oracle-ef.sh` **(new)** | missalemeum validation |

## Non-goals (this sub-project)

EF propers/readings (the 1-year lectionary — a later phase; scraper serves them meanwhile); full EF sanctoral population; the OF readings (#3-OF); the API (#4); migrating the daily-readings view off the scraper.

## Risks / open questions

- **Time after Pentecost + resumed Epiphany Sundays** and **Christ the King (last Sun of October)** are the fiddliest temporal bits; the oracle is the safety net (confidence ~6/10 on the first cut, ~8/10 on the design).
- The **Rank string refactor** touches validated OF code — mitigated by the OF oracle re-run as an acceptance gate.
- `tempora`-string → season/week parsing for the oracle needs a small mapping table (English ordinals, "after Pentecost/Epiphany", pre-Lent names).