Files
wanderbooks/verify/lean/Wanderbooks/Prorate.lean
T
swaitsandClaude Opus 5.5 b0a9fd8ad4 P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days;
a disposed asset stops on its disposal day. A skipped month is caught up
in the month the run is made; a reversed run un-posts its month. New
schedules spread by day; equal-months schedules must start on the 1st.
Amounts round to the currency's own minor unit. Kani and Lean proofs; an
exhaustive search over runs, reversals and disposals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00

325 lines
15 KiB
Lean4
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/-!
# Proration by days: the parts sum to the total, and none is negative,
for any number of parts
Mirrors `crates/ledger-core/src/kernel.rs` `prorated_through` and
`prorate_by_days` (depreciation, rules.md B-15; daily schedules, B-16).
The kernel computes, through any day of a span, the elapsed share of the
span as a fraction (the months held before, each as days held over days in
the month, plus the days of this one; in the last month, what the others
leave spread over its days). Here that share is `w / V`: `V` the whole
span over any common denominator, `w` the elapsed part of it, which the
walk only ever raises. The charge through it is `cum`: the total times the
share, rounded half up to a multiple of `unit`, capped at the total, and
the total itself once the whole span has elapsed (the kernel's last day,
exact even for a total that isn't a whole number of units).
Kani proves the kernel's laws for up to three parts at bounded sizes
(`prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`),
and each charge within half a unit of its exact share
(`a_months_charge_is_within_half_a_cent` and its siblings). Here they
hold for any number of parts and any sizes: `cum` never falls as the
share grows, never exceeds the total, and ends at it, so the parts
(differences of `cum` at each part's end) telescope to the total and are
each at least zero; each charge is within half a unit of its exact share,
so each part is within one unit of its days times the rate (`cum_near`,
`part_near`). The last section writes out the kernel's two formulas and
proves them `cum` (`chargeMid_eq_cum`, `chargeLast_eq_cum`), so these are
laws of the kernel's own arithmetic, not only of the model.
-/
namespace Wanderbooks.Prorate
/-- `num / den` rounded half up to a multiple of `unit`: the kernel's
`round_to_unit` (its `r >= step - r` is `2r >= step`, which is the floor of
`num / step + 1/2`). -/
def roundUnit (num den unit : Nat) : Nat :=
unit * ((2 * num + den * unit) / (2 * den * unit))
/-- The charge of total `t` once `w` of the span's `V` has elapsed. -/
def cum (t unit V w : Nat) : Nat :=
if w = V then t else min t (roundUnit (t * w) V unit)
/-- Rounding never falls as the amount rounded grows. -/
theorem roundUnit_mono (den unit : Nat) {a b : Nat} (h : a ≤ b) :
roundUnit a den unit ≤ roundUnit b den unit := by
unfold roundUnit
apply Nat.mul_le_mul_left
apply Nat.div_le_div_right
omega
/-- The charge never exceeds the total. -/
theorem cum_le (t unit V w : Nat) : cum t unit V w ≤ t := by
unfold cum
split
· exact Nat.le_refl t
· exact Nat.min_le_left _ _
/-- Once the whole span has elapsed, the charge is the total exactly. -/
theorem cum_full (t unit V : Nat) : cum t unit V V = t := by
simp [cum]
/-- Before anything has elapsed, nothing is charged. -/
theorem cum_zero (t unit V : Nat) (hV : 0 < V) (hu : 0 < unit) : cum t unit V 0 = 0 := by
have hne : (0 : Nat) ≠ V := by omega
have hlt : V * unit < 2 * V * unit := by
have : 0 < V * unit := Nat.mul_pos hV hu
rw [Nat.mul_assoc]
omega
simp [cum, hne, roundUnit, Nat.div_eq_of_lt hlt]
/-- **The charge never falls** as the elapsed share grows, up to the
whole span. -/
theorem cum_mono (t unit V : Nat) {w w' : Nat} (h : w ≤ w') (hw' : w' ≤ V) :
cum t unit V w ≤ cum t unit V w' := by
by_cases e' : w' = V
· subst e'
rw [cum_full]
exact cum_le t unit w' w
· have e : w ≠ V := by omega
simp only [cum, e, e', ite_false]
have r := roundUnit_mono V unit (Nat.mul_le_mul_left t h)
omega
/-- The parts of a split: at each part's end (`w` its elapsed share), the
charge through it less the charge before it (the kernel's `out[i]`,
`prorate_by_days`). -/
def parts (t unit V : Nat) : Nat → List Nat → List Int
| _, [] => []
| prev, w :: ws => ((cum t unit V w : Int) - cum t unit V prev) :: parts t unit V w ws
/-- **The parts telescope**: they sum to the charge at the last part's end
less the charge where they start (the shape of `runPart_telescopes`). -/
theorem parts_telescope (t unit V : Nat) (prev : Nat) (ws : List Nat) :
(parts t unit V prev ws).sum = (cum t unit V (ws.getLastD prev) : Int) - cum t unit V prev := by
induction ws generalizing prev with
| nil => simp [parts]
| cons w rest ih =>
simp only [parts, List.sum_cons, ih]
cases rest with
| nil => simp
| cons _ _ => simp only [List.getLastD_cons]; omega
/-- **The parts sum to the total**, for any number of parts: a span
walked from nothing elapsed to all of it. -/
theorem prorate_total (t unit V : Nat) (hV : 0 < V) (hu : 0 < unit) (ws : List Nat)
(hlast : ws.getLastD 0 = V) : (parts t unit V 0 ws).sum = t := by
rw [parts_telescope, hlast, cum_full, cum_zero t unit V hV hu]
simp
/-- The parts' ends rise: each at least the one before, none past the
span. -/
def Rising (V : Nat) : Nat → List Nat → Prop
| _, [] => True
| prev, w :: ws => prev ≤ w ∧ w ≤ V ∧ Rising V w ws
/-- **No part is negative** when the parts' ends rise, for any number of
parts. -/
theorem prorate_nonneg (t unit V : Nat) (prev : Nat) (ws : List Nat) (h : Rising V prev ws) :
∀ x ∈ parts t unit V prev ws, 0 ≤ x := by
induction ws generalizing prev with
| nil => simp [parts]
| cons w rest ih =>
obtain ⟨hpw, hwV, hrest⟩ := h
intro x hx
simp only [parts, List.mem_cons] at hx
rcases hx with hx | hx
· subst hx
have := cum_mono t unit V hpw hwV
omega
· exact ih w hrest x hx
/-- **Each part is within one unit of its exact share** when each charge
is within half a unit of its exact one (Kani's
`a_months_charge_is_within_half_a_cent` and its three siblings prove
that of the kernel): a part is the difference of two charges over the same
denominator `D` (W, the span in MONTH_L-ths of a month), so its error is
at most the two halves. With `x - y` = total × days × MONTH_L / days in
the month and `D` = months × MONTH_L, dividing through by MONTH_L / days in
the month gives the plan's form: |part × months × days in month − total ×
days| <= unit × months × days in month. -/
theorem part_within_a_unit (a b D x y u : Int)
(ha : -(u * D) ≤ 2 * (a * D - x) ∧ 2 * (a * D - x) ≤ u * D)
(hb : -(u * D) ≤ 2 * (b * D - y) ∧ 2 * (b * D - y) ≤ u * D) :
-(u * D) ≤ (a - b) * D - (x - y) ∧ (a - b) * D - (x - y) ≤ u * D := by
rw [Int.sub_mul]
omega
/-! ## The kernel's own formula
`prorated_through` charges in two ways. Through a part but the span's
last, the elapsed share is `e / W` (`W` = months × MONTH_L, `e` = before
+ k × MONTH_L / days in the month): `chargeMid`. Through `k` of the `d`
days of the last part, after `b` of `W`, it is `(b·d + (W − b)·k) / (W·d)`,
and the total itself on its last day: `chargeLast`. Both are `cum`, over
`W` and over `W·d` (`chargeMid_eq_cum`, `chargeLast_eq_cum`), and the two
meet where the last month starts (`chargeLast_start`, rounding being
unchanged when the amount and its denominator are scaled together). So
`cum`'s laws are the kernel's, at any size: through the last month the
charge never falls (`chargeLast_mono`), the split of any number of parts
sums to the total with none negative (`kernel_parts_total`,
`kernel_parts_nonneg`), and each part but the last is within one unit of
its days times the rate (`part_near`). A calendar walk keeps every part's
end short of the span's (rules.md B-15: the first month is at most one,
then whole months), which is the hypothesis `e < W`. -/
/-- **Rounding is within half a unit**, at any size (a tie goes up):
`2·den·r ≤ 2·num + den·unit` and `2·num < 2·den·r + den·unit`. -/
theorem roundUnit_near (num den unit : Nat) (hd : 0 < den) (hu : 0 < unit) :
2 * den * roundUnit num den unit ≤ 2 * num + den * unit ∧
2 * num < 2 * den * roundUnit num den unit + den * unit := by
unfold roundUnit
have hs : 0 < 2 * den * unit := Nat.mul_pos (Nat.mul_pos (by omega) hd) hu
have hdm := Nat.div_add_mod (2 * num + den * unit) (2 * den * unit)
have hml := Nat.mod_lt (2 * num + den * unit) hs
generalize (2 * num + den * unit) / (2 * den * unit) = q at *
generalize (2 * num + den * unit) % (2 * den * unit) = m at *
have e : 2 * den * (unit * q) = 2 * den * unit * q := by
rw [Nat.mul_assoc (2 * den) unit q]
have hS : 2 * den * unit = 2 * (den * unit) := Nat.mul_assoc 2 den unit
rw [e]
generalize 2 * den * unit * q = P at *
generalize 2 * den * unit = S at *
generalize den * unit = DU at *
omega
/-- Rounding is unchanged when the amount and its denominator are scaled
together. -/
theorem roundUnit_scale (n d u c : Nat) (hc : 0 < c) :
roundUnit (c * n) (c * d) u = roundUnit n d u := by
unfold roundUnit
have h1 : 2 * (c * n) + c * d * u = c * (2 * n + d * u) := by
rw [Nat.mul_add, Nat.mul_left_comm 2 c n, Nat.mul_assoc c d u]
have h2 : 2 * (c * d) * u = c * (2 * d * u) := by
rw [Nat.mul_left_comm 2 c d, Nat.mul_assoc c (2 * d) u]
rw [h1, h2, Nat.mul_div_mul_left _ _ hc]
/-- **The charge is within half a unit of its exact share** `t·w / V`, at
any size, short of the span's end (where it is the total): the cap at the
total included. -/
theorem cum_near (t u V w : Nat) (hV : 0 < V) (hu : 0 < u) (hw : w < V) :
2 * V * cum t u V w ≤ 2 * (t * w) + V * u ∧
2 * (t * w) < 2 * V * cum t u V w + V * u := by
have hne : w ≠ V := by omega
simp only [cum, hne, ite_false]
obtain ⟨h1, h2⟩ := roundUnit_near (t * w) V u hV hu
have htw : t * w ≤ t * V := Nat.mul_le_mul_left t (Nat.le_of_lt hw)
have hVu : 0 < V * u := Nat.mul_pos hV hu
by_cases hc : t ≤ roundUnit (t * w) V u
· rw [Nat.min_eq_left hc]
have hm : 2 * V * t ≤ 2 * V * roundUnit (t * w) V u := Nat.mul_le_mul_left _ hc
have e : 2 * V * t = 2 * (t * V) := by rw [Nat.mul_assoc, Nat.mul_comm V t]
constructor <;> omega
· rw [Nat.min_eq_right (by omega)]
exact ⟨h1, h2⟩
/-- **Each part is within one unit of its exact share**, at any size: a
part `c' − c` of the charge (its ends `w ≤ w'` short of the span's end),
against its exact share `t·(w' − w) / V`:
`|(c' − c)·V − t·(w' − w)| ≤ u·V`, written without subtraction. Over
the span `V` = months × MONTH_L and `w' − w` = days × MONTH_L / days in
the month, this is the plan's "each part equals days × rate within one
unit" (plan/served.md P3 defect 9). -/
theorem part_near (t u V w w' : Nat) (hV : 0 < V) (hu : 0 < u) (hw' : w' < V)
(h : w ≤ w') :
cum t u V w' * V + t * w ≤ cum t u V w * V + t * w' + u * V ∧
cum t u V w * V + t * w' ≤ cum t u V w' * V + t * w + u * V := by
obtain ⟨a1, a2⟩ := cum_near t u V w' hV hu hw'
obtain ⟨b1, b2⟩ := cum_near t u V w hV hu (by omega)
have e1 : 2 * V * cum t u V w' = 2 * (cum t u V w' * V) := by
rw [Nat.mul_assoc, Nat.mul_comm V]
have e2 : 2 * V * cum t u V w = 2 * (cum t u V w * V) := by
rw [Nat.mul_assoc, Nat.mul_comm V]
have e3 : V * u = u * V := Nat.mul_comm V u
constructor <;> omega
/-- **A total in whole units is charged in whole units**: every charge is
then a multiple of the unit, and so is every part (a difference of two). -/
theorem cum_dvd (t u V w : Nat) (h : u ∣ t) : u ∣ cum t u V w := by
unfold cum roundUnit
split
· exact h
· rcases Nat.le_total t (u * ((2 * (t * w) + V * u) / (2 * V * u))) with hc | hc
· rw [Nat.min_eq_left hc]; exact h
· rw [Nat.min_eq_right hc]; exact Nat.dvd_mul_right u _
/-- The kernel's charge through a part but the span's last
(`prorated_through`, `last = false`): `e` of the span's `W`. -/
def chargeMid (t u W e : Nat) : Nat := min t (roundUnit (t * e) W u)
/-- The kernel's charge through `k` of the `d` days of the span's last
part, after `b` of `W` (`prorated_through`, `last = true`). -/
def chargeLast (t u W b d k : Nat) : Nat :=
if k = d then t else min t (roundUnit (t * (b * d + (W - b) * k)) (W * d) u)
/-- A part but the last, short of the span's end, is charged `cum`. -/
theorem chargeMid_eq_cum (t u W e : Nat) (h : e < W) : chargeMid t u W e = cum t u W e := by
have : e ≠ W := by omega
simp [chargeMid, cum, this]
/-- The span's last part is charged `cum` over `W·d`. -/
theorem chargeLast_eq_cum (t u W b d k : Nat) (hb : b < W) (hk : k ≤ d) :
chargeLast t u W b d k = cum t u (W * d) (b * d + (W - b) * k) := by
by_cases hkd : k = d
· subst hkd
have e : b * k + (W - b) * k = W * k := by
rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)]
simp [chargeLast, cum, e]
· have hlt : (W - b) * k < (W - b) * d :=
(Nat.mul_lt_mul_left (by omega : 0 < W - b)).mpr (by omega)
have e : b * d + (W - b) * d = W * d := by
rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)]
have hne : b * d + (W - b) * k ≠ W * d := by omega
simp [chargeLast, cum, hkd, hne]
/-- **The last month starts where the month before it ends**: its charge
through none of its days is the charge through the end of the part before
it. -/
theorem chargeLast_start (t u W b d : Nat) (hd : 0 < d) :
chargeLast t u W b d 0 = chargeMid t u W b := by
have hne : (0 : Nat) ≠ d := by omega
have e1 : t * (b * d + (W - b) * 0) = d * (t * b) := by
rw [Nat.mul_zero, Nat.add_zero, ← Nat.mul_assoc, Nat.mul_comm (t * b) d]
have e2 : W * d = d * W := Nat.mul_comm W d
simp only [chargeLast, chargeMid, hne, ite_false, e1, e2, roundUnit_scale _ _ _ _ hd]
/-- **The charge never falls through the span's last month**, at any size:
for days `k ≤ k'` of it. -/
theorem chargeLast_mono (t u W b d k k' : Nat) (hb : b < W) (hk : k ≤ k') (hk' : k' ≤ d) :
chargeLast t u W b d k ≤ chargeLast t u W b d k' := by
rw [chargeLast_eq_cum t u W b d k hb (by omega), chargeLast_eq_cum t u W b d k' hb hk']
apply cum_mono
· exact Nat.add_le_add_left (Nat.mul_le_mul_left _ hk) _
· have : (W - b) * k' ≤ (W - b) * d := Nat.mul_le_mul_left _ hk'
have e : b * d + (W - b) * d = W * d := by
rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)]
omega
/-- The kernel's split (`prorate_by_days`): each part but the last the
charge at its end (`es`, each short of the span's end, `chargeMid_eq_cum`)
less the charge before it; the last, the total less the charge at the end
of the part before it (its last day is charged the total). -/
def kernelParts (t u W : Nat) (es : List Nat) : List Int :=
parts t u W 0 es ++ [(t : Int) - cum t u W (es.getLastD 0)]
/-- **The kernel's parts sum to the total**, for any number of parts. -/
theorem kernel_parts_total (t u W : Nat) (hW : 0 < W) (hu : 0 < u) (es : List Nat) :
(kernelParts t u W es).sum = t := by
simp only [kernelParts, List.sum_append, parts_telescope, cum_zero t u W hW hu,
List.sum_cons, List.sum_nil]
omega
/-- **No part of the kernel's split is negative**, for any number of
parts, when their ends rise within the span. -/
theorem kernel_parts_nonneg (t u W : Nat) (es : List Nat) (h : Rising W 0 es) :
∀ x ∈ kernelParts t u W es, 0 ≤ x := by
intro x hx
simp only [kernelParts, List.mem_append, List.mem_singleton] at hx
rcases hx with hx | hx
· exact prorate_nonneg t u W 0 es h x hx
· subst hx
have := cum_le t u W (es.getLastD 0)
omega
end Wanderbooks.Prorate