Skip to content

Lean Model for PathMap with no dangling paths - #154

Open
imlvts wants to merge 12 commits into
Adam-Vandervorst:masterfrom
imlvts:spec/pruned-model
Open

imlvts wants to merge 12 commits into
Adam-Vandervorst:masterfrom
imlvts:spec/pruned-model

Conversation

@imlvts

@imlvts imlvts commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

The model includes a proof for PathMap<V> being a lattice and commutativity for PathMap<()>.
The proofs are in lean/PrunedModel/Lattice.lean, join_assoc, meet_assoc, join_idem, etc. for concrete types, section below, starting with unit_join_assoc.

imlvts and others added 12 commits October 2, 2026 16:03
`PathMapModel` models a trie as `List (Path × Option V)`, because `pathmap`
locations really do come in three states and `create_path` /
`remove_val(false)` produce the middle one.  Modelling that state is what most
of the specification's difficulty is: `subtract` needs a separate rule for
"where the source has no node, keep self's subtree verbatim, dangling paths
included", and every algebraic operation has to say which valueless locations
survive it.

`PrunedModel` takes the other branch.  It covers only the subset of the API
that cannot leave a dangling path, and in exchange drops the `Option`:

    entries : List (Path × V)

A location exists iff some key has it as a prefix -- existence is derived, not
stored, so a dangling path is unrepresentable rather than merely absent.  There
is no third state to specify, no prune flag to thread, and subtract is
pointwise.

The claim is correspondingly stronger.  Where `PathMapModel` says what the
crate does with dangling paths, this one says these operations never make one,
so a divergence is either a wrong value or a location that leads nowhere.
`Spec.no_dangling` is the proof, in three lines, next to 30 `#guard`s that
exercise the definitions on concrete tries at elaboration time.

Shares `PathMapModel.Basic` -- Path orders, ByteMask, ValOps, AlgStatus -- which
is vocabulary rather than model; everything else is independent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three pieces, mirroring the existing harness one for one:

* `lean/PrunedModel/Fuzz.lean` + `lean/PrunedMain.lean` -- the oracle, with the
  same wire format and resident-server protocol.
* `differential/src/pruned.rs` + `bin/pruned_trace.rs` -- the crate side.  One
  read source, so no `ReadSource` trait and no ACT mode; `graft_child_maps`,
  quarantined in the other harness, is simply not in this table.
* `lean/pruned_differential.py` -- the driver, which is `differential.py` with
  `ORACLE`, `TRACE_CANDIDATES` and `KNOWN` repointed.  Nothing about *how*
  inputs are run is duplicated.

Three restrictions define the subset.  No `create_path`, whose whole purpose is
a location with no value.  `prune = true` everywhere, with `prune_path` and
`prune_ascend` kept in the table as assertions -- the model says `0`, so a
non-zero count means something in the subset leaked a dangling path for it to
find.  And the write zipper is rooted at the map root, because one rooted below
it holds a node at its own root that survives as a location leading nowhere,
which `prune_path` is documented not to rise above; off-root writing is still
covered via `descend_to`.

`pruned_trace --check` needs no oracle at all: it asserts the zipper invariants
after every operation and, at the end, that the write target contains no
dangling path -- the whole claim the subset makes.

First 30 random inputs: 23 agree, and 6 of the 7 divergences are one class,
reported next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 of 70 divergences are one mechanism, across seven operations: an operation
whose result is empty materialises the focus anyway.  Where the focus existed it
leaves the chain behind, which is a missing prune; where it did not, it *creates*
it -- the write path builds the node chain down to the focus before discovering
it has nothing to put there, so `graft` with an empty source is accidentally
`create_path`.  `remove_prefix` creates the whole chain, not just the tip.

The seven are exactly the operations with no `prune` parameter: `graft`,
`graft_src_at`, `graft_masked_branches`, `meet_2`, `restrict`, `restricting`,
`remove_prefix`.  The ones that have one clean up correctly with it set, which is
what this harness passes throughout, so `meet_into` and `meet_2` -- the same
operation with and without a destination -- differ only in whether a caller can
ask for a tidy trie.

A second finding is the same mechanism one level down and worse there:
`graft_masked_branches` creates a *child* for a mask bit whose branch the source
lacks, so `child_mask` gains a bit for a branch holding nothing and every
consumer that recurses on the mask walks into it.

The remaining two shapes are already recorded against the other model
(`join_into` value bias, `join_map_into` status) and are in the corpus so a
regression shows up in whichever harness is run.

