From 3461aa123d36d0be759cf619c43cc575cc19da32 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Mon, 13 Apr 2026 15:11:17 +0200 Subject: [PATCH] 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) --- crates/omnigraph-compiler/src/ir/lower.rs | 46 +++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/omnigraph-compiler/src/ir/lower.rs b/crates/omnigraph-compiler/src/ir/lower.rs index fa906b8..aca40a8 100644 --- a/crates/omnigraph-compiler/src/ir/lower.rs +++ b/crates/omnigraph-compiler/src/ir/lower.rs @@ -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 == "_" + )); + } }