Easter

Thirty years of Easter Sundays, worked out by pure arithmetic.

easter.py · output · proof · check · try it in the playground


The question

Easter moves around: it falls on the first Sunday after a church-defined “full moon” in spring, so it can land anywhere from late March to late April.

Long before computers, people found a way to get the date with nothing but whole-number arithmetic on the year. One famous recipe is the anonymous Gregorian algorithm (also known as Meeus/Jones/Butcher). Computus is the traditional name for computing the date of Easter.

When is Easter Sunday in each year from 2021 to 2050?


What we tell eyekind: the recipe

implied_by(
  computus(Year, [Month, Day]),
  [
    # the year's place in the 19-year cycle of moon phases
    is_(A, mod(Year, 19)),
    # century and year within the century
    is_(B, floordiv(Year, 100)),
    is_(C, mod(Year, 100)),
    # roughly the days from 21 March to the paschal full moon, with the
    # Gregorian century corrections
    is_(
      D,
      mod(
        add(
          sub(
            sub(add(mul(19, A), B), floordiv(B, 4)),
            floordiv(add(sub(B, floordiv(add(B, 8), 25)), 1), 3),
          ),
          15,
        ),
        30,
      ),
    ),
    # the weekday correction that moves the date on to a Sunday
    is_(
      E,
      mod(sub(sub(add(add(32, mul(2, mod(B, 4))), mul(2, floordiv(C, 4))), D), mod(C, 4)), 7),
    ),
    # the date as Month * 31 + (Day - 1); the 451 term moves the few dates
    # that would come out too late back one week
    is_(F, add(sub(add(D, E), mul(7, floordiv(add(add(A, mul(11, D)), mul(22, E)), 451))), 114)),
    is_(Month, floordiv(F, 31)),
    is_(Day, add(mod(F, 31), 1)),
  ],
)

implied_by(conclusion, conditions) is a backward rule: to establish the conclusion, eyekind works through the conditions in the list, in order. It is used on demand, when some other rule asks for it.

Capitalized names (Year, A, B, …) are variables: placeholders that get a value as the rule is used. is_(A, …) computes the expression and names the result A. Arithmetic is written as words: add, sub and mul are +, − and ×, floordiv is whole-number division (dropping the remainder) and mod is the remainder. Every number is a whole number, so every step is exact.

[Month, Day] is a list of two numbers: [4, 4] means April 4. F packs the month and day into one number, Month × 31 + (Day − 1), which the last two lines unpack.


What we tell eyekind: which years

# in_range(Low, High, N) gives each integer N from Low through High.
implied_by(in_range(Low, High, Low), le(Low, High))
implied_by(
  in_range(Low, High, N),
  [lt(Low, High), is_(Next, add(Low, 1)), in_range(Next, High, N)],
)

implies([in_range(2021, 2050, Year), computus(Year, Date)], easter(Year, Date))

in_range produces the years one at a time, by counting:

The last line is a forward rule, implies(conditions, conclusion): for every year in the range, it asks the recipe for the date and adds the fact easter(Year, Date).


What eyekind concludes

// Conclusions: the facts the program derives, in the order derived.
easter(2021, [4, 4])
easter(2022, [4, 17])
easter(2023, [4, 9])
easter(2024, [3, 31])
easter(2025, [4, 20])
easter(2026, [4, 5])
// …
easter(2050, [4, 10])

One line per year, 30 in all. [4, 4] means April 4; [3, 31] means March 31. The earliest date in this period is March 25 (2035 and 2046) and the latest April 25 (2038).


The proof, line by line

eyekind --proof writes down how it reached each conclusion. The full proof has 858 lines and 818 steps (proof/easter.py). Here are its claims, its four clauses, and the complete chains of steps for 2021 and 2022:

// Conclusions: the facts the program derives, in the order derived.
easter(2021, [4, 4])
easter(2022, [4, 17])
// … 28 more claims, one per year up to 2050
easter(2050, [4, 10])

// Clauses: the program clauses the steps cite, by clause number.
clause(1, implied_by(computus(Year, [Month, Day]), [is(A, mod(Year, 19)), is(B, floordiv(Year, 100)), is(C, mod(Year, 100)), is(D, mod(add(sub(sub(add(mul(19, A), B), floordiv(B, 4)), floordiv(add(sub(B, floordiv(add(B, 8), 25)), 1), 3)), 15), 30)), is(E, mod(sub(sub(add(add(32, mul(2, mod(B, 4))), mul(2, floordiv(C, 4))), D), mod(C, 4)), 7)), is(F, add(sub(add(D, E), mul(7, floordiv(add(add(A, mul(11, D)), mul(22, E)), 451))), 114)), is(Month, floordiv(F, 31)), is(Day, add(mod(F, 31), 1))]))
clause(2, implied_by(in_range(Low, High, Low), le(Low, High)))
clause(3, implied_by(in_range(Low, High, N), [lt(Low, High), is(Next, add(Low, 1)), in_range(Next, High, N)]))
clause(4, implies([in_range(2021, 2050, Year), computus(Year, Date)], easter(Year, Date)))

// Steps: each goal, what justifies it (a clause, a builtin, a control, or an
// absence or collection taken on trust), the bindings and the goals it uses.
step(easter(2021, [4, 4]), clause(4), {Year: 2021, Date: [4, 4]}, [in_range(2021, 2050, 2021), computus(2021, [4, 4])])
step(in_range(2021, 2050, 2021), clause(2), {Low: 2021, High: 2050}, [le(2021, 2050)])
step(le(2021, 2050), 'builtin', {}, [])
step(computus(2021, [4, 4]), clause(1), {Year: 2021, Month: 4, Day: 4, A: 7, B: 20, C: 21, D: 7, E: 6, F: 127}, [is(7, mod(2021, 19)), is(20, floordiv(2021, 100)), is(21, mod(2021, 100)), is(7, mod(add(sub(sub(add(mul(19, 7), 20), floordiv(20, 4)), floordiv(add(sub(20, floordiv(add(20, 8), 25)), 1), 3)), 15), 30)), is(6, mod(sub(sub(add(add(32, mul(2, mod(20, 4))), mul(2, floordiv(21, 4))), 7), mod(21, 4)), 7)), is(127, add(sub(add(7, 6), mul(7, floordiv(add(add(7, mul(11, 7)), mul(22, 6)), 451))), 114)), is(4, floordiv(127, 31)), is(4, add(mod(127, 31), 1))])
step(is(7, mod(2021, 19)), 'builtin', {}, [])
step(is(20, floordiv(2021, 100)), 'builtin', {}, [])
step(is(21, mod(2021, 100)), 'builtin', {}, [])
step(is(7, mod(add(sub(sub(add(mul(19, 7), 20), floordiv(20, 4)), floordiv(add(sub(20, floordiv(add(20, 8), 25)), 1), 3)), 15), 30)), 'builtin', {}, [])
step(is(6, mod(sub(sub(add(add(32, mul(2, mod(20, 4))), mul(2, floordiv(21, 4))), 7), mod(21, 4)), 7)), 'builtin', {}, [])
step(is(127, add(sub(add(7, 6), mul(7, floordiv(add(add(7, mul(11, 7)), mul(22, 6)), 451))), 114)), 'builtin', {}, [])
step(is(4, floordiv(127, 31)), 'builtin', {}, [])
step(is(4, add(mod(127, 31), 1)), 'builtin', {}, [])
step(easter(2022, [4, 17]), clause(4), {Year: 2022, Date: [4, 17]}, [in_range(2021, 2050, 2022), computus(2022, [4, 17])])
step(in_range(2021, 2050, 2022), clause(3), {Low: 2021, High: 2050, N: 2022, Next: 2022}, [lt(2021, 2050), is(2022, add(2021, 1)), in_range(2022, 2050, 2022)])
step(lt(2021, 2050), 'builtin', {}, [])
step(is(2022, add(2021, 1)), 'builtin', {}, [])
step(in_range(2022, 2050, 2022), clause(2), {Low: 2022, High: 2050}, [le(2022, 2050)])
step(le(2022, 2050), 'builtin', {}, [])
step(computus(2022, [4, 17]), clause(1), {Year: 2022, Month: 4, Day: 17, A: 8, B: 20, C: 22, D: 26, E: 0, F: 140}, [is(8, mod(2022, 19)), is(20, floordiv(2022, 100)), is(22, mod(2022, 100)), is(26, mod(add(sub(sub(add(mul(19, 8), 20), floordiv(20, 4)), floordiv(add(sub(20, floordiv(add(20, 8), 25)), 1), 3)), 15), 30)), is(0, mod(sub(sub(add(add(32, mul(2, mod(20, 4))), mul(2, floordiv(22, 4))), 26), mod(22, 4)), 7)), is(140, add(sub(add(26, 0), mul(7, floordiv(add(add(8, mul(11, 26)), mul(22, 0)), 451))), 114)), is(4, floordiv(140, 31)), is(17, add(mod(140, 31), 1))])
step(is(8, mod(2022, 19)), 'builtin', {}, [])
step(is(20, floordiv(2022, 100)), 'builtin', {}, [])
step(is(22, mod(2022, 100)), 'builtin', {}, [])
step(is(26, mod(add(sub(sub(add(mul(19, 8), 20), floordiv(20, 4)), floordiv(add(sub(20, floordiv(add(20, 8), 25)), 1), 3)), 15), 30)), 'builtin', {}, [])
step(is(0, mod(sub(sub(add(add(32, mul(2, mod(20, 4))), mul(2, floordiv(22, 4))), 26), mod(22, 4)), 7)), 'builtin', {}, [])
step(is(140, add(sub(add(26, 0), mul(7, floordiv(add(add(8, mul(11, 26)), mul(22, 0)), 451))), 114)), 'builtin', {}, [])
step(is(4, floordiv(140, 31)), 'builtin', {}, [])
step(is(17, add(mod(140, 31), 1)), 'builtin', {}, [])
// … 791 more steps for 2023 to 2050, built the same way (818 steps in all)