10 minimal reproducers, 9-47 bytes, in lean/pruned-corpus/.  The driver's shape
classifiers name the two new ones, so a future run can tell them from whatever
is underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first classifier keyed on `e` alone, so an input where the crate gained both
the focus and a child of it (`e0 c0` -> `e1 c1`) fell through as unclassified --
4 of 4000, all `graft_masked_branches`, all the same mechanism as the 897 that
did classify.

The rule is now stated the other way round, which is what it was always about:
`e` and/or `c` moved *up* while `v` and `n` did not move at all.  A location
appeared without a value appearing anywhere at or below the focus, so whatever
the crate created or kept leads nowhere.  Both halves of the finding -- the focus
itself and a child of it -- land in one entry.

The dump-shaped variant is renamed DANGLING-DUMP, since differential.py already
uses DANGLING-KEPT for a different shape and a shared name would make the two
indistinguishable in `KNOWN`.

4000 random programs on master now leave 0 unclassified.  README gains a section
on why there are two models at all, and the agreement table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Built the pruned harness on fuzz-fixes-v3 (ee7546e) and ran the same 4000
inputs with the oracle unchanged.

Neither new finding is fixed there: 974 hits, and all 8 dangling-path
reproducers still diverge -- including on graft_masked_branches, which the
branch does touch (3a16a57, "Fix graft_child_maps and graft_masked_branches"),
so that fix addressed something else.  Both classes the model *inherited* are
fixed: value bias by 3dae731, and Identity-not-reported-where-nothing-changed
across join_map_into, subtract_into and meet_into.

That split is the argument for keeping two models rather than one.
fuzz-fixes-v3 was driven by the model that reproduces dangling paths, and no
amount of fuzzing against it could have reported an operation for *making* one.

Two v3 columns are spec drift, not findings, and are now labelled as such:
v3 changes the k-path walk (420 inputs diverge in descend_first_k_path /
k_path_walk) and the integer psubtract (f8a4599 -- 45 of 600 show
subtract_into reporting Identity, the opposite direction from the inherited
finding).  This oracle is pinned to master's reading of both.  The STATUS-ONLY
shape is two-directional as a result, so its KNOWN note now says to read the
direction rather than the tag.

Also corrects the previous commit's claim that the Identity class was only a
join_map_into defect: it shows up in subtract_into and meet_into too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
join and meet are meant to be union and intersection.  PrunedModel/Lattice.lean
proves it, rather than checking it on examples as PathMapModel/Spec.lean's
#guards do.

The laws hold in this model and are *false* in the other one, which is the
interesting part.  PathMapModel.meet keeps a location only when it leads to a
surviving value, so `meet a a` drops a's dangling paths: idempotence fails on
exactly the states PrunedModel cannot represent.  A specification that
reproduces the crate's dangling-path behaviour cannot also be a lattice.

Four layers:

