Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created July 19, 2026 08:21
Show Gist options
  • Select an option

  • Save mizchi/6318485f609cb34fa06ddcf3797a85ae to your computer and use it in GitHub Desktop.

Select an option

Save mizchi/6318485f609cb34fa06ddcf3797a85ae to your computer and use it in GitHub Desktop.
Using Lean as an executable test oracle / Leanを実行可能なテストOracleとして使う

Using Lean as an Executable Test Oracle

Summary

Lean is useful even when you do not verify the implementation itself. You can encode a specification as a small executable model, generate a test corpus from that model, feed the same inputs to a real checker, compiler, or runtime, and report disagreements as counterexamples.

The essential separation is:

  1. Correctness inside the model: Lean checks the semantic decision procedure and its theorems.
  2. Correspondence with the implementation: an external adapter runs the implementation and compares its observations with Lean's decisions.

A successful Lean build does not prove that the real compiler is correct. Conversely, a mismatch is not a failed formalization. It is a valuable result showing that the specification, model, adapter, or implementation disagrees with another layer.

Architecture

ADR / language specification
        |
        v
Lean semantic model ---- proofs and broken-model witnesses
        |
        v
versioned generated corpus
        |
        v
implementation adapter
        |
        +---- checker
        +---- compiler
        +---- runtime
        |
        v
match / mismatch / infrastructure error
        |
        v
minimal counterexample + issue + regression guard

In this architecture, Lean is the source of truth for expected semantics and the implementation is the observed system. If the specification is ambiguous, do not silently change the expected result to match current behavior. Present the smallest input and both outcomes to the domain owner, then record the decision.

1. Model only the required semantic slice

Do not begin by formalizing the parser, resolver, type checker, code generator, and runtime together. Retain only the types and rules required to decide the property under investigation.

For call typing, a useful first slice may contain only:

  • primitive types
  • nominal types with recursively visible type arguments
  • type variables
  • function signatures
  • positional and labeled arguments
  • type-variable substitutions
  • an accept-or-reject decision
inductive Ty where
  | int
  | string
  | array (element : Ty)
  | var (index : Nat)
  deriving DecidableEq, Repr

structure Signature where
  parameters : List Ty
  result : Ty
  deriving DecidableEq, Repr

inductive Verdict where
  | accept (result : Ty)
  | reject (reason : String)
  deriving DecidableEq, Repr

def checkCall (signature : Signature) (actual : List Ty) : Verdict :=
  if signature.parameters.length ≠ actual.length then
    .reject "arity-mismatch"
  else if signature.parameters = actual then
    .accept signature.result
  else
    .reject "type-mismatch"

In a real model, make successful checking return more than a Boolean. A proof-carrying result can retain resolved arguments, a substitution, the return type, and evidence that all of them follow the rules. Later theorems can reuse the fact that every accepted call is well typed.

Document where the model begins and what it excludes. For example: “This model begins after parsing and name resolution” and “Diagnostic wording and Wasm representation are out of scope.” These boundaries do not hide weaknesses; they state precisely what has and has not been established.

2. Keep positive, negative, and broken-model witnesses together

With negative cases alone, an oracle that always rejects can pass. With positive cases alone, an oracle that performs no checking can pass. For each rule, include at least:

  • a sanity case that must be accepted
  • a counterexample that must be rejected
  • a deliberately broken checker with the important rule removed

For example, a checker that compares only the outer constructor of Array[Int] and Array[String] erases the element type and accepts the mismatch.

def sameHead : Ty → Ty → Bool
  | .array _, .array _ => true
  | left, right => decide (left = right)

example : sameHead (.array .int) (.array .string) = true := by
  native_decide

This broken witness demonstrates that the corpus can actually detect type-argument erasure. It is a small form of mutation testing.

3. Represent cases as data and generate the corpus from Lean

Do not duplicate expected results by hand in shell code or in the implementation under test. Define each case once in Lean.

structure OracleCase where
  name : String
  issue : String
  source : String
  signature : Signature
  arguments : List Ty
  expected : Verdict

def OracleCase.actual (testCase : OracleCase) : Verdict :=
  checkCall testCase.signature testCase.arguments

def OracleCase.passes (testCase : OracleCase) : Bool :=
  decide (testCase.actual = testCase.expected)

def allCasesPass : Bool :=
  cases.all OracleCase.passes

example : allCasesPass = true := by
  native_decide

Each case should contain at least a stable name, a related issue, an expected verdict, details, and a source fixture for the real implementation. A TSV record might look like this:

version  1
name  issue  verdict  detail  source
same-type-variable-mismatch  #1001  reject  type-mismatch  fn same[T](...)

Version the corpus format so changes to the Lean-to-adapter contract are explicit. TSV is easy to inspect and consume from shell. JSON Lines is often better when observations contain nested diagnostics or multiple phases.

Committing the generated corpus makes semantic changes visible in review and lets adapters consume it without installing Lean. In CI, regenerate to a temporary file and diff it against the committed artifact. This rejects manual edits and forgotten regeneration.

lake build --wfail
lake env lean --run OracleMain.lean > "$actual"
diff -u oracle/call-typing.tsv "$actual"

4. Keep the implementation adapter small

The adapter should only turn one corpus row into a source file, execute the target command, and normalize the result into a stable observation.

if compiler check "$source_file" >"$log_file" 2>&1; then
  actual=accept
else
  status=$?
  case "$status" in
    1) actual=reject ;;
    *) actual=error ;;
  esac
fi

Separate semantic rejection from infrastructure failure. A type error reported with exit code 1 is an observation. A command crash, timeout, fixture-generation failure, or unexpected exit code is not an oracle mismatch and must be reported separately.

Diagnostic strings are unstable, so begin with exit codes or machine-readable error kinds. If wording and source locations matter, treat them as a separate presentation oracle.

5. Classify mismatches as counterexamples

At minimum, distinguish these outcomes:

Lean Implementation Interpretation
accept reject over-rejection, missing implementation, or an overly broad model
reject accept a soundness hole or an unresolved specification decision
accept/reject error compiler, adapter, or fixture infrastructure failure

Observing the checker, compiler, and runtime separately also finds phase-parity bugs.

expected: reject
checker:  accept
compiler: reject
runtime:  not-run

This is not harmless merely because compilation eventually rejects the program. If successful check results are consumed by an IDE, build cache, or agent tool, the phase contract is broken.

A general system should use an observation vector instead of one Boolean verdict:

Observation {
  parse
  check
  compile
  run
  normalized_output
}

Compare only the phases required for the property, while keeping “not run” distinct from failure.

6. Separate report mode from strict mode

If every mismatch fails CI immediately, an oracle cannot be introduced while known implementation gaps exist. Provide two modes:

  • Report mode lists semantic mismatches but exits successfully. Infrastructure errors still fail.
  • Strict mode fails when any semantic mismatch exists.

Start in report mode and build a counterexample ledger. Link every mismatch to an issue. Move a slice to strict mode once the implementation agrees. Do not make CI green by weakening expected semantics to current behavior.

7. Ways to generate more counterexamples

Turn production bugs into witnesses

The highest-value cases usually come from real incidents reduced to minimal source programs. Preserve a neighboring positive case as well as the failing case.

Enumerate a bounded semantic space

Enumerate a finite subset of types, effect rows, patterns, or state transitions in Lean and extract combinations that violate an invariant.

def smallTypes : List Ty :=
  [.int, .string, .array .int, .array .string]

def pairs (items : List Ty) : List (Ty × Ty) :=
  items.flatMap fun left =>
    items.map fun right => (left, right)

def disagreements : List (Ty × Ty) :=
  pairs smallTypes |>.filter fun pair =>
    sameHead pair.1 pair.2 && decide (pair.1 ≠ pair.2)

This resembles bounded model checking. It is not a proof over the infinite domain, but it naturally produces concrete and already-small witnesses.

Mutate rules

Remove type-argument comparison, erase effect rows, check only arity, or remove a cancellation-point restriction. If the corpus cannot distinguish the broken variant from the real model, it needs more cases.

Combine Lean with an external generator

A property-based testing tool can generate and shrink source ASTs while Lean supplies the semantic expectation. Remember that the source-to-model translator then becomes another component whose correctness must be tested.

8. A practical TDD loop

  1. Extract one allowed, forbidden, or invariant claim from a specification, ADR, or bug.
  2. Add the corresponding Lean example first and observe an undefined-name or proof-failure Red.
  3. Implement the smallest types, rules, and cases needed to make Lean Green.
  4. Confirm that a broken variant accepts the counterexample, proving the test is load-bearing.
  5. Regenerate the corpus only from Lean.
  6. Run the implementation adapter in report mode.
  7. File each mismatch with the minimal source, expected result, observed result, and affected phase.
  8. Keep the case after the implementation is fixed and promote the slice to a strict gate.

9. CI layout

Lean does not need to run on every ordinary change when the formal model is untouched. A path-filtered workflow can run only for:

on:
  pull_request:
    paths:
      - "formal/**"
      - ".github/workflows/formal.yml"

