diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 90baa18e..5b8331ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,12 @@ on:
benches:
description: space separated bench targets (empty = default set)
default: ""
+ alg_fuzz_cases:
+ description: random cases per algebraic fuzz sweep (release and debug-assert)
+ default: "1000000"
+ alg_fuzz_seed:
+ description: seed for the algebraic fuzz sweeps
+ default: "1"
permissions:
contents: read
@@ -61,9 +67,9 @@ jobs:
bench:
name: bench A/B vs base
- # Runs last, after the tests and the fuzz gate pass (skipped when either fails), on PRs
+ # Runs last, after the tests and both fuzz gates pass (skipped when any fails), on PRs
# and manual runs only; a push to master has nothing to compare against.
- needs: [test, fuzz]
+ needs: [test, fuzz, alg-fuzz]
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, x64]
timeout-minutes: 300
@@ -251,3 +257,110 @@ jobs:
${{ runner.temp }}/fuzz-out/fails-*
${{ runner.temp }}/fuzz-out/repro
if-no-files-found: ignore
+
+ alg-fuzz:
+ name: algebraic equivalence fuzz
+ # No oracle: every route pathmap offers through its algebra (whole-map ops, write-zipper
+ # forms, experimental::zipper_algebra, OverlayZipper, SubtractZipper, ...) must give the
+ # same trie, and a set of lattice laws must hold. See differential/ALGEBRAIC_FUZZING.md.
+ # Fails on any finding whose signature is missing from differential::algebraic::KNOWN,
+ # which lists what already reproduces on master. The seed and the job count are fixed, so
+ # a red run reproduces locally with the command printed in the job summary: each job draws
+ # its own input stream, so --jobs decides which inputs run, not only how fast. 64 is the
+ # cap; a smaller machine runs the same inputs, oversubscribed.
+ needs: test
+ if: ${{ !cancelled() }}
+ runs-on: [self-hosted, linux, x64]
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v5
+ - name: toolchain
+ # rustup is installed per runner user on first use
+ run: |
+ [ -x "$HOME/.cargo/bin/rustup" ] || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none --no-modify-path
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+ echo "CARGO_TARGET_DIR=$HOME/cache/target" >> "$GITHUB_ENV" # persists outside _work, so builds stay incremental
+ export PATH="$HOME/.cargo/bin:$PATH"
+ rustup toolchain install stable --profile minimal
+ rustup default stable
+ rustc --version && cargo --version
+ - name: harness gate
+ # recogniser invariants, a short sweep, and route coverage; no corpus in CI
+ run: cargo test --release -p differential --test algebraic
+ - name: sweep (release)
+ if: ${{ !cancelled() }}
+ env:
+ CASES: ${{ inputs.alg_fuzz_cases || '1000000' }}
+ SEED: ${{ inputs.alg_fuzz_seed || '1' }}
+ JOBS: 64
+ OUT: ${{ runner.temp }}/alg-fuzz-out
+ run: |
+ cargo build --release -p differential --bin alg_fuzz
+ mkdir -p "$OUT/release"
+ # a hang is a finding too: the survival-search bug in SubtractZipper looped forever
+ rc=0
+ timeout 20m "$CARGO_TARGET_DIR/release/alg_fuzz" --random "$CASES" --seed "$SEED" --jobs "$JOBS" \
+ --out "$OUT/release" > "$OUT/release.txt" 2>&1 || rc=$?
+ [ $rc -eq 124 ] && echo "::error::release sweep timed out (hang?)"
+ tail -n 40 "$OUT/release.txt"
+ exit $rc
+ - name: sweep (debug assertions)
+ # debug_assert!s inside the merge primitives catch defects no route comparison sees.
+ # RUSTFLAGS replaces .cargo/config.toml's flags, so target-cpu=native (needed by gxhash)
+ # is repeated; a separate target dir keeps the release build intact.
+ if: ${{ !cancelled() }}
+ env:
+ CASES: ${{ inputs.alg_fuzz_cases || '1000000' }}
+ SEED: ${{ inputs.alg_fuzz_seed || '1' }}
+ JOBS: 64
+ OUT: ${{ runner.temp }}/alg-fuzz-out
+ RUSTFLAGS: -C target-cpu=native -C debug-assertions=yes
+ run: |
+ export CARGO_TARGET_DIR="$CARGO_TARGET_DIR/dbgassert"
+ cargo build --release -p differential --bin alg_fuzz
+ mkdir -p "$OUT/dbgassert"
+ rc=0
+ timeout 30m "$CARGO_TARGET_DIR/release/alg_fuzz" --random "$CASES" --seed "$SEED" --jobs "$JOBS" \
+ --out "$OUT/dbgassert" > "$OUT/dbgassert.txt" 2>&1 || rc=$?
+ [ $rc -eq 124 ] && echo "::error::debug-assert sweep timed out (hang?)"
+ tail -n 40 "$OUT/dbgassert.txt"
+ exit $rc
+ - name: summary
+ if: always()
+ env:
+ CASES: ${{ inputs.alg_fuzz_cases || '1000000' }}
+ SEED: ${{ inputs.alg_fuzz_seed || '1' }}
+ JOBS: 64
+ OUT: ${{ runner.temp }}/alg-fuzz-out
+ run: |
+ {
+ echo "### Algebraic equivalence fuzz"
+ for mode in release dbgassert; do
+ f="$OUT/$mode.txt"
+ [ -e "$f" ] || continue
+ if ! grep -q '^checked' "$f"; then
+ # alg_fuzz reports only at the end, so a killed run leaves no counts
+ echo; echo "**$mode**: no report (timed out or crashed; see the step log)"
+ continue
+ fi
+ echo
+ echo "**$mode** ($(grep -m 1 '^checked' "$f")): $(tail -n 1 "$f")"
+ echo
+ echo "findings by value type"
+ echo
+ echo '```text'
+ sed -n '/^by value type/,/^seen under/p' "$f" | grep -v '^seen under'
+ echo '```'
+ echo ""
+ done
+ echo
+ echo "Reproduce: \`cargo run --release -p differential --bin alg_fuzz -- --random $CASES --seed $SEED --jobs $JOBS\`"
+ echo "(debug assertions: add \`RUSTFLAGS='-C target-cpu=native -C debug-assertions=yes'\`)."
+ echo "Saved inputs are in the \`alg-fuzz-out\` artifact; shrink one with \`--shrink FILE --target SIG\`."
+ } >> "$GITHUB_STEP_SUMMARY"
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: alg-fuzz-out
+ path: ${{ runner.temp }}/alg-fuzz-out
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 790fe2ad..996db83b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ whisper_benches/logs
/lean/.lake
/lean/.shrink.bin
__pycache__/
+# Algebraic fuzzer reproducers: local only, written by alg_fuzz sweeps
+/differential/algebraic-corpus/
diff --git a/Cargo.toml b/Cargo.toml
index 90abcd3c..29111ab0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,7 +60,17 @@ zipper_alg = [] # Enables experimental implementations of Zipper-based algebraic
fast-slice-utils = { version = "0.1.2", features = ["miri_safe"] }
[target.'cfg(not(any(miri,target_arch="riscv64")))'.dependencies]
-gxhash = {version="3.5"} # for dag_serialization, merkleization, and caching catamorphism
+# gxhash: for dag_serialization, merkleization, and caching catamorphism.
+#
+# Pinned to a git commit instead of the 3.5.0 release. 3.5.0 hashes inputs shorter than 16 bytes with a
+# 16-byte `_mm_loadu_si128` that reads past their end. Since Rust 1.99 `_mm_loadu_si128` is a checked
+# `copy_nonoverlapping`, so in a build with debug assertions that over-read can fail "unsafe precondition(s)
+# violated" and abort the process (seen in `merkleize`; it depends on stack layout, so it comes and goes by
+# binary and thread). Upstream: https://github.com/ogxd/gxhash/issues/111.
+# Commit 390c3e5 (PR #118) does that load with inline assembly instead and is otherwise 3.5.0, so hash values
+# do not change. Do not move to gxhash 4.0 (main) without checking serialized hashes: gxhash only keeps hashes
+# stable within a major version. Go back to a crates.io version once a 3.x release has this commit.
+gxhash = { git = "https://github.com/ogxd/gxhash", rev = "390c3e5bc07a0b57026982686f69caa442473969" }
[target.'cfg(any(miri,target_arch="riscv64"))'.dependencies]
xxhash-rust = { version = "0.8.15", features = ["xxh64", "xxh3", "const_xxh3"] } # Replacement for gxhash running under miri
diff --git a/differential/ALGEBRAIC_FUZZING.md b/differential/ALGEBRAIC_FUZZING.md
new file mode 100644
index 00000000..7a53b3ca
--- /dev/null
+++ b/differential/ALGEBRAIC_FUZZING.md
@@ -0,0 +1,445 @@
+# Algebraic equivalence fuzzer
+
+The third fuzzer in this repository, and the one that needs no oracle.
+
+The other two ask *is the answer right?* — the Lean model in `../lean` is the
+authority and `bin/pathmap_trace` is diffed against it, and the crash fuzzer
+asks only whether anything blows up. This one asks a different question:
+
+> **Does it matter which way you compute it?**
+
+It should not, and `pathmap` offers enough different ways that the question has
+teeth. Each case is one algebraic expression over a few generated tries,
+evaluated by every route that applies to it, with every answer required to
+match.
+
+```sh
+cargo build --release -p differential --bin alg_fuzz
+
+# a million cases takes about twenty seconds
+cargo run --release -p differential --bin alg_fuzz -- --random 1000000 --seed 1 --jobs 8
+
+# replay the local corpus (gitignored; sweeps save one input per signature there)
+cargo run --release -p differential --bin alg_fuzz -- differential/algebraic-corpus/*.bin
+
+# minimise a failing input, keeping one particular finding
+cargo run --release -p differential --bin alg_fuzz -- --shrink input.bin --target values:nary
+
+# the findings so far, as plain pathmap calls with no fuzzer involved
+cargo run --release -p differential --bin alg_bug_repros
+```
+
+`cargo test -p differential --test algebraic` is the cheap gate: it replays the
+local corpus if there is one, does a short sweep, and pins the harness's own invariants.
+
+## Why equivalence instead of an oracle
+
+Writing the Lean model was most of the cost of the existing differential
+fuzzer, and it bought a very strong property: the model says what every call
+*means*. But it has to be kept in step with the crate by hand — `harness.rs`
+and `PathMapModel/Fuzz.lean` share a wire format and an operation table as a
+contract — and that cost is paid again for every operation added.
+
+The algebra is the part of `pathmap` where that trade is worst and where it is
+least necessary. Worst, because the operations are the crate's most
+re-implemented surface: join exists six times over. Least necessary, because
+*the implementations can check each other*. If `PathMap::join`, `join_into`,
+`join_map_into`, `join_into_take`, `zipper_join`, `zipper_n_join`,
+`zipper_merge_dnf` and `OverlayZipper` all compute a least upper bound, then
+they agree, and any disagreement is a defect without anyone having to say in
+advance which answer was right.
+
+That is the whole design. It costs nothing to maintain — a new operation gets
+a new entry in a table — and it found six defects in the first few thousand
+cases.
+
+What it gives up is the ability to say *which* side is wrong. A divergence is a
+fact about two implementations; deciding between them is a human judgement, and
+`algebraic::KNOWN` records the ones that have been made.
+
+## What is being compared
+
+### Routes
+
+A **route** is one consistent way to evaluate a whole expression. Two kinds:
+
+**Pointwise** routes walk the expression bottom-up and evaluate each node with
+a two-operand call, materialising every intermediate into a trie. Route `k`
+picks strategy `k % n` for an operator with `n` strategies, so every strategy
+is reached by some route and the routes mix them:
+
+| operator | strategies |
+| --- | --- |
+| join `\|` | `PathMap::join`, `join_into`, `join_map_into`, `join_into_take`, `zipper_join`, `OverlayZipper` |
+| meet `&` | `PathMap::meet`, `meet_into`, `meet_2`, `zipper_meet` |
+| subtract `-` | `PathMap::subtract`, `subtract_into`, `zipper_subtract` |
+| sym. difference `^` | `zipper_sym_diff`, `(a \| b) - (a & b)` |
+| restrict `/` | `PathMap::restrict`, `WriteZipper::restrict` |
+
+**Whole-expression** routes recognise a shape and hand the entire thing to one
+call. These are the routes with real independence, because no intermediate trie
+is ever built:
+
+| route | applies to | call |
+| --- | --- | --- |
+| `ternary` | a chain of three operands | `zipper_join3` and friends |
+| `nary` | a chain of any length | `zipper_n_join` and friends |
+| `nary_poly` | the same chain | `ZipperMergeF::join_n` and friends, via the `PolyZipper` enum |
+| `dnf` | a join of meets | `zipper_merge_dnf` |
+| `model` | anything | a flat `BTreeMap`, no trie at all |
+| `subtract_zipper` | anything with a subtraction | every `-` through the lazy `SubtractZipper`, other operators through the baseline |
+| `subtract_zipper_nested` | a left spine `x - y - z ...` of two or more subtractions, or `x - (y - z)` | nested `SubtractZipper`s, one reading from another; the `x`s go through the baseline |
+
+The two `SubtractZipper` routes are routes of their own, not a fourth subtract
+strategy for the pointwise routes: a new strategy would renumber which mix each
+`pw#k` uses, and a new finding would land under a `pw` signature `KNOWN` already
+attributes to something else. Holding the other operators at the baseline means a
+divergence on them points at `SubtractZipper` or at what consumes its output.
+Both walk the zipper with `to_next_step` and materialise the result; the walk
+records dangling paths, so nothing is lost in the comparison.
+
+The `model` route is the fourth opinion. Every other route goes through
+`pathmap`, so a defect common to the whole algebra layer would make them agree
+and still be wrong. It is also the route that found the value-bias defect
+fastest, because it has no node layout to be biased by.
+
+### Laws
+
+Route comparison catches an implementation that disagrees with its siblings. It
+cannot catch a mistake they all share. `src/algebraic/laws.rs` is the other
+half: each law is two *different expressions* that must evaluate to the same
+trie, so a wrong answer is visible even when every route computes it the same
+wrong way. Idempotence, units, associativity, absorption, distributivity, and
+the definition `zipper_sym_diff`'s own documentation gives.
+
+Laws are the reason a defect in the *baseline* is findable at all. When
+`PathMap::meet` takes the wrong operand's value, no route disagrees with the
+baseline in an interesting way — the baseline is what everyone is compared
+against. `meet-distributes-over-join` has no baseline to be fooled by: it puts
+the operands on different sides of a meet and notices.
+
+### Three value types, and the difference between them is a measurement
+
+Every case runs once per value type, and signatures are prefixed with the type
+(`u64:values:pw1`, `bits:law:join-associative`). That is the fuzzer's main
+discriminator:
+
+* **`bits`** is a 64-bit set under `|`, `&` and `& !`, with an empty result
+ collapsing to bottom — i.e. to an absent value, the convention `SetLattice`'s
+ documentation already states. A genuine Boolean algebra, so every lattice
+ identity holds and a law that fails here is a real defect. It lives in
+ `src/algebraic/value.rs` rather than in `pathmap`, because a value type defined
+ outside the crate is what a real caller has.
+
+* **`unit`** is `PathMap<()>`: the set case, also lawful, since `Option<()>` is
+ the two-element Boolean algebra. It earns its own slot for a reason neither of
+ the others covers: `()`'s `pjoin` and `pmeet` return
+ `Identity(SELF_IDENT | COUNTER_IDENT)` — *both* identity bits — on every single
+ combination, where `u64` and `bits` do so only for equal values. That saturates
+ the "either operand will do" path the node code uses to decide it can hand back
+ an operand unchanged and keep sharing it, which is precisely the machinery
+ findings 4 and 6 are about. It is also what `unit-value-optimizations` and
+ `unit-size` are about; neither is on `master`, so today this covers the generic
+ path, and if they land it is the only thing covering the specialised one.
+ Nothing in `zipper_algebra.rs` tests `()` at all — every test there uses `u64`.
+
+* **`u64`** is what the rest of this crate's fuzzing uses, and its instances in
+ `pathmap::ring` are **not a lattice**: `pjoin` is `left_biased_pjoin`, `pmeet`
+ is `Identity(SELF_IDENT)` — both return the left operand, so `a | b == a & b`
+ for every pair, which in a lattice forces `a == b`. It is kept because it is
+ what callers use today, and because it reaches `Identity`-heavy paths that the
+ lawful type does not.
+
+A finding under `u64` alone is an artefact. One under a lawful type is a defect. `alg_fuzz` prints the split at the end of every run:
+
+```
+by value type -- bits is lawful, u64 is not:
+ finding u64 bits
+ law:absorb-join-meet 82 -
+ law:join-associative 3131 12
+ law:join-distributes-over-meet 2077 1
+ law:meet-distributes-over-join 1603 -
+ law:sym-diff-is-join-minus-meet 6684 5
+ panic:build:line_list_node.rs:1745 13476 13476
+ values:model 1258 2
+ values:pw1 30849 36071
+ ...
+```
+
+Read the counts, not just the membership. `law:join-associative` failing 3131
+times under `u64` and 12 under `bits` is **one** defect amplified by the value
+type: under a commutative join, picking the wrong operand is invisible except
+where one operand already contains the other, so only that residue survives. And
+every one of those residues shrinks to a value present on one side of the
+identity and *absent* on the other — which is finding 4, a lost value, not a
+misplaced one.
+
+So far: five findings are `u64`-only, nothing is `bits`-only, and `values:model`
+drops from 1258 to 2 — the flat reference model and the trie agree on values
+almost everywhere once the value algebra is lawful.
+
+### Why a bitmask and not some other lawful lattice
+
+Lawfulness is not sufficient, and this is the part worth remembering.
+`max`/`min` on a total order is a perfectly good distributive lattice, every
+identity holds, and it is **useless here**: `max(a, b)` and `min(a, b)` are
+always one of the operands, so they can only return
+`AlgebraicResult::Identity`, and every code path that allocates and stores a
+genuinely combined value stays unreachable. `a | b` is a new value, so a bitmask
+reaches them. `bin/alg_lattice_check` prints the table:
+
+```
+ type pjoin pmeet psubtract
+ u64 NO NO yes <- and PR #115 makes that NO too
+ bool NO NO NO
+ MinMax NO NO NO <- lawful, still useless
+ Bits yes yes yes
+ ByteMask yes yes yes
+```
+
+`pathmap::utils` already implements exactly this for `[u64; 4]` and `ByteMask`,
+via `bitmask_algebraic_result`; `Bits` is the same construction at 64 bits.
+
+### What the lawful type unlocked
+
+Four identities are checked **only** for a lawful value type, in
+`laws::lawful_only` — De Morgan for relative complement both ways,
+`a - b == a - (a & b)`, and symmetric difference being associative on *values*
+rather than only on paths. Each fails for `u64`, for the same reason everything
+else does.
+
+**Three of the four hold** across several million cases in both profiles:
+`subtract-over-meet`, `subtract-is-subtract-meet` and `sym-diff-associative`.
+They are the strongest laws the harness has and are deliberately absent from
+`KNOWN`, so one of them firing is news.
+
+The fourth, `subtract-over-join`, fires about once per two million cases under
+`unit` — and is cause 2 rather than a bad identity. Its shrunk case has a
+dangling-only `b` and a `c` that is `b` plus one value, so `b | c` is exactly
+finding 4.
+
+The `Level::Paths` laws are also promoted to `Level::Values` for a lawful type,
+so commutativity of join and meet is checked on values there.
+
+### One thing the lawful type cannot do
+
+`OverlayZipper`'s mapping has signature
+`Fn(Option<&'a AV>, Option<&'a BV>) -> Option<&'a OutV>` — it returns a
+*reference*, so it has nowhere to put a value it would have to create. The
+module's own comment says as much. So an overlay can stand in for a join only
+when the join never creates anything, and for a real lattice it cannot be a join
+at all. The strategy therefore *declines* for `bits` rather than answering
+wrongly, which is why `pw5` applies to fewer cases there. That is a limitation of
+the zipper, not a defect, and not reported as one.
+
+Note that declining is what keeps route numbers comparable. An earlier version
+shortened the join strategy table for such types, which silently renumbered every
+later route — so `pw4` and `pw5` meant different strategy mixes under the two
+types, and comparing their findings compared different things.
+
+## Generated operands
+
+Four tries per case, over a small alphabet so they collide, with few entries
+and short paths. Three details earn their keep:
+
+* **Three distinct values.** `psubtract` on `u64` is `None` only when the two
+ values are *equal*, and symmetric difference cancels on coincident paths. A
+ large value space would degenerate both into set difference and set union and
+ never exercise the value-combining code at all.
+
+* **Dangling paths**, written with `create_path` and carrying no value. Whether
+ one survives an operation is unsettled in the crate, so a divergence confined
+ to them is reported as its own class.
+
+* **Four build modes.** `Fresh` writes entries into a new map. `CloneOf`
+ clones an earlier operand and mutates it, so the two share *allocations*
+ rather than merely equal content. `ReinsertOf` writes an earlier operand's
+ entries back in a different order — equal content, different node layout, no
+ sharing. `MerkleizedOf` runs `merkleize` to factor equal subtries into one
+ allocation.
+
+The last three are not decoration. The value-bias defect needs operands whose
+nodes are laid out differently, and the value-loss defect needs operands that
+share nodes — it disappears if `b`'s entries are rebuilt into a fresh map
+instead of cloned. A generator that only wrote fresh tries would find neither.
+
+## Divergence classes
+
+| class | meaning | fails a run |
+| --- | --- | --- |
+| `values` | two routes put different values on a path, or disagree about whether a path has one | yes |
+| `shape` | the routes agree on every value and differ only in dangling paths | only under `--shape` |
+| `law` | an identity that must hold whatever the operands are does not | yes |
+| `panic:build` | a crash while *writing the operand tries*, before any algebra ran | yes |
+| `panic:eval` | a crash inside an algebraic operation | yes |
+
+`shape` is separated because the semantics are genuinely unsettled — see
+`../SPEC_WARTS.md` and the `meet_into-keeps-dangling*` corpus entries, which are
+the same question. The two families answer it differently and *consistently*,
+which is worth knowing because it says which side has to change once the question
+is settled:
+
+* the lockstep traversals in `experimental::zipper_algebra` **discard** dangling
+ structure;
+* `PathMap`'s whole-map operations and the write-zipper forms (`join_into`,
+ `meet_into`, `subtract_into`) **preserve** it.
+
+`bin/alg_bug_repros` case 7 shows three of them side by side, the sharpest being
+`d ^ {}`: symmetric difference with the empty trie ought to be the identity, and
+`zipper_sym_diff` returns nothing where `(d | e) - (d & e)` returns `d`. A wrong
+*value* is never ambiguous.
+
+Signatures are `class:route`, and nothing more. An earlier version appended the
+operators the expression used, which looked more informative and was much
+worse: one defect in `meet` produced dozens of signatures because it surfaced
+under every operator combination containing a meet. The expression, the
+operands and the diff all live in the saved `.txt`; the signature's only job is
+to collapse a million inputs onto a handful of lines.
+
+## Soundness limit: catching a panic in process is not safe
+
+**A long run that catches panics can abort with heap corruption.** Reproducibly:
+
+```
+$ ./alg_fuzz --random 2000000 --seed 55 --jobs 8 # debug-assertions build
+malloc(): unaligned tcache chunk detected
+Aborted (core dumped)
+```
+
+Narrowed by elimination, each at 2M cases, seed 55, 8 jobs:
+
+| build | `merkleize` in the generator | caught panics | result |
+| --- | --- | --- | --- |
+| debug assertions | yes | ~27k | **abort** |
+| debug assertions | no | ~100 | **abort** |
+| release | yes | ~27k | **abort** |
+| release | no | 0 | clean, exit 0 |
+
+So it tracks the *number of panics caught*, not the build profile, the thread
+count or the value type. The cause is that `catch_unwind` resumes after a panic
+that fired **mid-mutation inside `pathmap`'s node code**: unwinding out of a
+half-updated node leaves a trie that is not safe to drop, and the damage shows up
+later as an invalid free. Every panic the fuzzer catches is one of these — a
+`debug_assert!` inside a merge, or `merkleize`'s `unwrap`, both of which are
+"this cannot happen" sites rather than supported unwind paths.
+
+That is a limitation of *this* harness, not an independent defect. The crash
+fuzzer does not have it because AFL runs one input per process and so never
+continues after a panic.
+
+**Until this is fixed, treat the first panic in a run as the end of the useful
+output.** Findings reported before it are valid — the signature counts and
+reproducers are written as they are found — but anything after the first caught
+panic is suspect, and a run that aborts has lost whatever it had not yet printed.
+A release-profile run on a generator without `merkleize` catches nothing and is
+unaffected.
+
+The fix is to make panics terminal and rare, the way a crash fuzzer treats them:
+stop the run on the first one, and stop generating the one panic that is already
+documented with a standalone reproducer (`merkleize`, repro 5) so that the
+remaining ones are rare enough for stopping to cost nothing. That needs the
+corpus replay to run one input per process, so it is a design change rather than
+a patch, and it is not done yet.
+
+## Debug assertions are a separate mode
+
+`pathmap`'s `debug_assert!`s are the sharpest instrument here, and a release
+build cannot see them. Three of the findings below are visible *only* with
+assertions on, and two of them are the mechanism behind a defect that the
+release build can only observe as a wrong answer:
+
+```sh
+RUSTFLAGS="-C target-cpu=native -C debug-assertions=yes" \
+ cargo build --release -p differential --bin alg_fuzz --target-dir target/dbgassert
+./target/dbgassert/release/alg_fuzz --random 5000000 --seed 1 --jobs 8
+```
+
+Keep `-C target-cpu=native`: `.cargo/config.toml` sets it, a bare `RUSTFLAGS`
+replaces rather than extends it, and `gxhash` fails to compile without the
+`aes` and `sse2` intrinsics it implies.
+
+`cargo test` is itself a debug-assertions build, which is why the corpus test
+expects the `panic:eval:*` signatures.
+
+## Findings
+
+Six defects on `master` at `b4a6abd`, reproduced without the fuzzer in
+`src/bin/alg_bug_repros.rs`, plus the unsettled dangling-path question as case 7.
+`algebraic::KNOWN` maps every signature onto one of them.
+
+1. **`join_into` drops the source's root value when the destination is empty.**
+ `PathMap::new().join(&src)` keeps it; the write-zipper spelling does not.
+
+2. **`meet_2` drops root values outright.** Two maps whose only content is a
+ root value intersect to that root value; `meet_2` produces nothing.
+
+3. **Value bias by node layout.** `PathMap::join` and `PathMap::meet` take the
+ *right* operand's value at a shared path when the two operands' nodes are
+ laid out differently. Every zipper route gets this right, which is why it
+ surfaces as failing laws and as the `model` route disagreeing — the defect is
+ in the baseline.
+
+4. **`join` loses a value across shared structure.** With `b` a clone of `a`
+ plus one extra value, `a | b` drops that value and `b | a` keeps it. A join
+ is a least upper bound, so no ordering of the operands may lose a path; this
+ is the one finding that needs no argument about value bias. Found by
+ `join-commutative`, which is checked on paths only and so cannot be fooled by
+ cause 3.
+
+5. **`merkleize` panics on a trie holding only dangling paths.** Not algebraic:
+ it fires while the operands are still being written. Kept rather than
+ generated around, because `merkleize` is how the generator produces shared
+ subtries and finding 4 needs them.
+
+6. **Join's "empty result" path is reachable from non-empty nodes.** Three
+ debug assertions, one story, and the mechanism behind finding 4:
+ `merge_from_list_node` returns `AlgebraicStatus::None` from nodes that are not
+ empty (`line_list_node.rs:2669` and `:2720`), and a cofree `pjoin` returns
+ `None` while the left side still has a non-empty onward node
+ (`dense_byte_node.rs:2080`). Debug-assertions builds only, so it has no
+ standalone reproducer — replay the `panic-eval-*` corpus inputs.
+
+## Harness invariants
+
+A harness that reports its own mistakes as crate defects is worse than no
+harness. Two of the recognisers that decide which route applies were wrong when
+this fuzzer was first run, and both failures looked exactly like crate defects:
+
+* `a ^ (b ^ c)` was flattened into the n-ary call, which folds values left. For
+ root values `a = 2`, `b = 1`, the nested form is `a ^ nothing = 2` and the
+ fold is `(2 ^ 2) ^ 1 = 1`. Both are defensible; they are not equal, so a
+ right-nested symmetric difference is not a chain.
+
+* A DNF clause was allowed to stand in for `b & a`. A `Clause` is a *bitmask* —
+ it records which zippers take part, not in what order — and `zipper_merge_dnf`
+ meets a clause's members in slot order while `pmeet` is left-biased, so `a &
+ b` and `b & a` carry different values and only one of them is what the clause
+ computes.
+
+Both are now pinned by tests in `tests/algebraic.rs`, along with the invariant
+that every strategy index is reachable from some pointwise route. When a route
+disagrees with the rest, suspect the route first.
+
+## Layout
+
+| file | what |
+| --- | --- |
+| `src/algebraic.rs` | input decoding, operand generation, the comparison, `KNOWN` |
+| `src/algebraic/expr.rs` | the expression language and the shape recognisers |
+| `src/algebraic/routes.rs` | the routes, and one arm per strategy |
+| `src/algebraic/laws.rs` | the identities, and the ones deliberately absent |
+| `src/algebraic/model.rs` | flat `BTreeMap` semantics, delegating to the value algebra |
+| `src/algebraic/value.rs` | the `FuzzValue` trait and the lawful `Bits` type |
+| `src/algebraic/shape.rs` | what "the same result" means |
+| `src/bin/alg_fuzz.rs` | the driver: generation, replay, shrinking, reporting |
+| `src/bin/alg_bug_repros.rs` | every cause in `KNOWN` as plain `pathmap` calls |
+| `src/bin/alg_lattice_check.rs` | whether a divergence is the crate's fault or `u64`'s |
+| `tests/algebraic.rs` | the corpus gate and the harness invariants |
+| `algebraic-corpus/` | local and gitignored: one saved input per signature, from sweeps |
+
+Nothing outside `differential/` is touched.
+
+Byte decoding reuses `harness::Dec`. The wire format is *not* the one
+`harness.rs` shares with `PathMapModel.Fuzz`: this fuzzer has no model to stay
+in step with, so its inputs are free to be whatever generates interesting
+tries.
diff --git a/differential/Cargo.toml b/differential/Cargo.toml
index 4218b74a..6b2ff2ea 100644
--- a/differential/Cargo.toml
+++ b/differential/Cargo.toml
@@ -6,4 +6,4 @@ publish = false
description = "The Rust side of the differential fuzzing harness for pathmap's zipper API; the Lean model in ../lean is the oracle"
[dependencies]
-pathmap = { path = "..", features = ["arena_compact"] }
+pathmap = { path = "..", features = ["arena_compact", "zipper_alg"] }
diff --git a/differential/src/algebraic.rs b/differential/src/algebraic.rs
new file mode 100644
index 00000000..2bbc8582
--- /dev/null
+++ b/differential/src/algebraic.rs
@@ -0,0 +1,941 @@
+//! Equivalence fuzzer for the algebraic operations.
+//!
+//! The other two fuzzers in this crate ask *is the answer right?* -- the Lean
+//! model in `../lean` is an oracle, and `bin/pathmap_trace.rs` is diffed
+//! against it. This one asks a different question: **does it matter which way
+//! you compute it?** It should not, and `pathmap` offers enough different ways
+//! that the question has teeth.
+//!
+//! Each case is one algebraic expression over a few generated operand tries,
+//! evaluated by every route in `routes.rs` that applies to it -- eagerly on
+//! whole maps, in place through a write zipper, as a lockstep traversal of two
+//! read zippers, as a single n-ary traversal, through the DNF engine, through a
+//! lazy `OverlayZipper` over a virtual trie, and over a flat `BTreeMap` with no
+//! trie at all. Then `laws.rs` evaluates pairs of expressions that must agree
+//! whatever the operands, which catches the mistakes every route shares.
+//!
+//! No Lean build is needed and there is no oracle: a divergence says the
+//! implementations disagree, not which one is wrong.
+//!
+//! The wire format here is **not** the one `harness.rs` shares with
+//! `PathMapModel.Fuzz`; this fuzzer has no model to stay in step with, so its
+//! inputs are free to be whatever generates interesting tries.
+//!
+//! See `../ALGEBRAIC_FUZZING.md`.
+
+pub mod expr;
+pub mod laws;
+pub mod model;
+pub mod routes;
+pub mod shape;
+pub mod value;
+
+use pathmap::PathMap;
+use pathmap::zipper::{ZipperMoving, ZipperWriting};
+
+use expr::{Expr, Op, MAX_VARS};
+use laws::{Level, EMPTY, LAW_OPERANDS};
+use routes::Route;
+use shape::{shape_of_map, show_shape, Shape, Values};
+use value::FuzzValue;
+
+/// Operand tries per case. Fixed rather than generated so the expression
+/// generator can always name any of them, and so [`MAX_VARS`] is the only
+/// bound the const-generic route dispatch has to respect.
+pub const OPERANDS: usize = MAX_VARS;
+
+/// Upper bound on entries written into one operand. Small on purpose: the
+/// interesting cases are the ones where operands overlap at a node boundary,
+/// and small tries over a small alphabet collide far more often than large
+/// ones.
+pub const MAX_ENTRIES: usize = 7;
+
+/// Upper bound on a generated path's length.
+pub const MAX_PATH_LEN: usize = 5;
+
+/// Byte decoding with the out-of-input behaviour this fuzzer wants.
+///
+/// Not `harness::Dec`: that one returns `None` when the input runs out, because
+/// the Lean harness stops executing there, and it reads a wire header this
+/// format does not have. Here every input has to decode to *some* case or
+/// shrinking would have to care about length, so exhaustion saturates to zero
+/// instead.
+pub struct Gen<'a> {
+ bytes: &'a [u8],
+ pos: usize,
+ /// Size of the path alphabet. A small one makes operands share prefixes
+ /// and collide; the full 256 exercises wide nodes.
+ alphabet: u16,
+}
+
+impl<'a> Gen<'a> {
+ pub fn new(bytes: &'a [u8]) -> Self {
+ Gen { bytes, pos: 0, alphabet: 3 }
+ }
+ pub fn byte(&mut self) -> u8 {
+ let b = self.bytes.get(self.pos).copied().unwrap_or(0);
+ self.pos += 1;
+ b
+ }
+ pub fn modn(&mut self, m: usize) -> usize {
+ if m == 0 { 0 } else { self.byte() as usize % m }
+ }
+ pub fn boolean(&mut self) -> bool {
+ self.byte() % 2 == 1
+ }
+ /// True with probability `pct`/100.
+ pub fn chance(&mut self, pct: u8) -> bool {
+ (self.byte() as u16 * 100 / 256) < pct as u16
+ }
+ pub fn path_byte(&mut self) -> u8 {
+ (self.byte() as u16 % self.alphabet) as u8
+ }
+ pub fn path(&mut self, max_len: usize) -> Vec {
+ let n = self.modn(max_len + 1);
+ (0..n).map(|_| self.path_byte()).collect()
+ }
+}
+
+/// How one operand trie was built. Recorded so a failure report can say
+/// whether the case depended on structural sharing or on node layout, which is
+/// the difference between a logic bug and a representation bug.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Build {
+ /// Entries written into a fresh map.
+ Fresh,
+ /// A clone of an earlier operand, then mutated. Clones share nodes, so
+ /// this is how a case gets two operands whose subtries are the *same*
+ /// allocation rather than merely equal -- the distinction several of the
+ /// defects in `../lean/FINDINGS.md` turned on.
+ CloneOf(usize),
+ /// The same `(path, value)` entries as an earlier operand, rewritten into a
+ /// fresh map in a different order. Equal content, possibly different node
+ /// layout and certainly no shared nodes: the counterpart to `CloneOf`, and
+ /// the shape that catches an operation whose answer depends on how its
+ /// input happens to be stored.
+ ReinsertOf(usize),
+ /// A clone of an earlier operand with `merkleize` run over it, which
+ /// factors equal subtries into one shared allocation.
+ MerkleizedOf(usize),
+}
+
+pub struct Case {
+ pub expr: Expr,
+ pub operands: Vec>,
+ pub builds: Vec,
+ pub alphabet: u16,
+}
+
+/// Decode a byte string into a case. Total: every input decodes.
+pub fn decode(bytes: &[u8]) -> Case {
+ let mut g = Gen::new(bytes);
+ // A 3-letter alphabet is the default because collisions are the point; the
+ // wider ones are sampled to keep node-type coverage honest, since `pathmap`
+ // switches node representation on child count.
+ g.alphabet = match g.modn(10) {
+ 0 => 256,
+ 1 => 16,
+ 2 => 2,
+ _ => 3,
+ };
+ let alphabet = g.alphabet;
+
+ let mut operands: Vec> = Vec::with_capacity(OPERANDS);
+ let mut builds: Vec = Vec::with_capacity(OPERANDS);
+ for i in 0..OPERANDS {
+ let build = pick_build(&mut g, i);
+ operands.push(build_operand(&mut g, build, &operands));
+ builds.push(build);
+ }
+
+ let expr = gen_expr(&mut g, 3);
+ Case { expr, operands, builds, alphabet }
+}
+
+fn pick_build(g: &mut Gen, i: usize) -> Build {
+ if i == 0 {
+ return Build::Fresh;
+ }
+ match g.modn(8) {
+ 0 | 1 | 2 | 3 => Build::Fresh,
+ 4 | 5 => Build::CloneOf(g.modn(i)),
+ 6 => Build::ReinsertOf(g.modn(i)),
+ _ => Build::MerkleizedOf(g.modn(i)),
+ }
+}
+
+fn build_operand(g: &mut Gen, build: Build, prior: &[PathMap]) -> PathMap {
+ match build {
+ Build::Fresh => fresh(g),
+ Build::CloneOf(j) => {
+ let mut m = prior[j].clone();
+ mutate(g, &mut m);
+ m
+ }
+ Build::ReinsertOf(j) => {
+ // Collect the entries, then write them back in a rotated order.
+ // Rotation rather than a full shuffle because it is enough to
+ // change which insert splits which node, and it keeps the decoding
+ // cost one byte.
+ let entries: Vec<(Vec, V)> =
+ shape::values_of_shape(&shape_of_map(&prior[j])).into_iter().collect();
+ let mut out = PathMap::new();
+ if !entries.is_empty() {
+ let r = g.modn(entries.len());
+ let mut wz = out.write_zipper();
+ for off in 0..entries.len() {
+ let (p, v) = &entries[(off + r) % entries.len()];
+ wz.reset();
+ wz.descend_to(p);
+ wz.set_val(v.clone());
+ }
+ }
+ out
+ }
+ Build::MerkleizedOf(j) => {
+ let mut m = prior[j].clone();
+ m.merkleize();
+ mutate(g, &mut m);
+ m
+ }
+ }
+}
+
+fn fresh(g: &mut Gen) -> PathMap {
+ let n = g.modn(MAX_ENTRIES + 1);
+ let mut out = PathMap::new();
+ {
+ let mut wz = out.write_zipper();
+ for _ in 0..n {
+ let p = g.path(MAX_PATH_LEN);
+ wz.reset();
+ wz.descend_to(&p);
+ // A path written with `create_path` and no value is a dangling
+ // path: structure with nothing under it. Operations disagree about
+ // whether those survive, so they are generated on purpose and the
+ // comparison reports them as their own class.
+ if g.chance(20) {
+ wz.create_path();
+ } else {
+ wz.set_val(V::generate(g));
+ }
+ }
+ }
+ out
+}
+
+fn mutate(g: &mut Gen, m: &mut PathMap) {
+ let n = g.modn(3);
+ let mut wz = m.write_zipper();
+ for _ in 0..n {
+ let p = g.path(MAX_PATH_LEN);
+ wz.reset();
+ wz.descend_to(&p);
+ match g.modn(4) {
+ 0 => {
+ wz.remove_val(false);
+ }
+ 1 => {
+ wz.create_path();
+ }
+ 2 => {
+ wz.remove_branches(false);
+ }
+ _ => {
+ wz.set_val(V::generate(g));
+ }
+ }
+ }
+}
+
+fn gen_expr(g: &mut Gen, depth: usize) -> Expr {
+ // Leaf at depth 0, and otherwise often enough that the chain and DNF
+ // recognisers fire regularly -- a tree that is always maximally deep is
+ // never a chain of operands, so the whole-expression routes would almost
+ // never apply.
+ if depth == 0 || g.chance(35) {
+ return Expr::Var(g.modn(OPERANDS));
+ }
+ let op = match g.modn(12) {
+ 0 | 1 | 2 | 3 => Op::Join,
+ 4 | 5 | 6 => Op::Meet,
+ 7 | 8 => Op::Subtract,
+ 9 | 10 => Op::SymDiff,
+ _ => Op::Restrict,
+ };
+ Expr::bin(op, gen_expr(g, depth - 1), gen_expr(g, depth - 1))
+}
+
+// ------------------------------------------------------------- comparison
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Class {
+ /// Two routes put different values on a path, or disagree about whether a
+ /// path carries one. Settled semantics: one of them is wrong.
+ Values,
+ /// Two routes agree on every value and differ only in dangling paths --
+ /// structure with nothing under it. Unsettled semantics (see
+ /// `../SPEC_WARTS.md`), so this is reported apart from `Values` rather than
+ /// mixed in with it.
+ Shape,
+ /// An identity that must hold whatever the operands are does not.
+ Law,
+}
+
+impl Class {
+ pub fn tag(self) -> &'static str {
+ match self {
+ Class::Values => "values",
+ Class::Shape => "shape",
+ Class::Law => "law",
+ }
+ }
+}
+
+pub struct Divergence {
+ pub class: Class,
+ /// Stable short key for grouping findings across runs: the class and the
+ /// route, and nothing else.
+ ///
+ /// Coarse on purpose. An earlier version appended the operators the
+ /// expression used, which looked more informative and was much worse: one
+ /// root cause in `meet` produced dozens of signatures because it surfaced
+ /// under every combination of operators that happened to contain a meet,
+ /// and a known-findings table over them was unmaintainable. The operators,
+ /// the operands and the diff all live in `detail`; the signature's only job
+ /// is to collapse a million inputs onto a handful of lines.
+ pub signature: String,
+ pub detail: String,
+}
+
+/// Evaluate every applicable route and every law, and return what disagrees.
+///
+/// The baseline every route is compared against is `Pointwise(0)` -- the whole
+/// expression through `PathMap::join`/`meet`/`subtract`/`restrict`. Those are
+/// the oldest and most heavily used entry points in the crate, which makes them
+/// the right thing to hold still, though the choice only affects which side of
+/// a disagreement gets named, not whether it is found.
+pub fn check(case: &Case) -> Vec {
+ let mut out = Vec::new();
+
+ let baseline_route = Route::Pointwise(0);
+ let baseline = routes::eval(baseline_route, &case.expr, &case.operands)
+ .expect("route 0 is the whole-map operations, which apply to everything");
+ let base_name = baseline_route.name(&case.expr);
+
+ // The model is computed up front so every divergence can say which side it
+ // backs. Worth the one extra evaluation: the baseline is `PathMap::join`
+ // and friends, which have defects of their own, so "route X disagrees with
+ // the baseline" on its own points at the wrong file about as often as the
+ // right one. The model has no trie and no node layout, so when it sides
+ // with the route, the baseline is where to look.
+ let model = routes::eval(Route::Model, &case.expr, &case.operands)
+ .expect("the model route applies to every expression");
+
+ for route in Route::all() {
+ if route == baseline_route {
+ continue;
+ }
+ let Some(got) = routes::eval(route, &case.expr, &case.operands) else { continue };
+ let name = route.name(&case.expr);
+
+ if got.values != baseline.values {
+ out.push(Divergence {
+ class: Class::Values,
+ signature: signature::("values", &route_key::(route)),
+ detail: format!(
+ "{} vs {name} evaluating {}: {}\n {base_name}: {}\n {name}: {}\n {}",
+ base_name,
+ case.expr,
+ shape::first_values_diff(&baseline.values, &got.values).unwrap_or_default(),
+ shape::show_values(&baseline.values),
+ shape::show_values(&got.values),
+ model_sides_with(&model.values, &baseline.values, &got.values, &base_name, &name),
+ ),
+ });
+ // One class per route is enough; a value divergence will usually
+ // show up as a shape divergence too, and reporting both would
+ // double every finding.
+ continue;
+ }
+
+ if let (Some(bs), Some(gs)) = (&baseline.shape, &got.shape) {
+ if bs != gs {
+ out.push(Divergence {
+ class: Class::Shape,
+ signature: signature::("shape", &route_key::(route)),
+ detail: format!(
+ "{} vs {name} evaluating {}: {}\n {base_name}: {}\n {name}: {}",
+ base_name,
+ case.expr,
+ shape::first_shape_diff(bs, gs).unwrap_or_default(),
+ show_shape(bs),
+ show_shape(gs),
+ ),
+ });
+ }
+ }
+ }
+
+ out.extend(check_laws(case));
+ out
+}
+
+/// Evaluate each law's two sides and report the ones that disagree.
+///
+/// Laws run through the baseline route only. A law that fails under one route
+/// and holds under another is already a route disagreement, which the loop
+/// above reports; running every law through every route would multiply the cost
+/// of a case by the number of laws for no new coverage.
+pub fn check_laws(case: &Case) -> Vec {
+ // Operands 0..3 from the case, then the empty trie, which is what lets a
+ // law state a unit or annihilator.
+ let mut ops: Vec> = (0..EMPTY).map(|i| case.operands[i].clone()).collect();
+ ops.push(PathMap::new());
+ debug_assert_eq!(ops.len(), LAW_OPERANDS);
+
+ let mut out = Vec::new();
+ for law in laws::laws::() {
+ let l = routes::eval(Route::Pointwise(0), &law.lhs, &ops).unwrap();
+ let r = routes::eval(Route::Pointwise(0), &law.rhs, &ops).unwrap();
+ let (ok, detail) = match law.level {
+ Level::Values => (
+ l.values == r.values,
+ format!(
+ "{}\n lhs {}: {}\n rhs {}: {}",
+ shape::first_values_diff(&l.values, &r.values).unwrap_or_default(),
+ law.lhs,
+ shape::show_values(&l.values),
+ law.rhs,
+ shape::show_values(&r.values),
+ ),
+ ),
+ Level::Paths => {
+ // Compare the value-carrying paths only. Comparing every path
+ // in the shape would drag the dangling-path question into laws
+ // that are not about it.
+ let lk: Vec<&Vec> = l.values.keys().collect();
+ let rk: Vec<&Vec> = r.values.keys().collect();
+ (
+ lk == rk,
+ format!(
+ "lhs {}: {}\n rhs {}: {}",
+ law.lhs,
+ shape::show_values(&l.values),
+ law.rhs,
+ shape::show_values(&r.values),
+ ),
+ )
+ }
+ };
+ if !ok {
+ out.push(Divergence {
+ class: Class::Law,
+ signature: signature::("law", law.name),
+ detail: format!("{} ({:?}): {detail}", law.name, law.level),
+ });
+ }
+ }
+ out
+}
+
+/// Which side of a value divergence the reference model backs, as one line for
+/// the report.
+///
+/// Neither side being backed is the interesting case: it means the model
+/// disagrees with both, so the defect is unlikely to be in either route's
+/// traversal and is more likely in a shared primitive -- or in the model, which
+/// is worth suspecting too.
+fn model_sides_with(
+ model: &Values,
+ baseline: &Values,
+ got: &Values,
+ base_name: &str,
+ name: &str,
+) -> String {
+ match (model == baseline, model == got) {
+ (true, true) => "model: agrees with both (unreachable)".to_string(),
+ (true, false) => format!("model: sides with {base_name}, so {name} is the odd one out"),
+ (false, true) => format!("model: sides with {name}, so {base_name} is the odd one out"),
+ (false, false) => format!(
+ "model: agrees with neither -- {}",
+ shape::show_values(model)
+ ),
+ }
+}
+
+/// Signatures are prefixed with the value type, so the two instantiations never
+/// collide in one report and the difference between them is readable directly.
+pub fn signature(class: &str, rest: &str) -> String {
+ format!("{}:{class}:{rest}", V::NAME)
+}
+
+fn route_key(r: Route) -> String {
+ match r {
+ Route::Pointwise(k) => format!("pw{k}"),
+ _ => r.name(&Expr::Var(0)),
+ }
+}
+
+// ---------------------------------------------------------------- known findings
+
+/// A finding that already reproduces on `master` and has a reproducer of its
+/// own, so a run that only hits these is not a regression.
+pub struct Known {
+ pub signature: &'static str,
+ /// Which defect it traces to. Several signatures share one entry: a single
+ /// wrong value inside `join` surfaces on every route that reaches a join
+ /// and in every law that mentions one, so the table maps many signatures
+ /// onto few causes.
+ pub cause: &'static str,
+}
+
+/// Findings confirmed on `master`, with the defect each traces to.
+/// `bin/alg_bug_repros.rs` reproduces every cause in a dozen lines, without the
+/// fuzzer; run it to see which still stand.
+///
+/// This table decides the **exit status** and nothing else. Counts are always
+/// printed, known and unknown alike, so a known defect that starts firing ten
+/// times more often is still visible even though it does not turn the run red.
+/// `--strict` ignores the table entirely.
+///
+/// # The causes
+///
+/// 1. **Value bias by node layout** (`bias`). `pjoin` on `u64` is
+/// `left_biased_pjoin` and `pmeet` is `Identity(SELF_IDENT)`, so where both
+/// operands carry a value the *left* one must win. `PathMap::join` and
+/// `PathMap::meet` take the right one when the two operands' nodes are laid
+/// out differently -- in the reproducer, because one side has a chain of
+/// dangling descendants below the shared path. Every zipper route gets this
+/// right, so it shows up as *laws* failing and as the `model` route
+/// disagreeing, not as one route out of step with the others: the defect is
+/// in the baseline the others are compared against.
+///
+/// 2. **`join` loses a value across shared structure** (`loss`). With `b` a
+/// clone of `a` plus one extra value, `a | b` drops that value and `b | a`
+/// keeps it. A join is a least upper bound, so no ordering may lose a path;
+/// this is the one finding that needs no argument about value bias. It
+/// needs the operands to *share nodes* -- rebuilding `b`'s entries into a
+/// fresh map makes it go away -- which is why the generator clones and
+/// merkleizes operands instead of only writing them fresh.
+///
+/// 3. **Root values lost by the write-zipper forms** (`root`). `join_into`
+/// into an empty destination drops the source's root value, and `meet_2`
+/// drops root values outright. These are routes disagreeing with the
+/// baseline in the ordinary way.
+///
+/// 4. **Dangling paths** (`dangling`). The lockstep traversals in
+/// `experimental::zipper_algebra` discard dangling structure; `PathMap`'s
+/// whole-map operations and the write-zipper forms preserve it. Unsettled rather than
+/// wrong -- see `../SPEC_WARTS.md` -- hence its own class, `shape`, which
+/// does not fail a run unless `--shape` says so.
+///
+/// 5. **`merkleize` panics on dangling-only structure** (`merkleize`). Not an
+/// algebraic defect: it fires while the operands are still being written,
+/// before any operation runs, which is what the `build` in its signature
+/// means.
+const BIAS: &str = "value bias by node layout, u64-only (repro 3)";
+const LOSS: &str = "join loses a value across shared structure (repro 4)";
+const ROOT: &str = "write-zipper forms lose root values (repros 1, 2)";
+const DANGLING: &str = "zipper traversals drop dangling paths, map ops keep them (repro 7)";
+const MERKLEIZE: &str = "merkleize panics on dangling-only structure (repro 5)";
+/// Re-nesting a join moves which operand is on the left *and* which pair of
+/// tries meets a shared node first, so both cause 1 and cause 2 reach these.
+/// Under the lawful type only cause 2 survives, which is why the counts collapse.
+const BIAS_OR_LOSS: &str = "value bias or lost value (repros 3, 4)";
+pub const KNOWN: &[Known] = &[
+ // ---------------------------------------------------------------- u64
+ // Root values lost by the write-zipper forms. Value-type-independent.
+ Known { signature: "u64:values:pw1", cause: ROOT },
+ Known { signature: "u64:values:pw2", cause: ROOT },
+ Known { signature: "u64:values:pw3", cause: ROOT },
+ // Everything below here on the u64 side is the bias, which the lawful type
+ // mostly cannot see: under a commutative join, picking the wrong operand
+ // only shows where one operand already contains the other.
+ Known { signature: "u64:values:pw4", cause: BIAS },
+ Known { signature: "u64:values:pw5", cause: BIAS },
+ Known { signature: "u64:values:ternary", cause: BIAS },
+ Known { signature: "u64:values:nary", cause: BIAS },
+ Known { signature: "u64:values:nary_poly", cause: BIAS },
+ Known { signature: "u64:values:dnf", cause: BIAS },
+ Known { signature: "u64:values:model", cause: BIAS },
+ Known { signature: "u64:shape:pw1", cause: DANGLING },
+ Known { signature: "u64:shape:pw2", cause: DANGLING },
+ Known { signature: "u64:shape:pw3", cause: DANGLING },
+ Known { signature: "u64:shape:pw4", cause: DANGLING },
+ Known { signature: "u64:shape:pw5", cause: DANGLING },
+ Known { signature: "u64:shape:ternary", cause: DANGLING },
+ Known { signature: "u64:shape:nary", cause: DANGLING },
+ Known { signature: "u64:shape:nary_poly", cause: DANGLING },
+ Known { signature: "u64:shape:dnf", cause: DANGLING },
+ // The subtract_zipper route's own subtraction agrees with PathMap::subtract
+ // on every path and value; it builds a different node layout, and the
+ // baseline join or meet that consumes it is what then goes wrong.
+ Known { signature: "u64:values:subtract_zipper", cause: BIAS_OR_LOSS },
+ Known { signature: "u64:shape:subtract_zipper", cause: DANGLING },
+ Known { signature: "u64:law:join-associative", cause: BIAS_OR_LOSS },
+ Known { signature: "u64:law:join-distributes-over-meet", cause: BIAS_OR_LOSS },
+ Known { signature: "u64:law:majority-is-pairwise-meets", cause: BIAS_OR_LOSS },
+ Known { signature: "u64:law:join-commutative", cause: LOSS },
+ // These four fire only for u64: they need join and meet to be different
+ // functions, and for u64 they are the same one.
+ Known { signature: "u64:law:meet-associative", cause: BIAS },
+ Known { signature: "u64:law:meet-distributes-over-join", cause: BIAS },
+ Known { signature: "u64:law:absorb-join-meet", cause: BIAS },
+ Known { signature: "u64:law:sym-diff-is-join-minus-meet", cause: BIAS },
+ Known { signature: "u64:panic:build:line_list_node.rs:1785", cause: MERKLEIZE },
+ Known { signature: "u64:panic:eval:line_list_node.rs:2732", cause: LOSS },
+ Known { signature: "u64:panic:eval:line_list_node.rs:2783", cause: LOSS },
+ Known { signature: "u64:panic:eval:dense_byte_node.rs:2088", cause: LOSS },
+
+ // ---------------------------------------------------------------- bits
+ // The lawful type. Everything here is a real defect: there is no value
+ // bias to blame, because `a | b` does not depend on operand order.
+ Known { signature: "bits:values:pw1", cause: ROOT },
+ Known { signature: "bits:values:pw2", cause: ROOT },
+ Known { signature: "bits:values:pw3", cause: ROOT },
+ Known { signature: "bits:values:pw5", cause: ROOT },
+ // Rare under the lawful type, and cause 2 every time: the law residues all
+ // shrink to a value present on one side of an identity and absent on the
+ // other, which is a lost value rather than a misplaced one.
+ Known { signature: "bits:values:pw4", cause: LOSS },
+ // Fires about once per three million cases for each lawful type, and the
+ // model sides with the route, so it is the baseline that lost the value.
+ Known { signature: "bits:values:ternary", cause: LOSS },
+ Known { signature: "bits:values:nary", cause: LOSS },
+ Known { signature: "bits:values:nary_poly", cause: LOSS },
+ Known { signature: "bits:values:dnf", cause: LOSS },
+ Known { signature: "bits:values:model", cause: LOSS },
+ Known { signature: "bits:shape:pw1", cause: DANGLING },
+ Known { signature: "bits:shape:pw2", cause: DANGLING },
+ Known { signature: "bits:shape:pw3", cause: DANGLING },
+ Known { signature: "bits:shape:pw4", cause: DANGLING },
+ Known { signature: "bits:shape:pw5", cause: DANGLING },
+ Known { signature: "bits:shape:ternary", cause: DANGLING },
+ Known { signature: "bits:shape:nary", cause: DANGLING },
+ Known { signature: "bits:shape:nary_poly", cause: DANGLING },
+ Known { signature: "bits:shape:dnf", cause: DANGLING },
+ // About once per million: SubtractZipper's result is dangling-only and
+ // equal in shape to the baseline's, and the join after it loses the other
+ // operand's value (the debug assert behind cause 2 fires).
+ Known { signature: "bits:values:subtract_zipper", cause: LOSS },
+ Known { signature: "bits:shape:subtract_zipper", cause: DANGLING },
+ Known { signature: "bits:law:join-associative", cause: LOSS },
+ Known { signature: "bits:law:join-commutative", cause: LOSS },
+ Known { signature: "bits:law:join-distributes-over-meet", cause: LOSS },
+ Known { signature: "bits:law:sym-diff-is-join-minus-meet", cause: LOSS },
+ Known { signature: "bits:law:majority-is-pairwise-meets", cause: LOSS },
+ Known { signature: "bits:panic:build:line_list_node.rs:1785", cause: MERKLEIZE },
+ Known { signature: "bits:panic:eval:line_list_node.rs:2732", cause: LOSS },
+ Known { signature: "bits:panic:eval:line_list_node.rs:2783", cause: LOSS },
+ Known { signature: "bits:panic:eval:dense_byte_node.rs:2088", cause: LOSS },
+ // ---------------------------------------------------------------- unit
+ // `PathMap<()>`: the set case, and lawful. Nothing here can be a value
+ // bias, because there is no value to misplace -- so every entry is either a
+ // lost path or the dangling-path question. It confirms the real defects
+ // independently of `bits`.
+ Known { signature: "unit:values:pw1", cause: ROOT },
+ Known { signature: "unit:values:pw2", cause: ROOT },
+ Known { signature: "unit:values:pw3", cause: ROOT },
+ Known { signature: "unit:values:pw5", cause: ROOT },
+ Known { signature: "unit:values:pw4", cause: LOSS },
+ Known { signature: "unit:values:nary", cause: LOSS },
+ Known { signature: "unit:values:nary_poly", cause: LOSS },
+ Known { signature: "unit:values:dnf", cause: LOSS },
+ Known { signature: "unit:values:model", cause: LOSS },
+ Known { signature: "unit:shape:pw1", cause: DANGLING },
+ Known { signature: "unit:shape:pw2", cause: DANGLING },
+ Known { signature: "unit:shape:pw3", cause: DANGLING },
+ Known { signature: "unit:shape:pw4", cause: DANGLING },
+ Known { signature: "unit:shape:pw5", cause: DANGLING },
+ Known { signature: "unit:shape:ternary", cause: DANGLING },
+ Known { signature: "unit:shape:nary", cause: DANGLING },
+ Known { signature: "unit:shape:nary_poly", cause: DANGLING },
+ Known { signature: "unit:shape:dnf", cause: DANGLING },
+ Known { signature: "unit:values:subtract_zipper", cause: LOSS },
+ Known { signature: "unit:shape:subtract_zipper", cause: DANGLING },
+ Known { signature: "unit:law:join-associative", cause: LOSS },
+ Known { signature: "unit:law:join-commutative", cause: LOSS },
+ Known { signature: "unit:law:join-distributes-over-meet", cause: LOSS },
+ Known { signature: "unit:law:sym-diff-is-join-minus-meet", cause: LOSS },
+ Known { signature: "unit:law:majority-is-pairwise-meets", cause: LOSS },
+ Known { signature: "unit:panic:build:line_list_node.rs:1785", cause: MERKLEIZE },
+ Known { signature: "unit:panic:eval:line_list_node.rs:2732", cause: LOSS },
+ Known { signature: "unit:panic:eval:dense_byte_node.rs:2088", cause: LOSS },
+ // Fires roughly once per million random cases, and the corpus reproduces it
+ // for all three types.
+ Known { signature: "unit:panic:eval:line_list_node.rs:2783", cause: LOSS },
+ // Two laws that fire only under `unit`, both once per couple of million
+ // cases, and both cause 2. `unit` reaches it where the other types do not
+ // because every value is identical, so `merkleize` and `clone` share far
+ // more structure and the dangling-only operand that triggers the loss is
+ // easier to generate.
+ Known { signature: "unit:law:meet-distributes-over-join", cause: LOSS },
+ // One of the four identities that are only checked for a lawful value type.
+ // It failing is still cause 2, not a bad identity: the shrunk case has a
+ // dangling-only `b` and a `c` that is `b` plus one value, so `b | c` is
+ // exactly repro 4.
+ Known { signature: "unit:law:subtract-over-join", cause: LOSS },
+ Known { signature: "unit:values:ternary", cause: LOSS },
+
+ // Of the four identities in `laws::lawful_only` -- checked only for a lawful
+ // value type -- three have never failed: subtract-over-meet,
+ // subtract-is-subtract-meet and sym-diff-associative. They are the
+ // strongest laws the harness has, and they are deliberately absent from this
+ // table so that one of them firing is news. The fourth,
+ // subtract-over-join, fires once per couple of million cases under `unit`,
+ // and is cause 2 rather than a bad identity.
+];
+
+pub fn known(signature: &str) -> Option<&'static Known> {
+ KNOWN.iter().find(|k| k.signature == signature)
+}
+
+// ------------------------------------------------------------------ running
+
+/// Which half of a case was running when it panicked.
+///
+/// Worth separating, because the two mean different things. `Build` is a panic
+/// while *writing the operand tries* -- plain `set_val`, `create_path`,
+/// `remove_branches`, `merkleize` on a write zipper, before any algebra has
+/// run. That is a crash in the write path and belongs to the crash fuzzer;
+/// this fuzzer just happens to reach it. `Eval` is a panic inside an algebraic
+/// operation, which is this fuzzer's own business.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Phase {
+ Build,
+ Eval,
+}
+
+impl Phase {
+ pub fn tag(self) -> &'static str {
+ match self {
+ Phase::Build => "build",
+ Phase::Eval => "eval",
+ }
+ }
+}
+
+/// What one input produced. A panic is kept distinct from a divergence: it
+/// means a route never got as far as producing an answer, so there is nothing
+/// to compare and the finding is the crash itself.
+pub enum Outcome {
+ Clean,
+ Diverged(Vec),
+ Panicked(Phase, String, String),
+}
+
+std::thread_local! {
+ /// Where the last panic came from. The payload of an `unwrap` on `None`
+ /// carries no location, and the location is the whole value of a crash
+ /// signature -- without it every distinct panic site collapses onto one
+ /// line called "panic". A hook is the only way to see it.
+ static PANIC_SITE: core::cell::RefCell =
+ const { core::cell::RefCell::new(String::new()) };
+ /// The input [`run_all`] is checking, so a panic that aborts the process
+ /// can still name its reproducer.
+ static CURRENT_INPUT: core::cell::RefCell> =
+ const { core::cell::RefCell::new(Vec::new()) };
+}
+
+/// Replace the panic hook with one that records the panic site and prints
+/// nothing.
+///
+/// Both halves matter for a long run: the default hook would print a backtrace
+/// notice per panicking case and drown the output, and `catch_unwind` alone
+/// cannot see where the panic came from.
+pub fn install_panic_hook() {
+ std::panic::set_hook(Box::new(|info| {
+ let site = info
+ .location()
+ .map(|l| {
+ // Just the file name and line: the absolute path would make
+ // signatures depend on where the checkout lives.
+ let f = l.file();
+ let f = f.rsplit('/').next().unwrap_or(f);
+ format!("{f}:{}", l.line())
+ })
+ .unwrap_or_else(|| "?".to_string());
+ // A panic that cannot unwind aborts the whole process, so it can never
+ // become a finding; say what and where before the abort, or the run just
+ // dies with "thread caused non-unwinding panic". Stable Rust cannot ask
+ // `info` whether it unwinds, so match std's messages for the three cases:
+ // a debug-mode unsafe-precondition check, a panic in a destructor during
+ // unwinding, and a panic in a function that cannot unwind.
+ let msg = info.payload_as_str().unwrap_or("");
+ if msg.starts_with("unsafe precondition")
+ || msg.starts_with("panic in a destructor")
+ || msg.starts_with("panic in a function that cannot unwind")
+ {
+ let input: String = CURRENT_INPUT
+ .with(|c| c.try_borrow().map(|b| b.iter().map(|x| format!("{x:02x}")).collect()))
+ .unwrap_or_default();
+ eprintln!(
+ "non-unwinding panic at {site}: {msg}\ninput (hex, replay with xxd -r -p): {input}\n{}",
+ std::backtrace::Backtrace::force_capture()
+ );
+ }
+ PANIC_SITE.with(|s| *s.borrow_mut() = site);
+ }));
+}
+
+fn panic_msg(e: Box) -> (String, String) {
+ let msg = e
+ .downcast_ref::()
+ .cloned()
+ .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
+ .unwrap_or_else(|| "".to_string());
+ let site = PANIC_SITE.with(|s| s.borrow().clone());
+ (site, msg)
+}
+
+/// Decode an input, run every route and every law over it, and say what
+/// disagreed.
+///
+/// Two `catch_unwind`s rather than one, so a crash can say which phase it was
+/// in. The case has to outlive the first, hence the separation.
+pub fn run(bytes: &[u8]) -> Outcome {
+ let case = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| decode::(bytes))) {
+ Err(e) => {
+ let (site, msg) = panic_msg(e);
+ return Outcome::Panicked(Phase::Build, site, msg);
+ }
+ Ok(c) => c,
+ };
+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(&case))) {
+ Err(e) => {
+ let (site, msg) = panic_msg(e);
+ Outcome::Panicked(Phase::Eval, site, msg)
+ }
+ Ok(divs) if divs.is_empty() => Outcome::Clean,
+ Ok(divs) => Outcome::Diverged(divs),
+ }
+}
+
+/// Signatures an input produces: the grouping keys, without the detail. Also
+/// the invariant the shrinker preserves.
+/// Every value type the fuzzer runs, by name.
+///
+/// This list and [`run_all`] are the only two places that know which types
+/// exist; the driver stays generic over them and reads the type back off a
+/// signature's first field.
+pub const VALUE_TYPES: &[&str] = &[
+ ::NAME,
+ ::NAME,
+ <() as FuzzValue>::NAME,
+];
+
+/// Run one input under every value type.
+///
+/// Both, every time, and that is the point: a finding under `u64` and not under
+/// `bits` is an artefact of `u64`'s degenerate lattice instance, and one under
+/// both -- or under `bits` alone -- is a defect in the crate. Checking only the
+/// lawful type would be cheaper and would lose the comparison.
+pub fn run_all(bytes: &[u8], f: &mut impl FnMut(&'static str, Outcome)) {
+ CURRENT_INPUT.with(|c| {
+ let mut c = c.borrow_mut();
+ c.clear();
+ c.extend_from_slice(bytes);
+ });
+ f(::NAME, run::(bytes));
+ f(::NAME, run::(bytes));
+ f(<() as FuzzValue>::NAME, run::<()>(bytes));
+}
+
+/// Signatures of one outcome, which for a panic has to be built here because
+/// the phase and site are not a `Divergence`.
+pub fn outcome_signatures(type_name: &str, outcome: &Outcome) -> Vec {
+ match outcome {
+ Outcome::Clean => Vec::new(),
+ Outcome::Diverged(d) => d.iter().map(|d| d.signature.clone()).collect(),
+ Outcome::Panicked(p, site, _) => vec![panic_signature(type_name, *p, site)],
+ }
+}
+
+pub fn panic_signature(type_name: &str, phase: Phase, site: &str) -> String {
+ format!("{type_name}:panic:{}:{site}", phase.tag())
+}
+
+/// Every signature an input produces, across all value types. Also the
+/// invariant the shrinker preserves.
+pub fn signatures(bytes: &[u8]) -> Vec {
+ let mut out = Vec::new();
+ run_all(bytes, &mut |name, outcome| {
+ out.extend(outcome_signatures(name, &outcome));
+ });
+ out
+}
+
+/// The value type named in a signature's first field.
+pub fn value_type_of(signature: &str) -> &str {
+ signature.split(':').next().unwrap_or("")
+}
+
+/// Render a case under the value type named by `type_name`.
+pub fn describe_as(type_name: &str, bytes: &[u8]) -> String {
+ if type_name == ::NAME {
+ describe(&decode::(bytes))
+ } else if type_name == <() as FuzzValue>::NAME {
+ describe(&decode::<()>(bytes))
+ } else {
+ describe(&decode::(bytes))
+ }
+}
+
+/// Reproducible input generation, so a seed names a run.
+///
+/// xorshift64*, rather than `rand`: this crate depends on nothing but `pathmap`
+/// and is worth keeping that way.
+pub struct Rng(pub u64);
+
+impl Rng {
+ /// Derive a stream for job `job` of a run seeded with `seed`. The odd
+ /// multiplier keeps jobs from sharing a stream.
+ pub fn for_job(seed: u64, job: usize) -> Rng {
+ Rng((seed
+ .wrapping_mul(0x9E37_79B9_7F4A_7C15)
+ .wrapping_add(job as u64 * 0x1234_5679))
+ | 1)
+ }
+
+ pub fn next(&mut self) -> u64 {
+ self.0 ^= self.0 >> 12;
+ self.0 ^= self.0 << 25;
+ self.0 ^= self.0 >> 27;
+ self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
+ }
+
+ /// An input long enough that the decoder rarely runs out mid-case -- which
+ /// would saturate the rest to zeros and waste the draw -- and short enough
+ /// to shrink quickly.
+ pub fn input(&mut self) -> Vec {
+ let n = 48 + (self.next() % 96) as usize;
+ (0..n).map(|_| (self.next() >> 24) as u8).collect()
+ }
+}
+
+/// Human-readable rendering of a case, for a failure report and for replay.
+pub fn describe(case: &Case) -> String {
+ let mut s = String::new();
+ s.push_str(&format!("values: {}\n", V::NAME));
+ s.push_str(&format!("expr: {}\n", case.expr));
+ s.push_str(&format!("alphabet: {}\n", case.alphabet));
+ for (i, (m, b)) in case.operands.iter().zip(&case.builds).enumerate() {
+ let sh: Shape = shape_of_map(m);
+ s.push_str(&format!(
+ "operand {}: {:?}\n {}\n",
+ (b'a' + i as u8) as char,
+ b,
+ show_shape(&sh)
+ ));
+ }
+ s
+}
+
+/// Values of each operand, for a report that only needs the settled part.
+pub fn operand_values(case: &Case) -> Vec> {
+ case.operands
+ .iter()
+ .map(|m| shape::values_of_shape(&shape_of_map(m)))
+ .collect()
+}
diff --git a/differential/src/algebraic/expr.rs b/differential/src/algebraic/expr.rs
new file mode 100644
index 00000000..7fa2d9ac
--- /dev/null
+++ b/differential/src/algebraic/expr.rs
@@ -0,0 +1,218 @@
+//! The expression language the fuzzer evaluates.
+//!
+//! An expression is a tree over a handful of generated operand tries. It is
+//! deliberately small: the point is not to cover a rich language but to give
+//! each expression as many *distinct implementation routes* as possible, and a
+//! route only exists where the crate offers one. So the operator set is
+//! exactly the crate's algebra -- join, meet, subtract, symmetric difference,
+//! restrict -- and the interesting structure is the shape of the tree, because
+//! that is what decides which routes apply:
+//!
+//! * any tree at all can be walked bottom-up with two-operand calls, so the
+//! pairwise routes always apply;
+//! * a chain of three of the same associative operator unlocks the `*3` forms;
+//! * a chain of `n` unlocks the n-ary forms;
+//! * a join of meets of operands is a DNF, which unlocks
+//! [`zipper_merge_dnf`](pathmap::experimental::zipper_algebra::zipper_merge_dnf).
+//!
+//! [`Expr::dnf`] and [`Expr::chain`] are the recognisers for the last three.
+
+use core::fmt;
+
+/// Maximum operand tries in a case. Bounded because the n-ary and DNF routes
+/// are const-generic over it and have to be dispatched by a `match` over
+/// monomorphisations; see `routes::nary`.
+pub const MAX_VARS: usize = 4;
+
+/// Maximum DNF clauses dispatched to `zipper_merge_dnf`.
+pub const MAX_CLAUSES: usize = 4;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Op {
+ Join,
+ Meet,
+ Subtract,
+ SymDiff,
+ Restrict,
+}
+
+impl Op {
+ pub const ALL: [Op; 5] = [Op::Join, Op::Meet, Op::Subtract, Op::SymDiff, Op::Restrict];
+
+ pub fn sym(self) -> &'static str {
+ match self {
+ Op::Join => "|",
+ Op::Meet => "&",
+ Op::Subtract => "-",
+ Op::SymDiff => "^",
+ Op::Restrict => "/",
+ }
+ }
+
+ /// Whether `(a op b) op c == a op (b op c)` holds, which is what makes a
+ /// chain of this operator collapsible to one n-ary call.
+ ///
+ /// Subtract is listed as associative in the sense the n-ary routes mean it:
+ /// `zipper_n_subtract` is documented as *left*-associative, so it matches a
+ /// left-nested chain only. [`Expr::chain`] enforces that nesting.
+ pub fn chainable(self) -> bool {
+ matches!(self, Op::Join | Op::Meet | Op::SymDiff | Op::Subtract)
+ }
+
+ /// Whether a chain of this operator may nest to the right as well as the
+ /// left, and still mean what the n-ary call computes.
+ ///
+ /// Join and meet may: `pjoin` and `pmeet` on `u64` are left-biased, so both
+ /// nestings select the leftmost present operand's value and agree with the
+ /// left fold the n-ary routes perform.
+ ///
+ /// Subtract and symmetric difference may not. Subtract obviously -- `a -
+ /// (b - c)` is a different function. Symmetric difference less obviously:
+ /// the n-ary form folds values left, so for root values `a = 2`, `b = 1`,
+ /// `a ^ (a ^ b)` is `a ^ nothing = 2` while the fold is `(2 ^ 2) ^ 1 = 1`.
+ /// Both are defensible; they are not equal, so a right-nested symmetric
+ /// difference is not a chain. `laws.rs` states the associativity that does
+ /// hold, which is on paths.
+ pub fn right_nestable(self) -> bool {
+ matches!(self, Op::Join | Op::Meet)
+ }
+}
+
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub enum Expr {
+ Var(usize),
+ Bin(Op, Box, Box),
+}
+
+impl Expr {
+ pub fn bin(op: Op, l: Expr, r: Expr) -> Expr {
+ Expr::Bin(op, Box::new(l), Box::new(r))
+ }
+
+ pub fn nodes(&self) -> usize {
+ match self {
+ Expr::Var(_) => 1,
+ Expr::Bin(_, l, r) => 1 + l.nodes() + r.nodes(),
+ }
+ }
+
+ /// Operand indices the expression mentions, ascending.
+ pub fn vars(&self) -> Vec {
+ let mut v = Vec::new();
+ self.collect_vars(&mut v);
+ v.sort();
+ v.dedup();
+ v
+ }
+
+ fn collect_vars(&self, out: &mut Vec) {
+ match self {
+ Expr::Var(i) => out.push(*i),
+ Expr::Bin(_, l, r) => {
+ l.collect_vars(out);
+ r.collect_vars(out);
+ }
+ }
+ }
+
+ /// If the whole expression is a chain of one chainable operator over bare
+ /// operands, return that operator and the operands in evaluation order.
+ ///
+ /// Only bare operands qualify, not arbitrary subexpressions: the n-ary
+ /// routes take read zippers, and a subexpression would have to be
+ /// materialised into a temporary trie first, at which point the route is no
+ /// longer testing the n-ary call against the same inputs the pairwise route
+ /// saw. Keeping it to operands keeps the comparison exact.
+ pub fn chain(&self) -> Option<(Op, Vec)> {
+ let Expr::Bin(op, _, _) = self else { return None };
+ if !op.chainable() {
+ return None;
+ }
+ let mut operands = Vec::new();
+ self.collect_chain(*op, &mut operands).then_some((*op, operands))
+ }
+
+ fn collect_chain(&self, op: Op, out: &mut Vec) -> bool {
+ match self {
+ Expr::Var(i) => {
+ out.push(*i);
+ true
+ }
+ Expr::Bin(o, l, r) if *o == op => {
+ // The left spine may always recurse. The right spine may only
+ // recurse for operators that nest both ways, so `a - (b - c)`
+ // is not mistaken for the chain `a - b - c`.
+ if !l.collect_chain(op, out) {
+ return false;
+ }
+ match &**r {
+ Expr::Var(i) => {
+ out.push(*i);
+ true
+ }
+ Expr::Bin(..) => op.right_nestable() && r.collect_chain(op, out),
+ }
+ }
+ Expr::Bin(..) => false,
+ }
+ }
+
+ /// If the expression is a join of meets of operands, return one bitmask of
+ /// operand indices per clause.
+ ///
+ /// This is the form `zipper_merge_dnf` evaluates directly. A clause is a
+ /// *set*, so `a & a` collapses to `a`; that is sound because meet is
+ /// idempotent, which is itself one of the laws checked in `laws.rs`.
+ pub fn dnf(&self) -> Option> {
+ match self {
+ Expr::Bin(Op::Join, l, r) => {
+ let mut cs = l.dnf()?;
+ cs.extend(r.dnf()?);
+ Some(cs)
+ }
+ _ => Some(vec![self.meet_mask()?]),
+ }
+ }
+
+ /// If the expression is a meet of operands, return their index bitmask.
+ ///
+ /// Rejects a meet whose operands are not in ascending index order, even
+ /// though the bitmask would be the same. A [`Clause`] is a *set*: it
+ /// records which zippers take part, not in what order, and
+ /// `zipper_merge_dnf` meets a clause's members in slot order, which is
+ /// ascending operand index. Because `pmeet` on `u64` is left-biased, `b &
+ /// a` and `a & b` carry different values, so only one of them is what the
+ /// clause actually computes. Returning a mask for the other would make the
+ /// DNF route report a divergence that is the route's own fault.
+ ///
+ /// [`Clause`]: pathmap::experimental::zipper_algebra::Clause
+ fn meet_mask(&self) -> Option {
+ let mut vs = Vec::new();
+ self.meet_vars(&mut vs).then_some(())?;
+ if vs.windows(2).any(|w| w[0] > w[1]) {
+ return None;
+ }
+ Some(vs.iter().fold(0u64, |m, i| m | 1u64 << i))
+ }
+
+ /// Operands of a meet-only subexpression, left to right.
+ fn meet_vars(&self, out: &mut Vec) -> bool {
+ match self {
+ Expr::Var(i) => {
+ out.push(*i);
+ true
+ }
+ Expr::Bin(Op::Meet, l, r) => l.meet_vars(out) && r.meet_vars(out),
+ _ => false,
+ }
+ }
+}
+
+impl fmt::Display for Expr {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Expr::Var(i) => write!(f, "{}", (b'a' + *i as u8) as char),
+ Expr::Bin(op, l, r) => write!(f, "({l} {} {r})", op.sym()),
+ }
+ }
+}
diff --git a/differential/src/algebraic/laws.rs b/differential/src/algebraic/laws.rs
new file mode 100644
index 00000000..221991fb
--- /dev/null
+++ b/differential/src/algebraic/laws.rs
@@ -0,0 +1,243 @@
+//! Identities that must hold whatever the operands are.
+//!
+//! The route comparison in `routes.rs` catches an implementation that disagrees
+//! with its siblings. It cannot catch a mistake they all share. Laws are the
+//! other half: each one is two *different expressions* that must evaluate to
+//! the same trie, so a wrong answer is visible even when every route computes
+//! it the same wrong way.
+//!
+//! A law is written as a pair of [`Expr`]s and evaluated by the ordinary route
+//! machinery, which keeps this file declarative and means a law automatically
+//! inherits whatever the expression evaluator can do.
+//!
+//! # Strength depends on the value type
+//!
+//! [`laws`] is parameterised by the value type, and returns a *stronger* set for
+//! one whose instances actually form a distributive lattice with a relative
+//! complement -- see [`FuzzValue::LAWFUL`]. Three things change:
+//!
+//! * The laws marked [`Level::Paths`] are promoted to [`Level::Values`]. They
+//! are weakened for `u64` only because its `pjoin` and `pmeet` both return the
+//! left operand, so swapping the operands swaps the result's value and
+//! commutativity cannot hold on values there.
+//!
+//! * Three further identities are added, listed in [`lawful_only`]. They are
+//! laws in any distributive lattice and fail for `u64`, again because join and
+//! meet have collapsed into one function.
+//!
+//! * Symmetric difference becomes associative on values, not merely on paths.
+//!
+//! So the difference between the two sets is a measurement: a law that holds for
+//! `bits` and fails for `u64` is telling you about `u64`, and one that fails for
+//! `bits` is telling you about the crate. That split is the whole reason for
+//! running both.
+
+use super::expr::{Expr, Op};
+use super::value::FuzzValue;
+
+/// Operand slot holding the empty trie, appended after the case's own
+/// operands so identities can mention it. See [`LAW_OPERANDS`].
+pub const EMPTY: usize = 3;
+
+/// Operands a law may mention: three from the case, then the empty trie.
+pub const LAW_OPERANDS: usize = 4;
+
+/// How much of the result a law constrains.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Level {
+ /// Which path carries which value.
+ Values,
+ /// Which paths survive, values unconstrained.
+ Paths,
+}
+
+pub struct Law {
+ pub name: &'static str,
+ pub lhs: Expr,
+ pub rhs: Expr,
+ pub level: Level,
+}
+
+fn v(i: usize) -> Expr {
+ Expr::Var(i)
+}
+fn j(a: Expr, b: Expr) -> Expr {
+ Expr::bin(Op::Join, a, b)
+}
+fn m(a: Expr, b: Expr) -> Expr {
+ Expr::bin(Op::Meet, a, b)
+}
+fn s(a: Expr, b: Expr) -> Expr {
+ Expr::bin(Op::Subtract, a, b)
+}
+fn x(a: Expr, b: Expr) -> Expr {
+ Expr::bin(Op::SymDiff, a, b)
+}
+fn r(a: Expr, b: Expr) -> Expr {
+ Expr::bin(Op::Restrict, a, b)
+}
+
+/// The laws to check for value type `V`.
+///
+/// For a lawful `V` every `Paths` level is promoted to `Values` and
+/// [`lawful_only`] is appended.
+pub fn laws() -> Vec {
+ let mut out = base();
+ if V::LAWFUL {
+ for law in &mut out {
+ law.level = Level::Values;
+ }
+ out.extend(lawful_only());
+ }
+ out
+}
+
+/// Identities that hold whatever the value type.
+fn base() -> Vec {
+ use Level::{Paths, Values};
+ let (a, b, c, e) = (0usize, 1usize, 2usize, EMPTY);
+ vec![
+ // --- lattice basics
+ Law { name: "join-idempotent", lhs: j(v(a), v(a)), rhs: v(a), level: Values },
+ Law { name: "meet-idempotent", lhs: m(v(a), v(a)), rhs: v(a), level: Values },
+ Law { name: "join-unit", lhs: j(v(a), v(e)), rhs: v(a), level: Values },
+ Law { name: "meet-zero", lhs: m(v(a), v(e)), rhs: v(e), level: Values },
+ // Path set only: both `pjoin` and `pmeet` return the left operand, so
+ // swapping the operands swaps which value lands. A real lattice would
+ // make these value-level laws; see the module comment.
+ Law { name: "join-commutative", lhs: j(v(a), v(b)), rhs: j(v(b), v(a)), level: Paths },
+ Law { name: "meet-commutative", lhs: m(v(a), v(b)), rhs: m(v(b), v(a)), level: Paths },
+ // Associativity does hold on values even here: both nestings select the
+ // leftmost present operand's value.
+ Law {
+ name: "join-associative",
+ lhs: j(j(v(a), v(b)), v(c)),
+ rhs: j(v(a), j(v(b), v(c))),
+ level: Values,
+ },
+ Law {
+ name: "meet-associative",
+ lhs: m(m(v(a), v(b)), v(c)),
+ rhs: m(v(a), m(v(b), v(c))),
+ level: Values,
+ },
+ Law { name: "absorb-meet-join", lhs: m(v(a), j(v(a), v(b))), rhs: v(a), level: Values },
+ Law { name: "absorb-join-meet", lhs: j(v(a), m(v(a), v(b))), rhs: v(a), level: Values },
+ Law {
+ name: "meet-distributes-over-join",
+ lhs: m(v(a), j(v(b), v(c))),
+ rhs: j(m(v(a), v(b)), m(v(a), v(c))),
+ level: Values,
+ },
+ Law {
+ name: "join-distributes-over-meet",
+ lhs: j(v(a), m(v(b), v(c))),
+ rhs: m(j(v(a), v(b)), j(v(a), v(c))),
+ level: Values,
+ },
+ // --- subtraction
+ Law { name: "subtract-self", lhs: s(v(a), v(a)), rhs: v(e), level: Values },
+ Law { name: "subtract-unit", lhs: s(v(a), v(e)), rhs: v(a), level: Values },
+ Law { name: "subtract-from-empty", lhs: s(v(e), v(a)), rhs: v(e), level: Values },
+ // Each step drops the paths whose value the subtrahend matches, and
+ // "matches b or matches c" does not depend on the order.
+ Law {
+ name: "subtract-steps-commute",
+ lhs: s(s(v(a), v(b)), v(c)),
+ rhs: s(s(v(a), v(c)), v(b)),
+ level: Values,
+ },
+ // Subtracting cannot add a path: re-joining the removed part recovers
+ // no more than the original.
+ Law { name: "subtract-shrinks", lhs: j(s(v(a), v(b)), v(a)), rhs: v(a), level: Values },
+ // --- symmetric difference
+ Law { name: "sym-diff-self", lhs: x(v(a), v(a)), rhs: v(e), level: Values },
+ Law { name: "sym-diff-unit", lhs: x(v(a), v(e)), rhs: v(a), level: Values },
+ // Fully commutative, values included: a coincident path cancels on both
+ // sides, so each surviving value comes from the single side that has it.
+ Law { name: "sym-diff-commutative", lhs: x(v(a), v(b)), rhs: x(v(b), v(a)), level: Values },
+ Law {
+ name: "sym-diff-associative-paths",
+ lhs: x(x(v(a), v(b)), v(c)),
+ rhs: x(v(a), x(v(b), v(c))),
+ level: Paths,
+ },
+ // The definition `zipper_sym_diff`'s own documentation gives.
+ Law {
+ name: "sym-diff-is-join-minus-meet",
+ lhs: x(v(a), v(b)),
+ rhs: s(j(v(a), v(b)), m(v(a), v(b))),
+ level: Values,
+ },
+ // --- restrict
+ // Every path is a prefix of itself, so restricting by its own operand
+ // admits everything.
+ Law { name: "restrict-self", lhs: r(v(a), v(a)), rhs: v(a), level: Values },
+ Law { name: "restrict-empty", lhs: r(v(a), v(e)), rhs: v(e), level: Values },
+ Law { name: "restrict-of-empty", lhs: r(v(e), v(a)), rhs: v(e), level: Values },
+ Law { name: "restrict-idempotent", lhs: r(r(v(a), v(b)), v(b)), rhs: r(v(a), v(b)), level: Values },
+ // A path present in both operands is admitted by itself, so the meet is
+ // contained in the restriction.
+ Law {
+ name: "meet-under-restrict",
+ lhs: m(m(v(a), v(b)), r(v(a), v(b))),
+ rhs: m(v(a), v(b)),
+ level: Values,
+ },
+ // --- majority, the worked example in `zipper_majority`'s docs, written
+ // out as the DNF it claims to equal. Exercised here so the DNF engine
+ // is checked against a hand-written join of meets as well as against
+ // the pointwise routes.
+ Law {
+ name: "majority-is-pairwise-meets",
+ lhs: j(j(m(v(a), v(b)), m(v(a), v(c))), m(v(b), v(c))),
+ rhs: j(m(v(a), v(b)), j(m(v(a), v(c)), m(v(b), v(c)))),
+ level: Values,
+ },
+ ]
+}
+
+/// Identities that need the value type to be a real distributive lattice with a
+/// relative complement.
+///
+/// Each of these fails for `u64`, and each failure is explained by the same
+/// thing: `pjoin` and `pmeet` both return the left operand, so `a | b == a & b`,
+/// and `psubtract` is `None` exactly on equality.
+fn lawful_only() -> Vec {
+ use Level::Values;
+ let (a, b, c) = (0usize, 1usize, 2usize);
+ vec![
+ // De Morgan for relative complement. Fails for `u64` because `b | c`
+ // carries `b`'s value where both are present, so the left side keeps a
+ // path whose value matches `c` but not `b`.
+ Law {
+ name: "subtract-over-join",
+ lhs: s(v(a), j(v(b), v(c))),
+ rhs: m(s(v(a), v(b)), s(v(a), v(c))),
+ level: Values,
+ },
+ Law {
+ name: "subtract-over-meet",
+ lhs: s(v(a), m(v(b), v(c))),
+ rhs: j(s(v(a), v(b)), s(v(a), v(c))),
+ level: Values,
+ },
+ // Subtracting the overlap is the same as subtracting the whole. Fails
+ // for `u64` because `a & b` carries *`a`'s* value, so the right side
+ // drops every shared path rather than only the equal ones.
+ Law {
+ name: "subtract-is-subtract-meet",
+ lhs: s(v(a), v(b)),
+ rhs: s(v(a), m(v(a), v(b))),
+ level: Values,
+ },
+ // Symmetric difference is the XOR of a Boolean ring, so it is
+ // associative on values and not merely on paths.
+ Law {
+ name: "sym-diff-associative",
+ lhs: x(x(v(a), v(b)), v(c)),
+ rhs: x(v(a), x(v(b), v(c))),
+ level: Values,
+ },
+ ]
+}
diff --git a/differential/src/algebraic/model.rs b/differential/src/algebraic/model.rs
new file mode 100644
index 00000000..c987be6d
--- /dev/null
+++ b/differential/src/algebraic/model.rs
@@ -0,0 +1,114 @@
+//! Flat reference semantics, as a fourth opinion.
+//!
+//! Every route in `routes.rs` goes through `pathmap`, so a defect common to the
+//! whole algebra layer would make them agree with each other and still be wrong.
+//! This computes the same expression over a plain `BTreeMap, V>` with no
+//! trie involved, which gives the comparison something to anchor against.
+//!
+//! It models only *which path carries which value* and abstains on structure: a
+//! flat map cannot represent a dangling path, and whether a dangling path
+//! survives an operation is unsettled in the crate. `shape.rs` explains the
+//! split.
+//!
+//! # The value algebra is not restated here
+//!
+//! Each operation delegates to the value type's own `pjoin`, `pmeet` or
+//! `psubtract` through the `Option` impls in `pathmap::ring`. An earlier
+//! version spelled out `u64`'s behaviour by hand -- left-biased join, left-biased
+//! meet, subtract-on-equality -- which worked only because it happened to match,
+//! and which meant the model was asserting the harness author's idea of the
+//! algebra rather than the type's. Delegating means this file is correct for
+//! any [`FuzzValue`] without changes, and that a disagreement between the model
+//! and a route is always about *where a value ends up*, never about what
+//! combining two values means.
+//!
+//! What remains genuinely this file's own is the set structure: which paths an
+//! operation visits at all, and `restrict`, which is not a lattice operation.
+
+use pathmap::ring::{DistributiveLattice, Lattice};
+
+use super::expr::{Expr, Op};
+use super::shape::Values;
+use super::value::{resolve, FuzzValue};
+
+pub fn eval(e: &Expr, operands: &[Values]) -> Values {
+ match e {
+ Expr::Var(i) => operands[*i].clone(),
+ Expr::Bin(op, l, r) => {
+ let a = eval(l, operands);
+ let b = eval(r, operands);
+ apply(*op, &a, &b)
+ }
+ }
+}
+
+pub fn apply(op: Op, a: &Values, b: &Values) -> Values {
+ match op {
+ Op::Join => join(a, b),
+ Op::Meet => meet(a, b),
+ Op::Subtract => subtract(a, b),
+ Op::SymDiff => sym_diff(a, b),
+ Op::Restrict => restrict(a, b),
+ }
+}
+
+/// Every path either side mentions.
+fn keys(a: &Values, b: &Values) -> Vec> {
+ let mut k: Vec> = a.keys().chain(b.keys()).cloned().collect();
+ k.sort();
+ k.dedup();
+ k
+}
+
+/// Combine both sides pointwise with one of the value operations, dropping the
+/// paths where the result is bottom.
+fn pointwise(a: &Values, b: &Values, f: F) -> Values
+where
+ F: Fn(&Option, &Option) -> Option,
+{
+ let mut out = Values::new();
+ for k in keys(a, b) {
+ let (lv, rv) = (a.get(&k).cloned(), b.get(&k).cloned());
+ if let Some(v) = f(&lv, &rv) {
+ out.insert(k, v);
+ }
+ }
+ out
+}
+
+pub fn join(a: &Values, b: &Values) -> Values {
+ pointwise(a, b, |l, r| resolve(l.pjoin(r), l, r))
+}
+
+pub fn meet(a: &Values, b: &Values) -> Values {
+ pointwise(a, b, |l, r| resolve(l.pmeet(r), l, r))
+}
+
+pub fn subtract(a: &Values, b: &Values) -> Values {
+ pointwise(a, b, |l, r| resolve(l.psubtract(r), l, r))
+}
+
+/// `(a | b) \ (a & b)`, the definition `zipper_sym_diff`'s own documentation
+/// gives.
+///
+/// In a distributive lattice with a relative complement this equals
+/// `(a \ b) | (b \ a)`, and `laws.rs` checks that -- for a lawful value type it
+/// is a law, and for `u64` the two differ, which is a fact about `u64` rather
+/// than about symmetric difference.
+pub fn sym_diff(a: &Values, b: &Values) -> Values {
+ subtract(&join(a, b), &meet(a, b))
+}
+
+/// Left paths that some path-to-a-value in the right side is a prefix of.
+///
+/// Not a lattice operation, so there is nothing to delegate to: the value is
+/// carried across unchanged, and only the path set is decided. The prefix is
+/// inclusive at both ends -- the empty path counts, so a root value in `b`
+/// admits all of `a`, and `k` counts as a prefix of itself -- both of which
+/// follow from the note on `PathMap::restrict`.
+pub fn restrict(a: &Values, b: &Values) -> Values {
+ a.iter()
+ .filter(|(k, _)| (0..=k.len()).any(|n| b.contains_key(&k[..n])))
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect()
+}
diff --git a/differential/src/algebraic/routes.rs b/differential/src/algebraic/routes.rs
new file mode 100644
index 00000000..9a025906
--- /dev/null
+++ b/differential/src/algebraic/routes.rs
@@ -0,0 +1,597 @@
+//! The different ways to compute the same thing.
+//!
+//! `pathmap` offers each algebraic operation several times over: once eagerly
+//! on whole maps, once in place through a write zipper, once as a lockstep
+//! traversal of two read zippers, and -- for the associative ones -- again as
+//! an n-ary traversal, a DNF evaluator, and a lazy zipper over a virtual trie.
+//! Those are not alternative spellings of one implementation; they are separate
+//! implementations with separate pruning, grafting and value-combining logic,
+//! which is exactly why they are worth running against each other.
+//!
+//! A *route* is one consistent way to evaluate a whole expression. There are
+//! two kinds:
+//!
+//! * **pointwise** routes walk the expression bottom-up and evaluate each node
+//! with a two-operand call, materialising each intermediate into a trie.
+//! Route `k` picks strategy `k % n` for an operator with `n` strategies, so
+//! every strategy is covered by some route and the routes mix them.
+//! * **whole-expression** routes recognise a shape -- a chain of one
+//! associative operator, or a join of meets -- and hand the entire thing to
+//! one call that does it in a single traversal. These are the routes with
+//! real independence from the pointwise ones: no intermediate trie is ever
+//! built, so they exercise code the pointwise routes never reach.
+//!
+//! Every route must produce the same trie. `shape.rs` says what "same" means
+//! and why dangling-path divergence is reported separately.
+
+use pathmap::PathMap;
+use pathmap::experimental::zipper_algebra::{
+ Clause, zipper_join, zipper_join3, zipper_meet, zipper_meet3, zipper_merge_dnf,
+ zipper_n_join, zipper_n_meet, zipper_n_subtract, zipper_n_sym_diff, zipper_subtract,
+ zipper_subtract3, zipper_sym_diff, zipper_sym_diff3, ZipperMergeF,
+};
+use pathmap::zipper::{
+ OverlayZipper, ReadZipperUntracked, SubtractZipper, ZipperMoving, ZipperPath, ZipperValues,
+ ZipperWriting,
+};
+
+use super::expr::{Expr, Op};
+use super::model;
+use super::shape::{shape_of_map, shape_of_zipper, values_of_shape, Shape, Values, ESCAPED_ROOT, TRUNCATED};
+use super::value::FuzzValue;
+
+/// Names of the per-operator strategies, in the order route `k % n` indexes
+/// them. Kept as tables so a route's name can say which strategy it used for
+/// each operator the expression contains.
+pub const JOIN_STRATEGIES: &[&str] =
+ &["map", "join_into", "join_map_into", "join_into_take", "zipper_join", "overlay"];
+pub const MEET_STRATEGIES: &[&str] = &["map", "meet_into", "meet_2", "zipper_meet"];
+pub const SUBTRACT_STRATEGIES: &[&str] = &["map", "subtract_into", "zipper_subtract"];
+pub const SYM_DIFF_STRATEGIES: &[&str] = &["zipper_sym_diff", "join_minus_meet"];
+pub const RESTRICT_STRATEGIES: &[&str] = &["map", "wz_restrict"];
+
+/// The strategy table is the same for every value type, so route `k` means the
+/// same thing under each of them.
+///
+/// That matters more than it looks. An earlier version shortened the join table
+/// for value types that cannot use the `OverlayZipper` strategy, which silently
+/// renumbered every later route -- so `pw4` and `pw5` meant different strategy
+/// mixes under `u64` and under `bits`, and comparing their findings across the
+/// two types was comparing different things. A strategy that does not apply to
+/// a value type now *declines* instead, which leaves the numbering alone and
+/// simply makes that route absent for that type.
+pub fn strategies(op: Op) -> &'static [&'static str] {
+ match op {
+ Op::Join => JOIN_STRATEGIES,
+ Op::Meet => MEET_STRATEGIES,
+ Op::Subtract => SUBTRACT_STRATEGIES,
+ Op::SymDiff => SYM_DIFF_STRATEGIES,
+ Op::Restrict => RESTRICT_STRATEGIES,
+ }
+}
+
+/// How many pointwise routes exist: enough that every strategy of every
+/// operator is reached by at least one of them.
+pub const POINTWISE_ROUTES: usize = 6;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Route {
+ /// Bottom-up two-operand evaluation, strategy `k % n` per operator.
+ Pointwise(usize),
+ /// `zipper_join3` / `zipper_meet3` / `zipper_subtract3` / `zipper_sym_diff3`
+ /// on a chain of exactly three operands.
+ Ternary,
+ /// `zipper_n_*` on a chain of operands, via the slice-of-zippers entry point.
+ Nary,
+ /// The same chain through the `ZipperMergeF` tuple entry point, which goes
+ /// via the `PolyZipper`-derived enum rather than a homogeneous array.
+ NaryPoly,
+ /// `zipper_merge_dnf` on a join of meets.
+ Dnf,
+ /// Every subtraction through the lazy `SubtractZipper`, materialised, and
+ /// every other operator through the baseline's whole-map call.
+ ///
+ /// A route of its own rather than a fourth subtract strategy: adding a
+ /// strategy would renumber which mix each `pw#k` route uses, and a new
+ /// finding would land under a `pw` signature `KNOWN` already attributes to
+ /// another cause. Holding the other operators at the baseline also means a
+ /// divergence here can only come from `SubtractZipper`.
+ SubtractZipper,
+ /// Nested `SubtractZipper`s, materialised once at the end: a left spine of
+ /// subtractions `x - y - z ...` with each zipper the left input of the
+ /// next, or `x - (y - z)` with one as the right input. This is the route
+ /// that tests a `SubtractZipper` reading from another one.
+ SubtractZipperNested,
+ /// Flat `BTreeMap` semantics, no trie. Values only; abstains on structure.
+ Model,
+}
+
+impl Route {
+ pub fn all() -> Vec {
+ let mut v: Vec = (0..POINTWISE_ROUTES).map(Route::Pointwise).collect();
+ v.extend([
+ Route::Ternary,
+ Route::Nary,
+ Route::NaryPoly,
+ Route::Dnf,
+ Route::Model,
+ Route::SubtractZipper,
+ Route::SubtractZipperNested,
+ ]);
+ v
+ }
+
+ /// Name including, for a pointwise route, the strategy it picked for each
+ /// operator the expression actually uses. A failure report needs this to
+ /// be actionable: "pw#3" alone does not name a function to go and read.
+ pub fn name(&self, e: &Expr) -> String {
+ match self {
+ Route::Pointwise(k) => {
+ let mut used: Vec = Vec::new();
+ collect_ops(e, &mut used);
+ let picks: Vec = Op::ALL
+ .iter()
+ .filter(|op| used.contains(op))
+ .map(|op| {
+ let t = strategies(*op);
+ format!("{}={}", op.sym(), t[k % t.len()])
+ })
+ .collect();
+ format!("pw#{k}[{}]", picks.join(","))
+ }
+ Route::Ternary => "ternary".into(),
+ Route::Nary => "nary".into(),
+ Route::NaryPoly => "nary_poly".into(),
+ Route::Dnf => "dnf".into(),
+ Route::Model => "model".into(),
+ Route::SubtractZipper => "subtract_zipper".into(),
+ Route::SubtractZipperNested => "subtract_zipper_nested".into(),
+ }
+ }
+}
+
+fn collect_ops(e: &Expr, out: &mut Vec) {
+ if let Expr::Bin(op, l, r) = e {
+ if !out.contains(op) {
+ out.push(*op);
+ }
+ collect_ops(l, out);
+ collect_ops(r, out);
+ }
+}
+
+/// What a route produced. `shape` is `None` for a route that cannot represent
+/// dangling paths, which is only [`Route::Model`].
+pub struct Outcome {
+ pub values: Values,
+ pub shape: Option>,
+}
+
+impl Outcome {
+ fn from_map(m: &PathMap) -> Outcome {
+ let shape = shape_of_map(m);
+ Outcome { values: values_of_shape(&shape), shape: Some(shape) }
+ }
+}
+
+/// Evaluate `e` over `operands` by `route`, or `None` if the route does not
+/// apply to this expression's shape.
+pub fn eval(route: Route, e: &Expr, operands: &[PathMap]) -> Option> {
+ match route {
+ Route::Pointwise(k) => pointwise(k, e, operands).map(|m| Outcome::from_map(&m)),
+ Route::Ternary => ternary(e, operands).map(|m| Outcome::from_map(&m)),
+ Route::Nary => nary(e, operands, false).map(|m| Outcome::from_map(&m)),
+ Route::NaryPoly => nary(e, operands, true).map(|m| Outcome::from_map(&m)),
+ Route::Dnf => dnf(e, operands).map(|m| Outcome::from_map(&m)),
+ Route::Model => {
+ let vals: Vec> =
+ operands.iter().map(|m| values_of_shape(&shape_of_map(m))).collect();
+ Some(Outcome { values: model::eval(e, &vals), shape: None })
+ }
+ Route::SubtractZipper => subtract_zipper(e, operands).map(|m| Outcome::from_map(&m)),
+ Route::SubtractZipperNested => subtract_zipper_nested(e, operands).map(|m| Outcome::from_map(&m)),
+ }
+}
+
+// ---------------------------------------------------------------- pointwise
+
+/// `None` when the strategy route `k` selects for some operator in `e` does not
+/// apply to this value type.
+fn pointwise(k: usize, e: &Expr, operands: &[PathMap]) -> Option> {
+ match e {
+ Expr::Var(i) => Some(operands[*i].clone()),
+ Expr::Bin(op, l, r) => {
+ let a = pointwise(k, l, operands)?;
+ let b = pointwise(k, r, operands)?;
+ apply(*op, k, &a, &b)
+ }
+ }
+}
+
+/// One operator, one strategy. Every arm is a different code path in the
+/// crate, not a different way of calling the same one.
+pub fn apply(op: Op, k: usize, a: &PathMap, b: &PathMap) -> Option> {
+ let n = strategies(op).len();
+ Some(match (op, k % n) {
+ // -- join
+ (Op::Join, 0) => a.join(b),
+ (Op::Join, 1) => {
+ let mut out = a.clone();
+ {
+ let mut wz = out.write_zipper();
+ wz.join_into(&b.read_zipper());
+ }
+ out
+ }
+ (Op::Join, 2) => {
+ let mut out = a.clone();
+ {
+ let mut wz = out.write_zipper();
+ wz.join_map_into(b.clone());
+ }
+ out
+ }
+ (Op::Join, 3) => {
+ // Consumes the source. `prune = false` keeps whatever dangling
+ // structure the operation leaves, so this route is comparable with
+ // the other write-zipper forms rather than silently tidier.
+ let mut out = a.clone();
+ let mut src = b.clone();
+ {
+ let mut wz = out.write_zipper();
+ let mut sz = src.write_zipper();
+ wz.join_into_take(&mut sz, false);
+ }
+ out
+ }
+ (Op::Join, 4) => {
+ let mut out = PathMap::new();
+ {
+ let mut lz = a.read_zipper();
+ let mut rz = b.read_zipper();
+ let mut wz = out.write_zipper();
+ zipper_join(&mut lz, &mut rz, &mut wz);
+ }
+ out
+ }
+ (Op::Join, 5) => {
+ // Declines rather than answering wrongly: see
+ // `FuzzValue::JOIN_PICKS_LEFT`.
+ if !V::JOIN_PICKS_LEFT {
+ return None;
+ }
+ // `OverlayZipper`'s default mapping is `a.or(b)`, which is the same
+ // bias a left-picking `pjoin` has, so for such a value type the
+ // virtual trie it walks is exactly the join. Only reachable when
+ // `V::JOIN_PICKS_LEFT`; see `strategies`. It never builds a trie, so
+ // it has to be materialised to be composed into a larger expression;
+ // the walk records dangling paths too, so nothing is lost.
+ let overlay = OverlayZipper::new(a.read_zipper(), b.read_zipper());
+ materialize(overlay)
+ }
+
+ // -- meet
+ (Op::Meet, 0) => a.meet(b),
+ (Op::Meet, 1) => {
+ let mut out = a.clone();
+ {
+ let mut wz = out.write_zipper();
+ wz.meet_into(&b.read_zipper(), false);
+ }
+ out
+ }
+ (Op::Meet, 2) => {
+ // Writes the intersection of two sources into a fresh destination,
+ // rather than intersecting a destination with one source.
+ let mut out = PathMap::new();
+ {
+ let mut wz = out.write_zipper();
+ wz.meet_2(&a.read_zipper(), &b.read_zipper());
+ }
+ out
+ }
+ (Op::Meet, 3) => {
+ let mut out = PathMap::new();
+ {
+ let mut lz = a.read_zipper();
+ let mut rz = b.read_zipper();
+ let mut wz = out.write_zipper();
+ zipper_meet(&mut lz, &mut rz, &mut wz);
+ }
+ out
+ }
+
+ // -- subtract
+ (Op::Subtract, 0) => a.subtract(b),
+ (Op::Subtract, 1) => {
+ let mut out = a.clone();
+ {
+ let mut wz = out.write_zipper();
+ wz.subtract_into(&b.read_zipper(), false);
+ }
+ out
+ }
+ (Op::Subtract, 2) => {
+ let mut out = PathMap::new();
+ {
+ let mut lz = a.read_zipper();
+ let mut rz = b.read_zipper();
+ let mut wz = out.write_zipper();
+ zipper_subtract(&mut lz, &mut rz, &mut wz);
+ }
+ out
+ }
+
+ // -- symmetric difference
+ (Op::SymDiff, 0) => {
+ let mut out = PathMap::new();
+ {
+ let mut lz = a.read_zipper();
+ let mut rz = b.read_zipper();
+ let mut wz = out.write_zipper();
+ zipper_sym_diff(&mut lz, &mut rz, &mut wz);
+ }
+ out
+ }
+ (Op::SymDiff, 1) => {
+ // The definition `zipper_sym_diff`'s doc comment gives, built out
+ // of the other three operations. Where this disagrees with the
+ // specialised traversal, one of the two is wrong.
+ let j = a.join(b);
+ let m = a.meet(b);
+ j.subtract(&m)
+ }
+
+ // -- restrict
+ (Op::Restrict, 0) => a.restrict(b),
+ (Op::Restrict, 1) => {
+ let mut out = a.clone();
+ {
+ let mut wz = out.write_zipper();
+ wz.restrict(&b.read_zipper());
+ }
+ out
+ }
+
+ _ => unreachable!("strategy index out of range for {op:?}"),
+ })
+}
+
+/// Walk a zipper over a virtual trie and build the real trie it describes.
+///
+/// `create_path` is what keeps this faithful: a path with no value still has to
+/// appear in the output, or materialising would quietly erase exactly the
+/// dangling structure the comparison is trying to detect.
+fn materialize(mut z: Z) -> PathMap
+where
+ Z: ZipperMoving + ZipperPath + ZipperValues,
+{
+ let shape = shape_of_zipper(&mut z);
+ let mut out = PathMap::new();
+ {
+ let mut wz = out.write_zipper();
+ for (p, v) in &shape {
+ if &p[..] == ESCAPED_ROOT || &p[..] == TRUNCATED {
+ continue;
+ }
+ wz.reset();
+ wz.descend_to(p);
+ match v {
+ Some(v) => {
+ wz.set_val(v.clone());
+ }
+ None => {
+ wz.create_path();
+ }
+ }
+ }
+ }
+ out
+}
+
+/// [`Route::SubtractZipper`]. `None` when `e` has no subtraction, because the
+/// route would then be the baseline over again.
+fn subtract_zipper(e: &Expr, operands: &[PathMap]) -> Option> {
+ fn go(e: &Expr, operands: &[PathMap]) -> PathMap {
+ match e {
+ Expr::Var(i) => operands[*i].clone(),
+ Expr::Bin(op, l, r) => {
+ let a = go(l, operands);
+ let b = go(r, operands);
+ if *op == Op::Subtract {
+ materialize(SubtractZipper::new(a.read_zipper(), b.read_zipper()))
+ } else {
+ apply(*op, 0, &a, &b).expect("strategy 0 applies to every value type")
+ }
+ }
+ }
+ }
+ let mut ops = Vec::new();
+ collect_ops(e, &mut ops);
+ ops.contains(&Op::Subtract).then(|| go(e, operands))
+}
+
+// -------------------------------------------------------- whole-expression
+
+/// [`Route::SubtractZipperNested`].
+///
+/// The expression's left spine of subtractions, `x0 - x1 - ... - xk`, becomes
+/// `k` nested zippers when `k >= 2`. With a single subtraction whose right
+/// operand is itself one, `x - (y - z)`, the inner zipper is the right input
+/// instead. The `x`s may be any subexpression; they go through the baseline.
+fn subtract_zipper_nested(e: &Expr, operands: &[PathMap]) -> Option> {
+ let mut spine: Vec<&Expr> = Vec::new();
+ let mut cur = e;
+ while let Expr::Bin(Op::Subtract, l, r) = cur {
+ spine.push(r);
+ cur = l;
+ }
+ spine.push(cur);
+ spine.reverse();
+ let base = |x: &Expr| pointwise(0, x, operands).expect("strategy 0 applies to every value type");
+
+ if spine.len() == 2 {
+ let Expr::Bin(Op::Subtract, y, z) = spine[1] else { return None };
+ let (x, y, z) = (base(spine[0]), base(y), base(z));
+ let rhs = SubtractZipper::new(y.read_zipper(), z.read_zipper());
+ return Some(materialize(SubtractZipper::new(x.read_zipper(), rhs)));
+ }
+
+ let maps: Vec> = spine.iter().map(|x| base(x)).collect();
+ let rz = |i: usize| maps[i].read_zipper();
+ // Each level of nesting is a different type, so the chain is spelled out
+ // per length.
+ macro_rules! nest {
+ ($z:expr) => { $z };
+ ($z:expr, $next:expr $(, $rest:expr)*) => {
+ nest!(SubtractZipper::new($z, $next) $(, $rest)*)
+ };
+ }
+ Some(match maps.len() {
+ 3 => materialize(nest!(rz(0), rz(1), rz(2))),
+ 4 => materialize(nest!(rz(0), rz(1), rz(2), rz(3))),
+ 5 => materialize(nest!(rz(0), rz(1), rz(2), rz(3), rz(4))),
+ 6 => materialize(nest!(rz(0), rz(1), rz(2), rz(3), rz(4), rz(5))),
+ _ => return None,
+ })
+}
+
+/// Chain of exactly three operands through the `*3` entry points.
+fn ternary(e: &Expr, operands: &[PathMap]) -> Option> {
+ let (op, idx) = e.chain()?;
+ if idx.len() != 3 {
+ return None;
+ }
+ let (a, b, c) = (&operands[idx[0]], &operands[idx[1]], &operands[idx[2]]);
+ let mut out = PathMap::new();
+ {
+ let mut za = a.read_zipper();
+ let mut zb = b.read_zipper();
+ let mut zc = c.read_zipper();
+ let mut wz = out.write_zipper();
+ match op {
+ Op::Join => zipper_join3(&mut za, &mut zb, &mut zc, &mut wz),
+ Op::Meet => zipper_meet3(&mut za, &mut zb, &mut zc, &mut wz),
+ Op::Subtract => zipper_subtract3(&mut za, &mut zb, &mut zc, &mut wz),
+ Op::SymDiff => zipper_sym_diff3(&mut za, &mut zb, &mut zc, &mut wz),
+ Op::Restrict => return None,
+ }
+ }
+ Some(out)
+}
+
+/// A chain of any length through the n-ary entry points.
+///
+/// Two of them: the slice form (`zipper_n_*`, const-generic over a homogeneous
+/// array) and the tuple form (`ZipperMergeF::*_n`, which routes through the
+/// `PolyZipper`-derived enum). They are separate monomorphisations of the
+/// merge engine and are worth distinguishing, so `poly` selects between them.
+fn nary(e: &Expr, operands: &[PathMap], poly: bool) -> Option> {
+ let (op, idx) = e.chain()?;
+ if op == Op::Restrict {
+ return None;
+ }
+ let mut out = PathMap::new();
+ {
+ let mut wz = out.write_zipper();
+ // Const-generic arity has to be dispatched by hand: there is one
+ // monomorphisation per operand count, and the count is only known at
+ // run time. The tuple form additionally has one impl per arity.
+ macro_rules! slice_arm {
+ ($n:literal) => {{
+ let mut zs: [ReadZipperUntracked<'_, '_, V>; $n] =
+ core::array::from_fn(|i| operands[idx[i]].read_zipper());
+ match op {
+ Op::Join => zipper_n_join(&mut zs, &mut wz),
+ Op::Meet => zipper_n_meet(&mut zs, &mut wz),
+ Op::Subtract => zipper_n_subtract(&mut zs, &mut wz),
+ Op::SymDiff => zipper_n_sym_diff(&mut zs, &mut wz),
+ Op::Restrict => unreachable!(),
+ }
+ }};
+ }
+ macro_rules! tuple_arm {
+ ($($i:literal),+) => {{
+ let t = ( $( &mut operands[idx[$i]].read_zipper() ),+ );
+ match op {
+ Op::Join => t.join_n(&mut wz),
+ Op::Meet => t.meet_n(&mut wz),
+ Op::Subtract => t.subtract_n(&mut wz),
+ Op::SymDiff => t.sym_diff_n(&mut wz),
+ Op::Restrict => unreachable!(),
+ }
+ }};
+ }
+ match (poly, idx.len()) {
+ (false, 2) => slice_arm!(2),
+ (false, 3) => slice_arm!(3),
+ (false, 4) => slice_arm!(4),
+ (false, 5) => slice_arm!(5),
+ (false, 6) => slice_arm!(6),
+ (true, 2) => tuple_arm!(0, 1),
+ (true, 3) => tuple_arm!(0, 1, 2),
+ (true, 4) => tuple_arm!(0, 1, 2, 3),
+ // The tuple impls go higher, but there is nothing to learn from
+ // arities the slice form already covers at the same width.
+ _ => return None,
+ }
+ }
+ Some(out)
+}
+
+/// A join of meets through `zipper_merge_dnf`.
+///
+/// The operand indices are compacted first: the clause masks are indices into
+/// the array handed to the call, so an expression over operands `a` and `c`
+/// becomes a two-zipper call with the masks renumbered, not a four-zipper call
+/// with two unused slots -- an unused slot would change what the traversal
+/// prunes on and make the route test something else.
+fn dnf(e: &Expr, operands: &[PathMap]) -> Option> {
+ let clauses = e.dnf()?;
+ let vars = e.vars();
+ if vars.is_empty() || vars.len() > super::expr::MAX_VARS || clauses.len() > super::expr::MAX_CLAUSES {
+ return None;
+ }
+ // Remap each clause's bits from operand index to position in `vars`.
+ let packed: Vec = clauses
+ .iter()
+ .map(|m| {
+ vars.iter()
+ .enumerate()
+ .filter(|(_, v)| m >> **v & 1 == 1)
+ .fold(0u64, |acc, (i, _)| acc | 1 << i)
+ })
+ .collect();
+
+ let mut out = PathMap::new();
+ {
+ let mut wz = out.write_zipper();
+ macro_rules! dnf_arm {
+ ($n:literal, $m:literal) => {{
+ let mut zs: [ReadZipperUntracked<'_, '_, V>; $n] =
+ core::array::from_fn(|i| operands[vars[i]].read_zipper());
+ let cs: [Clause<$n>; $m] = core::array::from_fn(|i| Clause::from_mask(packed[i]));
+ zipper_merge_dnf(&mut zs, cs, &mut wz);
+ }};
+ }
+ macro_rules! dnf_m {
+ ($n:literal) => {
+ match packed.len() {
+ 1 => dnf_arm!($n, 1),
+ 2 => dnf_arm!($n, 2),
+ 3 => dnf_arm!($n, 3),
+ 4 => dnf_arm!($n, 4),
+ _ => return None,
+ }
+ };
+ }
+ match vars.len() {
+ 1 => dnf_m!(1),
+ 2 => dnf_m!(2),
+ 3 => dnf_m!(3),
+ 4 => dnf_m!(4),
+ _ => return None,
+ }
+ }
+ Some(out)
+}
diff --git a/differential/src/algebraic/shape.rs b/differential/src/algebraic/shape.rs
new file mode 100644
index 00000000..52f5cd56
--- /dev/null
+++ b/differential/src/algebraic/shape.rs
@@ -0,0 +1,166 @@
+//! What "the same result" means.
+//!
+//! Two routes through the same algebraic expression are compared on the trie
+//! they produce, not on the calls they made. A trie is rendered two ways:
+//!
+//! * [`Shape`] — every path the trie contains, with the value at it if any.
+//! Dangling paths (structure with no value under it) are included, so this
+//! distinguishes tries that `iter()` cannot tell apart.
+//! * [`Values`] — only the paths that carry values.
+//!
+//! The split matters because the two have different authority. Which paths
+//! carry which values is settled: every route must agree, and a disagreement
+//! is a bug in one of them. Whether a dangling path survives an operation is
+//! *not* settled (see `../../SPEC_WARTS.md`), and routes legitimately built on
+//! `graft` of whole subtries will keep dangling structure that routes built by
+//! walking and inserting cannot. So a divergence confined to dangling paths
+//! is reported as its own class rather than lumped in with a wrong value.
+//!
+//! Both renderings come off a zipper, not a `PathMap`, so a lazy zipper that
+//! never materialises a trie — an `OverlayZipper`, say — is comparable against
+//! an eagerly built map without special-casing.
+
+use pathmap::PathMap;
+use pathmap::zipper::{ZipperMoving, ZipperPath, ZipperValues};
+use std::collections::BTreeMap;
+
+use super::value::FuzzValue;
+
+/// Every path in a trie, in depth-first order, with the value at it if any.
+pub type Shape = Vec<(Vec, Option)>;
+
+/// The paths of a trie that carry values.
+pub type Values = BTreeMap, V>;
+
+/// Cap on paths recorded per trie. A generated case that needs more than this
+/// is not interesting enough to be worth the comparison cost; the cap is
+/// recorded in the rendering so a truncated trie can never compare equal to a
+/// complete one by accident.
+pub const SHAPE_CAP: usize = 8192;
+
+/// Marker path emitted when a zipper walks out of its own root. No real path
+/// can collide with it: generated paths use a small alphabet of low bytes.
+pub const ESCAPED_ROOT: &[u8] = b"";
+/// Marker path emitted when [`SHAPE_CAP`] is hit.
+pub const TRUNCATED: &[u8] = b"";
+
+/// Depth-first walk of everything at and below the zipper's root.
+///
+/// The root is recorded explicitly: `to_next_step` moves before it reports, so
+/// a loop driven by it alone would miss the value at the empty path.
+pub fn shape_of_zipper(z: &mut Z) -> Shape
+where
+ Z: ZipperMoving + ZipperPath + ZipperValues,
+{
+ z.reset();
+ let mut out: Shape = vec![(Vec::new(), z.val().cloned())];
+ loop {
+ if out.len() >= SHAPE_CAP {
+ out.push((TRUNCATED.to_vec(), None));
+ break;
+ }
+ if !z.to_next_step() {
+ break;
+ }
+ // A depth-first step from the root can only go deeper or sideways, so
+ // returning to the empty path means the zipper left its own root --
+ // `lean/FINDINGS.md` #3. Stop rather than loop forever, and say so.
+ if z.path().is_empty() {
+ out.push((ESCAPED_ROOT.to_vec(), None));
+ break;
+ }
+ out.push((z.path().to_vec(), z.val().cloned()));
+ }
+ out
+}
+
+pub fn shape_of_map(m: &PathMap) -> Shape {
+ shape_of_zipper(&mut m.read_zipper())
+}
+
+/// The value-carrying subset of a [`Shape`].
+pub fn values_of_shape(s: &Shape) -> Values {
+ s.iter()
+ .filter_map(|(p, v)| v.clone().map(|v| (p.clone(), v)))
+ .collect()
+}
+
+/// The paths of a [`Shape`], values ignored. Used by the laws that hold only
+/// up to path presence, which is how the laws that depend on join and meet being
+/// different functions are checked when the value type is not a lattice.
+pub fn paths_of_shape(s: &Shape) -> Vec> {
+ s.iter().map(|(p, _)| p.clone()).collect()
+}
+
+/// Render a path the way the rest of the harness does, so traces interleave.
+pub fn show_path(p: &[u8]) -> String {
+ if p == ESCAPED_ROOT || p == TRUNCATED {
+ String::from_utf8_lossy(p).into_owned()
+ } else if p.is_empty() {
+ "_".to_string()
+ } else {
+ p.iter().map(|b| format!("{b:02x}")).collect()
+ }
+}
+
+pub fn show_shape(s: &Shape) -> String {
+ s.iter()
+ .map(|(p, v)| match v {
+ Some(v) => format!("{}:{}", show_path(p), v.show()),
+ None => format!("{}:-", show_path(p)),
+ })
+ .collect::>()
+ .join(",")
+}
+
+pub fn show_values(v: &Values) -> String {
+ v.iter()
+ .map(|(p, v)| format!("{}:{}", show_path(p), v.show()))
+ .collect::>()
+ .join(",")
+}
+
+/// First point at which two shapes differ, for the failure report.
+pub fn first_shape_diff(a: &Shape, b: &Shape) -> Option {
+ for i in 0..a.len().max(b.len()) {
+ match (a.get(i), b.get(i)) {
+ (Some(x), Some(y)) if x == y => continue,
+ (Some(x), Some(y)) => {
+ return Some(format!(
+ "at #{i}: {}:{} vs {}:{}",
+ show_path(&x.0),
+ x.1.as_ref().map(|v| v.show()).unwrap_or("-".into()),
+ show_path(&y.0),
+ y.1.as_ref().map(|v| v.show()).unwrap_or("-".into()),
+ ));
+ }
+ (Some(x), None) => {
+ return Some(format!("at #{i}: {} vs ", show_path(&x.0)));
+ }
+ (None, Some(y)) => {
+ return Some(format!("at #{i}: vs {}", show_path(&y.0)));
+ }
+ (None, None) => break,
+ }
+ }
+ None
+}
+
+/// First point at which two value maps differ.
+pub fn first_values_diff(a: &Values, b: &Values) -> Option {
+ let mut keys: Vec<&Vec> = a.keys().chain(b.keys()).collect();
+ keys.sort();
+ keys.dedup();
+ for k in keys {
+ let (x, y) = (a.get(k), b.get(k));
+ if x != y {
+ return Some(format!(
+ "at {}: {} vs {}",
+ show_path(k),
+ x.map(|v| v.show()).unwrap_or("-".into()),
+ y.map(|v| v.show()).unwrap_or("-".into()),
+ ));
+ }
+ }
+ None
+}
diff --git a/differential/src/algebraic/value.rs b/differential/src/algebraic/value.rs
new file mode 100644
index 00000000..e821334b
--- /dev/null
+++ b/differential/src/algebraic/value.rs
@@ -0,0 +1,241 @@
+//! The value types the fuzzer runs every case against.
+//!
+//! The harness is generic over this trait and the driver instantiates it twice,
+//! because the two instantiations answer different questions.
+//!
+//! * [`Bits`] is a genuine Boolean algebra: `pjoin` is `|`, `pmeet` is `&`,
+//! `psubtract` is `& !`, and an empty result is bottom, which `pathmap`
+//! represents as an absent value. Every lattice identity holds, so a law
+//! that fails here is a real defect.
+//!
+//! * `u64` is the type the rest of this crate's fuzzing uses, and its instances
+//! in `pathmap::ring` are **not a lattice**: `pjoin` is `left_biased_pjoin`
+//! and `pmeet` is `Identity(SELF_IDENT)`, so both return the left operand and
+//! `a | b == a & b` for every pair -- which in a lattice would force
+//! `a == b`. It is kept because it is what real callers use today, and
+//! because dropping it would lose coverage of the `Identity`-heavy paths.
+//!
+//! Running both is the point. A finding that appears under `u64` and not under
+//! `Bits` is an artefact of that degenerate instance; one that appears under
+//! both, or under `Bits` alone, is a defect in the crate. `bin/alg_fuzz`
+//! prints the split explicitly.
+//!
+//! # Why a bitmask rather than some other lawful lattice
+//!
+//! Lawfulness alone is not enough. `max`/`min` on a total order is a perfectly
+//! good distributive lattice, and useless here: `max(a, b)` and `min(a, b)` are
+//! always *one of the operands*, so they can only ever return
+//! `AlgebraicResult::Identity`, and every code path that allocates and stores a
+//! genuinely combined value stays unreachable. `a | b` is a new value, so a
+//! bitmask reaches them. `bin/alg_lattice_check` prints the comparison.
+
+use pathmap::ring::{AlgebraicResult, DistributiveLattice, Lattice, COUNTER_IDENT, SELF_IDENT};
+
+use super::Gen;
+
+/// What the harness needs of a value type.
+pub trait FuzzValue:
+ Clone
+ + Send
+ + Sync
+ + Unpin
+ + PartialEq
+ + Ord
+ + core::fmt::Debug
+ + core::hash::Hash
+ + Lattice
+ + DistributiveLattice
+ + 'static
+{
+ /// Appears as the first field of every signature, so findings from the two
+ /// instantiations never collide.
+ const NAME: &'static str;
+
+ /// Whether this type's instances actually form a distributive lattice with a
+ /// relative complement.
+ ///
+ /// `laws.rs` reads this: when it is false, the identities that depend on
+ /// join and meet being different functions are checked on path presence
+ /// only, and the ones that cannot hold at all are skipped. When it is true
+ /// every identity is checked on values, which is the stronger test and the
+ /// reason for having a lawful type at all.
+ const LAWFUL: bool;
+
+ /// Whether `pjoin` always yields one of its operands rather than a new
+ /// value -- true exactly when the "join" is really "the left one wins".
+ ///
+ /// This gates the `OverlayZipper` join strategy, and it is not a detail.
+ /// `OverlayZipper`'s mapping function has signature
+ /// `Fn(Option<&'a AV>, Option<&'a BV>) -> Option<&'a OutV>`: it returns a
+ /// *reference*, so it has nowhere to put a value it would have to create.
+ /// The module's own comment says as much. So an overlay can stand in for a
+ /// join only when the join never creates anything, and for a real lattice it
+ /// cannot be a join at all. That is a limitation of the zipper, not a
+ /// defect, so the strategy is dropped rather than reported.
+ const JOIN_PICKS_LEFT: bool;
+
+ fn generate(g: &mut Gen) -> Self;
+
+ fn show(&self) -> String;
+}
+
+/// A 64-bit set under union, intersection and relative complement.
+///
+/// The same construction `pathmap::utils` already uses for `[u64; 4]` and
+/// `ByteMask`, at a narrower width: see `bitmask_algebraic_result` there. An
+/// empty result is `AlgebraicResult::None` rather than `Element(Bits(0))`,
+/// matching the convention `SetLattice`'s documentation states -- an empty set
+/// is equivalent to a nonexistent one.
+///
+/// Defined here rather than in `pathmap` on purpose: a value type living outside
+/// the crate is what a real caller has, so the generic algebra is exercised the
+/// way a downstream user would exercise it.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Bits(pub u64);
+
+impl core::fmt::Debug for Bits {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{:b}", self.0)
+ }
+}
+
+/// Classify a bitwise result exactly as `pathmap::utils` does for `[u64; 4]`.
+#[inline]
+fn bits_result(result: u64, lhs: u64, rhs: u64) -> AlgebraicResult {
+ if result == 0 {
+ return AlgebraicResult::None;
+ }
+ let mut mask = 0;
+ if result == lhs {
+ mask = SELF_IDENT;
+ }
+ if result == rhs {
+ mask |= COUNTER_IDENT;
+ }
+ if mask != 0 {
+ AlgebraicResult::Identity(mask)
+ } else {
+ AlgebraicResult::Element(Bits(result))
+ }
+}
+
+impl Lattice for Bits {
+ #[inline]
+ fn pjoin(&self, other: &Self) -> AlgebraicResult {
+ bits_result(self.0 | other.0, self.0, other.0)
+ }
+ #[inline]
+ fn pmeet(&self, other: &Self) -> AlgebraicResult {
+ bits_result(self.0 & other.0, self.0, other.0)
+ }
+}
+
+impl DistributiveLattice for Bits {
+ #[inline]
+ fn psubtract(&self, other: &Self) -> AlgebraicResult {
+ bits_result(self.0 & !other.0, self.0, other.0)
+ }
+}
+
+/// Width of the generated masks.
+///
+/// Four bits, so operands collide constantly -- the whole point of a small
+/// alphabet applies to values too -- while still overlapping only partially, so
+/// `pjoin` and `pmeet` return `Element` rather than `Identity` most of the time.
+pub const BITS_WIDTH: u32 = 4;
+
+impl FuzzValue for Bits {
+ const NAME: &'static str = "bits";
+ const LAWFUL: bool = true;
+ // `Bits(0b01) | Bits(0b10)` is `Bits(0b11)`, which is neither operand.
+ const JOIN_PICKS_LEFT: bool = false;
+
+ fn generate(g: &mut Gen) -> Self {
+ // Never zero. Bottom is representable as `Bits(0)`, but the lattice
+ // collapses it to `None`, so a stored `Bits(0)` is a value that is also
+ // bottom -- a genuinely interesting edge case, and a separate question
+ // from the one this type is here to answer. Excluded deliberately.
+ let n = 1u64 << BITS_WIDTH;
+ Bits(g.byte() as u64 % (n - 1) + 1)
+ }
+
+ fn show(&self) -> String {
+ format!("{:b}", self.0)
+ }
+}
+
+/// The set case: a path is either present or absent, with nothing attached.
+///
+/// `Option<()>` is exactly the two-element Boolean algebra, so `()` is **lawful**
+/// -- unlike `u64`, and for the same reason `bits` is. It earns a place of its
+/// own anyway, for two reasons neither of the others covers.
+///
+/// First, `PathMap<()>` is what a caller writes when the trie *is* the data, and
+/// it is what the `unit-value-optimizations` and `unit-size` branches are about.
+/// None of that is on `master` yet, so today this exercises the generic path; if
+/// those land it becomes the only thing covering the specialised one.
+///
+/// Second, and more useful now: `()`'s `pjoin` and `pmeet` return
+/// `Identity(SELF_IDENT | COUNTER_IDENT)` *unconditionally* -- **both** identity
+/// bits, always. `u64` returns that only for equal values and `bits` only for
+/// equal masks, so neither saturates the "either side will do" path that the node
+/// code uses to decide it can hand back an operand unchanged and keep sharing it.
+/// Findings 4 and 6 are both about exactly that machinery, so a value type that
+/// drives it on every single combination is worth having.
+///
+/// It cannot produce `Element`, but that is not a gap here: with one inhabitant
+/// there is no combined value to store. `bits` covers that.
+impl FuzzValue for () {
+ const NAME: &'static str = "unit";
+ const LAWFUL: bool = true;
+ // Both operands are `()`, so returning either is returning the left one, and
+ // `OverlayZipper`'s `a.or(b)` is the join.
+ const JOIN_PICKS_LEFT: bool = true;
+
+ fn generate(_g: &mut Gen) -> Self {}
+
+ fn show(&self) -> String {
+ "*".to_string()
+ }
+}
+
+/// Distinct values in circulation for `u64`.
+///
+/// Tiny on purpose: `psubtract` on `u64` is `None` only when the two values are
+/// *equal*, so a large value space would turn subtraction into set difference
+/// and never exercise the value comparison at all.
+pub const U64_VALUES: u64 = 3;
+
+impl FuzzValue for u64 {
+ const NAME: &'static str = "u64";
+ const LAWFUL: bool = false;
+ const JOIN_PICKS_LEFT: bool = true;
+
+ fn generate(g: &mut Gen) -> Self {
+ g.byte() as u64 % U64_VALUES + 1
+ }
+
+ fn show(&self) -> String {
+ self.to_string()
+ }
+}
+
+/// Resolve an `AlgebraicResult` over `Option` to a value, which is how
+/// `pathmap` stores the outcome: `None` means the value is gone.
+///
+/// Used by `model.rs` so the reference semantics are derived from the value
+/// type's own operations rather than restated by hand -- restating them is how
+/// a harness ends up asserting its own idea of the algebra.
+pub fn resolve(
+ result: AlgebraicResult