1. `valAt_mk'` -- canonicalisation moves no lookup.  This is the load-bearing
   lemma and the bulk of the file: dedupVals keeps the first binding per key,
   insertValSorted is a lookup update given a fresh key, and the insertion sort
   preserves lookups because dedupVals leaves keys distinct (`DistinctKeys`,
   proved here, is the invariant Map.lean's docstring only asserted).
2. `valAt_join` / `valAt_meet` -- each operation is pointwise on valAt.  This is
   the step that would fail for a model storing locations separately from
   values, where what meet does at a path depends on what lies below it.
3. The ten laws, from `IsLatticeVals`: associativity, idempotence, empty as
   join's identity and meet's annihilator, both absorptions, both
   distributivities.  Commutativity is a separate `IsCommVals`, because
   pathmap's integer pjoin returns Identity(SELF_IDENT) and ignores the
   counterpart -- `u64_not_isComm` proves join over u64 is not commutative, which
   is also why the value-bias class was worth fixing: with a non-commutative
   join, which operand lands on the left is observable.
4. `u64_isLattice` and `unit_isLattice` / `unit_isComm` discharge the
   hypotheses for the two instances the crate provides, plus `unitOps` in
   Basic.lean transcribing `impl Lattice for ()`.

Over () the correspondence is exact rather than merely satisfied: Option Unit
has two inhabitants, so valAt carries one bit per path and
`unit_join_isSome` / `unit_meet_isSome` are that bit's || and &&.  A trie over
() *is* a finite set of paths.

Not claimed: a Boolean algebra proper.  Path is infinite, so there is no top
element and no complement -- this is the lattice of finite sets of paths, a
distributive lattice with a least element.  The relative complement that would
make it generalized Boolean is PrunedMap.sub, whose pointwise characterisation
needs the DistinctKeys hypothesis and is not proved here.

Statements are `Agree a b` (equal at every path) rather than `a = b`; upgrading
them needs Path.lt shown to be a strict total order, which is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The laws were `Agree a b` -- equal at every path.  They are now `a = b`, which is
the model's own equality: `beqT`, which decides AlgebraicStatus::Identity,
compares entry lists structurally.

The bridge is that the canonical form is *unique*: a strictly-key-sorted
association list is determined by its lookup function
(`eq_of_sortedKeys_of_lookup`).  Proving it needs `Path.lt` to be a strict total
order, so `lt_trans` and `lt_total` join `lt_irrefl` in Basic.lean, beside the
definition -- `lt_irrefl` moves there from PathMapModel/Spec.lean, where it was
the only one of the three.  `SortedKeys` folds distinctness into strictness, so
one predicate is the whole invariant, and `canonical_mk'` shows everything the
model builds satisfies it.

Where the right-hand side is an operation's output there is no side condition.
Where it is the bare `a` -- idempotence, the `empty` identity, both absorptions
-- `a` must be canonical, and that is not a technicality: a trie whose entry list
binds a key twice really is not equal to its own join, because the join keeps
only the first binding.  `canonical_setVal`, `canonical_removeVal`,
`canonical_subtrie` and friends discharge it for anything the model constructs.

The file ends with all ten laws applied at `unitOps` and at `u64Ops` with
nothing left abstract, as `example`s the build checks -- twelve for `()`
including both commutativity laws, ten for `UInt64` without them.

67 theorems, no `sorry`, no new axiom.  Both harnesses unchanged: 5000 pruned
inputs and 3000 old-harness inputs at 0 new divergences, cargo test --lib 1035.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…trie level

Two gaps, both found by asking where commutativity was actually restricted.

The restriction was real but invisible: `IsCommVals ops` as a hypothesis,
discharged only by `unit_isComm`, with `u64_not_isComm` ruling out `u64Ops`.  But
the only place the `()` laws were *applied* was a block of anonymous `example`s,
so "join over () is commutative" was checked by the build and citable by nobody.
All twenty-two instantiations are now named theorems: twelve for `PrunedMap Unit`
including `unit_join_comm` / `unit_meet_comm`, ten for `PrunedMap UInt64`.

The second gap was rigour, not presentation.  `u64_not_isComm` rules out the
*value-level* hypothesis, which is not the same claim as the trie operation being
non-commutative -- a priori the asymmetry could have been invisible once lifted.
`u64_join_not_comm` and `u64_meet_not_comm` now prove it where it is claimed:

    ¬ ∀ a b : PrunedMap UInt64, join u64Ops a b = join u64Ops b a

by the two single-path tries {[0] -> 1} and {[0] -> 2}, which join to themselves
in whichever order puts them on the left.  Four `#guard`s run the same
counterexample through `beqT` -- the equality the model uses to decide
AlgebraicStatus::Identity -- so the claim is both proved and executed, and a
fifth shows the same two paths over `()` give the same trie, which is where the
asymmetry goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`IsLatticeVals` is a condition on a value type, and `PrunedMap V` has join/meet
of its own, so the question is whether the structure composes.

It does not for `PrunedMap V`, and the reason is worth having written down.  Five
of the ten laws hold -- both associativities, both `none` laws, meet over join --
and five fail, because the *type* has more inhabitants than the lattice has
elements.  `nonCanonical = ⟨[([0],1),([0],2)]⟩` binds one key twice and `join`
keeps only the first binding, so `join a a = a` is false for it.  Every failing
law is one whose right-hand side is an operand rather than another operation's
output -- precisely the laws carrying the `Canonical` side condition in
Lattice.lean §7.  That side condition was never bookkeeping; it is this
obstruction, and `trieOps_not_isLattice` makes it a theorem.

Restricting to `CMap V = { t : PrunedMap V // Canonical t }` fixes it: all ten
laws, the subtype's own proof field discharging every side condition.  So tries
nest, and it is `CMap V` rather than `PrunedMap V` that is the lattice object.

One law needed more than the §3 bundle.  `joinDistribMeet` has a single case,
`(some a, some b, none)`, that reduces to `meet (join a b) a = a` -- absorption
with the join on the *left*.  A commutative lattice gets that from the standard
axiom read backwards; pathmap's join is not commutative, so the two orientations
are different statements and only one is an axiom.  Added as `IsFlipAbsorb`, with
both instances discharging it: `u64Ops` satisfies it despite being
non-commutative, because a left-biased `pjoin` makes `join x y` agree with `x`
wherever `x` has a value, which is exactly where the meet then looks.  So nesting
works over `UInt64` as well as `()`, to any depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`entries : List (Path × V)` becomes a structure with a second field,
`sorted : SortedKeys entries`.  Keys strictly increasing, which by
`distinctKeys_of_sortedKeys` also rules out a key bound twice -- one predicate is
the whole invariant, so one proof field suffices.

What that buys, in order of how much it mattered:

* **Every side condition goes.**  The ten lattice laws were `=` only with
  `Canonical a` wherever the right-hand side was the bare `a` -- idempotence, the
  `empty` identity, both absorptions.  The hypothesis existed because the old
  type admitted `⟨[([0],1),([0],2)]⟩`, and such a trie really is not equal to its
  own join.  That expression is now a type error, so the hypothesis is gone from
  all ten laws and from the twenty-two per-instance theorems.
* **`PrunedMap` becomes the lattice object.**  `Nested.lean`'s `CMap` subtype
  existed only to exclude the junk; it is deleted, `trieOps_not_isLattice`
  (which was a theorem) is replaced by `trieOps_isLattice` (all ten laws), and
  the file drops from 230 lines to 169 while gaining a three-deep nesting
  example.
* **`eq_of_agree` is one line.**  `PrunedMap.ext`, from
  `eq_of_sortedKeys_of_lookup` plus proof irrelevance on the `Prop` field.

Mechanically: the list-level canonicity development -- `dedupVals`,
`insertValSorted`, `normVals`, `DistinctKeys`, `SortedKeys` and their twenty-odd
lemmas -- moves out of `Lattice.lean`'s first and last thirds into a new
`Canon.lean`, since `Map.lean` now needs it to build the proof field.  No proof
changed; they were already there, just on the wrong side of the file boundary.
`Lattice.lean` 851 -> 496 lines.  `Repr` is a hand-written instance now, the
`Prop` field making `deriving` unavailable and pointless.

I asked first rather than using `Std.TreeMap` as requested: TreeMap ships an
`Equiv` relation precisely because `=` is too strong for it -- tree shape is not
determined by contents -- so it would have removed the side conditions at the
cost of the equational form.  The sorted-list subtype gets both.

Unchanged: 20000 pruned inputs and 3000 old-harness inputs at 0 new divergences,
the same known class at the same rate, cargo test --lib 1035, zero warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The empty-source-node exit used to `return` the node status on its own, which
threw away a value the operation had already written: with no root value in the
destination and only a root value in the source, the trie gained a value and the
call reported Identity.  That is issue Adam-Vandervorst#139, and master fixed it in PR Adam-Vandervorst#142
(276fca0) by ending both exits of join_map_into in the same
`node_status.merge(val_status, true, true)`.  Both models still described the
early return, so they reported Identity where the crate now reports Element --
104 of 4000 inputs on the pruned harness, which I had mislabelled as a crate
defect fixed on fuzz-fixes-v3.  It is the opposite: master moved and the models
lagged.  The non-short-circuit exit also passed `valWasNone` as merge's second
flag where the crate passes `true`.

Pruned harness: the Identity class drops from 104/4000 to 8/4000, the rest being
genuine subtract_into/meet_into imprecision.  The old harness is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both of these I got wrong on the first pass, and both are the kind of mistake
that makes a findings file worse than useless.

The `join_map_into` Identity class was never a crate defect.  Master changed that
status in PR Adam-Vandervorst#142 (276fca0, issue Adam-Vandervorst#139) -- the empty-source-node exit used to
`return` the node status on its own, throwing away a value status for a value it
had already written -- and both models still described the early return.  So
master moved and the models lagged, the opposite of what §4 claimed.
`fuzz-fixes-v3` scored 0 on this not by fixing anything but by predating the PR.

And `3e839e8`, which looks like the obvious candidate to pick for it, is already
on master as `1e3b1c3` (PR Adam-Vandervorst#116).

The KNOWN notes said "fixed on fuzz-fixes-v3" for both shared classes; only the
value-bias one is.  Note added: check `git log master -S<string>` before
attributing anything to v3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@luketpeterson

Copy link
Copy Markdown
Collaborator

The question is, do we want A. a model that is agnostic about dangling paths? Or B. one that has a strong opinion that dangling paths should not exist?

Both start by defining correctness only as a set of values at paths. But they diverge on the details of what they model. It appears this PR implements B.

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.

2 participants