feat(engine): execute Direction::Both on both Expand arms

CSR arm: an undirected hop chains csr(edge).neighbors with
csc(edge).neighbors under the existing visited/seen_dst gates, so
both-direction pairs and self-loops dedup for free (set semantics),
including per-hop in variable-length BFS. Indexed arm: endpoint_probes
returns both (src,dst) and (dst,src) orientations for Both; the per-hop
scan runs once per orientation into one neighbor_map, deduped by the
existing per-source seen_dst sets. Bulk anti-join: "no edge in EITHER
direction" (csr || csc has_neighbors).

Also fixes a lowering gap the anti-join test caught red: negation
inners are typechecked into a discarded context clone, so the
ResolvedTraversal direction lookup silently fell back to Out inside
not{} — undirectedness now travels on the AST node itself (the syntax
is the source of truth), immune to context-propagation gaps.

Tests: traversal.rs (out ∪ in vs the directional miss, both-ways dedup,
var-length from an incoming-only node, undirected anti-join vs
directional), traversal_indexed.rs both_modes equivalence, and the
proptest arm-equivalence property widened with <knows>{1,3}.
This commit is contained in:
aaltshuler 2026-07-05 22:57:11 +03:00 committed by Andrew Altshuler
parent d4b21ce4eb
commit 9460f097d9
5 changed files with 254 additions and 40 deletions

View file

@ -267,14 +267,24 @@ fn lower_clauses(
))
})?;
let direction = type_ctx
.traversals
.iter()
.find(|rt| {
rt.src == traversal.src && rt.dst == traversal.dst && rt.edge_type == edge.name
})
.map(|rt| rt.direction)
.unwrap_or(Direction::Out);
// Undirected is carried on the AST node itself — negation inners
// are typechecked into a discarded context clone, so the
// ResolvedTraversal lookup below cannot see their direction; the
// syntax is the source of truth for Both.
let direction = if traversal.undirected {
Direction::Both
} else {
type_ctx
.traversals
.iter()
.find(|rt| {
rt.src == traversal.src
&& rt.dst == traversal.dst
&& rt.edge_type == edge.name
})
.map(|rt| rt.direction)
.unwrap_or(Direction::Out)
};
let dst_type = match direction {
Direction::Out => edge.to_type.clone(),