Skip to content

Refine meet model - #117

Open
imlvts wants to merge 6 commits into
Adam-Vandervorst:masterfrom
imlvts:fix/meet-rule
Open

imlvts wants to merge 6 commits into
Adam-Vandervorst:masterfrom
imlvts:fix/meet-rule

Conversation

@imlvts

@imlvts imlvts commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

There was an issue with meet: meet with self-node is a noop, but meet with equal node drops dangling paths.
This refines the meet spec to preserve dangling paths if they're both present.

imlvts and others added 6 commits September 16, 2026 23:25
Joining with a TinyRefNode swapped operands without inverting the identity
mask, so an unchanged destination reported COUNTER_IDENT and join_into
replaced it with the source. merge_guts and the integer pjoin also
under-reported identities, giving Element for unchanged joins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
Meets and joins keep the left operand's value on a collision, but a byte
node paired with a list node ran the operation with operands swapped and
kept the list node's values. join_k_path_into also folded k-paths in
reverse. Values now follow operand order and path order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
prune_path walked the node stack up to find where pruning stops but left it
there when the zipper did not move, so the next write through the focus went
to the wrong node (get_val_or_set_mut panicked). Walk the stack back down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
An empty link sharing its key with a value in the same list node carries
nothing. Subtract dropped it and reported Element for an unchanged node;
it is now Identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
A path survives a meet exactly when both sides have it, dangling or not,
and a value exactly when both hold one. Dense bytes that meet to nothing
stay as dangling paths, list nodes meet slot by slot keeping the shared
key prefix, and meet_into never removes its focus. With prune, dangling
paths below the focus are dropped, possibly skipping nodes shared with
the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
meet keeps a location both sides have, dangling or not, and a value both
hold. meetPruned drops dangling paths; since pathmap may skip shared
nodes with prune, the fuzzer compares prune = false only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019R2H8fnco29asY2v3TPbtF
@adamv-symbolica

Copy link
Copy Markdown
Contributor

What's the impact for performance here (both constant, and asymptotic)? The right call may be to change the spec to care less about dangling paths.

@imlvts

imlvts commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

The current implementation of meet can have different results between map.meet(&map) and map.meet(&equal_map)). This is logically inconsistent.
The spec is blind with respect to sharing. It cannot express that at all. The only thing I can do is not to check meet this way.
The best we can do now is to leave this as a suggestion and disable meet checks in the differential fuzzer.

@imlvts

imlvts commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author
//! `meet` of a map with itself keeps a dangling path; `meet` with an equal map
//! built separately drops it.
use pathmap::PathMap;
use pathmap::zipper::*;

fn locations(m: &PathMap<u64>) -> Vec<(Vec<u8>, Option<u64>)> {
    let mut rz = m.read_zipper();
    let mut out = vec![];
    while rz.to_next_step() {
        if rz.val().is_some() || rz.child_count() == 0 { out.push((rz.path().to_vec(), rz.val().cloned())); }
    }
    out
}

fn build() -> PathMap<u64> {
    let mut map = PathMap::new();
    map.set_val_at([1], 1);
    map.create_path([0, 2, 1]);
    map.set_val_at([1, 2, 2], 1);
    map.write_zipper_at_path(&[0, 2, 1]).remove_branches(true);
    map
}

fn main() {
    let map = build();
    let same = build();
    println!("map                 {:?}", locations(&map));
    println!("map.meet(&map)      {:?}", locations(&map.meet(&map)));
    println!("map.meet(&clone)    {:?}", locations(&map.meet(&map.clone())));
    println!("map.meet(&same)     {:?}", locations(&map.meet(&same)));
    println!("map == same paths:  {}", locations(&map) == locations(&same));
    assert_eq!(locations(&map.meet(&map)), locations(&map.meet(&same)), "meet depends on node identity");
}

@Adam-Vandervorst

Copy link
Copy Markdown
Owner

"the same" is load bearing here; "equivalent up to" with "dangling paths" and "sharing" and...

@luketpeterson

Copy link
Copy Markdown
Collaborator

Yikes... It took me more than 2 weeks to write the generic meet function back in early 2025. It was one of the trickiest parts of the whole crate - and the crate has a lot of tricky parts.

I know Fable can probably do better than the un-assisted me of 18 months ago. Especially with the fuzzer to help. But hopefully we can get some more extensive benchmarks and test coverage on how well we preserve sharing too so we make sure not to lose any perf...

Anyway, I don't want to rubber stamp this PR because it's huge and hits the heart of tricky stuff. But I don't have time to spend really understanding this week.

@luketpeterson

Copy link
Copy Markdown
Collaborator

load bearing

As recently as 2025 I thought "load bearing" was clever engineer-architect-speak. Now it's a calling card of slop.

@adamv-symbolica

adamv-symbolica commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

I believe the algebraic ops have more interesting structure to them with respect to dangling paths, depending on if you treat them as an optimization or representation feature.

a.join(b) would be the easiest case:
This could return paths pathset(a.iter()).union(pathset(b.iter())), dangling paths subsumption(steps(a)).union(subsumption(steps(b))).subtract(paths), and assert all(a.join(b).node_at(a) == a.node_at(path) or b.contains(path) for path in a) and all(a.join(b).node_at(b) == b.node_at(path) or a.contains(path) for path in b).
Another sensible implementation would instead prune dandling paths while traversing the join (unless we can just accept subtries), that is dangling paths subsumption(steps(a)).sym_diff(subsumption(steps(b))).subtract(paths)

For e.g. a.meet(b)
This ofc returns paths pathset(a.iter()).meet(pathset(b.iter())),
but for dangling paths

  1. subsumption(steps(a)).union(subsumption(steps(b))).subtract(paths) would be a "don't drop (i.e. recurse into) any dangling path subtries"
  2. subsumption(steps(a)).meet(subsumption(steps(b))).subtract(paths) would be a "calculate the dangling paths in the intersection, and drop a \ b and b \ a"
  3. subsumption(steps(a).subtract(steps(b.restrict(a)))).subtract(paths) would be a "keep the dangling paths originating from nodes in a"

They uphold three different guarantees:

  1. No dangling paths are cleaned up implicitly
  2. Logically dangling paths are paths
  3. No compute is wasted on dangling paths

And have three different asymptotics:

  1. Worst case additional cost (compared to pruned versions) is time+space |subsumption(steps(a))| + |subsumption(steps(b))| but crucially this can happen even in the case where the pruned version is just wlog |subsumption(steps(a))|
  2. Worst case additional cost is time |subsumption(steps(a))| + |subsumption(steps(b))| and space |subsumption(steps(a))|.min(|subsumption(steps(b))|)
  3. Worst case additional cost is time is none and space is |subsumption(steps(a).subtract(b.restrict(pathset(a))))|

luketpeterson added a commit that referenced this pull request Oct 10, 2026
…s the purview of #117, so it doesn't appear as a new divergence
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants