Fix: exclude wildcard $_ from traversal adjacency graph

The anonymous wildcard variable _ was included as a regular node in the
undirected adjacency graph used for component analysis. When multiple
traversals referenced $_, it falsely bridged otherwise-independent
components, causing bindings in separate components to be deferred.
The deferred binding would never be introduced (since _ is never added
to bound_vars), leading to silently dropped traversals.

Fix: skip edges involving _ when building the adjacency graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-04-13 15:11:17 +02:00
parent fabd65b08a
commit 3461aa123d
No known key found for this signature in database

View file

@ -163,11 +163,17 @@ fn lower_clauses(
let binding_set: HashSet<&str> = bindings.iter().map(|b| b.variable.as_str()).collect();
// Build undirected traversal adjacency (variable → neighbours)
// Build undirected traversal adjacency (variable → neighbours).
// Exclude the anonymous wildcard "_" so it cannot falsely bridge
// otherwise-independent components.
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for t in &traversals {
adj.entry(t.src.as_str()).or_default().push(t.dst.as_str());
adj.entry(t.dst.as_str()).or_default().push(t.src.as_str());
let src = t.src.as_str();
let dst = t.dst.as_str();
if src != "_" && dst != "_" {
adj.entry(src).or_default().push(dst);
adj.entry(dst).or_default().push(src);
}
}
// Walk components to find deferred binding variables
@ -979,4 +985,38 @@ query q() {
if src_var == "f" && dst_var == "c" && dst_filters.len() == 1
));
}
/// Wildcard $_ must not bridge unrelated components in the adjacency graph.
#[test]
fn test_lower_wildcard_does_not_bridge_components() {
let catalog = setup();
let qf = parse_query(
r#"
query q() {
match {
$p: Person
$p knows $_
$c: Company
}
return { $p.name, $c.name }
}
"#,
)
.unwrap();
let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap();
let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap();
// $p and $c are in separate components (connected only through $_).
// Both must get their own NodeScan — $c must NOT be deferred.
// Bindings are emitted first, then traversals.
assert_eq!(ir.pipeline.len(), 3);
assert!(matches!(&ir.pipeline[0], IROp::NodeScan { variable, .. } if variable == "p"));
assert!(matches!(&ir.pipeline[1], IROp::NodeScan { variable, .. } if variable == "c"));
// The expand for $p knows $_ (wildcard destination)
assert!(matches!(
&ir.pipeline[2],
IROp::Expand { src_var, dst_var, .. }
if src_var == "p" && dst_var == "_"
));
}
}