It has three parts:

Two kinds of justification occur:

Read the steps for 2021 as sentences:

  1. Easter 2021 is April 4 — by rule 4, with Year = 2021, because 2021 is in the range and the recipe gives [4, 4] for 2021.
  2. 2021 is in the range from 2021 to 2050 — by rule 2, because 2021 ≤ 2050.
  3. 2021 ≤ 2050 — a calculation.
  4. The recipe gives April 4 for 2021 — by rule 1, with A = 7, B = 20, C = 21, D = 7, E = 6, F = 127, Month = 4 and Day = 4.
  5. The eight calculations of the recipe, each with the numbers filled in: 2021 mod 19 = 7, 2021 ÷ 100 = 20, 2021 mod 100 = 21, then D = 7, E = 6, F = 127, Month = 127 ÷ 31 = 4, and Day = (127 mod 31) + 1 = 4.

And for 2022:

  1. Easter 2022 is April 17 — by rule 4, with Year = 2022.
  2. 2022 is in the range from 2021 — by rule 3, because 2021 < 2050, 2021 + 1 = 2022, and 2022 is in the range from 2022.
  3. Two calculations confirm 2021 < 2050 and 2021 + 1 = 2022.
  4. 2022 is in the range from 2022 — by rule 2, because 2022 ≤ 2050, which is the next calculation.
  5. The recipe gives April 17 for 2022 — by rule 1, followed by its eight calculations, ending with F = 140, Month = 4 and Day = 17.

Each later year counts up from 2021 again: 2050 needs 30 counting steps. That is why the proof has 465 counting steps, plus 30 rule-4 steps and 30 recipe steps. Calculations that already appear, such as 2021 < 2050, are not repeated.


Checked: what the report says

A proof is only worth something if someone else can check it. eyekind -c generates the proof and checks it against the program; --check-proof checks a saved proof. The checker never reasons by itself: it only confirms each step against the program. This is the complete report:

// Checked answers: these follow from the input program.
easter(2021, [4, 4])
easter(2022, [4, 17])
easter(2023, [4, 9])
easter(2024, [3, 31])
easter(2025, [4, 20])
easter(2026, [4, 5])
easter(2027, [3, 28])
easter(2028, [4, 16])
easter(2029, [4, 1])
easter(2030, [4, 21])
easter(2031, [4, 13])
easter(2032, [3, 28])
easter(2033, [4, 17])
easter(2034, [4, 9])
easter(2035, [3, 25])
easter(2036, [4, 13])
easter(2037, [4, 5])
easter(2038, [4, 25])
easter(2039, [4, 10])
easter(2040, [4, 1])
easter(2041, [4, 21])
easter(2042, [4, 6])
easter(2043, [3, 29])
easter(2044, [4, 17])
easter(2045, [4, 9])
easter(2046, [3, 25])
easter(2047, [4, 14])
easter(2048, [4, 5])
easter(2049, [4, 18])
easter(2050, [4, 10])

// C1-C8: resolution, acyclicity, justification, coverage,
// re-decision, boundary consistency, relevance and witness integrity.
condition('C1', 'resolution', 'ok', 525)
condition('C2', 'well_founded', 'ok', 818)
condition('C3', 'justification', 'ok', 818)
condition('C4', 'coverage', 'ok', 1665)
condition('C5', 're_decision', 'ok', 293)
condition('C6', 'boundary_consistency', 'ok', 0)
condition('C7', 'relevance', 'ok', 848)
condition('C8', 'witness_integrity', 'ok', 0)

// Proof statistics: recorded steps and how they were checked.
steps(818)
verified(525)
recomputed(293)
composed(0)
trusted(0)
claims(30)

// This verdict applies to the claims above; it does not establish completeness.
verdict('checked')

Each condition line names a check, says 'ok', and counts what it looked at:

The statistics repeat this: 818 steps, 525 verified against the program, 293 recomputed, none taken on trust. The verdict is checked: all 30 dates follow from the program. As the last comment says, this confirms the claims; it does not say the program has no other conclusions. Nor does it say the recipe matches the church calendar: it says the dates follow from this recipe.


Try it

eyekind examples/easter.py            # the 30 dates
eyekind --proof examples/easter.py    # with every calculation
eyekind -c examples/easter.py
eyekind --check-proof examples/proof/easter.py examples/easter.py

Or open it in the playground.


Takeaway

A centuries-old calendar recipe becomes a list of dates where each one comes with its own worked arithmetic, recomputed by an independent checker.