LLM が証明自動化の解決策として注目される背景
依存型言語の証明コストという課題に対し、自動化技術による解決が試みられている現状と、SMT ソルバーの限界に関する分析が行われている。
AI深層分析を開く2026年7月28日 00:16
AI深層分析
キーポイント
依存型言語の価値と課題
複雑な不変条件を形式化して機械検証できる利点がある一方、証明に膨大な時間がかかるという根本的な課題が存在する。
実証プロジェクトにおけるコスト
seL4 プロジェクトの事例では、設計と実装よりも証明に約 10 倍の時間を要し、証明コード量が C コードの 20 倍以上になった。
自動化への試みとその限界
F*などのシステムでSMTソルバーによる自動解決を試みるが、複雑なケースではソルバーが無限ループに陥るリスクがある。
SMT ソルバーの自動化限界
複雑なケースでは SMT ソルバーが無限に実行され、開発者がソルバーを満足させるための直感や特殊な手法に頼る必要がある。
LLM による証明自動化の可能性
証明の存在のみが重要という性質と LLM を組み合わせることで、型チェックの爆発を防ぎつつ実用的な依存型システムの構築が可能になる。
重要な引用
The problem has always been that with great type-system power comes great proof effort.
They ended up with more than 20 times as many lines of proof code as they did C code.
It's very easy to craft something that causes the SMT solver to go off into space and run for hours.
it converts the problem into mysticism: you end up serving a complex and fickle god.
編集コメントを表示
編集コメント
記事は依存型言語の理論的優位性と、それを支えるための実証コストという現実的なギャップを浮き彫りにしている。自動化技術が成熟するまで、開発者は証明作業における時間的・心理的負荷を十分に理解した上で言語選択を行う必要があるだろう。
私は昔から、Coq、Rocq、Lean といった依存型言語に特別な思いを抱いています。これらは、非常に微妙な不変条件を記述し、強制できるような型システムの実現可能性を提供します。通常のプログラミング言語では、こうした不変条件はせいぜいコメントとして残るだけであり、チーム規模が大きくなるにつれてすぐに忘れ去られてしまいます。その結果、微妙な誤解が生じたり、コンポーネント同士がうまく噛み合わなかったりすることがよくあります。
問題に気づいたときには、すでに各コンポーネントがある程度の大きさに成長してしまっていることが多く、その時点でどちらかを修正するのは疲弊する作業になりがちです。そこで依存型言語は魅力的な提案をします。「不変条件を形式化して記述し、マシンがそれを検証すればよいのです」。
(追伸:Coq は名前を変更しました! 昔、プリンストンで開催された Coq のカンファレンスで、英語圏では「Coq」という名前のプログラミング言語を持つことが障壁になるのではないか、と提案したことを覚えています。当時の聴衆は賛同しなかったようです。また、その会場の講演の多くが『ゲーム・オブ・スローンズ』のティリオン・ランニスター卿の演説のように聞こえた、Coq と Hoare があまりにも多いせいで、というジョークも飛び交いました。このジョークは当時としては非常に面白く、タイミングも良かったのですが、その番組の最終シーズンが放送される前であり、私たちが記憶からそれを消し去った後だったため、完全に空回りしてしまいました。)
強力な型システムには、それに見合う証明の労力が必要になるという問題は昔からあります。私自身も、非常に単純なことを証明するために丸一日を費やした経験があります。
実際、証明を行うのはとても楽しいものです。挑戦的でインタラクティブであり、明確なゴールがあるからです。しかし、何と言っても時間がかかります。特に、私のように何をすればいいか分からない場合などはなおさらです。また、数時間の努力の末に、自分が証明しようとしていた目標が実は*偽*だったと気づくという、たまに訪れる悔しい経験もあります。
この分野における古典的な結果として、seL4 プロジェクトの回顧録があります。そこでは、プロジェクト規模が大きくエンジニアたちが十分な経験を積んでいたにもかかわらず、設計や実装に費やす時間の約10倍を証明に費やしたことが示されています。その結果、C コードの行数よりも20倍以上もの証明コードが作成されることになりました。
型付き言語でのプログラミングが極めてニッチな領域に留まっている背景には、このオーバーヘッドがあります。そのため、人々はこれを自動化しようとしてきました。
私が少しばかり知っている事例は F* です。このシステムでは、SMT ソルバーを使って証明の義務を自動的に解消しようと試みます。確かに単純なケースでは機能しますが、SMT ソルバーが途方もない時間を実行し続けるようなケースを容易に作れてしまいます。そうすると、「本当に完了するのだろうか」と不安になるばかりです。
実際にこれらの言語を頻繁に使う人々は、ソルバーを満足させるものが何かを直感的に見抜く「第六感」を身につけなければなりません。そして、その感覚に合わせてコードを組み立てる必要があります。これはある程度は役立ちますが、一方で問題を神秘主義的なものに変えてしまう側面もあります。結局のところ、複雑で気まぐれな神様にお仕えすることになるのです。
重要な事実として、理論的には一度命題が正しいことが確認されれば、その証明の内容自体は重要ではありません。重要なのは「証明が存在する」という事実だけです。
ただし、これは完全には正しくありません。二つの要因がこの単純化を複雑にします。第一に、seL4 グループが「証明エンジニアリング」と呼んだものです。コードの変更後に再調整する労力を減らすために、証明をどのように構造化すべきかという課題です。第二に、十分に複雑な証明では、型チェッカーでさえ暴走して膨大なメモリを消費してしまう可能性があります。
現在、証明の不要性(proof irrelevance)と組み合わせることで、極めて高度な証明自動化を実現できる LLM が登場しました。十分な自動化が進めば、証明エンジニアリングについて以前ほど神経質になる必要もなくなるかもしれません。
もちろん、型チェッカーを破綻させるようなミスを避ける必要は残りますが、私の限られたテスト範囲では、LLM はそれを回避できることが確認できました。その可能性により、依存型システムが劇的に実用的なものになるかもしれません。私はこの可能性を探るために Lean で Zstandard のデコンプレッサを実装しました。Zstandard 自体への興味も一因です。
Zstandard は、gzip を置き換えて標準的な圧縮ユーティリティとなる競争で勝っているように見えます。LZ77 スタイルの圧縮アルゴリズムですが、より優れたエントロピー符号化と、驚異的なデコンプレッション速度を可能にする慎重な設計が特徴です。
美しさという点では bzip2 に敵いませんが、ブルワーズ=ウィーラー変換の輝かしいエレガンスは、圧倒的な実用上の利点の前にはあまり重要視されません。
※測定は標準的な参照環境(執筆当時の著者使用機材)で行われたものです。なお、縦軸は対数スケールです。gzip と Zstandard はそれぞれ独自の高速クラスに属しています。これは Apple 製マシンの結果であり、Apple の gzip 実装は特に最適化されています。他の環境では gzip の速度はこれより遅くなる可能性があります。
Zstandard は Yann Collet 氏によって開発され、Jarek Duda 氏の画期的な研究 ANS を基盤としています。この圧縮方式には RFC 8878 が存在しますが、記述は非常に簡潔です。デコーダを実装するために必要な情報はすべて含まれていますが、すでに圧縮技術に精通していない限り、内容を理解するには数回読み直す必要があるでしょう。私自身も、セクション 4.1 を理解するまでに少なくとも 6 回は読み返しました。
このプロセスの終盤になって、私の同僚である Nigel Tao 氏が、私が書く予定だったものよりも優れた Zstandard の解説記事 Zstandard Part 1: Concepts を執筆していることを知りました。したがって、Zstandard を理解したい場合は、ぜひ彼の記事をお読みください。
ここでは私が特に興味深いと感じる部分、すなわちエントロピー符号化器について解説し、Lean に関する紹介も交えていきます。
エントロピー符号化器の役割は、非一様な確率を持つシンボル群が与えられたとき、そのシーケンスを最小限のビット数で符号化するものです。古典的なエントロピー符号化方式としてハフマン符号化があります。
ハフマン符号化では、葉ノードにシンボルを配置した二分木を構築します。ハフマンは、非常に単純なアルゴリズムが最適な接頭辞木(プレフィックスツリー)を生み出すことを示しました。具体的には、シンボルのリストから確率が最も低い 2 つを選び出し、それらを子ノードとする新しい木ノードを作成します。この新しいノードの確率は、その 2 つの子ノードの確率の合計になります。その後、要素数が 1 つ減った状態で、作成された木ノードも対象に加えてアルゴリズムを繰り返します。
明らかに、このアルゴリズムの各ステップで処理する要素セットは 1 つずつ小さくなるため、必ず終了し、最適な木が得られます。ハフマン木は非常に高速です。なぜなら、次の n ビット(n は最長の符号長)をインデックスとして用いたテーブルを構築できるからです。このテーブルエントリには、復号されたシンボルと、読み戻すべきビット数が格納されています。
ハフマン木の欠点は、各シンボルに割り当てられるビット数が整数でなければならない点です。例えば、あるシンボルの確率 p に対して -log2(p) = 2.3 という理想的な符号長が得られる場合でも、ハフマン方式では 2.3 ビットを使用することはできません。必ず 3 ビットに切り上げるか、あるいは下位に切り捨てる必要があります。後者の場合、他のシンボルがより多くのビットを消費することを強いることになります。
Zstandard は Huffman 木を使用しますが、より高い圧縮率を実現する FSE というエントロピー符号化器も備えています。FSE は状態機械です。
このシステムでは、状態の数が記号の数よりも多く設定されています。各記号は、ストリーム内での出現確率に応じて、状態の一部を割り当てられます。例えば、ある記号が 50% の頻度で現れると予想される場合、その記号には約 50% の状態が割り当てられるのです。
各状態には 3 つの値が定義されています。1 つ目は「その状態に対応する記号」、2 つ目は「その状態でビットストリームから読み取るビット数」、そして 3 つ目は「読み取ったビット数に加算されて次の状態を計算するためのベースとなる状態番号」です。
ここで思い出してほしいのは、Huffman 木の課題は「1 ビット単位でしか符号化できない」点でした。FSE の各状態もまた整数のビット数を読み取るため、一見すると同じ制約があるように思えます。しかし、ここには巧妙な工夫があります。「ある記号に対して平均して 1.5 ビット読み込む」ことを目指す場合、その記号に割り当てられた状態のうち半分は 1 ビットを読み込み、残りの半分は 2 ビットを読み込むように設計します。これにより、結果として「平均的に」目標値を達成できるのです。
この状態テーブル自体は送信されません。RFC では、記号の確率リストからこのテーブルを構築するアルゴリズムが規定されており、実際に通信されるのは確率データのみです。
具体例で見てみましょう。4 つの記号があり、16 個の状態を使うと仮定します。つまり、記号の確率を 16 分の幾つかで近似する必要があります(より正確な近似が必要な場合は、状態数を増やせばよいのです。実際、zstd では 32 未満の状態数を使うことはありません)。
state0123456789101112131415
SymbolAABDABCABCABCAAB
Num_Bits2124123122112111
Baseline1204028841206048102
任意の記号は他の記号に続くことができ、ある記号が単一の状態しか持たないこともあります。つまり、すべての記号はあらゆる状態に到達可能でなければなりません。
状態 3 を見てみましょう。これは記号 D の唯一の状態です。これが唯一の状態であるため、4 ビットを読み取る必要があり、他のあらゆる状態をエンコードするのに十分です。しかし、記号 B のような記号を見ると、その状態が要求するのは 1 ビットまたは 2 ビットの読み取りだけです。それでも、16 個の可能な次の状態のセットは、記号 B のこれらの状態で完全に分割されています。つまり、特定の状態に対して、それを到達できる記号 B の状態はただ一つだけ存在します。
FSE デコードテーブルセルが状態空間をタイル状に埋め尽くす様子
記号 B が状態 2, 5, 8, 11, 15 を占める 16 セルのデコードテーブルです。矢印は、B の各セルが下のどの範囲へ遷移するかを示しています:状態 11 は次の状態 0〜1 をカバーし、状態 15 は 2〜3 を、状態 2 は 4〜7 を、状態 5 は 8〜11 を、そして状態 8 は 12〜15 をそれぞれカバーします。これら 5 つの範囲を合わせると、すべての 16 の状態が網羅されます。
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
A
A
B
D
A
B
C
A
B
C
A
B
C
A
A
B
0–11 bit
2–3 1 bit
4–7 2 bits
8–11 2 bits
12–15 2 bits
先ほど例として挙げた記号 B をもう一度考えてみましょう。この記号の出現確率は 5/16 としました。理想的には、この記号を符号化するために必要なビット数は -log2(5/16)、つまり約 1.68 ビットとなります。
実際には、2 ビットで読み取る B の状態が 3 つ、1 ビットで読み取るものが 2 つ存在します。これらの状態は等しく使われるわけではありませんが、使用頻度で重み付けして平均を計算すると、量子化された確率に対する理想的な値にほぼ一致することがわかります。もし記号の真の確率をより高精度に反映させたいのであれば、テーブルのサイズを大きくすればよいのです。
ここで重要なのは、頻出する記号に対して複数の状態を用意することで、エンコーダーが単に記号を選ぶだけでなく、「その記号のどの状態に着地するか」も同時に決定している点です。この選択自体が次の記号へ情報を伝達し、そこで 1.68 ビットのような「小数ビット分」の情報量が表現されることになります。ただし、このエントロピーエンコーダーは依然としてテーブルベースの仕組みであるため、非常に高速に動作します。
ただし、前方から処理することはできません。C と D を符号化したい場合、どの C の状態から始めるべきでしょうか?D は状態が一つしかないため、その状態に到達できる唯一の C の状態から始めなければなりません。もし D に複数の状態があるなら、D の後に何が来るかを考慮して、どれが必要かを見極める必要があります。FSE(Finite State Entropy)は、シーケンスの末尾から始めて逆方向に進むことを強制します。これはそれほど悪いことではありません。なぜなら、シンボルの確率を計算するには通常、すでに全体のシーケンスを知る必要があるからです。
さらに、Zstandard コンプレッサーはシンボルを後ろから前から符号化しますが、出力は逐次的に書き出されます。そのため、デコンプレッサ(解凍側)はブロックの末尾へシークしてビット列を逆方向に読み取り、データを正しい順序に戻さなければなりません。これはフォーマットのより広範な詳細に関する話であり、ここでは触れません。詳しくは
Nigel の記事をご覧ください。
基本的なエントロピー符号化器は、シンボル間の確率関係には関心を持ちません。つまり、「Q の次には U が続くことが多い」といった英語の特徴を利用できないのです。こうした冗長性を活用する別の符号化方式が必要です。Zstandard では、これは従来の Lempel–Ziv 構造によって実現されており、リテラルバイトか、すでに復号されたデータへのバック参照(後方参照)のいずれかを符号化します。したがって、FSE は主にこれらのバック参照オフセットと長さを効率的に符号化するために使われます。
Lean
Lean について話しましょう。冒頭で、Lean は依存型を持つ言語だと述べましたが、これは複雑な定義よりも具体例を通じて理解したほうがよい概念です。
例えば、ストリームから n バイトを読み取り、例外を発生させなければ、型システムがそのバイト配列の長さが正確に n であることを保証する関数の型は以下のようになります。
def IO.FS.Stream.readExact (st : Stream) (n : Nat) :
IO {ba : ByteArray // ba.size = n} := …
また、2 つの数値とバイト配列を返す関数も作れます。ここで、最初の数は素数であり、2 つの数の和は 6 で割り切れ、バイト配列の長さはその 2 つの数の中で小さい方以上であるという条件を満たします。
def getResult :
IO (Σ a b : Nat, { bytes : ByteArray //
Nat.Prime a ∧
6 ∣ a + b ∧
Nat.min a b ≤ bytes.size }) := …
このような型を、実際に必要とする人はまずいないでしょう。これは単に、依存型言語を使えばどれだけ大胆な型定義が可能かを示すデモンストレーションです。
依存型言語は、非常に複雑な数学的構造さえも記述・表現できる能力を持っています。現在、Lean の主な用途は、数学の記述と証明のための形式言語として使われていることです。
最近出版された『The Proof in the Code』The Proof in the Code は、Lean がどのようにして誕生したのかを短く、かつよく書かれた文章でまとめた本です。著者は数ページにわたって構築的数学の解釈を誤っていますが、それ以外は非常に面白く読めました!
Lean は Haskell のように純粋関数型言語ですが、プログラミング言語としてより便利になる性質をいくつか持っています。まず、Lean は厳密評価(strict)であり、Haskell は遅延評価(lazy)です。
厳密評価とは、関数呼び出しの前に引数が評価されることを意味します。一方、Haskell では値が必要とされるまで引数の評価が先送りされます。そのため Haskell では、高価な式を書いて関数に渡すことが自由に行えます。実際には使用された場合のみ計算が行われるからです。
しかしそれは同時に、プログラム内で非常に予期せぬ場所で計算が発生する可能性も意味します。これは議論の分かれる話題ですが、遅延性の美しさは理解できるものの、プログラムのパフォーマンスを推測するのがいかに難しいか痛感させられます。
次に、Lean にはいくつか便利な構文糖衣(シンタックス・シュガー)が用意されています。モノアドの do 記法には for ループや return 文、break 文が含まれています。命令型スタイルでプログラミングしたい場合でも、それなりに自然に記述できるのです。
最後に、Lean には参照カウントが 1 のオブジェクトに対しては変更を加える最適化機能があります。つまり、どこかで別の参照を保持していない限り、配列を不変操作のように効率的にインプレースで更新できます。
ただし、私が知る限り Lean に線形型システムの要素はなく、値に対する参照が唯一つであることを保証する機能はありません。そのため、コードのささいな変更によって、目立たない場所に大きな配列への参照が残ってしまうだけで、パフォーマンスが劇的に低下するという鋭いエッジケースが存在します。
しかし、これはつまり、パフォーマンス最適化を試みる際に、より多くの手段を手にできることを意味しています。
以下は、私がスケッチした zstd デコーダーからの例です:
while true do
let some blockHeaderBytes ← input.readExactOrEof 3 | break
let some blockHeader := BlockHeader.fromBytes blockHeaderBytes frameHeader
| throw (.userError "invalid block header")
let blockBytes ← input.readExact blockHeader.contentSize
match hty : blockHeader.type with
| .rle =>
let b := blockBytes.val[0]'(by
rw [blockBytes.property, blockHeader.contentSize_rle hty]; omega)
9 行目に注目してください。そこには配列のインデックスがあり、まさに暗黙的な不変条件が存在する場所です。blockBytes が空であってはなりません。C 言語のような古い言語ではこの場合、未定義動作が発生します。一方、現代の言語では実行時にエラーをスローするか、あるいはその回避策としてオプション値を返す仕組みになっています。Lean にはさらに別の選択肢があります:空ではないことを証明するのです。それが 10 行目で実現されています。
blockBytes.property は、「要求された読み取り分と同じ長さである」という事実、つまり blockHeader.contentSize バイトの長さを表しています。そして blockHeader.contentSize_rle は以下の通りです。
theorem BlockHeader.contentSize_rle (h : BlockHeader) (hty : h.type = .rle) :
h.contentSize = 1 := by
simp [contentSize, hty]これは、型が rle の場合、contentSize は常に 1 になることを証明したものです。これらの事実があれば、Lean が残りの部分を自動的に推論してくれます。
証明自体は非常に短く、私自身で導き出せたかもしれません。しかし、私たちはもっと高い目標を目指します。
私は RFC に記載されている FSE テーブル構築アルゴリズムの実装を行いました。RFC にはこのアルゴリズムの「テストベクトル」が含まれており、3 つのサンプル出力 が与えられた確率から生成されています。もちろんこれらはユニットテストに使用できますが、Lean においては関数の普遍的な性質も証明可能です:
theorem ofDistribution_wellFormed (h : ofDistribution accuracyLog probs = some t) :
t.entries.size = 2 ^ accuracyLog ∧
(∀ s : Fin probs.size,
t.entries.toList.countP (fun e => e.symbol == s.val) = probCells probs[s]) ∧
(∀ (i : Nat) (hi : i < t.entries.size) (v : Nat), v < 2 ^ (t.entries[i]'hi).nbBits →
(t.entries[i]'hi).baseline + v < 2 ^ accuracyLog) ∧
(∀ (s : Fin probs.size), 0 < probCells probs[s] → ∀ x < 2 ^ accuracyLog,
∃! i : Nat, ∃ hi : i < t.entries.size,
(t.entries[i]'hi).symbol = s.val ∧ (t.entries[i]'hi).baseline ≤ x ∧
x < (t.entries[i]'hi).baseline + 2 ^ (t.entries[i]'hi).nbBits) := …
これを言葉で言い換えると以下のようになります。
「精度」定数とシンボル確率のリストを引数としてテーブル構築関数に渡した結果が値を持つ場合、以下の条件がすべて成立します。
- テーブルは、その精度に対応する正しいサイズを持っている。
- 各シンボルについて、その確率に基づいて計算された状態数が正確に一致している。
- すべての状態において、nbBits ビットを読み込んでその状態のベースライン値を加算すると、有効な状態番号が得られる。
- 確率がゼロでないすべてのシンボルと、すべてのターゲット状態に対して、そのシンボルからターゲット状態へ到達できる状態はただ一つだけ存在する。
最適化されたデコードの内部ループが前提とするこれらの微妙な仮定は、弱い型システムでは暗黙的なものや単なるコメントに過ぎません。このような強力な命題を証明するのは、seL4 の回顧録で言及されている 10 倍の工数の一部であり、従来型のソフトウェアにおける依存型採用の大きな障壁となってきました。
しかし現在では、数多くの大規模言語モデル(LLM)が約 20 分でこれを自動で行えるようになっています。しかも、月額 20 ドルのサブスクリプション枠のごく一部のみを使用すれば済みます。来年にはこれが標準的な要件になるでしょう。
ただ、その過程でテーブル生成コードを変更せざるを得なかったことは認めざるを得ません。私は Id.run(つまり命令形モードへの切り替え)を多用しすぎていたため、証明機構が扱いにくいものになってしまったのです。(ただし Lean では これに向けた取り組み が進んでいます。)
私は証明の型チェックが通ることと、sorry(未実装部分)が存在しないことを確認しました。
依存型と大規模言語モデル(LLM)の組み合わせ自体は新しいアイデアではありませんが、それを日常的なソフトウェアエンジニアリングに応用した事例はまだほとんどありません。実用的に適用するには、さらに多くの経験が必要でしょう。
非常に強力な型システムは、変更範囲を拡大させる可能性があります。なぜなら、変更はすべての派生型を通じて伝播させなければならないからです。もしかすると、証明にかかる工数は大規模システムではスケーリングしないのかもしれません。そうなると、現代の LLM でも追いつくことができない状況になるでしょう。
Lean は高レベルな言語であり、すべてに適しているわけではありません。(私の玩具のような Zstandard デコーダーは、コマンドライン上の zstd と比べて 10 倍も遅いです。)それでも、証明自動化はすでに実現されており、実質的に私たちは新しい種類のプログラミング言語を手に入れたことになります。これは非常にワクワクすることです。
(コードを公開しないのは、正直に言って、このように小さく明確なケースでは LLM の方が私よりも良い結果を出せる可能性があるからです。私は Lean を少し学びたいという目的でこれを行いましたし、私の探求を模範として推奨するつもりはありません。これは lean-zip にインスパイアされたものであり、同プロジェクトはより多くの機能を持ち、圧縮器も備え、ラウンドトリップの証明も行っています。)
余談:検証済みアセンブリ
AWS が「LNSym」を発表しました。これは AArch64 向けのセマンティクスとシミュレータです。非常に興味深いツールですね。
この LNSym を活用すれば、最適化されたアセンブリコードの実装と、それに対応する Lean の実装との等価性を示すことが可能になるかもしれません。そして、実行時にはそのアセンブリコードを使用することもできるでしょう。こうすれば、LLM(大規模言語モデル)に最適化を任せても、機能的なバグを導入させる心配はなくなります。
検証済みアセンブリは暗号実装の分野ではすでに一般的ですが、今後はより「安価」に実現できるかもしれません。
私はこのアプローチを試すために、LLM を活用して時間を費やしました。リポジトリにある小さな popcount の例 では bv_decide(証明付きの SAT ソルバ)が使用されていますが、この例を実行するには私のシステムよりも多くのメモリが必要で、これは良い兆候ではありませんでした。
ただし、非常に小さな関数であれば動作し、Tiny な Lean 関数との等価性証明を取得することも可能です。その後、extern を用いて実行時に呼び出すことも実現できます。
しかし、私自身と数人の LLM にとって、これ以上規模を拡大させることはできませんでした。
原文を表示
I've long had a soft spot for dependently-typed languages like Coq Rocq and Lean.
They offer the possibility of a type system capable of encoding and enforcing
arbitrarily subtle invariants. The sort of thing that, in regular languages, ends
up (at best) as a comment, and which quickly gets lost as the size of the team grows.
Then you get subtle misunderstandings and components that don't quite
fit together. It's often the case that those components have grown to a
sufficient size that, when the problem is noticed, aligning either of them is a wearying prospect. Perhaps, say
dependent types seductively, you could write those invariants formally and have
a machine check them.
(p.s. Coq changed its name! I remember many years ago at a Coq conference in
Princeton, I tried suggesting that,
in an English-speaking world, having a programming language called Coq was an
impediment. I don't think the audience agreed at the time. I also joked that many of the talks there sounded like a speech by
Tyrion Lannister, there being so many Coqs and Hoares. A joke
that was hilarious and timely, even though it fell completely flat, coming as
it did before the final season of that show and our collective memory-holing of
it.)
The problem has always been that with great type-system power comes great
proof effort. I can certainly attest to entire days spent proving
really quite simple things. Doing proofs is actually quite fun: it's
challenging, interactive, and there's a clear goal. But gosh, does it take a lot
of time, especially if, like me, you don't know what you're doing. There's also the
periodic, galling experience, at the end of many hours of effort, where you realise that the goal
that you're trying to prove is, in fact, *false*. The classic result here is the
retrospective from the seL4 effort that found that, even though the project was
large enough for the engineers to develop considerable experience, they
spent about 10 times as much time proving as they did designing and
implementing. They ended up with more than 20 times as many lines of proof code as they
did C code.
That overhead has made programming in dependently-typed languages extremely niche. It has also spurred people to try and automate
it away. The attempt I'm passingly familiar with is F*, where the
system tries to have an SMT solver automatically discharge the obligations.
That certainly works for simple cases, but it's very easy to craft something
that causes the SMT solver to go off into space and run for hours, leaving you
wondering whether it's ever going to finish. I've seen that people who use
these languages a lot have to develop a sixth sense for what is going to make
the solver happy, and then craft everything around that. It can help,
but to an extent it converts the problem into mysticism: you end up
serving a complex and fickle god.
A critical fact is that, at least in theory, once the statement is correct, the contents of its proof
are irrelevant: only its existence matters. This is not entirely true
because of two complicating factors: first, what the seL4 group called “proof
engineering”: the need to structure proofs so that the effort of realigning them
after code changes is reduced. And, second, sufficiently complicated
proofs can cause even type checkers to blow up and consume vast amounts of
memory.
We now have LLMs which, combined with proof irrelevance, promise to be
an extremely capable form of proof automation. With sufficient amounts of
automation perhaps you don't need to worry about proof engineering nearly so much. You
still need to avoid blowing up the type checker but, in my limited tests, LLMs can avoid that. Potentially, LLMs suddenly make dependent-type systems dramatically
more practical. I wanted to play around with this so built a Zstandard decompressor
in Lean, mostly because I was also curious about Zstandard.
Zstandard seems like it's winning the competition to replace gzip as the
canonical compression utility. It's another LZ77-style compressor, but it offers
better entropy coding and a careful design that allows it to achieve very
impressive decompression speeds. It will never be as beautiful as bzip2, but
the shining elegance of the Burrows–Wheeler transform doesn't count for too
much in the face of significant practical advantages:
zstd
bzip2
gzip
lzma (XZ/LZMA2)
50
100
200
500
1000
2000
zstd
bzip2
gzip
lzma (XZ/LZMA2)
70
72
74
76
78
80
82
84
86
Compression tradeoff on 64 MiB of Lean/mathlib source
Space saved (%) — farther right is more compression
Decompression throughput (MiB/s, log scale) — higher is faster
(Measurements taken on the standard reference computer, i.e. whatever the author was using at the time. And note the log scale on the y-axis: gzip and Zstandard are in their own speed class. This is an Apple machine and Apple's gzip is especially optimised; expect gzip to be slower elsewhere.)
Zstandard (by Yann Collet, building on the seminal ANS work by Jarek Duda) has an RFC,
but it is quite terse. It contains all the information you need to implement a
decompressor, but unless you're already quite familiar with compression, I
think you'll need to re-read it a few times to understand what's going on. I,
at least, had to read section 4.1 half a dozen times before I felt that I
had a decent grasp of it. Too late into this process, I
discovered that my colleague, Nigel Tao, has written [a better
write-up of Zstandard](https://nigeltao.github.io/blog/2022/zstandard-part-1-concepts.html) than I was going to manage anyway. So, if you want to
understand Zstandard, you should read that. I'm just going to give an explanation of the most interesting bit,
the entropy encoder, and mix that in with some evangelism about Lean.
The job of an entropy encoder is, given a set of symbols with non-uniform
probabilities, to encode a sequence of those symbols using the fewest number of
bits. The classic entropy coder is a Huffman encoder. Huffman encoders build a
binary tree with symbols at the leaf nodes, and Huffman showed that a very
simple algorithm produces an optimal prefix-tree: you take the list of symbols, you
find the two with the least probability, and you form a tree node with them as
children. That tree node then has a probability that is the sum of its two
children, and then you repeat the algorithm with two fewer symbols,
but now with a tree node in the mix. Obviously each step of this
algorithm reduces the size of the set of elements by one, so it terminates, and
it also produces an optimal tree. Huffman trees are very fast because you can
build a table indexed by the next *n* bits (where *n* is the length of the longest code).
The table entry tells you what symbol you've decoded and
how many bits to unread. The drawback of Huffman trees is that they can
only use a whole number of bits for each symbol: if you have a symbol where -log2(p) = 2.3 then ideally you want to use 2.3 bits to
encode it. But Huffman forces you either to round up to 3 bits or to round
down, which will force some other symbols to consume more bits.
Zstandard uses Huffman trees, but it also has a higher-compression
entropy encoder called FSE. FSE is a state machine. There
are more states than symbols, and each symbol gets a fraction of the states
that mirrors its probability of occurrence in the stream. So if there's some
symbol that is expected to appear 50% of the time, it gets ~50% of the states.
Each state has three values: the symbol for that state, a number of bits to
read from the bitstream when in that state, and a baseline state number that is
added to those bits to get the next state. Now, if you recall, the problem with
Huffman trees was that they could only use a whole number of bits, and these
states also read a whole number of bits. But the trick is that if you are
aiming to read one and a half bits for a given symbol, then half of its states
will read one bit and half of them will read two bits. Then you hit your target *on average*.
The table of states is never transmitted. The RFC prescribes an
algorithm for building the table from a list of symbol probabilities, and so
only the probabilities need to be transmitted.
Let's do an example. Let's say we have four symbols and we're going to use
16 states. So we have to approximate the symbol probabilities in terms of
16ths. (If you want a more accurate approximation of the probabilities, you
can use a larger number of states; zstd actually never uses fewer than 32 states.)
state0123456789101112131415
SymbolAABDABCABCABCAAB
Num_Bits2124123122112111
Baseline1204028841206048102
Any symbol may follow any other symbol, and a symbol might only have a single
state. So every symbol must be able to reach every state.
Take a look at state three, which is the only state for symbol D.
Because it's the only one, it has to read four bits, which is sufficient to
encode any other state. But if you look at a symbol like B, its states only
demand that you read one or two bits. However, the set of 16 possible next states is
exactly partitioned between those states for symbol B. So, for any particular state, there
is exactly one state for symbol B that can reach it.
FSE decoding table cells tiling the state space
A 16-cell decoding table with symbol B occupying states 2, 5, 8, 11 and 15. Arrows connect each of B's cells to its destination range below: state 11 covers next states 0 to 1, state 15 covers 2 to 3, state 2 covers 4 to 7, state 5 covers 8 to 11, and state 8 covers 12 to 15. Together the five ranges cover all sixteen states.
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
A
A
B
D
A
B
C
A
B
C
A
B
C
A
A
B
0–11 bit
2–31 bit
4–72 bits
8–112 bits
12–152 bits
Again, consider symbol B,
which we said had a probability of 5/16. The ideal number of bits to encode that symbol is -log2(5/16) = 1.68. There are three symbol B states that read two bits and two that read one bit. The states aren't used equally often and, weighted by how often they're used, the average comes out to almost exactly the right value for the quantised probabilities. If you want to capture the true symbol probabilities with more accuracy, use a bigger table.
The central trick is that, by giving
multiple states to more common symbols, the encoder doesn't just pick a symbol: it also picks which of that symbol's states to land in, and that choice carries information forward to the next symbol. That's where the fractional bits of information go. But this entropy
encoder is still just table based, and so it runs very quickly.
The wrinkle is that you can't work forwards. Assume that you want to encode C, D. Which C state do you start in? Well, D only has one state so it has to be the C state which can reach that one. If D had multiple states then you would need to worry about what came after D to know which of *those* you needed. FSE forces you to start at the end of the sequence and work backwards. (That's not too bad because you usually need to know the whole sequence in order to calculate the symbol probabilities anyway.) Furthermore, a Zstandard compressor thus encodes symbols back-to-front, but writes output incrementally, so the decompressor has to seek to the end of a block and read the bits backwards in order to straighten it out! That's getting into broader details of the format that I'm not going to cover; see
Basic entropy encoders do not care about inter-symbol probabilities. I.e. they can't
use the fact that the letter Q is disproportionately followed by the letter
U (in English). There has to be some other encoding that is exploiting those redundancies.
In Zstandard, that's a traditional Lempel–Ziv structure where
it encodes either literal bytes or back references to previously decoded data.
So FSE is primarily used for efficiently encoding these back reference offsets
and lengths.
Lean
Let's talk about Lean! Above I said that it's a dependently-typed language,
and that is a concept better articulated in examples than in a complicated
definition. So here's the type of a function that reads *n* bytes from a stream
and, if it doesn't throw, returns a byte array that the type system knows is *n* bytes long.
def IO.FS.Stream.readExact (st : Stream) (n : Nat) :
IO {ba : ByteArray // ba.size = n} := …Here is a function that
returns two numbers and a byte array such that the first number is prime, the
sum of the two numbers is divisible by six, and the byte array is at least as
long as the smaller of those two numbers.
def getResult :
IO (Σ a b : Nat, { bytes : ByteArray //
Nat.Prime a ∧
6 ∣ a + b ∧
Nat.min a b ≤ bytes.size }) := …That is not a type that anyone will ever need. It's just demonstrating that
you can go as wild with this as you want. Dependently-typed languages are
sufficient to encode even very complicated mathematical structures, and Lean's
dominant use at the moment is as a formal language for stating and proving
mathematics. The recent book, The Proof in the Code, is a short,
well-written articulation of the story of how Lean came to be. The author
does completely butcher constructive mathematics for a few paragraphs but, other than that, I enjoyed it!
Lean is a purely functional language like Haskell, although it has a few
properties that make it potentially a lot more convenient as a programming
language. Firstly, Lean is strict, while Haskell is lazy. Strictness means that arguments to
functions are evaluated before the call happens, whereas in Haskell the
evaluation of arguments is deferred until the value is actually required. So in
Haskell it's free to write expensive expressions and pass them into functions,
because they'll only actually be computed if they end up being used. But it
also means that computation can happen in very surprising places in the
program. This is a
contentious topic but, while I appreciate the elegance of laziness, boy, it can
make the performance of programs hard to reason about.
Next, Lean has some nice helpings of sugar. Its monadic do
notation contains for loops and return statements and break statements. If you
want to program in an imperative style, you can do so pretty reasonably!
Lastly, Lean has an optimisation where it will make mutating updates to
objects as long as their reference count is equal to one. So you
can mutate an array in place as efficiently as in an imperative language, as
long as you are careful not to have a reference to it someplace else.
Unfortunately, Lean does not have any aspects of a linear type system that I'm
aware of, so it does not help you in ensuring that there is only a
single reference to a value. It's a bit of a sharp edge that a
seemingly minor tweak to the code can completely crater its performance by
holding on to a reference to a large array somewhere inconspicuous. But it does
mean that if you are trying to optimise the performance of something, you have
a lot more tools at your disposal.
Here's an example of some of this, from the zstd decoder I sketched:
while true do
let some blockHeaderBytes ← input.readExactOrEof 3 | break
let some blockHeader := BlockHeader.fromBytes blockHeaderBytes frameHeader
| throw (.userError "invalid block header")
let blockBytes ← input.readExact blockHeader.contentSize
match hty : blockHeader.type with
| .rle =>
let b := blockBytes.val[0]'(by
rw [blockBytes.property, blockHeader.contentSize_rle hty]; omega)Focus on line 9. There's an array index there, which is exactly the sort of place that implicit invariants live: blockBytes had better not be empty! C-like languages will give you undefined behaviour in that case. Modern languages will throw at run-time, or only give you an optional value to avoid that. Lean has another option: prove that it's not empty. That's what line 10 does. blockBytes.property is the fact that it's as long as the requested read, i.e. exactly blockHeader.contentSize bytes long. blockHeader.contentSize_rle is this:
theorem BlockHeader.contentSize_rle (h : BlockHeader) (hty : h.type = .rle) :
h.contentSize = 1 := by
simp [contentSize, hty]That's a proof that, when the type is rle, the contentSize is always one. With those facts, Lean can figure out the rest.
It's a really short proof and probably I could have figured that out myself, but we can aim much higher:
I wrote an implementation of the FSE table construction algorithm from the RFC. The RFC contains “test vectors” for it: three sample outputs from given probabilities. Obviously those go into unit tests. But, in Lean, we can also prove universal properties of the function:
theorem ofDistribution_wellFormed (h : ofDistribution accuracyLog probs = some t) :
t.entries.size = 2 ^ accuracyLog ∧
(∀ s : Fin probs.size,
t.entries.toList.countP (fun e => e.symbol == s.val) = probCells probs[s]) ∧
(∀ (i : Nat) (hi : i < t.entries.size) (v : Nat), v < 2 ^ (t.entries[i]'hi).nbBits →
(t.entries[i]'hi).baseline + v < 2 ^ accuracyLog) ∧
(∀ (s : Fin probs.size), 0 < probCells probs[s] → ∀ x < 2 ^ accuracyLog,
∃! i : Nat, ∃ hi : i < t.entries.size,
(t.entries[i]'hi).symbol = s.val ∧ (t.entries[i]'hi).baseline ≤ x ∧
x < (t.entries[i]'hi).baseline + 2 ^ (t.entries[i]'hi).nbBits) := …Repeating that, in words:
Assuming that the table construction function, when given the “accuracy” constant and a list of symbol probabilities, produces a value, then:
- The table has the correct size for that accuracy.
- The number of states for a given symbol is correct given its probability.
- For all states, reading nbBits bits and adding the baseline value for that state produces a valid state number.
- For all symbols with non-zero probability, and for all target states, there's exactly one state for that symbol which can reach the target state.
These are the subtle assumptions that an optimised decoding inner-loop requires, and things that can only ever be implicit or mere comments in weaker type systems. Proving strong statements like that is part of the 10× effort that the seL4 retrospective described, and a major barrier to the adoption of dependent types in regular software. Several LLMs can do it automatically now in about 20 minutes, and using only a fraction of a $20/month subscription quota. It'll probably be table-stakes next year. I must admit that they needed to change the table-generating code when doing so: I had used too much Id.run (i.e. dropping into imperative mode) and that's harder for the proof machinery to work with. (But Lean are working on it.) I confirmed that the proofs type-check and that there are no sorrys.
Combining dependent types and LLMs is not a new idea, but not much has been done on applying the combination to quotidian software engineering. Lots more experience would be needed. Very strong types can amplify the scope of changes as they have to be propagated out through all the derived types. Perhaps the proof effort scales poorly in larger systems, such that even modern LLMs can't keep up. Lean is a high-level language, and that's not suited to everything. (My toy Zstandard decoder is 10× slower than zstd on the command line.) Still, proof automation is here now and we, practically speaking, have a new type of programming language available to us. That's exciting!
(I'm not publishing the code because, frankly, for a small, well-defined case such as this, the LLMs can probably do a better job than I did. I did this to learn Lean a little and I don't hold my explorations up as an exemplar. This was inspired by lean-zip which does much more, includes a compressor, and proves round-tripping!)
Aside: verified assembly
AWS made LNSym: a semantics and simulator for AArch64. That's cool. Perhaps we could use it to show equivalence between an optimised assembly implementation of some functions, and their Lean counterparts, and then use the assembly code at run-time? Then we could let LLMs rip at optimisation and they couldn't introduce any functional bugs. Verified assembly is well-trodden in crypto implementations, but perhaps now it could be *cheap*?
I put some (mostly LLM) time into trying this. The small popcount example from the repo uses bv_decide, which is a certifying SAT solver, and that example requires more memory than my system has, which doesn't bode well. Tiny functions do work, and it is possible to get an equivalence proof to tiny Lean functions, and then to use extern to call them at run-time! But I, and a few LLMs, couldn't get it to scale any further.
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み