Skip to content

Subtract zipper - #94

Open
marcin-rzeznicki wants to merge 11 commits into
masterfrom
subtract_zipper
Open

marcin-rzeznicki wants to merge 11 commits into
masterfrom
subtract_zipper

Conversation

@marcin-rzeznicki

Copy link
Copy Markdown
Collaborator

Summary

This PR adds SubtractZipper<V, A, B>, a virtual zipper exposing the materialized algebraic subtraction:

A - B

without eagerly constructing the resulting trie.

The zipper exposes the same observable Zipper state as a zipper over a materialized subtraction result:

  • values are computed using psubtract;
  • LHS-only subtrees survive unchanged;
  • shared branches are retained only when their subtree difference is non-empty;
  • path_exists, is_val, child_mask, child_count, and val reflect the resulting virtual trie.

Traversal

SubtractZipper implements ZipperMoving and ZipperIteration.

Several movement operations are specialized because the default implementations would repeatedly recompute the virtual subtraction state after every intermediate byte movement.

The optimized implementations move the two backing zippers in sync and refresh the cached virtual state only at externally observable stopping points.

This includes optimized implementations of:

descend_to_existing
descend_to_val
descend_to_existing_byte
descend_until
descend_until_max_bytes
ascend_until
ascend_until_branch
to_next_sibling_byte
to_prev_sibling_byte
to_next_step
to_next_val
descend_first_k_path
to_next_k_path

Overlapping subtrees are inspected lazily and short-circuit as soon as the difference is known to be non-empty. Once RHS no longer contains the current path, traversal can treat the remaining subtree as plain LHS.

val_count() is computed lazily and cached.

ZipperValues split

The PR also separates current-focus value access from arbitrary-path value access.

Previously ZipperValues<V> required both:

val() -> Option<&V>
val_at(path) -> Option<&V>

This is problematic for computed zippers: a value at the current focus can be cached and returned by reference, while val_at() may need to synthesize a new value with no stable backing storage.

The traits are therefore split into:

pub trait ZipperValues<V> {
    fn val(&self) -> Option<&V>;
}

pub trait ZipperValuesAt<V>: ZipperValues<V> {
    fn val_at<K: AsRef<[u8]>>(&self, path: K) -> Option<&V>;
}

This lets computed zippers such as SubtractZipper remain compatible with generic algorithms that only require val(), while concrete zippers can continue supporting arbitrary-path borrowed access.

@imlvts

imlvts commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Writing a fuzzer with materialized subtraction as an oracle reveals a few bugs:

use pathmap::{PathMap, zipper::*};

fn check(label: &str, actual: impl std::fmt::Debug + PartialEq<bool>) {
    println!("{label} {actual:?} (expected false)");
    assert_eq!(actual, false, "{label}");
}

fn main() {
    // The trace format records the boolean; the path is intentionally unchanged.
    let lhs = PathMap::from_iter([([2], 7u64)]);
    let rhs = PathMap::<u64>::new();
    let mut zipper = SubtractZipper::new(lhs.read_zipper(), rhs.read_zipper());
    zipper.descend_to([2]);
    check("zero-k result", zipper.descend_first_k_path(0));
    assert_eq!(zipper.path(), [2], "zero-k must not move");

    // This exercises seek_k_path exhaustion and refresh-on-failure.
    let lhs = PathMap::from_iter([([10, 20], 1u64), ([10, 21], 2)]);
    let rhs = PathMap::from_iter([([10, 20], 1u64), ([10, 21], 2)]);
    let mut zipper = SubtractZipper::new(lhs.read_zipper(), rhs.read_zipper());
    zipper.descend_to([10]);
    check("k-path result", zipper.descend_first_k_path(2));
    check("k-path cache valid", zipper.path_exists());
    check("k-path result", zipper.descend_first_k_path(2));
    check("k-path state valid", zipper.path_exists());

    // to_next_k_path must refresh after advance_to_next_subtree fails.
    let lhs = PathMap::from_iter([([10, 20], 1u64), ([10, 21], 2)]);
    let rhs = lhs.clone();
    let mut zipper = SubtractZipper::new(lhs.read_zipper(), rhs.read_zipper());
    zipper.descend_to([10, 20]);
    check("next-k result", zipper.to_next_k_path(2));
    check("next-k state valid", zipper.path_exists());

    // empty result, so result.read_zipper().path_exists() reported true.
    let empty_lhs = PathMap::<u64>::new();
    let empty_rhs = PathMap::<u64>::new();
    let result = empty_lhs.subtract(&empty_rhs);
    let result_zipper = result.read_zipper();
    check("empty-map subtraction", result_zipper.path_exists());
}

@marcin-rzeznicki

Copy link
Copy Markdown
Collaborator Author

Thanks @imlvts I will turn these into tests and fix'em

@marcin-rzeznicki

Copy link
Copy Markdown
Collaborator Author

@imlvts The first failure is somewhat interesting:

check("zero-k result", zipper.descend_first_k_path(0));

The docs say that it must return true if the zipper successfully descended k steps (you can argue that descending 0 steps is always a success - I return true in this case), and then it contradicts itself saying that returning false means that zipper did not move (it obviously did not have to move when k=0). @luketpeterson ?

@marcin-rzeznicki

Copy link
Copy Markdown
Collaborator Author

@imlvts This passes: // This exercises seek_k_path exhaustion and refresh-on-failure.

@marcin-rzeznicki

Copy link
Copy Markdown
Collaborator Author

@imlvts This also passes: // to_next_k_path must refresh after advance_to_next_subtree fails

@luketpeterson

luketpeterson commented Sep 12, 2026 via email

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants