Repository navigation
Add node-based catamorphism POC - #31
Adam-Vandervorst wants to merge 70 commits into
Conversation
…zer more to work with. ~5% improvement to saturated val_count benchmark
…ad memory when retrieving the refcount of the empty node
|
@luketpeterson any problem merging this? |
In concept no. There is a (small) bit of work to make the API is consistent with the cata that's there already. |
|
Giving Fable some time with it: BlockingB1 —
|
|
Path byte should not be represented in prefix. |
…ld cata, and picking up another 5% perf
… wasn't offering a meaningful speedup when switched off
|
One more Iterative engine hangs on a LineListNode shape left behind by
Standalone test (drop into use pathmap::PathMap;
use pathmap::zipper::*;
use pathmap::morphisms::{CatamorphismCached, CatamorphismCachedIterative};
#[test]
fn drop_head_shared_first_byte_shape() {
let mut m = PathMap::<()>::new();
m.set_val_at(b"aaa", ());
m.set_val_at(b"bab", ());
assert!(m.write_zipper().join_k_path_into(1, false));
assert_eq!(m.iter().map(|(k, _)| k).collect::<Vec<_>>(), vec![b"aa".to_vec(), b"ab".to_vec()]);
let mut rz = m.read_zipper();
rz.descend_to_byte(b'a');
assert_eq!(rz.child_count(), 2);
assert!(rz.descend_indexed_byte(1).is_some(), "indexed descent cannot reach the second child"); // fails here today
assert_eq!(CatamorphismCached::val_count(&m), 2); // debug build: Case 7 assert
assert_eq!(CatamorphismCachedIterative::val_count(&m), 2); // never returns
}Fix that makes this pass (and keeps both full suites green): in let legal_overlap = overlap == 1 && (
(!self.is_child_ptr::<0>() && key0.len() == 1) ||
(!self.is_child_ptr::<1>() && key0.len()==1 && key1.len()==1 ));— so the shape gets factored into the canonical |
Values never reach summarize_f: a value with nothing below it goes to map_f(&V, prefix), a value above a subtrie goes to collapse_f(&V, W) on the value-free W of that subtrie, in both the recursive and iterative drivers. Shared nodes are therefore summarized once regardless of the value the parent slot stores for them. Removes the single-function cached catas and the CataChildren adapter, ports hash/hash_with/val_count, the debug cata, benches and tests. Splits LineListNode traversal into a header-dispatched 1-byte-key fast path (tail-recursion friendly) and the general path, and gives every node type its own recursion frame. Fixes value placement for a (Val, Child) LineListNode whose child key shares its first byte and is longer than two bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
merkleize identified nodes by hashing their physical layout, keyed with the value the parent slot stores for them, so byte-identical subtries reached through a valued and an unvalued slot never merged, and neither did logically equal subtries laid out in different nodes or key segments. Nodes are now identified by the cached cata's value-free logical hash, which is the same function as CatamorphismCached::hash (the shared scheme lives in morphisms::trie_hash, and MerkleizeResult::hash equals the map's cata hash). The cata cache became a crate-internal trait, CataCache, whose ordinary implementation is the HashMap it always was; merkleization plugs in a cache that records every node's hash in post-order. A bottom-up rewrite pass then reads those hashes back in the same order and replaces each repeated subtrie with the first node that produced its hash, visiting physically shared nodes once. Adds merkleize tests (hash equals the cata hash and contents are kept, different layouts merge, idempotence, shared input untouched), a divan bench and a viz example that reports sharing before and after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t read in the cata Make the trie hash's primitive pluggable and model the hash in Lean so the differential harness can check that it is a function of the logical trie alone. * `morphisms::trie_hash` is now public: a `HashScheme<V>` trait with the production `GxHashScheme`, `GxHashWith` (behind the existing `hash_with`), and `Fnv1a64Scheme` for fuzzing. Both cached cata traits gain `hash_with_scheme`. `merkleize_root` is generic over the scheme but stays crate-private; `PathMap::merkleize` remains pinned to gxhash, since a caller-supplied scheme with collisions would merge subtries that differ. * `lean/PathMapModel/Hash.lean` defines the same hash over the flat trie model. Ops 56-59 (`hash`, `hash_iter`, `map_hash`, `merkleize`) are added to both front ends (NOPS 60), with `#guard`s in Check.lean, six hasher mutants in mutants.toml, and README/FINDINGS entries. * The first fuzz run found a memory-safety bug (FINDINGS.md #17): the ordinary `CataCache::skips` read `refcount()` of the empty-node sentinel when a two-slot node had a dangling child, a segfault in release reachable from `hash`, `val_count` and `merkleize`. `skips` now accepts the sentinel without reading it, the trait documents the contract, `refcount` gets a `debug_assert`, and a minimal reproducer test is added. Also on this branch (adam2/merkle-cata + master): * Port the structural-sharing tests from `merkleize-fix` into the merkleize test module, plus a physical-layout dumper and topology exploration tests. * Fix test-only fallout from merging master: `val_count` moved to the cached cata traits (a `FuzzValCount` helper in the differential crate), the removed `Catamorphism::into_cata_cached` in one write_zipper test, and `assert_eq!(x, &[])` ambiguities. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…h messages lean/PathMapModel/HashSecurity.lean proves the collision reduction for the logical trie hash, for an arbitrary primitive H: if two positions whose subtries differ (`LogicalEq` fails) hash alike, then two different messages collide under H, or two different values under the value hash, and the proof constructs them (`collision_of_hash_eq`). `hashWith_inj_imp_logicalEq` is the ideal-primitive form and `fnv_collision_of_hashU64_eq` the instance for the harness's PathMap<u64> maps. The reduction rests on the messages being decodable: a tag byte separates node from value messages, the 32-byte mask bitmap determines the child set and hence how many fixed-width digests follow, and running out of fuel hashes the empty message. The primitive itself is treated as replaceable; nothing is claimed about gxhash or FNV. To make the proven layout the production layout, `GxHashScheme` now writes `NODE_TAG`/`VALUE_TAG` (b'N'/b'V') before its messages, shared with `Fnv1a64Scheme`. The mask stays a 32-byte bitmap. Hash.lean defines the bitmap through `digits` (bit b % 8 of byte b / 8) and `leBytes64` as an explicit list, both guarded against hand-computed bytes, and returns `fnv []` when fuel runs out so an inadequate fuel can never look like a real hash. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lean/PathMapModel/GraftNoop.lean: `graft_noop_of_hash_eq` shows that for an ideal primitive, `hash(wz) = hash(rz)` at the two foci makes `wz.graft(&rz)` leave every Entry of the write map as it was. `collision_of_graft_ne` is the unconditional contrapositive (a hash-equal graft that changes the trie exhibits a collision of H or of the value hash) and `graft_noop_of_hashU64_eq` the instance for the harness's PathMap<u64> maps under Fnv1a64Scheme. The hypotheses are the canonical-form invariants: the write map is prefix-closed with an existing root, the source is prefix-closed. The hashing half is HashSecurity.lean's soundness theorem. The rest characterizes the model's normalizer `mk'` (`valAt_mk'`: the first binding in the supplied list, via Perm-invariance of lookup on unique keys; `pathExists_mk'`: prefixes of the root, of a given path, or of a valued path) and, from it, `removeBelow`, `graftBelow`, `setVal`, `removeVal` and `subtrie` as observations at any path. Those lemmas need no hashing and are available to Spec.lean. Check.lean gains executable guards: a hash-equal graft at an interior focus and at the root leaves a fixture unchanged, a non-hash-equal graft changes it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
ACT should get a fast recursive implementation of cata, modeled on the ACT val_count in master, and val_count benchmarks that can easily be back-ported to master. |
Implement subtraction-aware recursive cached morphisms for SubtractZipper, delegate unaffected subtrees, and keep value counting in the morphism API. Adapt incoming tests to the new API.
# Conflicts: # src/write_zipper.rs
…cover it. (~3-5x uplift)
Bench A/B vs base: donejob log · 2026-11-11 04:53:39 UTC base 8172d18 → head 1a794c0, 3 round(s), median of each run averaged; negative is faster
59 case(s) moved more than 5%
Full tables per bench are in the job log and the bench-out artifact. |
… the stem of a prefix zipper too)
|
I think this PR is finally ready to go. However, the one last piece of validation I want to do is see if the bespoke val_counts that are getting replaces got any slower by moving the implementation over to the recursive catamorphism. |
|
Here are the complete benchmark results. Bottom line, recursive cata is massively faster than iterative cata, as discussed above. Since val_count is now subsumed by recursive cata, the val_count benchmark results are a bit more nuanced. There is an essential tradeoff in the implementation whether to count the value first or to visit the downstream first. Recursive cata made a different tradeoff than the hard-coded val_count resulting in better performance for catamorphism workloads, and about a ~10-20% uplift on "real" tries (binary trie shape), roughly neutral on the superdense benchmark, but a similar penalty on the "sparse" tries (a bunch of non-branching paths). On ACT, there was no recursive cata at all. Implementing one gave a 3-8x speedup over the iterative cata but a 5-40x perf improvement in val_count vs the implementation on the ACTZipper! |
Preserve the original Merkle lineage and its Adam/Igor commits while adapting logical hashing and Merkleization to the current three-callback cata API. Record full and value-free node hashes through a private cache abstraction, including reuse of shared nodes reached through valued parent slots. Retain the current public cata API and its recursive ACT implementation. Integrate the logical hash model, collision and graft proofs, regression tests, benchmark and example. Append hash/Merkle fuzz operations at IDs 60-63 so the existing versioned input formats retain their meaning. Update reproducer codegen and mutation targets for the current API. Validation: - cargo test --offline --workspace --features viz,arena_compact,random - lake build, including HashSecurity/GraftNoop proofs and build-time guards - Python input-format tests and the new Rust harness integration test - 500 targeted hash/Merkle programs against Lean on each of native and ACT - 1,000 general ACT differential inputs (seed 2), all agree - All six adapted hash/cache mutants compile and are detected - Benchmarks/examples compile; dense-node Merkle regression tests pass General native differential seed 1 also exposes the existing meet value-bias issue: the reduced meet_into changes [3,1] from 0 to the source value 1. This was reproduced on pre-merge 1e95979 and is outside this integration. Building without slim_ptrs also fails on the pre-merge commit and remains unchanged.
|
imlvts/merkle-cata-sharing was already descended from merkle-cata, so merging it already got all that work. Cata API adaptation was straightforward. |
Bring in master's meet, graft, prune, relative-write and empty-focus fixes. Preserve the Merkle integration, current catamorphism API and roadmap comments. Resolve the fuzz opcode collision by retaining Merkle operations at 60-63 and appending master's set_val_at at 64. New inputs use 65 selectors; inputs from master's 61-selector format retain their set_val_at meaning through explicit Rust/Lean decoding. Preserve the 64-selector Merkle format as well. Add Rust and Lean regression checks for both historical formats, and update reproducer codegen, Python selector count and input-format documentation. Validation: - cargo test --offline --workspace --features viz,arena_compact,random - lake build (including proofs and format regression guards) - Python input-format tests - 2,000 native differential inputs: 45 known findings, no new divergences - 2,000 ACT differential inputs: all agree - 500 targeted hash/Merkle differential programs: all agree
Retain set_val_at at opcode 60 and append hash, hash_iter, map_hash and merkleize at 61-64. Restore plain modulo decoding in both Rust and Lean, and remove the intermediate-format compatibility tests and documentation. Update reproducer generation, retained harness tests and mutation comments. Intermediate Merkle/integration runs can be regenerated; no migration is kept. Validation: differential crate tests, Python input-format tests, lake build, 500 fresh targeted inputs on each native and ACT, and 1,000 general native inputs (33 known findings, no new divergences).
Differential fuzz vs Lean model: successjob log · 2026-11-11 04:38:54 UTC WARNING: 1 new divergence(s) relative to base Base was built with its own harness and model (head's did not build against it), so harness changes may show up as differences. crate: 20000 inputs, seed 7agree = model and crate match; known = the divergence matches a classified bug in differential.py; new = it matches none. Only the new set is compared input by input below.
1 input(s) diverge on head but not on base; 0 diverge on base but not on head. Known-class hits that changed
Newly diverging inputs (head only): 1 input(s), 1 kind(s)
Reproducers: first 1 distinct kind(s), shrunk
|
| side | agree | known | new divergences |
|---|---|---|---|
| head | 4997/5000 | 3 | 0 |
| base | 3584/5000 | 1416 | 0 |
0 input(s) diverge on head but not on base; 0 diverge on base but not on head.
Known-class hits that changed
| known class | base | head |
|---|---|---|
| ACTZipper::val_count() counts from the zipper root, not the focus [act: val_count_ignores_focus] | 1416 | 0 |
| which value survives a collision follows node layout rather than the left-biased spec (join/meet and | 0 | 3 |
I purpose we use this paradigm under the cata interface.