A useful job split is:

  • Lean build and theorem checking
  • generated-corpus freshness
  • adapter unit tests
  • implementation differential report
  • strict correspondence for slices with zero known mismatches

To detect implementation regressions, run a lightweight corpus adapter in normal CI or in another job filtered to the relevant implementation paths.

10. What not to formalize in Lean

Lean is a strong fit for semantics intended to remain a durable source of truth: type-and-effect relations, handler rules, module visibility, transition systems, and scheduler invariants.

Ordinary property or differential tests are often cheaper for:

  • exact diagnostic wording and colors
  • formatter whitespace
  • backend-specific numeric corner cases
  • OS, filesystem, and network integration
  • performance thresholds

If one of these contains a durable mathematical contract such as round-trip preservation, no out-of-bounds access, or exactly-once execution, formalize that abstract property and test implementation correspondence separately.

11. A concrete vibe-lang example

vibe-lang generates a versioned TSV corpus from a Lean call-typing model and feeds each source fixture to its self-hosted checker.

In the 2026-07-19 expansion, 22 of 25 cases matched and three remained as explicit counterexamples:

  • a nominal-head mismatch between Result and Option
  • a nested nominal-type mismatch
  • checker/compiler disagreement for mixed positional and labeled arguments

The Oracle was not weakened to match current behavior. The gaps were recorded in Issue #1001. PR #1002 adds cases for repeated type-variable correlation, nested generics, arity, argument modes, and broken-model witnesses.

The important claim is not “Lean proved the real compiler.” Lean made the semantic expectation executable and reviewable, while the bridge continuously turned implementation disagreement into reproducible source programs.

Checklist

  • The source of truth and observed system are distinct
  • The model's starting point and exclusions are documented
  • Positive cases, negative cases, and broken witnesses exist
  • Successful checking retains evidence
  • The corpus is generated only from Lean
  • The corpus format is versioned and deterministic
  • Semantic rejection and infrastructure failure are distinct
  • Report and strict modes are separate
  • Mismatches are preserved as minimal sources and linked issues
  • Phase parity is observed across checker, compiler, and runtime when needed
  • Lean model correctness and implementation correspondence are not called the same proof

Lean を実行可能なテスト Oracle として使う

要約

Lean は、実装コードそのものを証明しなくても役に立つ。仕様を小さな実行可能モデルとして書き、そのモデルからテスト corpus を生成し、実際の checker・compiler・runtime に同じ入力を与えて結果を比較できる。

重要なのは、次の二つを分離することだ。

  1. モデル内の正しさ: Lean が仕様上の判定と定理を検査する。
  2. 実装との対応: 外部 adapter が実装を実行し、Lean の判定との差分を反例として報告する。

Lean の build が成功しただけでは、実コンパイラの正しさは証明されない。反対に、実装との mismatch は Lean の失敗ではなく、仕様・モデル・adapter・実装のどこかに差分があることを示す価値の高い成果である。

全体構成

ADR / language spec
        |
        v
Lean semantic model ---- proofs and broken-model witnesses
        |
        v
versioned generated corpus
        |
        v
implementation adapter
        |
        +---- checker
        +---- compiler
        +---- runtime
        |
        v
match / mismatch / infrastructure error
        |
        v
minimal counterexample + Issue + regression guard

この構成では Lean が期待値の source of truth であり、実装は観測対象になる。ただし、仕様が曖昧な場合は実装に合わせて期待値を変える前に、最小の入力と両方の結果を domain owner に提示して判断を記録する。

1. 最小の意味論だけをモデル化する

最初から parser、名前解決、型検査、codegen、runtime の全体を形式化しない。調べたい性質を判定するために必要な型と規則だけを残す。

たとえば call typing なら、次で十分な場合がある。

  • primitive type
  • nominal type と再帰的な型引数
  • type variable
  • function signature
  • positional / labeled argument
  • 型変数 substitution
  • accept または reject の判定
inductive Ty where
  | int
  | string
  | array (element : Ty)
  | var (index : Nat)
  deriving DecidableEq, Repr

structure Signature where
  parameters : List Ty
  result : Ty
  deriving DecidableEq, Repr

inductive Verdict where
  | accept (result : Ty)
  | reject (reason : String)
  deriving DecidableEq, Repr

def checkCall (signature : Signature) (actual : List Ty) : Verdict :=
  if signature.parameters.length ≠ actual.length then
    .reject "arity-mismatch"
  else if signature.parameters = actual then
    .accept signature.result
  else
    .reject "type-mismatch"

