Skip to content

TBD: Split this? Fixes for no-dangling-path - #155

Draft
imlvts wants to merge 18 commits into
Adam-Vandervorst:masterfrom
imlvts:fix/dangling-paths
Draft

imlvts wants to merge 18 commits into
Adam-Vandervorst:masterfrom
imlvts:fix/dangling-paths

Conversation

@imlvts

@imlvts imlvts commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Built on top of #154.
This has three fixes:

  • An empty write materialises the focus
  • A value collision resolves to the counterpart
  • Identity not reported where nothing changed
  • join_map_into's status. both models described an early return that master had already replaced with node_status.merge(val_status, true, true)
  • meet as an intersection of locations

imlvts and others added 18 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>
The seven operations in PRUNED_FINDINGS.md #1 and #2 are one bug in one place.
`graft_internal` is the only code that learns "the result is empty", fourteen
sites across nine operations hand it a `None`, and its `None` arm reads

    None => { self.remove_branches(false); }

Flipping that `false` is not available: `meet_into` and `subtract_into` are
documented to leave dangling paths when their own `prune` is false.  So
`graft_internal` takes the flag instead of deciding it.  The three operations
that own a `prune` parameter forward it -- which is how the code already reads,
since each follows the call with `if prune { self.prune_path(); }` -- and the
rest pass `true`, having no caller intent to honour.

Three places do their own removing and need the same change:

* the value step of graft/graft_src_at/graft_map.  The node step cannot reclaim
  a location whose value is removed after it, so both halves must prune, and
  neither alone suffices: a focus with a value and no children is fixed only by
  the value step, one with children and no value only by the node step.
* graft_masked_branches' own removals, and the identical pair in the
  ZipperWriting default implementation.
* graft_masked_branches' bulk arm for masks of three bits or more, which merges
  through a borrow of the focus node and never reaches graft_internal.  It gets
  a prune_path() after the merge; that is already a no-op unless the focus is a
  dangling tip, so it costs one node_is_empty check.

4000 random programs, seed 11, through lean/pruned_differential.py: 901
divergences in this class become 0, and agreement rises from 3005 to 3888 of
4000.  cargo test --lib stays at 1030 passing.

meet_2 is deliberately left alone, and PRUNED_FINDINGS.md says why: pruning it
is worth 100 of the 901, but it breaks a regression test that uses meet_2 to
*construct* a dangling path in a specific node representation, and it collides
with the meet-is-an-intersection rule settled on fuzz-fixes-v3.  The defect
there is that meet_2 has no prune parameter where meet_into does -- an API
decision, not a bug fix.

PathMapModel moves with the crate, since it specified the old behaviour: a
`tidy` helper (prune_path, which already no-ops unless the focus is a dangling
tip) on graft_map, remove_prefix, restrict and restricting.  Its stop depth is
the zipper's root, not the map root, because prune_path_internal breaks its
ascent at root_len -- getting that wrong cost 421 spurious divergences before
the harness said so.  `takeThenGraft` gains the proviso that there was something
to take: take_map(false) at an empty focus leaves a dangling path and returns
None, and graft_map of an empty map now reclaims it, so that one state is where
the round-trip genuinely stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cherry-pick of 94b4043 from archive-bugfix/value-bias-by-node-layout:
five swapped-orientation sites in trie_node/line_list_node/
dense_byte_node, and join_k_path_into's fold made ascending to match
`PathMap.dropHead`.

Verified: all four corpus reproducers (join_into, join_k_path_into,
meet_2, meet_into) go from failing to agreeing, the class falls from 10
hits to 0 on 20000 crate inputs and 2 to 0 on 5000 ACT inputs at seed 7,
and agreement rises 19960 -> 19975 and 4957 -> 4959.  The finding 8
class falls 20 -> 16 and the dangling class 2 -> 1 with it.

Three hunks conflicted with the join_into commit applied earlier.  The
two tiny-ref dispatch arms keep that commit's form, which fixes the same
orientation and also reports the identity mask.  The third is a real
overlap: it takes this commit's operand order, since the join is
left-biased and `a` must be on the left, while keeping the join_into
commit's identity reporting, reading COUNTER_IDENT where it read
SELF_IDENT because swapping the operands swaps what the bits refer to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3dae731)
`3dae731` ("Make meet/join value bias independent of node layout") from
fuzz-fixes-v3 is the one fix this branch genuinely needed from there.  Three
hunks in line_list_node.rs conflicted; the commit documents its own resolutions,
and master is newer in two of them -- `clone_as_dense` for cell nodes, and
`preserve_prune_limit` -- so those keep master's form and take only the new
`list_is_left` argument.  The third takes the commit's operand order (the join
is left-biased, so `a` must be on the left) and its COUNTER_IDENT read, since
swapping the operands swaps what the bits refer to.  Comments are master's
shorter ones throughout.  The merge also had to restore a `}` the conflict
region ate.

Two other candidates turned out not to be fixes to pick:

* `3e839e8` already landed on master as `1e3b1c3` (PR Adam-Vandervorst#116), so cherry-picking
  it yields comment churn and duplicate tests.
* 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) and both models still described
  the early `return` it replaced.  Fixed in the preceding commit; the class
  falls from 104 of 4000 to 8, and fuzz-fixes-v3 scored 0 on it only by
  predating the PR.

