性質と証明
examples の一行は、一つの入力に対する答えを書きます。性質(property)は、関数の入力の全部について何かを言います。返金を配った分の合計は返金額になる、どの明細にも負の額は配らない、といったことです。kaname は、性質を自分で選んだ入力で試し、さらに Lean の定理の文として書き出します。入力の全部で成り立つことは、Lean での証明が示します。証明を探すのは kaname の仕事ではありません。人かエージェントが、.kn の隣のファイルに証明を書き、Lean がそれを確かめます。
性質を書く
例は crates/kaname/examples/refund_split です。注文の明細に、まだ払われていない残りの比で返金を配ります。allocate と running_totals を使います。
fn 按分する(split)(明細(lines): list[明細] count >=1 <=10, 返金(refund): money[USDc] range >=0USDc <=10_000_000USDc) -> list[明細の返金] total
description "どの明細にも、その明細の終わりまでの返金から、手前までの返金を引いた分を配る"
map(running_totals(明細, 一件 => 一件.残り), 途中 =>
明細の返金 { 品番: 途中.item.品番, 残り: 途中.item.残り, 配分: allocate(返金, 途中.upto, 途中.total) - allocate(返金, 途中.before, 途中.total) })
property 配分の合計は返金額(shares_add_up)(明細(lines): list[明細] count >=1 <=10, 返金(refund): money[USDc] range >=0USDc <=10_000_000USDc)
description "配った分を足すと返金額になる"
sum(按分する(明細: 明細, 返金: 返金), 一件 => 一件.配分) = 返金
性質は関数と同じ形で書き、引数に範囲を宣言します。読み方は、その範囲のどの入力でも最後の行が true になる、です。when の行を書くと、性質が語る入力を絞れます。
property 残りより多くは配らない(no_share_is_more_than_its_left)(明細(lines): list[明細] count >=1 <=10, 返金(refund): money[USDc] range >=0USDc <=10_000_000USDc)
description "残りの合計を超えない返金なら、どの明細にも、その明細の残りより多くは配らない"
when 返金 <= sum(明細, 一件 => 一件.残り)
all(按分する(明細: 明細, 返金: 返金), 一件 => 一件.配分 <= 一件.残り)
性質は呼べず、生成するコードにも出ません。total と書いた関数と同じに扱うので、失敗しうる本体は E401 です。失敗を「成り立たない」と読むか「当てはまらない」と読むかで、性質の意味が二つに割れるからです。引数に関数の型は書けません(E605)。
check が言うこと
kaname check と ritsu check は、性質をいつも引数のベクタ(kaname vectors が選ぶ入力)で試します。規則と日付は、examples と同じく本物を呼びます。どれかのベクタで成り立たなければ、その入力を挙げてエラーにします。
$ cd crates/kaname
$ ritsu kaname check tests/properties/shares.ja.kn --lang ja
エラー[E603]: tests/properties/shares.ja.kn:12:1: 性質 `どの明細にも配る` は、明細: [明細 { 品番: "a", 残り: 500000USDc }, 明細 { 品番: "a", 残り: 500000USDc }], 返金: 1USDc で成り立ちません
12 | property どの明細にも配る(every_line_gets_a_share)(明細(lines): list[明細] count >=1 <=10, 返金(refund): money[USDc] range >=0USDc <=10_000_000USDc)
= 15 行目: `配分` は [0USDc, 1USDc]
= `check` は、性質を引数のベクタで参照インタプリタに流します。この入力では、`when` の行がどれも `true` で、最後の行が `false` になりました。
= 関数を直すか、`when` の行か引数の範囲で性質を狭めるか、性質を直してください。
2 行の明細に 1 セントの返金を配ると、一つ目の明細に 0 セントが配られます。関数とこの性質のどちらが誤りかは、人が決めることです。
反例が無くても、性質を示したことにはなりません。ベクタは入力の一部だからです。check は警告で、示されていないことと、その理由を言います。when の行がどのベクタでも成り立たない性質も警告します。そういう性質は、最後の行が何であっても証明できてしまうからです。
$ cd crates/kaname
$ ritsu kaname check tests/properties/thresholds.ja.kn --lang ja
警告[W603]: tests/properties/thresholds.ja.kn:4:1: 性質 `しきい値の先` は、`when` の行が成り立つ 3 個のベクタ(6 個のうち)のすべてで成り立ちましたが、示されていません。証明がありません(thresholds_ja.lean に、この性質の定理がありません)
4 | property しきい値の先(past_the_threshold)(値(x): number range >=0 <=100)
= `ritsu gen --target lean` が、定理の名前と `sorry` だけの下書き `samples/thresholds_ja.lean` を書きます。それを .kn の隣に thresholds_ja.lean として置き、`sorry` を証明に置き換えてください。
警告[W603]: tests/properties/thresholds.ja.kn:9:1: 性質 `範囲の外` は示されていません。証明がありません(thresholds_ja.lean に、この性質の定理がありません)
9 | property 範囲の外(past_the_end)(値(x): number range >=0 <=100)
= `ritsu gen --target lean` が、定理の名前と `sorry` だけの下書き `samples/thresholds_ja.lean` を書きます。それを .kn の隣に thresholds_ja.lean として置き、`sorry` を証明に置き換えてください。
警告[W605]: tests/properties/thresholds.ja.kn:9:1: 性質 `範囲の外` の `when` の行は、3 個のベクタのどれでも成り立ちません
9 | property 範囲の外(past_the_end)(値(x): number range >=0 <=100)
= どの入力でも `when` の行が成り立たない性質は、何を言っていても証明できてしまいます。`when` の行と引数の範囲を見直してください。
ok tests/properties/thresholds.ja.kn: 関数 0 個
性質の答えは三つです。
| 答え | いつ | check の表示 |
|---|---|---|
| 反例 | あるベクタで、when の行が成り立ち、最後の行が false になった |
E603(入力つき) |
| 示した | ritsu check --lean が証明を受け付けた |
何も出ません |
| 決められない | 反例が無く、示してもいない | W603(か W604)と理由 |
W604 は、まだ Lean に書けないものを使う性質です。辞書、集合、= 以外の文字列の組み込み、sort_by・distinct・max_by・min_by・to_dict・group_by、キーに限った型の引数がそうです。ベクタでの確かめはします。規則や日付のファイルの答えに頼る性質も W603 で、理由はそれだと言います。証明が使えるのは、規則や日付の口が教える事実だけだからです。
Lean での文
ritsu gen --target lean は、ほかの出力先と同じように、関数を Lean 4 の定義に、性質を定理の文に書き出します。
$ cd crates/kaname/examples/refund_split
$ ritsu gen --root . --out generated --target lean
generated: generated/lean/lean-toolchain
generated: generated/lean/lakefile.toml
generated: generated/lean/.gitignore
generated: generated/lean/Kaname/Prelude.lean
generated: generated/lean/Kaname/refund_split_ja.lean
generated: generated/lean/Kaname/refund_split.lean
generated: generated/lean/samples/refund_split_ja.lean
generated: generated/lean/samples/refund_split.lean
Kaname/refund_split.lean に、定義と性質ごとの文が入ります。お金や量は、型の単位で数えた Int です。Within はレコードのフィールドの範囲を表します(以下は英語の版のモジュールの出力です。日本語の版は Kaname.refund_split_ja に書かれます)。
abbrev shares_add_up : Prop :=
∀ (lines : List Line) (refund : Int),
1 ≤ lines.length → lines.length ≤ 10 → (∀ e₁ ∈ lines, e₁.Within) →
0 ≤ refund → refund ≤ 10000000 →
((List.map (fun (s : Share) => s.share) (split lines refund)).sum) = refund
samples/refund_split.lean は下書きで、定理の名前だけが書いてあり、証明はどれも sorry です。
証明を書く
証明は、.kn の隣の refund_split.lean に書きます。下書きを最初の三行を除いて写し、sorry を証明に置き換えます。定理の名前は Proofs.refund_split.<性質> で、型は ritsu gen が書いた文そのものです。使うのは、Lean 自身の omega、simp、decide、grind と、丸め・allocate・running_totals のための Kaname/Prelude.lean の補題です。Mathlib は使いません。書きながら試すには、generated/lean/ で lake build を走らせます。
例の証明のファイルの全体です。
import Kaname.refund_split
namespace Proofs.refund_split
open Kaname Kaname.refund_split
/-- What is left on one line or more, each of a cent or more, comes to more than nothing. -/
theorem lefts_pos : ∀ ls : List Line, 1 ≤ ls.length → (∀ l ∈ ls, l.Within) →
0 < (ls.map fun l => l.left).sum
| [], h, _ => by simp at h
| l :: ls, _, h => by
simp only [List.map_cons, List.sum_cons]
have hl := (h l (List.mem_cons_self ..)).1
have hs : 0 ≤ (ls.map fun l => l.left).sum := by
match ls with
| [] => simp
| m :: ms => exact Int.le_of_lt (lefts_pos (m :: ms) (by simp) (fun x hx => h x (List.mem_cons_of_mem _ hx)))
omega
/-- The shares, each the refund up to its line's end less the refund before it, telescope to the
refund of the whole. -/
theorem shares_add_up : Kaname.refund_split.shares_add_up := by
intro lines refund hlen _ hwithin _ _
have hT := lefts_pos lines hlen hwithin
have key : (split lines refund).map (fun s => s.share)
= (runningTotals lines (fun l => l.left)).map
(fun r => allocate refund r.upto (lines.map fun l => l.left).sum - allocate refund r.before (lines.map fun l => l.left).sum) := by
unfold split
rw [List.map_map]
apply List.map_congr_left
intro r hr
simp only [Function.comp, runningTotals_total _ _ r hr]
rw [key, runningTotals_telescope lines (fun l => l.left) (fun x => allocate refund x (lines.map fun l => l.left).sum)]
rw [allocate_self refund hT, allocate_zero]
omega
/-- A share is the refund up to its line's end less the refund before it, and `allocate` does not go
down as its share grows. -/
theorem no_share_is_negative : Kaname.refund_split.no_share_is_negative := by
intro lines refund hlen _ hwithin hr0 _ s hs
have hT := lefts_pos lines hlen hwithin
unfold split at hs
rw [List.mem_map] at hs
obtain ⟨r, hr, rfl⟩ := hs
have hw : ∀ l ∈ lines, 0 ≤ (fun (l : Line) => l.left) l := fun l hl => by
have := (hwithin l hl).1
show 0 ≤ l.left
omega
have ⟨hwide, _, _⟩ := mem_runningTotals hw r hr
have htot := runningTotals_total _ _ r hr
have hmono := allocate_mono (t := refund) hr0 (htot ▸ hT) (show r.before ≤ r.upto by
have hitem : r.item ∈ lines := by
have := List.mem_map_of_mem (f := (·.item)) hr
rwa [runningTotals_items] at this
have := (hwithin r.item hitem).1
omega)
show allocate refund r.upto r.total - allocate refund r.before r.total ≥ 0
omega
/-- What one line gets is at most its width of the whole, when the refund is no more than the whole. -/
theorem no_share_is_more_than_its_left : Kaname.refund_split.no_share_is_more_than_its_left := by
intro lines refund hlen _ hwithin hr0 _ hle s hs
have hT := lefts_pos lines hlen hwithin
unfold split at hs
rw [List.mem_map] at hs
obtain ⟨r, hr, rfl⟩ := hs
have hw : ∀ l ∈ lines, 0 ≤ (fun (l : Line) => l.left) l := fun l hl => by
have := (hwithin l hl).1
show 0 ≤ l.left
omega
have ⟨hwide, _, _⟩ := mem_runningTotals hw r hr
have htot := runningTotals_total _ _ r hr
have hitem : r.item ∈ lines := by
have := List.mem_map_of_mem (f := (·.item)) hr
rwa [runningTotals_items] at this
have := (hwithin r.item hitem).1
have hsub := allocate_sub_le (t := refund) hr0 (htot ▸ hle) (htot ▸ hT) (show r.before ≤ r.upto by omega)
show allocate refund r.upto r.total - allocate refund r.before r.total ≤ r.item.left
omega
end Proofs.refund_split
性質を示したと言うのは、ritsu check --lean だけです。--lean は、.kn から Lean のパッケージを作り直し(generated/lean/ は読みません)、証明のファイルを文字として読んでから Lean にビルドさせ、証明をカーネルで流し直します。そのあと性質ごとに、定理があること、型が ritsu gen の書いた文であること、Lean の三つの公理(propext、Classical.choice、Quot.sound)のほかに頼らないことを確かめます。sorry、証明が自分で置いた公理、native_decide を使った証明は E604 です。
証明がすべて揃えば、--lean は W603 を出しません。
$ cd crates/kaname
$ ritsu check --lean examples/refund_split --lang ja
ok examples/refund_split/refund_split.ja.kn: 関数 1 個、total
ok examples/refund_split/refund_split.kn: 関数 1 個、total
ritsu check: ファイル 2 個(kaname 2)。どれも検査を通りました。言語の境目: 確かめた 0 か所、決められない 0 か所
no_share_is_negative の証明を sorry のまま残すと、propext・Classical.choice・Quot.sound のほかに sorryAx に頼るので、E604 になります。
証明のファイルは、何かをビルドする前に文字として読みます。他人が書いた証明を CI でビルドすると、そこに書かれたコードがビルドの中で走るからです。Kaname の外の import、コードを走らせるコマンド(#eval、run_cmd)、構文を足すコマンド(macro、syntax、elab)、native_decide、検査を切るオプションや属性は書けません。
Lean を入れる
Lean を走らせるのは --lean だけです。Lean のバージョン管理の道具 elan を入れてください。elan が最初に走らせる lake が、証明が使う toolchain の leanprover/lean4:v4.34.0 を一度だけダウンロードします。ライブラリと合わせて約 2.7 GB です。lake が無ければ、--lean は理由を言って終了コード 2 で止まります。
kaname は、このサイトのブラウザで試すページでは、まだ開けません。開けるようになれば、性質は Lean を使わずにベクタで試され、W603 になります。証明を確かめる --lean は、手元の機械で走らせるものです。
リファレンス(英語)に、名前の決まり、まだ Lean に書けないもの、証明のファイルに書けないもの、終了コードがあります。証明はスキルを使ってエージェントに書かせることもできます。