実際のモデルでは、成功値を単なる Bool にせず、解決された引数、substitution、return type と、それらが規則に従った証拠を持つ構造体にするとよい。後段の定理が「accept なら well-typed」という事実を再利用できる。

モデルの開始地点と非目標も明記する。たとえば「parse と名前解決の後から始まる」「診断文面と Wasm 表現は扱わない」と宣言する。これは弱点の隠蔽ではなく、証明した範囲を正確にするための境界である。

2. 正例・負例・壊れたモデルを一緒に置く

負例だけでは、常に reject する壊れた Oracle でも緑になる。正例だけでは、検査を行わない Oracle が緑になる。そのため、各規則に少なくとも次を用意する。

  • accept すべき sanity case
  • reject すべき counterexample
  • 問題の規則を意図的に削った broken checker

例として、Array[Int]Array[String] の外側の constructor だけを比較する checker は、型引数を消去して誤受理する。

def sameHead : Ty → Ty → Bool
  | .array _, .array _ => true
  | left, right => decide (left = right)

example : sameHead (.array .int) (.array .string) = true := by
  native_decide

この broken witness があると、corpus が本当に型引数消去バグを検出できることを示せる。mutation testing の最小版と考えられる。

3. Case をデータにして Lean から corpus を生成する

テストの期待値を shell や実装側に手書きで複製しない。Lean 内の case を唯一の定義にする。

structure OracleCase where
  name : String
  issue : String
  source : String
  signature : Signature
  arguments : List Ty
  expected : Verdict

def OracleCase.actual (testCase : OracleCase) : Verdict :=
  checkCall testCase.signature testCase.arguments

def OracleCase.passes (testCase : OracleCase) : Bool :=
  decide (testCase.actual = testCase.expected)

def allCasesPass : Bool :=
  cases.all OracleCase.passes

example : allCasesPass = true := by
  native_decide

各 case には最低限、安定した名前、関連 Issue、期待 verdict、詳細、実装へ渡す source fixture を持たせる。TSV の例は次のようになる。

version  1
name  issue  verdict  detail  source
same-type-variable-mismatch  #1001  reject  type-mismatch  fn same[T](...)

corpus を versioned format にする理由は、Lean と adapter の契約変更を明示するためである。TSV は単純で shell から扱いやすい。入れ子の診断や複数 phase の観測を扱うなら JSON Lines もよい。

生成物を repository に commit すると、review で意味論の差分が見え、Lean を導入していない adapter からも利用できる。CI では再生成した一時ファイルと commit 済み corpus を diff し、手編集や生成忘れを失敗させる。

lake build --wfail
lake env lean --run OracleMain.lean > "$actual"
diff -u oracle/call-typing.tsv "$actual"

4. 実装 adapter は小さく保つ

adapter の責務は、corpus の一行を実際の source file にし、対象コマンドを実行して、安定した観測値へ変換することだけである。

if compiler check "$source_file" >"$log_file" 2>&1; then
  actual=accept
else
  status=$?
  case "$status" in
    1) actual=reject ;;
    *) actual=error ;;
  esac
fi

ここで reject と infrastructure error を分ける。型エラーの exit 1 は意味論上の観測だが、command crash、timeout、fixture生成失敗などは Oracle mismatch と混ぜてはいけない。

診断文字列は頻繁に変わるため、最初は exit code や machine-readable error kind を比較する。診断位置や文面を検査したい場合は、それを別の presentation Oracle として扱う。

5. mismatch を反例として分類する

比較結果は少なくとも次の三種類に分ける。

Lean 実装 意味
accept reject 実装の過剰 reject、未実装、またはモデルが広すぎる
reject accept soundness hole、または仕様判断の不一致
accept/reject error compiler、adapter、fixture の infrastructure failure

さらに checker、compiler、runtime を観測すると phase parity のバグを見つけられる。

expected: reject
checker:  accept
compiler: reject
runtime:  not-run

この場合、最終的に compile が拒否するから安全とは言えない。check の成功が IDE、build cache、agent tooling に使われるなら、phase 間の契約が壊れている。

一般化するなら verdict を一個の Bool ではなく、次の observation vector にする。

Observation {
  parse
  check
  compile
  run
  normalized_output
}

必要な phase だけを比較し、未実行と失敗を区別する。

6. report mode と strict mode を分ける

最初から mismatch を CI failure にすると、既知の実装 gap がある間は Oracle 自体を導入できない。二つの mode を用意する。

  • report mode: mismatch を一覧化するが成功終了する。infrastructure error は失敗。
  • strict mode: mismatch が一件でもあれば失敗。

導入時は report mode で counterexample ledger を作り、各 mismatch を Issue に接地する。実装が一致した領域から strict に移行する。期待値を現在の実装へ合わせて緑にするのではなく、差分を明示的な負債として保存する。

7. 反例を増やす方法

実バグから witness を作る

最も価値が高いのは、実際の incident を最小 source に縮めた case である。修正前の挙動だけでなく、隣接する正例も追加する。

小さい入力空間を列挙する

型、effect row、pattern、state transition の有限部分を Lean 内で列挙し、期待する不変条件を満たさない組を抽出できる。

def smallTypes : List Ty :=
  [.int, .string, .array .int, .array .string]

def pairs (items : List Ty) : List (Ty × Ty) :=
  items.flatMap fun left =>
    items.map fun right => (left, right)

def disagreements : List (Ty × Ty) :=
  pairs smallTypes |>.filter fun pair =>
    sameHead pair.1 pair.2 && decide (pair.1 ≠ pair.2)

これは bounded model checking に近い。無限領域全体の証明ではないが、具体的で縮小済みの witness を生成しやすい。

規則を mutation する

型引数比較を削る、effect row を空にする、arity だけ検査する、cancel point 制約を外す、といった broken variant を作る。新しい corpus がそれを検出しなければ、case が不足している。

外部 generator と組み合わせる

source AST の生成や shrinking は property-based testing tool に任せ、意味論上の期待値だけ Lean に問い合わせてもよい。この場合、source から Lean model への変換器も検査対象であることを忘れない。

8. TDD の実践手順

  1. 仕様、ADR、既存 bug から allowed / forbidden / invariant を一文で書く。
  2. その主張を表す Lean example を先に追加し、未定義または proof failure の Red を確認する。
  3. 最小の型・規則・case を実装して Lean を Green にする。
  4. broken witness が誤受理されることを確認し、test が load-bearing であることを示す。
  5. corpus を Lean から再生成する。
  6. 実装 adapter を report mode で実行する。
  7. mismatch を最小 source、期待、実測、関連 phase とともに Issue 化する。
  8. 実装修正後も case を削除せず、strict gate へ昇格する。

9. CI の分割

形式化の変更がない通常開発で毎回 Lean を実行する必要はない。たとえば次だけで起動する。

on:
  pull_request:
    paths:
      - "formal/**"
      - ".github/workflows/formal.yml"

推奨する job 分離は次の通り。

  • Lean build と theorem check
  • generated corpus の freshness check
  • adapter 自体の unit test
  • implementation differential report
  • zero mismatch の slice だけ strict correspondence

実装側の変更で correspondence regression を検出したい場合は、軽量な corpus runner を通常 CI から呼ぶか、関連 path に限定した別 job にする。

10. 何を Lean にしないか

Lean が向くのは、型と effect の関係、handler 規則、module visibility、state transition、scheduler invariant のように、長期間 source of truth にしたい意味論である。

次は多くの場合、通常の property test や differential test の方が安い。

  • 診断文面と色付け
  • formatter の細かな whitespace
  • 特定 backend の数値 corner case
  • OS、filesystem、network の integration
  • performance threshold

ただし、これらにも「roundtrip」「no out-of-bounds」「exactly once」のような永続的な数学的契約があるなら、その抽象部分だけを形式化し、実装 correspondence は別に検査できる。

11. vibe-lang での具体例

vibe-lang では、Lean の call-typing model から versioned TSV corpus を生成し、selfhost checker に source fixture を渡して比較している。

2026-07-19 時点の拡張では25 caseのうち22件が一致し、3件が明示的な counterexample として残った。

  • ResultOption の nominal head mismatch
  • nested nominal type mismatch
  • positional / labeled argument 混在の checker / compiler parity

これらは Oracle を現在の実装へ合わせて弱めず、Issue #1001 に接地した。PR #1002 は同一型変数の相関、nested generic、arity、argument mode の case と broken witness を追加している。

この例の重要な点は「Lean が実コンパイラを証明した」ことではない。Lean が意味論上の期待を実行可能かつ review 可能な形にし、実装との差分を再現可能な source program として継続的に検出していることである。

Checklist

  • source of truth と観測対象を区別した
  • モデルの開始地点と非目標を書いた
  • 正例、負例、broken witness がある
  • accept が証拠を保持する
  • corpus は Lean だけから生成される
  • corpus format は versioned で deterministic
  • reject と infrastructure error を分けた
  • report mode と strict mode を分けた
  • mismatch を最小 source と Issue に保存した
  • checker / compiler / runtime の phase parity を必要に応じて観測した
  • Lean build と実装 correspondence を同じ「証明」と呼んでいない
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment