Skip to content

Model the type system in Lean, and check the solver's subtyping and bound merge against it - #109

Draft
dpmills wants to merge 1 commit into
dmills/type-merge-fuzzfrom
dmills/formal-model
Draft

Model the type system in Lean, and check the solver's subtyping and bound merge against it#109
dpmills wants to merge 1 commit into
dmills/type-merge-fuzzfrom
dmills/formal-model

Conversation

@dpmills

@dpmills dpmills commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

The ground subtype relation and the solver's bound merge exist only operationally — constrain_go and CompactType::merge implement them without ever writing them down — so their properties (is subtyping transitive? is the merge associative? does the checker decide the relation?) have been readings of code, not statements. This PR adds formal/, a Lean 4 model that states both declaratively over the index coordinate the stack below establishes, proves the metatheory sorry-free, and pins the model to the implementation with a differential oracle per operation. The headline theorem for subtyping is transitivity with no fragment restriction and no environment side conditions: earlier editions of this model (developed on the retired name-coordinate stack, #79) proved it only for a canonical-spelling fragment under six identity-acting rename environments, and the σ-gap they reconciled never forms here — the measured sense in which the coordinate below is the right design. For the merge it is associativity with no side condition, and uniqueness: the merge is the only way the induced order admits of combining two bounds.

Each oracle earned its keep before landing. The subtype oracle's first sweep caught a real capture in the solver's Fun/Fun opening, fixed below in the telescope PR with the mechanism it repairs. The merge oracle's first sweep caught the model intersecting away refinements a hole should have passed through, fixed here.

The model

Ty/Pred mirror the ground fragment of Type, with Pred.piBound mirroring Name::PiBound and refined carrying a refinement set (grammar and adjudications). Sub is one constructor per constrain_go arm; deliberate departures (no trivial-equality short-circuit, no partition-collapse arm, no binder-correspondence edge) are each recorded as tracked decisions in CclFormal/Sub.lean. Proved: Sub.refl under the uniquely-keyed Ty.WF (naming a builder invariant the Rust leaves implicit), checker soundness/completeness giving decidability (Equiv.lean), sub_trans for well-formed types (Transitivity.lean), refinement-order unobservability, and the M2 safety battery — progress, preservation, refinement soundness (Safety.lean).

The merge algebra

Merge.lean mirrors compact.rs's bound-merging: CTy for the ground fragment of CompactType, merge for the polar CompactType::merge/CompactFun::merge, and eqv for CompactType's own PartialEq, so every theorem is quotient-compatible by construction. Proved: eqv is an equivalence, merge is commutative, idempotent under the input-bound invariant wf, a congruence, and associative unconditionally at both polarities — the kinds join in the flat semilattice unknown < {data, compute} < conflict (joinKind) and the domains are combined by polarity alone, so no fold step reads a value a later step can change. The fold is therefore invariant under permutation and duplication of the bound list, and merge pol is the least upper bound of the order it induces (le) and the only one up to eqv (join_unique), with the empty position as the least element.

Associativity was a non-theorem when this model first stated it, and the counterexample was real: the solver selected a function slot's domain combination from a kind that was not settled, so bound arrival order decided accept-vs-reject and one association accepted a collection whose domain was the meet of two others'. The PR below defers that choice to the resolved kind at coalesce, which is what removes the side condition here.

The differential oracles