Then the bigger fuzz.  200000 random programs at seed 7 through the pruned
harness: 193413 agree, 0 unclassified, 0 value bias.  What is left is 5901
inputs of `meet_2`, which is deliberately not pruned, and 686 of the
subtract_into/meet_into Identity imprecision this branch does not touch.
`pruned_trace --check`, which needs no oracle, aborts on 16 of 4000 -- and on 0
once meet_2 prunes too, which independently confirms meet_2 is the only source
left.  Its checker also had to stop flagging the root of an empty map, which its
own docstring already said to exempt.

The old harness, 30000 programs: 28989 agree, 0 unclassified.  Its 5 residual
`remove_prefix` divergences are FINDINGS.md Adam-Vandervorst#7 -- the prune flag's in-node
effect -- made reachable by operations that now prune unconditionally, and only
off the map root.  That is also why `tidy` is unconditional: guarding it on "did
the removal report a removal" is the intuitive reading and costs 302 divergences
in 8000 inputs against 5 in 30000 for not guarding, because `node_prune_limit`
reclaims within the node while reporting `false`.

cargo test --lib: 1033 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The class the 200000-input sweep leaves behind, reduced: a destination
{[] -> 0, [1] -> 0} and a source node {[1] -> 1}.  psubtract(0, 1) keeps the
destination's 0, so the two dumps are byte-identical, and the crate still
reports Element where the model reports Identity.

Two layers.  u64::psubtract returns Element(*self) where the values differ --
"here is a newly computed value", which is self's own -- rather than
Identity(SELF_IDENT), which ring.rs's own docs name as the way a
non-commutative op reports no change.  And the node algebra assembles its
status as it walks without ever asking whether the node it built equals the one
it replaces, where nodeStatus decides by comparing them.

Worth stating what it costs, since the data is correct either way:
subtract_into's Identity arm returns without calling graft_internal, while the
Element arm grafts the new node over the old, so a destination shared with
another trie is copied apart for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cherry-pick of a subagent's 24ccaaf, plus the one model line it has to
land with.

`impl DistributiveLattice for u64` and `for u16` in src/ring.rs answered
a subtraction of unequal values with `Element(*self)` -- the right value
under the wrong constructor.  The node algebra propagates identity
*masks*, not values, so a single `Element` below a node forced the whole
node, and with it `subtract_into`, to report `Element` for a byte-
identical trie.  `impl DistributiveLattice for bool` in the same file
already returned `Identity(SELF_IDENT)`; the integer instances were the
outliers.

`u64Ops` in lean/PathMapModel/Basic.lean is a transcription of that
instance, not an independent claim -- its own docstring says it
"reproduces it exactly rather than assuming a real lattice", and
SPEC_WARTS.md records it as "copied from `impl Lattice for u64`".  So it
follows the crate here: `psub` returns `.identity true false`.  The wart
SPEC_WARTS.md actually flags is that `pjoin`/`pmeet` are left-biased
projections, which is untouched.  Changing the crate without this line
leaves the model asserting the behaviour of a version that no longer
exists, and the two must move together.

Verified, with the KNOWN table unmodified throughout:

  crate seed 0, 10000   9992 -> 10000/10000, 0 known
  crate seed 7, 10000   9992 ->  9999/10000, 1 known
  crate seed 10, 10000  9992 ->  9999/10000, 1 known
  crate seed 12, 10000  9993 -> 10000/10000, 0 known
  ACT seed 0 and 7, 5000        5000/5000,   0 known

0 new divergences in every run.  The one hit left at seeds 7 and 10 is a
different defect: `restrict` against a destination holding an empty node
materialised by an earlier `meet_into` keeps a dangling branch the spec
drops.  That belongs to the empty-node-materialisation family, not to
this identity-mask class.

Tests: 911 and 1047 pass, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f8a4599)
f8a4599 is the value level of the Identity imprecision: two lines,
Element(*self) -> Identity(SELF_IDENT) in the u64 and u16 instances, plus the
one Basic.lean line that has to land with them, since u64Ops is a
transcription of that instance rather than an independent claim.  Both models
share Basic.lean, so the one line covers both.

200000 programs at seed 7: the class falls from 686 to 8 -- 6 meet_into and 2
subtract_into, 0.004%.  The old harness's status_imprecise bucket empties
entirely, 28989 -> 29013 agreeing of 30000, still 0 unclassified in both.
cargo test --lib: 1035 passing.

What remains is the node level, where the assembled node equals the one it
replaces and nothing notices.  That is per node type and fuzz-fixes-v3 has a
commit for each; none is picked here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@imlvts
imlvts marked this pull request as draft October 2, 2026 16:42
@luketpeterson

Copy link
Copy Markdown
Collaborator

How much overlap is there between this, and the fixes shaken out by #152

Specifically, I'm thinking there is likely to be overlap with #153

@imlvts

imlvts commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

I think we should go with your changes, I like the more straightforward model.
Two fixes that are here but not in the #153: graft_masked_branches, AlgebraicResult::Identity, value bias.
I'll separate those fixes. AlgebraicResult::Identity is covered by #151, value bias is a simple fix, and graft_masked_branches is the difficult one.

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