lake build produces the oracle binary; each case is one JSONL line tagged by "op", and tests/differential_oracle.rs generates cases with the seeded generator it shares with the type-merge fuzz (tests/type_gen/mod.rs), computes the solver's answer, and diffs. The harness is an integration test rather than a #[cfg(test)] module in the library: it is a test, and the only solver internal it cannot otherwise reach is CompactType::merge, which the test-helpers feature opens for it.

  • "sub": seeded ground pairs, each closed into the ground fragment (close_all — every arrow's own-binder references as indices), diffed against subCheck. 150k pairs across five seeds, zero mismatches, roughly one pair in twelve index-bearing.
  • "merge": bound lists folded through CompactType::merge exactly as compact_go folds a variable's bounds, every step diffed against merge up to eqv. Each step's left operand is the previous step's result, so the conflicted and multi-alternative states only merging produces are operands too; operands sometimes carry a kind variable, the only route to KindMerge::Unknown. Clean at 40k steps.

CoErr has no emptyProduct: the empty product is IncompatibleBounds on both sides, since bounds with no common shape is what that error already says.

Both skip loudly when the oracle binary is absent, so machines without a Lean toolchain stay green, and ./ci.sh oracle builds the model and runs them where lake exists — without that gate nothing notices the model drifting from the solver, in either direction. CAMBRA_DIFF_SEED/CAMBRA_DIFF_N/CAMBRA_DIFF_DUMP control runs.

Reading order

formal/design.md (the plan, adjudications, and findings — every claim in it is true of the code beneath this PR), then Ty.leanSub.leanDecide.lean/Equiv.leanTransitivity.lean, with Term.lean/Safety.lean and Merge.lean as independent limbs. In Merge.lean, read the refinement-slot laws and joinKind before the merge theorems that use them; the order and uniqueness section is last and depends only on the semilattice laws. The model's development history, including the name-coordinate editions this replaces, is on closed #79.

@dpmills
dpmills force-pushed the dmills/canonical-discharge branch from 145d805 to 643aff2 Compare August 19, 2026 20:30
@dpmills
dpmills force-pushed the dmills/formal-model branch from 11b8722 to fa57099 Compare August 19, 2026 20:30
@dpmills
dpmills force-pushed the dmills/canonical-discharge branch from 643aff2 to a31b7d6 Compare August 19, 2026 21:36
@dpmills
dpmills force-pushed the dmills/formal-model branch from fa57099 to a84a09a Compare August 19, 2026 21:36
@dpmills
dpmills force-pushed the dmills/canonical-discharge branch from a31b7d6 to 66bc8ba Compare August 19, 2026 23:06
@dpmills
dpmills force-pushed the dmills/formal-model branch from a84a09a to 5ecb233 Compare August 19, 2026 23:06
@dpmills
dpmills force-pushed the dmills/canonical-discharge branch from 66bc8ba to ceac235 Compare August 19, 2026 23:34
@dpmills
dpmills force-pushed the dmills/formal-model branch from 5ecb233 to 06e1402 Compare August 19, 2026 23:34
@dpmills
dpmills force-pushed the dmills/canonical-discharge branch from ceac235 to 5ebd593 Compare August 20, 2026 00:12
@dpmills
dpmills force-pushed the dmills/formal-model branch 3 times, most recently from 4cfdc57 to 6991741 Compare August 20, 2026 01:39
@dpmills
dpmills changed the base branch from dmills/canonical-discharge to dmills/domain-rule-at-coalesce August 20, 2026 01:39
@dpmills dpmills changed the title Add a Lean model of the ground type system and fuzz the solver against it Model the type system in Lean, and check the solver's subtyping and bound merge against it Aug 20, 2026
@dpmills
dpmills changed the base branch from dmills/domain-rule-at-coalesce to dmills/domain-set August 20, 2026 05:37
@dpmills
dpmills force-pushed the dmills/formal-model branch from abf8036 to b96ca2c Compare August 20, 2026 05:37
@dpmills
dpmills changed the base branch from dmills/domain-set to dmills/empty-product August 20, 2026 08:21
@dpmills
dpmills force-pushed the dmills/formal-model branch 3 times, most recently from b17e147 to b2ad7df Compare August 20, 2026 20:27
@dpmills
dpmills force-pushed the dmills/empty-product branch from 4066331 to 6763585 Compare August 20, 2026 20:51
@dpmills
dpmills force-pushed the dmills/formal-model branch from d8be791 to bbe7fef Compare August 20, 2026 21:57
@dpmills
dpmills force-pushed the dmills/empty-product branch from 6763585 to b3faa41 Compare August 20, 2026 23:24
@dpmills
dpmills force-pushed the dmills/formal-model branch from bbe7fef to eaa3093 Compare August 20, 2026 23:24
@dpmills
dpmills force-pushed the dmills/empty-product branch from b3faa41 to 5ec1b3c Compare August 21, 2026 04:29
@dpmills
dpmills force-pushed the dmills/formal-model branch from eaa3093 to b21a940 Compare August 21, 2026 04:29
@dpmills
dpmills force-pushed the dmills/empty-product branch from 5ec1b3c to c18566f Compare August 21, 2026 04:39
@dpmills
dpmills force-pushed the dmills/formal-model branch from b21a940 to 2761e6a Compare August 21, 2026 04:39
@dpmills
dpmills force-pushed the dmills/empty-product branch from a98df57 to 776dd2b Compare August 21, 2026 05:47
@dpmills
dpmills force-pushed the dmills/formal-model branch from 9e65f4f to 44491f6 Compare August 21, 2026 05:47
@dpmills
dpmills force-pushed the dmills/formal-model branch from 44491f6 to 313631b Compare August 21, 2026 18:41
@dpmills
dpmills force-pushed the dmills/empty-product branch 2 times, most recently from 6ba5a31 to ce09a11 Compare August 21, 2026 19:42
@dpmills
dpmills force-pushed the dmills/formal-model branch 2 times, most recently from 048656a to eab05cc Compare August 21, 2026 20:45
@dpmills
dpmills force-pushed the dmills/empty-product branch from 89b22b5 to 9321478 Compare August 21, 2026 22:23
@dpmills
dpmills force-pushed the dmills/formal-model branch from eab05cc to 4be25f2 Compare August 21, 2026 22:23
@dpmills
dpmills force-pushed the dmills/empty-product branch from 9321478 to f4d7b30 Compare August 22, 2026 00:08
@dpmills
dpmills force-pushed the dmills/formal-model branch from 4be25f2 to 46128a8 Compare August 22, 2026 00:08
@dpmills
dpmills changed the base branch from dmills/empty-product to dmills/type-merge-fuzz August 22, 2026 00:09
@dpmills
dpmills force-pushed the dmills/type-merge-fuzz branch from e08acb4 to fe0519b Compare August 22, 2026 00:22
@dpmills
dpmills force-pushed the dmills/formal-model branch 2 times, most recently from b5c2817 to 387c167 Compare August 22, 2026 00:23
@dpmills
dpmills force-pushed the dmills/type-merge-fuzz branch from fe0519b to bd42fd4 Compare August 22, 2026 00:35
@dpmills
dpmills force-pushed the dmills/formal-model branch from 387c167 to 84a1710 Compare August 22, 2026 00:35
@dpmills
dpmills force-pushed the dmills/type-merge-fuzz branch from bd42fd4 to e378367 Compare August 22, 2026 00:46
@dpmills
dpmills force-pushed the dmills/formal-model branch from 84a1710 to 0149c9c Compare August 22, 2026 00:46
@dpmills
dpmills force-pushed the dmills/type-merge-fuzz branch from e378367 to e8a20dc Compare August 22, 2026 01:06
@dpmills
dpmills force-pushed the dmills/formal-model branch from 0149c9c to df65527 Compare August 22, 2026 01:06
…ound merge against it

The ground subtype relation and the solver's bound merge exist only operationally — `constrain_go` and `CompactType::merge` implement them without ever writing them down — so their properties (is subtyping transitive? is the merge associative? does the checker decide the relation?) have been readings of code, not statements. This PR adds `formal/`, a Lean 4 model that states both declaratively over the index coordinate the stack below establishes, proves the metatheory sorry-free, and pins the model to the implementation with a differential oracle per operation. The headline theorem for subtyping is **transitivity with no fragment restriction and no environment side conditions**: earlier editions of this model (developed on the retired name-coordinate stack, #79) proved it only for a canonical-spelling fragment under six identity-acting rename environments, and the σ-gap they reconciled never forms here — the measured sense in which the coordinate below is the right design. For the merge it is **associativity with no side condition**, and uniqueness: the merge is the only way the induced order admits of combining two bounds.

Each oracle earned its keep before landing. The subtype oracle's first sweep caught a real capture in the solver's Fun/Fun opening, fixed below in the telescope PR with the mechanism it repairs. The merge oracle's first sweep caught the model intersecting away refinements a hole should have passed through, fixed here.

### The model

`Ty`/`Pred` mirror the ground fragment of `Type`, with `Pred.piBound` mirroring `Name::PiBound` and `refined` carrying a refinement set ([grammar and adjudications](https://github.com/cambra-dev/cambra/blob/dmills/formal-model/formal/design.md)). `Sub` is one constructor per `constrain_go` arm; deliberate departures (no trivial-equality short-circuit, no partition-collapse arm, no binder-correspondence edge) are each recorded as tracked decisions in `CclFormal/Sub.lean`. Proved: `Sub.refl` under the uniquely-keyed `Ty.WF` (naming a builder invariant the Rust leaves implicit), checker soundness/completeness giving decidability (`Equiv.lean`), `sub_trans` for well-formed types (`Transitivity.lean`), refinement-order unobservability, and the M2 safety battery — progress, preservation, refinement soundness (`Safety.lean`).

### The merge algebra

`Merge.lean` mirrors `compact.rs`'s bound-merging: `CTy` for the ground fragment of `CompactType`, `merge` for the polar `CompactType::merge`/`CompactFun::merge`, and `eqv` for `CompactType`'s own `PartialEq`, so every theorem is quotient-compatible by construction. Proved: `eqv` is an equivalence, `merge` is commutative, idempotent under the input-bound invariant `wf`, a congruence, and **associative unconditionally at both polarities** — the kinds join in the flat semilattice `unknown < {data, compute} < conflict` (`joinKind`) and the domains are combined by polarity alone, so no fold step reads a value a later step can change. The fold is therefore invariant under permutation and duplication of the bound list, and `merge pol` is the least upper bound of the order it induces (`le`) and the only one up to `eqv` (`join_unique`), with the empty position as the least element.

Associativity was a *non*-theorem when this model first stated it, and the counterexample was real: the solver selected a function slot's domain combination from a kind that was not settled, so bound arrival order decided accept-vs-reject and one association accepted a collection whose domain was the meet of two others'. The PR below defers that choice to the resolved kind at coalesce, which is what removes the side condition here.

### The differential oracles

`lake build` produces the oracle binary; each case is one JSONL line tagged by `"op"`, and `tests/differential_oracle.rs` generates cases with the seeded generator it shares with the type-merge fuzz (`tests/type_gen/mod.rs`), computes the solver's answer, and diffs. The harness is an integration test rather than a `#[cfg(test)]` module in the library: it is a test, and the only solver internal it cannot otherwise reach is `CompactType::merge`, which the `test-helpers` feature opens for it.

- `"sub"`: seeded ground pairs, each closed into the ground fragment (`close_all` — every arrow's own-binder references as indices), diffed against `subCheck`. 150k pairs across five seeds, zero mismatches, roughly one pair in twelve index-bearing.
- `"merge"`: bound lists folded through `CompactType::merge` exactly as `compact_go` folds a variable's bounds, every step diffed against `merge` up to `eqv`. Each step's left operand is the previous step's result, so the conflicted and multi-alternative states only merging produces are operands too; operands sometimes carry a kind *variable*, the only route to `KindMerge::Unknown`. Clean at 40k steps.

`CoErr` has no `emptyProduct`: the empty product is `IncompatibleBounds` on both sides, since bounds with no common shape is what that error already says.

Both skip loudly when the oracle binary is absent, so machines without a Lean toolchain stay green, and `./ci.sh oracle` builds the model and runs them where `lake` exists — without that gate nothing notices the model drifting from the solver, in either direction. `CAMBRA_DIFF_SEED`/`CAMBRA_DIFF_N`/`CAMBRA_DIFF_DUMP` control runs.

### Reading order

`formal/design.md` (the plan, adjudications, and findings — every claim in it is true of the code beneath this PR), then `Ty.lean` → `Sub.lean` → `Decide.lean`/`Equiv.lean` → `Transitivity.lean`, with `Term.lean`/`Safety.lean` and `Merge.lean` as independent limbs. In `Merge.lean`, read the refinement-slot laws and `joinKind` before the `merge` theorems that use them; the order and uniqueness section is last and depends only on the semilattice laws. The model's development history, including the name-coordinate editions this replaces, is on closed #79.
@dpmills
dpmills force-pushed the dmills/formal-model branch from df65527 to a20dd09 Compare August 22, 2026 03:43
@dpmills
dpmills force-pushed the dmills/type-merge-fuzz branch from e8a20dc to b417392 Compare August 22, 2026 03:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant