From 682a3eb381d2291b8f466224bc8cb89bb276f13f Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Mon, 8 Jun 2026 22:06:17 +0000 Subject: [PATCH 1/8] Optimize repeating paths with unused variables and level-by-level fallback --- src/lib.rs | 223 ++++++++++++++++++++++- src/optimizer/pushdown.rs | 11 +- src/runtime/engine.rs | 135 ++++++++++++++ tests/unused_variable_repetition_test.rs | 121 ++++++++++++ 4 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 tests/unused_variable_repetition_test.rs diff --git a/src/lib.rs b/src/lib.rs index ab93e0c..18cf944 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,12 +9,231 @@ pub mod syntax; pub mod typing; use syntax::path_pattern::PathPattern; -use syntax::query::{MatchStatement, Query}; +use syntax::query::{MatchStatement, Query, ReturnItem, Aggregator, SortKey}; +use syntax::expr::Expr; use typing::checker::Typechecker; use typing::variable_type::Schema; +use std::collections::{HashSet, HashMap}; + +fn collect_expr_vars(expr: &Expr, acc: &mut HashSet) { + match expr { + Expr::Var(name) => { + acc.insert(name.clone()); + } + Expr::AttrLookup { var, .. } => { + acc.insert(var.clone()); + } + Expr::FieldAccess { base, .. } => { + collect_expr_vars(base, acc); + } + Expr::Binop { left, right, .. } => { + collect_expr_vars(left, acc); + collect_expr_vars(right, acc); + } + Expr::Unop { operand, .. } | Expr::IsNull { operand, .. } => { + collect_expr_vars(operand, acc); + } + Expr::Coalesce(args) | Expr::Call { args, .. } => { + for a in args { + collect_expr_vars(a, acc); + } + } + Expr::Record { fields } => { + for (_, e) in fields { + collect_expr_vars(e, acc); + } + } + Expr::ValueSubquery { body } | Expr::Exists { body } | Expr::NotExists { body } => { + collect_query_vars(body, acc); + } + Expr::Case { branches, else_expr } => { + for (cond, value) in branches { + collect_expr_vars(cond, acc); + collect_expr_vars(value, acc); + } + if let Some(value) = else_expr { + collect_expr_vars(value, acc); + } + } + Expr::ListComprehension { var, source, filter, body } => { + collect_expr_vars(source, acc); + let mut inner = HashSet::new(); + if let Some(f) = filter { + collect_expr_vars(f, &mut inner); + } + collect_expr_vars(body, &mut inner); + inner.remove(var); + acc.extend(inner); + } + Expr::Agg(agg) => match agg.as_ref() { + Aggregator::CountStar => {} + Aggregator::GeneralSet { expr, .. } => { + collect_expr_vars(expr, acc); + } + }, + Expr::Const(_) | Expr::Type(_) => {} + } +} + +fn collect_query_vars(q: &Query, acc: &mut HashSet) { + if let Some(returns) = &q.returns { + for item in returns { + match item { + ReturnItem::Expr { expr, .. } => { + collect_expr_vars(expr, acc); + } + ReturnItem::Aggregate { agg, .. } => match agg { + Aggregator::CountStar => {} + Aggregator::GeneralSet { expr, .. } => { + collect_expr_vars(expr, acc); + } + }, + } + } + } else { + for m in &q.matches { + acc.extend(m.pattern().freevars()); + } + } + if let Some(gb) = &q.group_by { + for e in gb { + collect_expr_vars(e, acc); + } + } + if let Some(ob) = &q.order_by { + for spec in ob { + if let SortKey::Expr(expr) = &spec.key { + collect_expr_vars(expr, acc); + } + } + } + let mut var_counts = HashMap::new(); + for m in &q.matches { + for v in m.pattern().freevars() { + *var_counts.entry(v).or_insert(0) += 1; + } + } + for (v, count) in var_counts { + if count > 1 { + acc.insert(v); + } + } + for m in &q.matches { + collect_pattern_filter_vars(m.pattern(), acc); + } +} + +fn collect_pattern_filter_vars(p: &PathPattern, acc: &mut HashSet) { + match p { + PathPattern::Filter(inner, expr) => { + collect_expr_vars(expr, acc); + collect_pattern_filter_vars(inner, acc); + } + PathPattern::Concat(a, b) | PathPattern::Union(a, b) | PathPattern::Join(a, b) => { + collect_pattern_filter_vars(a, acc); + collect_pattern_filter_vars(b, acc); + } + PathPattern::Repeat { pattern, .. } | PathPattern::Questioned(pattern) + | PathPattern::Selected { pattern, .. } | PathPattern::Named { pattern, .. } => { + collect_pattern_filter_vars(pattern, acc); + } + _ => {} + } +} + +fn remove_unused_pattern_vars(p: PathPattern, used: &HashSet) -> PathPattern { + match p { + PathPattern::Node(Some(mut d)) => { + if let Some(ref var) = d.var { + if !used.contains(var) { + d.var = None; + } + } + PathPattern::Node(Some(d)) + } + PathPattern::EdgeRight(Some(mut d)) => { + if let Some(ref var) = d.var { + if !used.contains(var) { + d.var = None; + } + } + PathPattern::EdgeRight(Some(d)) + } + PathPattern::EdgeLeft(Some(mut d)) => { + if let Some(ref var) = d.var { + if !used.contains(var) { + d.var = None; + } + } + PathPattern::EdgeLeft(Some(d)) + } + PathPattern::EdgeUndirected(Some(mut d)) => { + if let Some(ref var) = d.var { + if !used.contains(var) { + d.var = None; + } + } + PathPattern::EdgeUndirected(Some(d)) + } + PathPattern::EdgeAnyDirection(Some(mut d)) => { + if let Some(ref var) = d.var { + if !used.contains(var) { + d.var = None; + } + } + PathPattern::EdgeAnyDirection(Some(d)) + } + PathPattern::Concat(a, b) => PathPattern::Concat( + Box::new(remove_unused_pattern_vars(*a, used)), + Box::new(remove_unused_pattern_vars(*b, used)), + ), + PathPattern::Union(a, b) => PathPattern::Union( + Box::new(remove_unused_pattern_vars(*a, used)), + Box::new(remove_unused_pattern_vars(*b, used)), + ), + PathPattern::Join(a, b) => PathPattern::Join( + Box::new(remove_unused_pattern_vars(*a, used)), + Box::new(remove_unused_pattern_vars(*b, used)), + ), + PathPattern::Filter(inner, expr) => PathPattern::Filter( + Box::new(remove_unused_pattern_vars(*inner, used)), + expr, + ), + PathPattern::Repeat { pattern, lb, ub } => PathPattern::Repeat { + pattern: Box::new(remove_unused_pattern_vars(*pattern, used)), + lb, + ub, + }, + PathPattern::Questioned(inner) => { + PathPattern::Questioned(Box::new(remove_unused_pattern_vars(*inner, used))) + } + PathPattern::Selected { prefix, pattern } => PathPattern::Selected { + prefix, + pattern: Box::new(remove_unused_pattern_vars(*pattern, used)), + }, + PathPattern::Named { var, pattern } => PathPattern::Named { + var, + pattern: Box::new(remove_unused_pattern_vars(*pattern, used)), + }, + other => other, + } +} /// Optimize the query while preserving OPTIONAL and §16.6 prefix boundaries. -fn optimize_query(q: Query, schema: &Schema) -> Query { +fn optimize_query(mut q: Query, schema: &Schema) -> Query { + let mut used_vars = HashSet::new(); + collect_query_vars(&q, &mut used_vars); + for m in &mut q.matches { + match m { + MatchStatement::Simple { pattern } => { + *pattern = remove_unused_pattern_vars(std::mem::replace(pattern, PathPattern::Node(None)), &used_vars); + } + MatchStatement::Optional { pattern } => { + *pattern = remove_unused_pattern_vars(std::mem::replace(pattern, PathPattern::Node(None)), &used_vars); + } + } + } + // Selected patterns are evaluated in isolation; do not collapse across them. let mut q = if !q.has_any_optional() && !q.has_any_selected() { let pattern = optimizer::compile(q.collapsed_pattern()); diff --git a/src/optimizer/pushdown.rs b/src/optimizer/pushdown.rs index edf82b9..8c9199a 100644 --- a/src/optimizer/pushdown.rs +++ b/src/optimizer/pushdown.rs @@ -363,15 +363,16 @@ fn merge_constraints(p: PathPattern, c: &Constraints) -> PathPattern { fn boundary_node_vars(p: &PathPattern) -> [Option; 2] { let mut vars = Vec::new(); collect_top_node_vars(p, &mut vars); - [vars.first().cloned(), vars.last().cloned()] + [ + vars.first().cloned().flatten(), + vars.last().cloned().flatten(), + ] } -fn collect_top_node_vars(p: &PathPattern, out: &mut Vec) { +fn collect_top_node_vars(p: &PathPattern, out: &mut Vec>) { match p { PathPattern::Node(desc) => { - if let Some(var) = desc.as_ref().and_then(|d| d.var.clone()) { - out.push(var); - } + out.push(desc.as_ref().and_then(|d| d.var.clone())); } PathPattern::Concat(a, b) => { collect_top_node_vars(a, out); diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 7aaa4ea..5968357 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -1766,6 +1766,13 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { return optimized; } } + PathPattern::Repeat { pattern, lb, ub } => { + if let Some(optimized) = + self.try_concat_with_edge_repetition(&ir1, pattern, *lb, *ub, limit) + { + return optimized; + } + } _ => {} } @@ -1962,6 +1969,134 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { IntermediateResult::new(rows) } + fn try_concat_with_edge_repetition( + &self, + ir1: &IntermediateResult, + edge_pat: &PathPattern, + lb: usize, + ub: Option, + limit: usize, + ) -> Option { + let Some(ub_val) = ub else { + return None; + }; + if !matches!( + edge_pat, + PathPattern::EdgeRight(_) + | PathPattern::EdgeLeft(_) + | PathPattern::EdgeUndirected(_) + | PathPattern::EdgeAnyDirection(_) + ) { + return None; + } + + let mut anon_edge_pat = edge_pat.clone(); + let var_name = match &mut anon_edge_pat { + PathPattern::EdgeRight(Some(d)) + | PathPattern::EdgeLeft(Some(d)) + | PathPattern::EdgeUndirected(Some(d)) + | PathPattern::EdgeAnyDirection(Some(d)) => d.var.take(), + _ => None, + }; + + let freevars = edge_pat.freevars(); + let mut accumulated_rows = Vec::new(); + + if lb == 0 { + for r in &ir1.rows { + let mut row = r.clone(); + row.assignment.fill_empty_list(&freevars); + accumulated_rows.push(row); + if limit > 0 && accumulated_rows.len() >= limit { + accumulated_rows.truncate(limit); + return Some(IntermediateResult::new(accumulated_rows)); + } + } + } + + let mut level_ir = self.run_concat_pattern_step(ir1, &anon_edge_pat, 0); + if let Some(ref name) = var_name { + for row in &mut level_ir.rows { + let edge_val = Self::get_last_edge(row.path()); + row.assignment.extend(name.clone(), PathValue::Group(vec![edge_val])); + } + } + if lb <= 1 { + for r in &level_ir.rows { + accumulated_rows.push(r.clone()); + if limit > 0 && accumulated_rows.len() >= limit { + accumulated_rows.truncate(limit); + return Some(IntermediateResult::new(accumulated_rows)); + } + } + } + + for k in 2..=ub_val { + let next_ir = self.run_concat_pattern_step(&level_ir, &anon_edge_pat, 0); + level_ir = next_ir; + if let Some(ref name) = var_name { + for row in &mut level_ir.rows { + let edge_val = Self::get_last_edge(row.path()); + if let Some(PathValue::Group(prev_group)) = row.assignment.get(name) { + let mut new_group = prev_group.clone(); + new_group.push(edge_val); + row.assignment.extend(name.clone(), PathValue::Group(new_group)); + } + } + } + if k >= lb { + for r in &level_ir.rows { + accumulated_rows.push(r.clone()); + if limit > 0 && accumulated_rows.len() >= limit { + accumulated_rows.truncate(limit); + return Some(IntermediateResult::new(accumulated_rows)); + } + } + } + } + + Some(IntermediateResult::new(accumulated_rows)) + } + + fn run_concat_pattern_step( + &self, + ir: &IntermediateResult, + anon_edge_pat: &PathPattern, + limit: usize, + ) -> IntermediateResult { + match anon_edge_pat { + PathPattern::EdgeRight(desc) => { + self.concat_with_directed_edge(ir, desc.as_ref(), true, limit) + } + PathPattern::EdgeLeft(desc) => { + self.concat_with_directed_edge(ir, desc.as_ref(), false, limit) + } + PathPattern::EdgeUndirected(desc) => { + self.concat_with_undirected_edge(ir, desc.as_ref(), limit) + } + PathPattern::EdgeAnyDirection(desc) => { + let right = self.concat_with_directed_edge(ir, desc.as_ref(), true, limit); + if limit > 0 && right.rows.len() >= limit { + return right; + } + let remaining = if limit > 0 { limit - right.rows.len() } else { 0 }; + let left = self.concat_with_directed_edge(ir, desc.as_ref(), false, remaining); + let combined = right.union(left); + if limit > 0 && combined.rows.len() >= limit { + return combined; + } + let remaining = if limit > 0 { limit - combined.rows.len() } else { 0 }; + let und = self.concat_with_undirected_edge(ir, desc.as_ref(), remaining); + combined.union(und) + } + _ => unreachable!(), + } + } + + fn get_last_edge(path: &Path) -> PathValue { + path.0[path.0.len() - 2].clone() + } + /// Hash-join on the concatenation key (last node of ir1 = first node of ir2). /// O(n + m) expected instead of O(n × m) cross-product. fn hash_join( diff --git a/tests/unused_variable_repetition_test.rs b/tests/unused_variable_repetition_test.rs new file mode 100644 index 0000000..56c6d53 --- /dev/null +++ b/tests/unused_variable_repetition_test.rs @@ -0,0 +1,121 @@ +use gqlrust::compile_query; +use gqlrust::model::graph::MemoryGraphStore; +use gqlrust::model::value::Value; +use gqlrust::runtime::engine::Runtime; +use gqlrust::runtime::result::QueryResult; + +fn graph() -> MemoryGraphStore { + let json = r#"{ + "nodes": [ + {"id": "a", "labels": ["Person"], "props": {"id": 10}}, + {"id": "b", "labels": ["Person"], "props": {"id": 20}}, + {"id": "c", "labels": ["Person"], "props": {"id": 30}} + ], + "edges": [ + {"id": "e1", "labels": ["knows"], "props": {}, "endpoints": ["a", "b"], "directionality": "->"}, + {"id": "e2", "labels": ["knows"], "props": {}, "endpoints": ["b", "c"], "directionality": "->"} + ] + }"#; + MemoryGraphStore::from_json_str(json).unwrap() +} + +#[test] +fn test_unused_edge_variable_in_repeat_is_unrolled() { + let q_str = "MATCH (n1:Person)-[e]-{1,2}(n2:Person) WHERE n1.id = 10 RETURN n1.id, n2.id"; + let query = compile_query(q_str).unwrap(); + let pattern_str = query.matches[0].pattern().to_string(); + + // Because `e` is unused, the pass should strip its name, enabling unroll_repeat. + // Therefore, the compiled query pattern should NOT contain the repetition `{1,2}` anymore. + assert!(!pattern_str.contains("{1,2}"), "Unused repetition should be unrolled: {}", pattern_str); + assert!(pattern_str.contains("|"), "Unrolled repetition should be a Union: {}", pattern_str); + + // Run the query and verify correctness of results + let g = graph(); + let rt = Runtime::new(&g); + match rt.run_query(&query, 0) { + QueryResult::Projected(rows) => { + // Paths of length 1: (a)-[e1]-(b) -> (10, 20) + // Paths of length 2: (a)-[e1]-(b)-[e2]-(c) -> (10, 30) + // Plus reverse paths since they are undirected: + // Since the graph is undirected for -[e]-: + // We should get at least [10, 20] and [10, 30]. + let mut ids: Vec = rows + .iter() + .map(|row| match &row[1] { + Value::Int(val) => *val, + other => panic!("expected Int, got {:?}", other), + }) + .collect(); + ids.sort(); + assert_eq!(ids, vec![10, 20, 30]); + } + other => panic!("expected projected rows, got {other:?}"), + } +} + +#[test] +fn test_used_edge_variable_in_repeat_is_not_unrolled() { + // If the edge variable `e` is returned, it is used, so it must not be unrolled. + let q_str = "MATCH (n1:Person)-[e]-{1,2}(n2:Person) WHERE n1.id = 10 RETURN n1.id, e, n2.id"; + let query = compile_query(q_str).unwrap(); + let pattern_str = query.matches[0].pattern().to_string(); + + assert!(pattern_str.contains("{1,2}"), "Used repetition should NOT be unrolled: {}", pattern_str); + assert!(!pattern_str.contains("|"), "Used repetition should NOT be unrolled into a Union: {}", pattern_str); +} + +#[test] +fn test_no_returns_clause_preserves_all_variables() { + // If there is no returns clause, all variables are preserved because the raw intermediate result is returned. + let q_str = "MATCH (n1:Person)-[e]-{1,2}(n2:Person)"; + let query = compile_query(q_str).unwrap(); + let pattern_str = query.matches[0].pattern().to_string(); + + assert!(pattern_str.contains("{1,2}"), "Repetition without returns clause should NOT be unrolled: {}", pattern_str); +} + +#[test] +fn test_used_edge_variable_in_repeat_results_correctness() { + let q_str = "MATCH (n1:Person)-[e]-{1,2}(n2:Person) WHERE n1.id = 10 RETURN n1.id, e, n2.id"; + let query = compile_query(q_str).unwrap(); + let g = graph(); + let rt = Runtime::new(&g); + match rt.run_query(&query, 0) { + QueryResult::Projected(rows) => { + let mut results = Vec::new(); + for r in rows { + let n1_id = match &r[0] { Value::Int(v) => *v, _ => panic!() }; + let n2_id = match &r[2] { Value::Int(v) => *v, _ => panic!() }; + let e_list = match &r[1] { + Value::List(l) => l.iter().map(|item| match item { + Value::Edge(eid) => *eid, + other => panic!("expected Edge, got {:?}", other), + }).collect::>(), + other => panic!("expected List, got {:?}", other), + }; + results.push((n1_id, e_list, n2_id)); + } + results.sort_by_key(|r| (r.2, r.1.len(), r.1.clone())); + assert_eq!(results.len(), 3); + + // Result 1: to node 10 (length 2 path traversing e1 twice) + assert_eq!(results[0].0, 10); + assert_eq!(results[0].2, 10); + assert_eq!(results[0].1.len(), 2); + assert_eq!(results[0].1[0], results[0].1[1]); // same edge traversed twice + + // Result 2: to node 20 (length 1 path) + assert_eq!(results[1].0, 10); + assert_eq!(results[1].2, 20); + assert_eq!(results[1].1.len(), 1); + + // Result 3: to node 30 (length 2 path traversing e1 then e2) + assert_eq!(results[2].0, 10); + assert_eq!(results[2].2, 30); + assert_eq!(results[2].1.len(), 2); + assert_ne!(results[2].1[0], results[2].1[1]); // e1 and e2 are different edges + } + other => panic!("expected projected rows, got {other:?}"), + } +} From 6ef1f008ad415b69146a8b4f67d47ed42290ad1a Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Mon, 8 Jun 2026 23:13:33 +0000 Subject: [PATCH 2/8] Implement Cypher 25 allReduce predicate function and dynamic early path pruning - Add syntax support for 'allReduce(acc = init, x IN list | reduction, predicate)' in lexer and parser. - Implement typechecking for allReduce in checker.rs, validating list source, boolean predicate return types, and local variable scoping. - Add runtime evaluation of AllReduce expressions in engine.rs. - Integrate stepwise path pruning inside adjacency-driven repeat traversals, pruning branches early when the predicate returns false. - Allow querying global constraints (outer-bound variables) inside the allReduce predicate. - Add comprehensive integration tests in tests/all_reduce_test.rs. - Fix allocator subtraction overflow issues in tests/bench_test.rs. --- src/elaborate/mod.rs | 15 ++++ src/lib.rs | 17 ++++ src/optimizer/existential.rs | 12 +++ src/parser/grammar.rs | 34 ++++++++ src/parser/lexer.rs | 3 + src/runtime/engine.rs | 157 ++++++++++++++++++++++++++++++++++- src/syntax/expr.rs | 62 ++++++++++++++ src/typing/checker.rs | 30 +++++++ tests/all_reduce_test.rs | 132 +++++++++++++++++++++++++++++ tests/bench_test.rs | 12 +-- 10 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 tests/all_reduce_test.rs diff --git a/src/elaborate/mod.rs b/src/elaborate/mod.rs index d011b97..2da9177 100644 --- a/src/elaborate/mod.rs +++ b/src/elaborate/mod.rs @@ -174,6 +174,21 @@ pub fn elaborate_expr(e: Expr) -> Expr { filter: filter.map(|f| Box::new(elaborate_expr(*f))), body: Box::new(elaborate_expr(*body)), }, + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => Expr::AllReduce { + acc_name, + initial: Box::new(elaborate_expr(*initial)), + step_var, + list_expr: Box::new(elaborate_expr(*list_expr)), + reduction: Box::new(elaborate_expr(*reduction)), + predicate: Box::new(elaborate_expr(*predicate)), + }, other => other, } } diff --git a/src/lib.rs b/src/lib.rs index 18cf944..03da0a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,23 @@ fn collect_expr_vars(expr: &Expr, acc: &mut HashSet) { inner.remove(var); acc.extend(inner); } + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => { + collect_expr_vars(initial, acc); + collect_expr_vars(list_expr, acc); + let mut inner = HashSet::new(); + collect_expr_vars(reduction, &mut inner); + collect_expr_vars(predicate, &mut inner); + inner.remove(acc_name); + inner.remove(step_var); + acc.extend(inner); + } Expr::Agg(agg) => match agg.as_ref() { Aggregator::CountStar => {} Aggregator::GeneralSet { expr, .. } => { diff --git a/src/optimizer/existential.rs b/src/optimizer/existential.rs index 73c99a2..23135e8 100644 --- a/src/optimizer/existential.rs +++ b/src/optimizer/existential.rs @@ -143,6 +143,18 @@ fn fold_in_expr(e: &mut Expr, schema: &Schema) { } fold_in_expr(body, schema); } + Expr::AllReduce { + initial, + list_expr, + reduction, + predicate, + .. + } => { + fold_in_expr(initial, schema); + fold_in_expr(list_expr, schema); + fold_in_expr(reduction, schema); + fold_in_expr(predicate, schema); + } Expr::Const(_) | Expr::Var(_) | Expr::AttrLookup { .. } | Expr::Type(_) => {} } diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs index 8b915b1..0b30d4a 100644 --- a/src/parser/grammar.rs +++ b/src/parser/grammar.rs @@ -1954,6 +1954,10 @@ impl Parser { body: Box::new(body), }) } + Token::AllReduce => { + self.advance(); + self.parse_all_reduce() + } _ => Err(format!("expected expression, got {:?}", self.peek())), } } @@ -2039,6 +2043,36 @@ impl Parser { }) } + fn parse_all_reduce(&mut self) -> Result { + self.expect(&Token::LParen)?; + let acc_name = match self.advance() { + Token::Name(n) => n, + t => return Err(format!("allReduce: expected accumulator variable name, got {t:?}")), + }; + self.expect(&Token::Eq)?; + let initial = self.expr()?; + self.expect(&Token::Comma)?; + let step_var = match self.advance() { + Token::Name(n) => n, + t => return Err(format!("allReduce: expected step variable name, got {t:?}")), + }; + self.expect(&Token::In)?; + let list_expr = self.expr()?; + self.expect(&Token::Pipe)?; + let reduction = self.expr()?; + self.expect(&Token::Comma)?; + let predicate = self.expr()?; + self.expect(&Token::RParen)?; + Ok(Expr::AllReduce { + acc_name, + initial: Box::new(initial), + step_var, + list_expr: Box::new(list_expr), + reduction: Box::new(reduction), + predicate: Box::new(predicate), + }) + } + // ===== Top-level statement: query or DDL ===== fn statement(&mut self) -> Result { diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs index e03678b..07e6265 100644 --- a/src/parser/lexer.rs +++ b/src/parser/lexer.rs @@ -112,6 +112,8 @@ pub enum Token { /// Soft keyword (only before `{`) so `value` stays usable as an /// identifier. Runs a correlated subquery and projects one value. Value, + /// Cypher 25 `allReduce` function. Soft keyword (only before `(`). + AllReduce, // Symbols LParen, // ( @@ -616,6 +618,7 @@ impl Lexer { } "FLOOR" | "floor" if self.peek_non_space() == Some('(') => Token::Floor, "CAST" | "cast" if self.peek_non_space() == Some('(') => Token::Cast, + "allReduce" | "ALLREDUCE" if self.peek_non_space() == Some('(') => Token::AllReduce, // RECORD / VALUE are gated on a following `{` so the // lowercase forms stay usable as identifiers. "RECORD" | "record" if self.peek_non_space() == Some('{') => Token::Record, diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 5968357..e422b87 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -334,6 +334,9 @@ pub struct Runtime<'g, G: GraphAccess> { /// comprehension element (a `Value`, which `Assignment` cannot hold) /// resolves correctly. Pushed/popped per element during evaluation. comprehension_scope: RefCell>, + /// Active query Filters at the parent level, used to extract allReduce + /// predicates for early path pruning during repetition traversals. + active_filters: RefCell>, /// Outer bindings made visible inside a *parameter-correlated* subquery /// body — outer variables referenced in the body's WHERE/RETURN but not /// bound by the body's own pattern. `Expr::Var` / `Expr::AttrLookup` @@ -397,6 +400,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { value_subquery_cache: RefCell::new(HashMap::new()), unbounded_policy: Cell::new(UnboundedPolicy::Forbidden), comprehension_scope: RefCell::new(Vec::new()), + active_filters: RefCell::new(Vec::new()), correlation_scope: RefCell::new(Vec::new()), } } @@ -413,6 +417,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { value_subquery_cache: RefCell::new(HashMap::new()), unbounded_policy: Cell::new(UnboundedPolicy::Forbidden), comprehension_scope: RefCell::new(Vec::new()), + active_filters: RefCell::new(Vec::new()), correlation_scope: RefCell::new(Vec::new()), } } @@ -853,7 +858,10 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { Some(IntermediateResult::new(rows)) } PathPattern::Filter(inner, expr) => { - let ir = self.pinned_run(inner, index, var, id)?; + self.active_filters.borrow_mut().push(expr.clone()); + let ir = self.pinned_run(inner, index, var, id); + self.active_filters.borrow_mut().pop(); + let ir = ir?; let filtered: Vec = ir .rows .into_iter() @@ -889,7 +897,10 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { Some(IntermediateResult::new(rows)) } PathPattern::Filter(inner, expr) => { - let ir = self.pinned_run_multi(inner, index, pins, 0)?; + self.active_filters.borrow_mut().push(expr.clone()); + let ir = self.pinned_run_multi(inner, index, pins, 0); + self.active_filters.borrow_mut().pop(); + let ir = ir?; let filtered: Vec = ir .rows .into_iter() @@ -1425,7 +1436,9 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { PathPattern::Filter(inner, expr) => { // For filters, we can't know how many pre-filter results we need, // so pass 0 (unlimited) to inner and filter+truncate after. + self.active_filters.borrow_mut().push(expr.clone()); let ir = self.run_path_pattern(inner, 0); + self.active_filters.borrow_mut().pop(); let mut rows = Vec::new(); for r in ir.rows { if self.run_expr(&r.assignment, expr).get_bool() { @@ -1999,6 +2012,13 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { _ => None, }; + let mut active_reduces = Vec::new(); + if let Some(ref name) = var_name { + for filter_expr in self.active_filters.borrow().iter() { + active_reduces.extend(find_all_reduce_for_var(filter_expr, name)); + } + } + let freevars = edge_pat.freevars(); let mut accumulated_rows = Vec::new(); @@ -2020,6 +2040,16 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let edge_val = Self::get_last_edge(row.path()); row.assignment.extend(name.clone(), PathValue::Group(vec![edge_val])); } + if !active_reduces.is_empty() { + level_ir.rows.retain(|row| { + active_reduces.iter().all(|expr| { + matches!( + self.run_expr(&row.assignment, expr), + ExprResult::Success(Value::Bool(true)) + ) + }) + }); + } } if lb <= 1 { for r in &level_ir.rows { @@ -2043,6 +2073,16 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { row.assignment.extend(name.clone(), PathValue::Group(new_group)); } } + if !active_reduces.is_empty() { + level_ir.rows.retain(|row| { + active_reduces.iter().all(|expr| { + matches!( + self.run_expr(&row.assignment, expr), + ExprResult::Success(Value::Bool(true)) + ) + }) + }); + } } if k >= lb { for r in &level_ir.rows { @@ -3327,6 +3367,62 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { ExprResult::Success(Value::List(out)) } + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => { + let items = match self.run_expr(mu, list_expr) { + ExprResult::Success(Value::List(items)) => items, + ExprResult::Success(Value::Null) | ExprResult::Failure(_) => { + return ExprResult::Success(Value::Bool(true)); + } + ExprResult::Success(other) => { + return ExprResult::Failure(format!( + "allReduce list expression is not a list: {other}" + )); + } + }; + let mut acc_value = match self.run_expr(mu, initial) { + ExprResult::Success(v) => v, + ExprResult::Failure(err) => return ExprResult::Failure(err), + }; + for item in items { + self.comprehension_scope + .borrow_mut() + .push((acc_name.clone(), acc_value.clone())); + self.comprehension_scope + .borrow_mut() + .push((step_var.clone(), item.clone())); + let next_acc = self.run_expr(mu, reduction); + self.comprehension_scope.borrow_mut().pop(); + self.comprehension_scope.borrow_mut().pop(); + acc_value = match next_acc { + ExprResult::Success(v) => v, + ExprResult::Failure(err) => return ExprResult::Failure(err), + }; + self.comprehension_scope + .borrow_mut() + .push((acc_name.clone(), acc_value.clone())); + self.comprehension_scope + .borrow_mut() + .push((step_var.clone(), item.clone())); + let pred_res = self.run_expr(mu, predicate); + self.comprehension_scope.borrow_mut().pop(); + self.comprehension_scope.borrow_mut().pop(); + match pred_res { + ExprResult::Success(Value::Bool(true)) => {} + _ => { + return ExprResult::Success(Value::Bool(false)); + } + } + } + ExprResult::Success(Value::Bool(true)) + } + Expr::Type(_) => ExprResult::Failure("bare type in expression".into()), Expr::Call { name, args } => self.eval_call(mu, name, args), @@ -4904,6 +5000,63 @@ fn value_cmp(a: &Value, b: &Value) -> Option { } } +fn find_all_reduce_for_var(expr: &Expr, var_name: &str) -> Vec { + let mut results = Vec::new(); + fn walk(e: &Expr, var_name: &str, results: &mut Vec) { + match e { + Expr::AllReduce { list_expr, .. } => { + if let Expr::Var(ref name) = **list_expr { + if name == var_name { + results.push(e.clone()); + } + } + } + Expr::Binop { left, right, .. } => { + walk(left, var_name, results); + walk(right, var_name, results); + } + Expr::Unop { operand, .. } => { + walk(operand, var_name, results); + } + Expr::IsNull { operand, .. } => { + walk(operand, var_name, results); + } + Expr::FieldAccess { base, .. } => { + walk(base, var_name, results); + } + Expr::Coalesce(args) | Expr::Call { args, .. } => { + for a in args { + walk(a, var_name, results); + } + } + Expr::Record { fields } => { + for (_, ex) in fields { + walk(ex, var_name, results); + } + } + Expr::Case { branches, else_expr } => { + for (cond, value) in branches { + walk(cond, var_name, results); + walk(value, var_name, results); + } + if let Some(value) = else_expr { + walk(value, var_name, results); + } + } + Expr::ListComprehension { source, filter, body, .. } => { + walk(source, var_name, results); + if let Some(f) = filter { + walk(f, var_name, results); + } + walk(body, var_name, results); + } + _ => {} + } + } + walk(expr, var_name, &mut results); + results +} + #[cfg(test)] mod group_key_tests { //! Tests for `GroupKey` — the Hash + Eq wrapper used for grouping diff --git a/src/syntax/expr.rs b/src/syntax/expr.rs index 8793b50..91c0aca 100644 --- a/src/syntax/expr.rs +++ b/src/syntax/expr.rs @@ -210,6 +210,14 @@ pub enum Expr { filter: Option>, body: Box, }, + AllReduce { + acc_name: String, + initial: Box, + step_var: String, + list_expr: Box, + reduction: Box, + predicate: Box, + }, /// Right-hand side of `is`/`as` operators — a type, not a value. Type(SimpleType), /// `EXISTS { }` — Boolean predicate over a subquery body. @@ -268,6 +276,18 @@ impl Expr { || filter.as_deref().is_some_and(Expr::contains_agg) || body.contains_agg() } + Expr::AllReduce { + initial, + list_expr, + reduction, + predicate, + .. + } => { + initial.contains_agg() + || list_expr.contains_agg() + || reduction.contains_agg() + || predicate.contains_agg() + } Expr::Const(_) | Expr::Var(_) | Expr::AttrLookup { .. } @@ -317,6 +337,18 @@ impl Expr { || filter.as_deref().is_some_and(Expr::contains_subquery) || body.contains_subquery() } + Expr::AllReduce { + initial, + list_expr, + reduction, + predicate, + .. + } => { + initial.contains_subquery() + || list_expr.contains_subquery() + || reduction.contains_subquery() + || predicate.contains_subquery() + } Expr::Const(_) | Expr::Var(_) | Expr::AttrLookup { .. } @@ -388,6 +420,23 @@ impl Expr { inner.remove(var); acc.extend(inner); } + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => { + initial.referenced_vars(acc); + list_expr.referenced_vars(acc); + let mut inner = std::collections::BTreeSet::new(); + reduction.referenced_vars(&mut inner); + predicate.referenced_vars(&mut inner); + inner.remove(acc_name); + inner.remove(step_var); + acc.extend(inner); + } // Subqueries are handled via `contains_subquery`; their // internal references are out of scope here. Expr::Const(_) @@ -490,6 +539,19 @@ impl fmt::Display for Expr { Some(cond) => write!(f, "[{var} IN {source} WHERE {cond} | {body}]"), None => write!(f, "[{var} IN {source} | {body}]"), }, + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => { + write!( + f, + "allReduce({acc_name} = {initial}, {step_var} IN {list_expr} | {reduction}, {predicate})" + ) + } Expr::Type(t) => write!(f, "{t}"), Expr::Exists { body } => write!(f, "EXISTS {{ {} }}", display_subquery(body)), Expr::NotExists { body } => write!(f, "NOT EXISTS {{ {} }}", display_subquery(body)), diff --git a/src/typing/checker.rs b/src/typing/checker.rs index 22ff420..0766f39 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -893,6 +893,36 @@ impl Typechecker { SimpleType::List(Box::new(body_t)) } + Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr, + reduction, + predicate, + } => { + let _init_t = self.check_expr(initial, env); + let list_t = self.check_expr(list_expr, env); + if !matches!(list_t, SimpleType::List(_) | SimpleType::Star) + && SimpleType::meet(&list_t, &SimpleType::Star) != list_t + { + self.warnings.push(format!( + "allReduce source list has non-list type {list_t}" + )); + } + self.comprehension_scope.push(acc_name.clone()); + self.comprehension_scope.push(step_var.clone()); + let _red_t = self.check_expr(reduction, env); + let pred_t = self.check_expr(predicate, env); + if SimpleType::meet(&pred_t, &SimpleType::B) == SimpleType::Zero { + self.warnings + .push(format!("allReduce predicate has non-bool type {pred_t}")); + } + self.comprehension_scope.pop(); + self.comprehension_scope.pop(); + SimpleType::B + } + Expr::Record { fields } => { // A record constructor types as a closed record over the // field expression types. diff --git a/tests/all_reduce_test.rs b/tests/all_reduce_test.rs new file mode 100644 index 0000000..a64dee3 --- /dev/null +++ b/tests/all_reduce_test.rs @@ -0,0 +1,132 @@ +//! Tests for Cypher 25 `allReduce` function and early path pruning. + +use gqlrust::compile_query; +use gqlrust::model::graph::MemoryGraphStore; +use gqlrust::model::value::Value; +use gqlrust::runtime::engine::Runtime; +use gqlrust::runtime::result::QueryResult; + +fn graph() -> MemoryGraphStore { + let json = r#"{ + "nodes": [ + {"id": "a", "labels": ["Station"], "props": {"id": "a", "cost_limit": 6}}, + {"id": "b", "labels": ["Station"], "props": {"id": "b"}}, + {"id": "c", "labels": ["Station"], "props": {"id": "c"}}, + {"id": "d", "labels": ["Station"], "props": {"id": "d"}}, + {"id": "x", "labels": ["Station"], "props": {"id": "x"}}, + {"id": "y", "labels": ["Station"], "props": {"id": "y"}} + ], + "edges": [ + {"id": "e1", "labels": ["LINK"], "props": {"cost": 3}, "endpoints": ["a", "b"], "directionality": "->"}, + {"id": "e2", "labels": ["LINK"], "props": {"cost": 4}, "endpoints": ["b", "c"], "directionality": "->"}, + {"id": "e3", "labels": ["LINK"], "props": {"cost": 5}, "endpoints": ["c", "d"], "directionality": "->"}, + {"id": "e4", "labels": ["LINK"], "props": {"cost": 10}, "endpoints": ["a", "x"], "directionality": "->"}, + {"id": "e5", "labels": ["LINK"], "props": {"cost": 10}, "endpoints": ["x", "y"], "directionality": "->"} + ] + }"#; + MemoryGraphStore::from_json_str(json).unwrap() +} + +fn proj(g: &MemoryGraphStore, q: &str) -> Vec> { + let query = compile_query(q).unwrap(); + let rt = Runtime::new(g); + match rt.run_query(&query, 0) { + QueryResult::Projected(r) => r, + other => panic!("expected projected rows, got {other:?}"), + } +} + +#[test] +fn all_reduce_as_pure_expression() { + let g = graph(); + let rows = proj( + &g, + "MATCH (p:Station {id: 'a'}) RETURN allReduce(sum = 0, x IN [1, 2, 3] | sum + x, sum < 10) AS r", + ); + // 0 + 1 = 1 (<10, true) + // 1 + 2 = 3 (<10, true) + // 3 + 3 = 6 (<10, true) + // Entire list passes. + assert_eq!(rows, vec![vec![Value::Bool(true)]]); + + let rows_fail = proj( + &g, + "MATCH (p:Station {id: 'a'}) RETURN allReduce(sum = 0, x IN [1, 5, 5] | sum + x, sum < 10) AS r", + ); + // 0 + 1 = 1 (<10, true) + // 1 + 5 = 6 (<10, true) + // 6 + 5 = 11 (not <10, false) + // List fails at the last step. + assert_eq!(rows_fail, vec![vec![Value::Bool(false)]]); +} + +#[test] +fn all_reduce_path_pruning_under_6() { + let g = graph(); + // With threshold < 6: + // a -> b (cost 3, valid) + // a -> b -> c (cost 7, pruned) + // a -> x (cost 10, pruned) + let rows = proj( + &g, + "MATCH (a:Station {id: 'a'})-[e:LINK]->{1,3}(n) \ + WHERE allReduce(dist = 0, edge IN e | dist + edge.cost, dist < 6) \ + RETURN n.id AS nid ORDER BY nid ASC", + ); + assert_eq!(rows, vec![vec![Value::Str("b".into())]]); +} + +#[test] +fn all_reduce_path_pruning_under_8() { + let g = graph(); + // With threshold < 8: + // a -> b (cost 3, valid) + // a -> b -> c (cost 7, valid) + // a -> b -> c -> d (cost 12, pruned) + // a -> x (cost 10, pruned) + let rows = proj( + &g, + "MATCH (a:Station {id: 'a'})-[e:LINK]->{1,3}(n) \ + WHERE allReduce(dist = 0, edge IN e | dist + edge.cost, dist < 8) \ + RETURN n.id AS nid ORDER BY nid ASC", + ); + assert_eq!( + rows, + vec![vec![Value::Str("b".into())], vec![Value::Str("c".into())]] + ); +} + +#[test] +fn all_reduce_path_pruning_with_global_constraint() { + let g = graph(); + // With threshold < a.cost_limit (which is 6): + // a -> b (cost 3, valid) + // a -> b -> c (cost 7, pruned) + // a -> x (cost 10, pruned) + let rows = proj( + &g, + "MATCH (a:Station {id: 'a'})-[e:LINK]->{1,3}(n) \ + WHERE allReduce(dist = 0, edge IN e | dist + edge.cost, dist < a.cost_limit) \ + RETURN n.id AS nid ORDER BY nid ASC", + ); + assert_eq!(rows, vec![vec![Value::Str("b".into())]]); +} + +#[test] +fn typecheck_all_reduce() { + // Valid query compiles + assert!(compile_query( + "MATCH (a:Station)-[e:LINK]->{1,3}(n) \ + WHERE allReduce(dist = 0, edge IN e | dist + edge.cost, dist < 100) \ + RETURN n" + ) + .is_ok()); + + // Invalid list source should warn/fail typecheck depending on exact compiler strictness + // (We put a warning in check_expr if source is not a list/star). + let q = compile_query( + "MATCH (a:Station) \ + RETURN allReduce(dist = 0, edge IN 123 | dist + edge, dist < 100) AS r" + ); + assert!(q.is_ok()); // Warnings are not errors, query still compiles. +} diff --git a/tests/bench_test.rs b/tests/bench_test.rs index faf533d..fc33eab 100644 --- a/tests/bench_test.rs +++ b/tests/bench_test.rs @@ -530,15 +530,15 @@ fn bench_graph_vs_lazy() { // --- Measure memory for all three --- let before = allocated_bytes(); let g_mem = MemoryGraphStore::open(&db_path).unwrap(); - let mem_graph = allocated_bytes() - before; + let mem_graph = allocated_bytes().saturating_sub(before); let before = allocated_bytes(); let g_lazy = LazyGraphStore::open(&db_path).unwrap(); - let mem_lazy = allocated_bytes() - before; + let mem_lazy = allocated_bytes().saturating_sub(before); let before = allocated_bytes(); let g_disk = DiskGraphStore::open(&db_path).unwrap(); - let mem_disk = allocated_bytes() - before; + let mem_disk = allocated_bytes().saturating_sub(before); print_graph_stats(&g_mem); println!("\n Memory:"); @@ -650,19 +650,19 @@ fn bench_memory_scaling() { // Measure MemoryGraphStore memory let before = allocated_bytes(); let g = MemoryGraphStore::open(&db_path).unwrap(); - let graph_mem = allocated_bytes() - before; + let graph_mem = allocated_bytes().saturating_sub(before); drop(g); // Measure Lazy memory let before = allocated_bytes(); let l = LazyGraphStore::open(&db_path).unwrap(); - let lazy_mem = allocated_bytes() - before; + let lazy_mem = allocated_bytes().saturating_sub(before); drop(l); // Measure Disk memory let before = allocated_bytes(); let d = DiskGraphStore::open(&db_path).unwrap(); - let disk_mem = allocated_bytes() - before; + let disk_mem = allocated_bytes().saturating_sub(before); drop(d); println!( From 358abf76e687585c45b7433dfbc759a451f41fea Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Mon, 8 Jun 2026 23:45:09 +0000 Subject: [PATCH 3/8] Optimize allReduce traversal with incremental accumulator caching and add deep repetition tests --- src/model/value.rs | 2 +- src/runtime/engine.rs | 135 ++++++++++++++++++++++++++++++++++----- tests/all_reduce_test.rs | 26 ++++++++ 3 files changed, 147 insertions(+), 16 deletions(-) diff --git a/src/model/value.rs b/src/model/value.rs index d42b3f6..c43f132 100644 --- a/src/model/value.rs +++ b/src/model/value.rs @@ -130,7 +130,7 @@ impl fmt::Display for PathValue { } /// A path is a sequence of alternating nodes and edges: [n1, e1, n2, e2, n3, ...]. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Path(pub Vec); impl Path { diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index e422b87..02d0cf3 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -1990,9 +1990,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { ub: Option, limit: usize, ) -> Option { - let Some(ub_val) = ub else { - return None; - }; + let ub_val = ub?; if !matches!( edge_pat, PathPattern::EdgeRight(_) @@ -2034,6 +2032,8 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } } + let mut acc_cache: HashMap<(usize, Path), Value> = HashMap::new(); + let mut level_ir = self.run_concat_pattern_step(ir1, &anon_edge_pat, 0); if let Some(ref name) = var_name { for row in &mut level_ir.rows { @@ -2041,14 +2041,33 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { row.assignment.extend(name.clone(), PathValue::Group(vec![edge_val])); } if !active_reduces.is_empty() { + let mut cache_inserts = Vec::new(); level_ir.rows.retain(|row| { - active_reduces.iter().all(|expr| { - matches!( - self.run_expr(&row.assignment, expr), - ExprResult::Success(Value::Bool(true)) - ) - }) + let mut ok = true; + let edge_val = Self::get_last_edge(row.path()); + for (idx, expr) in active_reduces.iter().enumerate() { + if let Some((next_acc, match_ok)) = self.eval_all_reduce_incremental( + &row.assignment, + expr, + &edge_val, + None, + ) { + if match_ok { + cache_inserts.push(((idx, row.path().clone()), next_acc)); + } else { + ok = false; + break; + } + } else { + ok = false; + break; + } + } + ok }); + for (key, val) in cache_inserts { + acc_cache.insert(key, val); + } } } if lb <= 1 { @@ -2074,14 +2093,36 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } } if !active_reduces.is_empty() { + let mut cache_inserts = Vec::new(); level_ir.rows.retain(|row| { - active_reduces.iter().all(|expr| { - matches!( - self.run_expr(&row.assignment, expr), - ExprResult::Success(Value::Bool(true)) - ) - }) + let mut ok = true; + let path_len = row.path().0.len(); + let parent_path = Path(row.path().0[..path_len - 2].to_vec()); + let edge_val = Self::get_last_edge(row.path()); + for (idx, expr) in active_reduces.iter().enumerate() { + let prev_acc = acc_cache.get(&(idx, parent_path.clone())).cloned(); + if let Some((next_acc, match_ok)) = self.eval_all_reduce_incremental( + &row.assignment, + expr, + &edge_val, + prev_acc, + ) { + if match_ok { + cache_inserts.push(((idx, row.path().clone()), next_acc)); + } else { + ok = false; + break; + } + } else { + ok = false; + break; + } + } + ok }); + for (key, val) in cache_inserts { + acc_cache.insert(key, val); + } } } if k >= lb { @@ -2098,6 +2139,70 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { Some(IntermediateResult::new(accumulated_rows)) } + fn eval_all_reduce_incremental( + &self, + mu: &Assignment, + expr: &Expr, + new_edge: &PathValue, + prev_acc: Option, + ) -> Option<(Value, bool)> { + let Expr::AllReduce { + acc_name, + initial, + step_var, + list_expr: _, + reduction, + predicate, + } = expr else { + return None; + }; + + // 1. Get previous/initial accumulator value + let acc_value = match prev_acc { + Some(v) => v, + None => match self.run_expr(mu, initial) { + ExprResult::Success(v) => v, + ExprResult::Failure(_) => return None, + }, + }; + + let step_value = path_value_to_value(new_edge); + + // 2. Compute next accumulator value: acc_value = reduction(acc_value, step_value) + self.comprehension_scope + .borrow_mut() + .push((acc_name.clone(), acc_value)); + self.comprehension_scope + .borrow_mut() + .push((step_var.clone(), step_value.clone())); + let next_acc_res = self.run_expr(mu, reduction); + self.comprehension_scope.borrow_mut().pop(); + self.comprehension_scope.borrow_mut().pop(); + + let next_acc = match next_acc_res { + ExprResult::Success(v) => v, + ExprResult::Failure(_) => return None, + }; + + // 3. Evaluate predicate on the next accumulator: predicate(next_acc, step_value) + self.comprehension_scope + .borrow_mut() + .push((acc_name.clone(), next_acc.clone())); + self.comprehension_scope + .borrow_mut() + .push((step_var.clone(), step_value)); + let pred_res = self.run_expr(mu, predicate); + self.comprehension_scope.borrow_mut().pop(); + self.comprehension_scope.borrow_mut().pop(); + + let ok = match pred_res { + ExprResult::Success(Value::Bool(b)) => b, + _ => false, + }; + + Some((next_acc, ok)) + } + fn run_concat_pattern_step( &self, ir: &IntermediateResult, diff --git a/tests/all_reduce_test.rs b/tests/all_reduce_test.rs index a64dee3..2b8828b 100644 --- a/tests/all_reduce_test.rs +++ b/tests/all_reduce_test.rs @@ -130,3 +130,29 @@ fn typecheck_all_reduce() { ); assert!(q.is_ok()); // Warnings are not errors, query still compiles. } + +#[test] +fn all_reduce_deep_repetition_pruning() { + let g = graph(); + // With threshold < 15 and repetition {1,8} (which exceeds MAX_UNROLL (4) and triggers try_concat_with_edge_repetition): + // a -> b (cost 3, valid) + // a -> x (cost 10, valid) + // a -> b -> c (cost 7, valid) + // a -> x -> y (cost 20, pruned at step 2) + // a -> b -> c -> d (cost 12, valid) + let rows = proj( + &g, + "MATCH (a:Station {id: 'a'})-[e:LINK]->{1,8}(n) \ + WHERE allReduce(dist = 0, edge IN e | dist + edge.cost, dist < 15) \ + RETURN n.id AS nid ORDER BY nid ASC", + ); + assert_eq!( + rows, + vec![ + vec![Value::Str("b".into())], + vec![Value::Str("c".into())], + vec![Value::Str("d".into())], + vec![Value::Str("x".into())], + ] + ); +} From 5593e820a22f5193d4d632635db8389c9abab239 Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Mon, 8 Jun 2026 23:52:42 +0000 Subject: [PATCH 4/8] Implement edge variable binding in BFS/SHORTEST path patterns --- src/runtime/engine.rs | 49 ++++++++++++++++++++++++++++++++------ tests/shortest_bfs_test.rs | 20 ++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 02d0cf3..7df17ba 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -2720,9 +2720,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { return None; } let (edge_desc, dir) = single_edge_dir(inner)?; - if edge_desc.and_then(|d| d.var.as_deref()).is_some() { - return None; - } + let edge_var = edge_desc.and_then(|d| d.var.clone()); // Validate both endpoints are node patterns; evaluate the full // (Filter-wrapped) sub-patterns so endpoint value predicates run. node_endpoint(src)?; @@ -2764,9 +2762,17 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { if !a.assignment.can_unify(&b.assignment) { continue; } + let mut mu = a.assignment.unify(&b.assignment); + if let Some(ref name) = edge_var { + let group_pv = PathValue::Group(vec![]); + if !mu.can_unify(&Assignment::from_optional(Some(name), group_pv.clone())) { + continue; + } + mu.extend(name.clone(), group_pv); + } rows.push(ResultRow::with_paths( vec![Path(vec![PathValue::Node(id)])], - a.assignment.unify(&b.assignment), + mu, )); } } else if !self_ids.is_empty() { @@ -2778,7 +2784,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { if dir == BfsEdgeDir::Undirected { for &id in &self_ids { self.emit_undirected_self_cycles( - id, edge_desc, ub, groups, &src_end, &tgt_end, &mut rows, + id, edge_desc, &edge_var, ub, groups, &src_end, &tgt_end, &mut rows, ); } } else { @@ -2814,6 +2820,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { self.bfs_from_source( s, edge_desc, + &edge_var, dir, reverse, ub, @@ -2848,6 +2855,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { &self, n: Id, edge_desc: Option<&Descriptor>, + edge_var: &Option, ub: Option, groups: bool, src_end: &BfsEndpoint, @@ -2914,7 +2922,20 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { Vec::new() }; for p in paths { - rows.push(ResultRow::with_paths(vec![p], mu.clone())); + let mut row_mu = mu.clone(); + if let Some(ref name) = edge_var { + let edges: Vec = p.0.iter() + .enumerate() + .filter(|(idx, _)| idx % 2 == 1) + .map(|(_, pv)| pv.clone()) + .collect(); + let group_pv = PathValue::Group(edges); + if !row_mu.can_unify(&Assignment::from_optional(Some(name), group_pv.clone())) { + continue; + } + row_mu.extend(name.clone(), group_pv); + } + rows.push(ResultRow::with_paths(vec![p], row_mu)); } } @@ -2927,6 +2948,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { &self, s: Id, edge_desc: Option<&Descriptor>, + edge_var: &Option, dir: BfsEdgeDir, reverse: bool, ub: Option, @@ -3012,9 +3034,22 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { if !a.assignment.can_unify(&b.assignment) { continue; } + let mut mu = a.assignment.unify(&b.assignment); + if let Some(ref name) = edge_var { + let edges: Vec = oriented.iter() + .enumerate() + .filter(|(idx, _)| idx % 2 == 1) + .map(|(_, pv)| pv.clone()) + .collect(); + let group_pv = PathValue::Group(edges); + if !mu.can_unify(&Assignment::from_optional(Some(name), group_pv.clone())) { + continue; + } + mu.extend(name.clone(), group_pv); + } rows.push(ResultRow::with_paths( vec![Path(oriented)], - a.assignment.unify(&b.assignment), + mu, )); if limit > 0 && rows.len() >= limit { return; diff --git a/tests/shortest_bfs_test.rs b/tests/shortest_bfs_test.rs index c6762aa..6bdd634 100644 --- a/tests/shortest_bfs_test.rs +++ b/tests/shortest_bfs_test.rs @@ -219,3 +219,23 @@ fn differential_unreachable() { // 3 has no outgoing R edges, so 3→0 is unreachable forward. assert_same(&g, "MATCH path = ANY SHORTEST (s:N {id:3})-[:R]->*(t:N {id:0}) RETURN s.id, t.id, PATH_LENGTH(path) AS len"); } + +#[test] +fn differential_edge_variables() { + let g = g_undirected(); + // Test ANY SHORTEST with edge variable on undirected graph + assert_same(&g, "MATCH path = ANY SHORTEST (s:N {id:0})~[e:R]~*(t:N {id:3}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + // Test ALL SHORTEST with edge variable on undirected graph + assert_same(&g, "MATCH path = ALL SHORTEST (s:N {id:0})~[e:R]~*(t:N {id:3}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + // Test length-0 self-match with edge variable + assert_same(&g, "MATCH path = ANY SHORTEST (s:N {id:0})~[e:R]~*(t:N {id:0}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + // Test coincident endpoint with `+` undirected (which does self cycles) + assert_same(&g, "MATCH path = ANY SHORTEST (s:N {id:0})~[e:R]~+(t:N {id:0}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + assert_same(&g, "MATCH path = ALL SHORTEST (s:N {id:0})~[e:R]~+(t:N {id:0}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + + let g2 = g_directed(); + // Test ANY SHORTEST with edge variable on directed graph + assert_same(&g2, "MATCH path = ANY SHORTEST (s:N {id:0})-[e:R]->*(t:N {id:3}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); + // Test ALL SHORTEST with edge variable on directed graph + assert_same(&g2, "MATCH path = ALL SHORTEST (s:N {id:0})-[e:R]->*(t:N {id:3}) RETURN s.id, t.id, e, PATH_LENGTH(path) AS len"); +} From 7d97d5ade72065eac11ea35ac958cad6be56e7b9 Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Tue, 9 Jun 2026 00:20:29 +0000 Subject: [PATCH 5/8] Optimize property accesses with single-property lookup and bypass full map cloning - Add 'node_prop' and 'edge_prop' methods to GraphAccess trait, returning Option for single-property retrieval. - Implement 'node_prop' and 'edge_prop' on MemoryGraphStore, LazyGraphStore, and DiskGraphStore to perform O(1) single-property reads directly from record overlays or byte streams without decoding/cloning the full property HashMap. - Refactor runtime engine value predicate checks (check_node_value_preds, check_edge_value_preds), run_expr property evaluations, and BtreeMergeCursor to use single-property lookups. - Update LTJ runners (LtjRunner::check_filters) to evaluate FilterKind::NodeProperty and FilterKind::NodeAttrCmp predicates using node_prop. - Bypass fetching/cloning the entire property map in filter_node and filter_edge when the descriptor has no schema property type constraints. - Optimize temporary dump ID checking in dump.rs to avoid full map cloning. --- src/model/csv_loader.rs | 4 +- src/model/graph.rs | 30 ++++++++++++++ src/model/graph_access.rs | 4 ++ src/runtime/engine.rs | 78 ++++++++++++++++++++++++------------ src/runtime/ltj/algorithm.rs | 8 ++-- src/store/disk.rs | 22 ++++++++++ src/store/dump.rs | 4 +- src/store/lazy.rs | 46 +++++++++++++++++++++ 8 files changed, 162 insertions(+), 34 deletions(-) diff --git a/src/model/csv_loader.rs b/src/model/csv_loader.rs index bfb5104..1797bd8 100644 --- a/src/model/csv_loader.rs +++ b/src/model/csv_loader.rs @@ -1051,8 +1051,8 @@ mod ldbc_tests { let place = g .node_id_by_name("Place:1") .expect("place 1 should be loaded"); - match g.node_props(place).get("id") { - Some(Value::Int(v)) => assert_eq!(*v, 1), + match g.node_prop(place, "id") { + Some(Value::Int(v)) => assert_eq!(v, 1), other => panic!("expected id=Int(1) on place 1, got {:?}", other), } diff --git a/src/model/graph.rs b/src/model/graph.rs index e6776fd..3ac1815 100644 --- a/src/model/graph.rs +++ b/src/model/graph.rs @@ -460,6 +460,36 @@ impl super::graph_access::GraphAccess for MemoryGraphStore { apply_prop_mods(&mut base, overlay.mod_edge_props.get(&id)); base } + fn node_prop(&self, id: Id, prop: &str) -> Option { + let overlay = self.overlay.borrow(); + if let Some(n) = overlay.get_new_node(id) { + return n.props.get(prop).cloned(); + } + if let Some(mods) = overlay.mod_node_props.get(&id) { + if let Some(op) = mods.set.get(prop) { + return op.clone(); + } + if mods.cleared { + return None; + } + } + self.node_props[id as usize].get(prop).cloned() + } + fn edge_prop(&self, id: Id, prop: &str) -> Option { + let overlay = self.overlay.borrow(); + if let Some(e) = overlay.get_new_edge(id) { + return e.props.get(prop).cloned(); + } + if let Some(mods) = overlay.mod_edge_props.get(&id) { + if let Some(op) = mods.set.get(prop) { + return op.clone(); + } + if mods.cleared { + return None; + } + } + self.edge_props[id as usize].get(prop).cloned() + } fn src(&self, edge_id: Id) -> Id { if let Some(e) = self.overlay.borrow().get_new_edge(edge_id) { return e.src; diff --git a/src/model/graph_access.rs b/src/model/graph_access.rs index 1264cf8..d970ab3 100644 --- a/src/model/graph_access.rs +++ b/src/model/graph_access.rs @@ -19,6 +19,10 @@ pub trait GraphAccess { fn node_props(&self, id: Id) -> Props; /// Properties for an edge by internal ID. fn edge_props(&self, id: Id) -> Props; + /// Single property lookup for a node by internal ID and key name. + fn node_prop(&self, id: Id, prop: &str) -> Option; + /// Single property lookup for an edge by internal ID and key name. + fn edge_prop(&self, id: Id, prop: &str) -> Option; /// Source node ID of a directed edge. fn src(&self, edge_id: Id) -> Id; /// Target node ID of a directed edge. diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 7df17ba..49e729e 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -265,13 +265,31 @@ impl Ord for ShortestEntry { } } -/// Apply value predicates pushed down by the optimizer to raw graph properties. +/// Apply value predicates pushed down by the optimizer to node properties. /// Missing key → predicate is null → reject. -fn check_value_preds(preds: &[(String, BinOp, Value)], props: &Props) -> bool { +fn check_node_value_preds( + graph: &G, + id: Id, + preds: &[(String, BinOp, Value)], +) -> bool { preds .iter() - .all(|(attr, op, expected)| match props.get(attr) { - Some(actual) => cmp_values(actual, *op, expected), + .all(|(attr, op, expected)| match graph.node_prop(id, attr) { + Some(ref actual) => cmp_values(actual, *op, expected), + None => false, + }) +} + +/// Apply value predicates pushed down by the optimizer to edge properties. +fn check_edge_value_preds( + graph: &G, + id: Id, + preds: &[(String, BinOp, Value)], +) -> bool { + preds + .iter() + .all(|(attr, op, expected)| match graph.edge_prop(id, attr) { + Some(ref actual) => cmp_values(actual, *op, expected), None => false, }) } @@ -3155,14 +3173,19 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { match desc { None => true, Some(d) => { - let raw_props = self.graph.node_props(id); let actual_label = self.graph.node_labels(id); - let actual_props = Self::check_record(&raw_props); - let actual = DescriptorType::new(actual_label.clone(), actual_props); - if !DescriptorType::is_subtype(&actual, &d.dtype) { + if !LabelType::is_subtype(&actual_label, &d.dtype.label) { return false; } - check_value_preds(&d.value_preds, &raw_props) + if !matches!(&d.dtype.props, PropertyType::Open(m) if m.is_empty()) { + let raw_props = self.graph.node_props(id); + let actual_props = Self::check_record(&raw_props); + let actual = DescriptorType::new(actual_label, actual_props); + if !DescriptorType::is_subtype(&actual, &d.dtype) { + return false; + } + } + check_node_value_preds(self.graph, id, &d.value_preds) } } } @@ -3171,14 +3194,19 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { match desc { None => true, Some(d) => { - let raw_props = self.graph.edge_props(id); let actual_label = self.graph.edge_labels(id); - let actual_props = Self::check_record(&raw_props); - let actual = DescriptorType::new(actual_label.clone(), actual_props); - if !DescriptorType::is_subtype(&actual, &d.dtype) { + if !LabelType::is_subtype(&actual_label, &d.dtype.label) { return false; } - check_value_preds(&d.value_preds, &raw_props) + if !matches!(&d.dtype.props, PropertyType::Open(m) if m.is_empty()) { + let raw_props = self.graph.edge_props(id); + let actual_props = Self::check_record(&raw_props); + let actual = DescriptorType::new(actual_label, actual_props); + if !DescriptorType::is_subtype(&actual, &d.dtype) { + return false; + } + } + check_edge_value_preds(self.graph, id, &d.value_preds) } } } @@ -3254,13 +3282,13 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { Some(id) => id, None => return ExprResult::Failure(format!("variable '{var}' has no id")), }; - let props = if pv.is_node() { - self.graph.node_props(id) + let val_opt = if pv.is_node() { + self.graph.node_prop(id, attr) } else { - self.graph.edge_props(id) + self.graph.edge_prop(id, attr) }; - match props.get(attr) { - Some(v) => ExprResult::Success(v.clone()), + match val_opt { + Some(v) => ExprResult::Success(v), None => ExprResult::Failure(format!("attribute '{attr}' not found")), } } @@ -3270,12 +3298,12 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { /// attribute is null (3VL), consistent with `AttrLookup` on `mu`. fn attr_of_value(&self, v: &Value, attr: &str) -> ExprResult { match v { - Value::Node(id) => match self.graph.node_props(*id).get(attr) { - Some(v) => ExprResult::Success(v.clone()), + Value::Node(id) => match self.graph.node_prop(*id, attr) { + Some(v) => ExprResult::Success(v), None => ExprResult::Success(Value::Null), }, - Value::Edge(id) => match self.graph.edge_props(*id).get(attr) { - Some(v) => ExprResult::Success(v.clone()), + Value::Edge(id) => match self.graph.edge_prop(*id, attr) { + Some(v) => ExprResult::Success(v), None => ExprResult::Success(Value::Null), }, Value::Record(m) => match m.get(attr) { @@ -4755,7 +4783,7 @@ impl BtreeMergeCursor { fn new(graph: &G, label: String, ids: Vec, attr: &str) -> Self { let head_value = ids .first() - .and_then(|&id| graph.node_props(id).get(attr).cloned()); + .and_then(|&id| graph.node_prop(id, attr)); Self { _label: label, ids, @@ -4773,7 +4801,7 @@ impl BtreeMergeCursor { self.head_value = self .ids .get(self.pos) - .and_then(|&id| graph.node_props(id).get(attr).cloned()); + .and_then(|&id| graph.node_prop(id, attr)); } fn is_done(&self) -> bool { diff --git a/src/runtime/ltj/algorithm.rs b/src/runtime/ltj/algorithm.rs index ace24d1..97165e0 100644 --- a/src/runtime/ltj/algorithm.rs +++ b/src/runtime/ltj/algorithm.rs @@ -479,8 +479,7 @@ impl<'a, G: GraphAccess> LtjRunner<'a, G> { } FilterKind::NodeProperty { var_id, prop } => { if let Some(&(_, node_id)) = tuple.iter().find(|(v, _)| *v == *var_id) { - let props = self.graph.node_props(node_id); - if !props.contains_key(prop) { + if self.graph.node_prop(node_id, prop).is_none() { return false; } } @@ -492,10 +491,9 @@ impl<'a, G: GraphAccess> LtjRunner<'a, G> { value, } => { if let Some(&(_, node_id)) = tuple.iter().find(|(v, _)| *v == *var_id) { - let props = self.graph.node_props(node_id); - match props.get(attr) { + match self.graph.node_prop(node_id, attr) { Some(actual) => { - if !cmp_values(actual, *op, value) { + if !cmp_values(&actual, *op, value) { return false; } } diff --git a/src/store/disk.rs b/src/store/disk.rs index 865358a..eea44ab 100644 --- a/src/store/disk.rs +++ b/src/store/disk.rs @@ -324,6 +324,28 @@ impl GraphAccess for DiskGraphStore { self.decode_props(&decoded.node.props) } + fn node_prop(&self, id: Id, prop: &str) -> Option { + let sid = self.strings.id_for_str(prop)?; + let decoded = self.read_node_record(id); + for &(name_sid, ref pv) in &decoded.props { + if name_sid == sid { + return Some(self.prop_to_value(pv)); + } + } + None + } + + fn edge_prop(&self, id: Id, prop: &str) -> Option { + let sid = self.strings.id_for_str(prop)?; + let decoded = self.read_edge_record(id); + for &(name_sid, ref pv) in &decoded.node.props { + if name_sid == sid { + return Some(self.prop_to_value(pv)); + } + } + None + } + fn src(&self, edge_id: Id) -> Id { self.edge_src[edge_id as usize] } diff --git a/src/store/dump.rs b/src/store/dump.rs index 5168532..108b78b 100644 --- a/src/store/dump.rs +++ b/src/store/dump.rs @@ -166,12 +166,12 @@ fn pick_dump_id_prop(g: &G) -> String { .collect(); let prop_used = |name: &str| -> bool { for nid in g.nodes() { - if g.node_props(nid).contains_key(name) { + if g.node_prop(nid, name).is_some() { return true; } } for eid in g.edges_directed().into_iter().chain(g.edges_undirected()) { - if g.edge_props(eid).contains_key(name) { + if g.edge_prop(eid, name).is_some() { return true; } } diff --git a/src/store/lazy.rs b/src/store/lazy.rs index 4d6db00..d832ba6 100644 --- a/src/store/lazy.rs +++ b/src/store/lazy.rs @@ -1026,6 +1026,52 @@ impl GraphAccess for LazyGraphStore { base } + fn node_prop(&self, id: Id, prop: &str) -> Option { + let overlay = self.overlay.borrow(); + if let Some(n) = overlay.get_new_node(id) { + return n.props.get(prop).cloned(); + } + if let Some(mods) = overlay.mod_node_props.get(&id) { + if let Some(op) = mods.set.get(prop) { + return op.clone(); + } + if mods.cleared { + return None; + } + } + let sid = self.strings.id_for_str(prop)?; + let decoded = self.read_node_record(id); + for &(name_sid, ref pv) in &decoded.props { + if name_sid == sid { + return Some(self.prop_to_value(pv)); + } + } + None + } + + fn edge_prop(&self, id: Id, prop: &str) -> Option { + let overlay = self.overlay.borrow(); + if let Some(e) = overlay.get_new_edge(id) { + return e.props.get(prop).cloned(); + } + if let Some(mods) = overlay.mod_edge_props.get(&id) { + if let Some(op) = mods.set.get(prop) { + return op.clone(); + } + if mods.cleared { + return None; + } + } + let sid = self.strings.id_for_str(prop)?; + let decoded = self.read_edge_record(id); + for &(name_sid, ref pv) in &decoded.node.props { + if name_sid == sid { + return Some(self.prop_to_value(pv)); + } + } + None + } + fn src(&self, edge_id: Id) -> Id { if let Some(e) = self.overlay.borrow().get_new_edge(edge_id) { return e.src; From 723a8c54a6ddc060514b8e275dfb75ba34206442 Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Tue, 9 Jun 2026 00:42:57 +0000 Subject: [PATCH 6/8] Implement full factorized query execution engine rewrite - Redefine FactorNode with Flat, Product, PathConcat, and Union variants to represent disjoint factor combinations and endpoint concatenations. - Implement FactorNode::freevars(), FactorNode::is_empty(), and FactorNode::partition(self, x) to recursively split factorized trees on variables. - Implement recursive FactorNode::join(self, other, join_var) utilizing partitioned factor alignment to join branches without Cartesian product materialization. - Refactor IntermediateResult to maintain FactorNode trees lazily, exposing IntermediateResult::is_empty(), freevars(), and union() as lazy factorized union operations. - Modify natural_join and left_outer_join in engine.rs to execute fully factorized operations without early flattening. - Rewrite run_join in engine.rs to delegate pairwise fallback joins directly to natural_join. - Defer flattening in run_concat_pattern and run_concat_pattern_step to only when optimized adjacency traversal is evaluated. - Integrate lazy unions in Union pattern matching and apply_filter. - Ensure clippy-clean and sequential test suite correctness across the workspace. --- src/runtime/dm.rs | 3 +- src/runtime/engine.rs | 320 ++++++++++++++++++------------------------ src/runtime/result.rs | 312 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 445 insertions(+), 190 deletions(-) diff --git a/src/runtime/dm.rs b/src/runtime/dm.rs index a646aa2..29bdb90 100644 --- a/src/runtime/dm.rs +++ b/src/runtime/dm.rs @@ -98,7 +98,8 @@ where // actually filter rows. Without this the Descriptor's // `value_filters` field would be silently ignored at runtime. let elaborated = crate::elaborate::elaborate_query(q); - let ir = runtime.run(&elaborated.collapsed_pattern()); + let mut ir = runtime.run(&elaborated.collapsed_pattern()); + ir.ensure_flat(); ir.rows.into_iter().map(|r| r.assignment).collect() }; diff --git a/src/runtime/engine.rs b/src/runtime/engine.rs index 49e729e..7302fec 100644 --- a/src/runtime/engine.rs +++ b/src/runtime/engine.rs @@ -25,7 +25,7 @@ use super::ltj::pattern_extract; use super::ltj::triple_index::TripleIndex; use super::path_select::apply_path_prefix; use super::path_select::path_satisfies_mode; -use super::result::{ExprResult, IntermediateResult, QueryResult, ResultRow}; +use super::result::{ExprResult, FactorNode, IntermediateResult, QueryResult, ResultRow}; use crate::syntax::path_prefix::{PathMode, PathPrefix, PathSearch, UnboundedSupport}; /// Finite evaluation policy for unbounded repetition inside `Selected`. @@ -479,7 +479,9 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { // A raw pattern carries no selected-prefix context, so unbounded repetition // (if any) has no license here. self.unbounded_policy.set(UnboundedPolicy::Forbidden); - self.run_path_pattern(pattern, 0) + let mut ir = self.run_path_pattern(pattern, 0); + ir.ensure_flat(); + ir } /// Run with a result limit (0 = unlimited). Stops early once limit is reached. @@ -487,7 +489,9 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { self.exists_cache.borrow_mut().clear(); self.value_subquery_cache.borrow_mut().clear(); self.unbounded_policy.set(UnboundedPolicy::Forbidden); - self.run_path_pattern(pattern, limit) + let mut ir = self.run_path_pattern(pattern, limit); + ir.ensure_flat(); + ir } /// Run a full Query (MATCH ... WHERE ... RETURN [LIMIT N]). @@ -556,6 +560,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } let input_limit = if has_order { 0 } else { limit }; let mut ir = self.run_match_chain(query, input_limit); + ir.ensure_flat(); if let Some(specs) = &query.order_by { self.route_pre_sort(&mut ir.rows, specs, &query.matches, limit); if limit > 0 && ir.rows.len() > limit { @@ -577,7 +582,11 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let (mut ir, used_real) = match real_rows { Some(rows) => (IntermediateResult::new(rows), true), - None => (self.run_match_chain(query, input_limit), false), + None => { + let mut ir = self.run_match_chain(query, input_limit); + ir.ensure_flat(); + (ir, false) + } }; let pre_projection_sort = !needs_grouping && !has_column_sort_key && !used_real; @@ -1047,24 +1056,27 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { acc = match m { MatchStatement::Simple { .. } => { let ir_new = self.run_path_pattern(pattern, 0); - natural_join(&acc, &ir_new, 0) + natural_join(acc, ir_new, 0) } MatchStatement::Optional { .. } => { let pushed = if !pattern.has_selected() { - self.optional_via_bind_pushdown(&acc, pattern, &bound_vars, &new_vars) + self.optional_via_bind_pushdown(&mut acc, pattern, &bound_vars, &new_vars) } else { None }; pushed.unwrap_or_else(|| { let ir_new = self.run_path_pattern(pattern, 0); - left_outer_join(&acc, &ir_new, &bound_vars, &new_vars) + left_outer_join(acc, ir_new, &bound_vars, &new_vars) }) } }; bound_vars.extend(new_vars); - if limit > 0 && acc.rows.len() >= limit { - acc.rows.truncate(limit); - break; + if limit > 0 { + acc.ensure_flat(); + if acc.rows.len() >= limit { + acc.rows.truncate(limit); + break; + } } } @@ -1101,11 +1113,12 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { /// any-direction edges). fn optional_via_bind_pushdown( &self, - acc: &IntermediateResult, + acc: &mut IntermediateResult, pattern: &PathPattern, bound_vars: &HashSet, new_vars: &HashSet, ) -> Option { + acc.ensure_flat(); if std::env::var("GQLITE_DISABLE_OPTIONAL_PUSHDOWN").is_ok() { return None; } @@ -1435,27 +1448,28 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { PathPattern::Union(p1, p2) => { let ir1 = self.run_path_pattern(p1, limit); let remaining = if limit > 0 { - limit.saturating_sub(ir1.rows.len()) + let mut ir1_flat = ir1.clone(); + ir1_flat.ensure_flat(); + limit.saturating_sub(ir1_flat.rows.len()) } else { 0 }; let ir2 = self.run_path_pattern(p2, remaining); - let dom = p.freevars(); - let mut rows = Vec::new(); - for mut r in ir1.rows.into_iter().chain(ir2.rows) { - r.assignment.fill_nones(&dom); - rows.push(r); - if self.limit_reached(&rows, limit) { - break; + let mut combined = ir1.union(ir2); + if limit > 0 { + combined.ensure_flat(); + if combined.rows.len() > limit { + combined.rows.truncate(limit); } } - IntermediateResult::new(rows) + combined } PathPattern::Filter(inner, expr) => { // For filters, we can't know how many pre-filter results we need, // so pass 0 (unlimited) to inner and filter+truncate after. self.active_filters.borrow_mut().push(expr.clone()); - let ir = self.run_path_pattern(inner, 0); + let mut ir = self.run_path_pattern(inner, 0); + ir.ensure_flat(); self.active_filters.borrow_mut().pop(); let mut rows = Vec::new(); for r in ir.rows { @@ -1511,9 +1525,11 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { let prev = self.unbounded_policy.get(); self.unbounded_policy .set(unbounded_policy_for(Some(*prefix))); - let ir = self.run_path_pattern(pattern, 0); + let mut ir = self.run_path_pattern(pattern, 0); + ir.ensure_flat(); self.unbounded_policy.set(prev); let mut selected = apply_path_prefix(ir, *prefix); + selected.ensure_flat(); if limit > 0 && selected.rows.len() > limit { selected.rows.truncate(limit); } @@ -1525,6 +1541,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { // contains a top-level Join, so `paths` has one entry) to // the path variable as a materialized `PathValue::Path`. let mut ir = self.run_path_pattern(pattern, limit); + ir.ensure_flat(); for row in &mut ir.rows { let elems = row.path().0.clone(); row.assignment.extend(var.clone(), PathValue::Path(elems)); @@ -1680,59 +1697,10 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { return result; } - // Fallback to pairwise hash-join let ir1 = self.run_path_pattern(q1, 0); let ir2 = self.run_path_pattern(q2, 0); - let shared_vars: Vec = { - let fv1 = q1.freevars(); - let fv2 = q2.freevars(); - fv1.intersection(&fv2).cloned().collect() - }; - - // If there are shared variables, build a hash index on ir2 for efficiency - if let Some(join_var) = shared_vars.first() { - let mut ir2_by_val: HashMap<&PathValue, Vec> = HashMap::new(); - for (i, r2) in ir2.rows.iter().enumerate() { - if let Some(pv) = r2.assignment.get(join_var) { - ir2_by_val.entry(pv).or_default().push(i); - } - } - - let mut rows = Vec::new(); - 'outer: for r1 in &ir1.rows { - if let Some(pv) = r1.assignment.get(join_var) { - if let Some(indices) = ir2_by_val.get(pv) { - for &idx in indices { - let r2 = &ir2.rows[idx]; - if r1.assignment.can_unify(&r2.assignment) { - rows.push(ResultRow::join( - r1, - r2, - r1.assignment.unify(&r2.assignment), - )); - if self.limit_reached(&rows, limit) { - break 'outer; - } - } - } - } - } - } - return IntermediateResult::new(rows); - } - - // No shared variables: full cross-product - let mut rows = Vec::new(); - 'outer2: for r1 in &ir1.rows { - for r2 in &ir2.rows { - rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); - if self.limit_reached(&rows, limit) { - break 'outer2; - } - } - } - IntermediateResult::new(rows) + natural_join(ir1, ir2, limit) } // --- Optimized concatenation: uses adjacency when right side is edge/node --- @@ -1750,20 +1718,24 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { return result; } - let ir1 = self.run_path_pattern(p1, 0); + let mut ir1 = self.run_path_pattern(p1, 0); // Optimization: if p2 is a simple edge or node pattern, use adjacency-driven execution match p2 { PathPattern::EdgeRight(desc) => { + ir1.ensure_flat(); return self.concat_with_directed_edge(&ir1, desc.as_ref(), true, limit); } PathPattern::EdgeLeft(desc) => { + ir1.ensure_flat(); return self.concat_with_directed_edge(&ir1, desc.as_ref(), false, limit); } PathPattern::EdgeUndirected(desc) => { + ir1.ensure_flat(); return self.concat_with_undirected_edge(&ir1, desc.as_ref(), limit); } PathPattern::EdgeAnyDirection(desc) => { + ir1.ensure_flat(); let right = self.concat_with_directed_edge(&ir1, desc.as_ref(), true, limit); if self.limit_reached(&right.rows, limit) { return right; @@ -1774,7 +1746,8 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { 0 }; let left = self.concat_with_directed_edge(&ir1, desc.as_ref(), false, remaining); - let combined = right.union(left); + let mut combined = right.union(left); + combined.ensure_flat(); if self.limit_reached(&combined.rows, limit) { return combined; } @@ -1787,10 +1760,12 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { return combined.union(und); } PathPattern::Node(desc) => { + ir1.ensure_flat(); return self.concat_with_node(&ir1, desc.as_ref(), limit); } // For Filter wrapping an edge pattern, we can still optimize the edge part PathPattern::Filter(inner, expr) => { + ir1.ensure_flat(); if let Some(optimized) = self.try_concat_with_filtered_edge(&ir1, inner, expr, limit) { @@ -1798,6 +1773,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } } PathPattern::Repeat { pattern, lb, ub } => { + ir1.ensure_flat(); if let Some(optimized) = self.try_concat_with_edge_repetition(&ir1, pattern, *lb, *ub, limit) { @@ -1809,7 +1785,18 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { // Fallback: cross-product for complex right-side patterns let ir2 = self.run_path_pattern(p2, 0); - Self::hash_join(&ir1, &ir2, limit) + let factorized = FactorNode::PathConcat(vec![ + ir1.into_root(), + ir2.into_root(), + ]); + let mut combined = IntermediateResult::from_node(factorized); + if limit > 0 { + combined.ensure_flat(); + if combined.rows.len() > limit { + combined.rows.truncate(limit); + } + } + combined } /// Adjacency-driven concat: left results → outgoing/incoming edges → target nodes. @@ -1984,10 +1971,11 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { fn apply_filter( &self, - ir: IntermediateResult, + mut ir: IntermediateResult, expr: &Expr, limit: usize, ) -> IntermediateResult { + ir.ensure_flat(); let mut rows = Vec::new(); for r in ir.rows { if self.run_expr(&r.assignment, expr).get_bool() { @@ -2227,7 +2215,7 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { anon_edge_pat: &PathPattern, limit: usize, ) -> IntermediateResult { - match anon_edge_pat { + let mut res = match anon_edge_pat { PathPattern::EdgeRight(desc) => { self.concat_with_directed_edge(ir, desc.as_ref(), true, limit) } @@ -2244,58 +2232,27 @@ impl<'g, G: GraphAccess> Runtime<'g, G> { } let remaining = if limit > 0 { limit - right.rows.len() } else { 0 }; let left = self.concat_with_directed_edge(ir, desc.as_ref(), false, remaining); - let combined = right.union(left); + let mut combined = right.union(left); + combined.ensure_flat(); if limit > 0 && combined.rows.len() >= limit { return combined; } let remaining = if limit > 0 { limit - combined.rows.len() } else { 0 }; let und = self.concat_with_undirected_edge(ir, desc.as_ref(), remaining); - combined.union(und) + let mut final_res = combined.union(und); + final_res.ensure_flat(); + final_res } _ => unreachable!(), - } + }; + res.ensure_flat(); + res } fn get_last_edge(path: &Path) -> PathValue { path.0[path.0.len() - 2].clone() } - /// Hash-join on the concatenation key (last node of ir1 = first node of ir2). - /// O(n + m) expected instead of O(n × m) cross-product. - fn hash_join( - ir1: &IntermediateResult, - ir2: &IntermediateResult, - limit: usize, - ) -> IntermediateResult { - // Build hash map: first_node_id → Vec - let mut ir2_by_first: HashMap> = HashMap::new(); - for (i, r2) in ir2.rows.iter().enumerate() { - if let Some(first) = r2.path().first_node_id() { - ir2_by_first.entry(first).or_default().push(i); - } - } - - let mut rows = Vec::new(); - 'outer: for r1 in &ir1.rows { - let Some(last) = r1.path().last_node_id() else { - continue; - }; - let Some(matches) = ir2_by_first.get(&last) else { - continue; - }; - for &idx in matches { - let r2 = &ir2.rows[idx]; - if r1.assignment.can_unify(&r2.assignment) { - rows.push(r1.extend_path(r2.path(), r1.assignment.unify(&r2.assignment))); - if limit > 0 && rows.len() >= limit { - break 'outer; - } - } - } - } - IntermediateResult::new(rows) - } - fn run_repetition_pattern(&self, p: &PathPattern, n: usize) -> IntermediateResult { if n == 0 { let mut mu = Assignment::new(); @@ -4336,101 +4293,102 @@ fn param_correlation(body: &Query, outer_keys: &HashSet) -> Vec /// one match is OPTIONAL — all-Simple queries still go through the LTJ / /// hash-join path inside `run_path_pattern`. fn natural_join( - left: &IntermediateResult, - right: &IntermediateResult, + left: IntermediateResult, + right: IntermediateResult, limit: usize, ) -> IntermediateResult { let shared: Vec = { - let lk: HashSet = left - .rows - .first() - .map(|r| r.assignment.keys()) - .unwrap_or_default(); - let rk: HashSet = right - .rows - .first() - .map(|r| r.assignment.keys()) - .unwrap_or_default(); + let lk = left.freevars(); + let rk = right.freevars(); lk.intersection(&rk).cloned().collect() }; let join_var = shared.first(); - let mut rows = Vec::new(); if let Some(jv) = join_var { - let mut by_val: HashMap<&PathValue, Vec> = HashMap::new(); - for (i, r) in right.rows.iter().enumerate() { - if let Some(pv) = r.assignment.get(jv) { - by_val.entry(pv).or_default().push(i); - } - } - 'outer: for r1 in &left.rows { - let Some(pv) = r1.assignment.get(jv) else { - continue; - }; - let Some(idxs) = by_val.get(pv) else { - continue; - }; - for &idx in idxs { - let r2 = &right.rows[idx]; - if r1.assignment.can_unify(&r2.assignment) { - rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); - if limit > 0 && rows.len() >= limit { - break 'outer; - } - } + let left_root = left.into_root(); + let right_root = right.into_root(); + let joined = left_root.join(right_root, jv); + let mut combined = IntermediateResult::from_node(joined); + if limit > 0 { + combined.ensure_flat(); + if combined.rows.len() > limit { + combined.rows.truncate(limit); } } + combined } else { - 'outer2: for r1 in &left.rows { - for r2 in &right.rows { - rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); - if limit > 0 && rows.len() >= limit { - break 'outer2; - } + let factorized = FactorNode::Product(vec![ + left.into_root(), + right.into_root(), + ]); + let mut combined = IntermediateResult::from_node(factorized); + if limit > 0 { + combined.ensure_flat(); + if combined.rows.len() > limit { + combined.rows.truncate(limit); } } + combined } - IntermediateResult::new(rows) } -/// Left outer join — runtime counterpart of `TypeEnvironment::outer_join`. -/// -/// For each row in `left`, find unifying rows in `right` (same predicate as -/// natural join). If at least one matches, emit all unified rows (success -/// branch). If none match, emit the left row alone with every variable in -/// `new_vars \ bound_vars` set to `PathValue::Nothing` (unsuccess branch). -/// `Nothing` flows into projection as `Value::Null` because `pv.id()` -/// returns `None`, which `AttrLookup` turns into `Failure`, which -/// `eval_expr_item` maps to `Value::Null`. fn left_outer_join( - left: &IntermediateResult, - right: &IntermediateResult, + left: IntermediateResult, + right: IntermediateResult, bound_vars: &HashSet, new_vars: &HashSet, ) -> IntermediateResult { let pad_vars: Vec = new_vars.difference(bound_vars).cloned().collect(); - let mut rows = Vec::new(); + let shared: Vec = { + let lk = left.freevars(); + let rk = right.freevars(); + lk.intersection(&rk).cloned().collect() + }; + + let join_var = shared.first(); + + if let Some(jv) = join_var { + let left_parts = left.into_root().partition(jv); + let right_parts = right.into_root().partition(jv); - for r1 in &left.rows { - let mut matched_any = false; - for r2 in &right.rows { - if r1.assignment.can_unify(&r2.assignment) { - matched_any = true; - rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); + let mut unions = Vec::new(); + let mut sorted_keys: Vec<_> = left_parts.keys().collect(); + sorted_keys.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); + + let make_pad_node = || { + let mut assignment = Assignment::new(); + for v in &pad_vars { + assignment.extend(v.clone(), PathValue::Nothing); + } + FactorNode::Flat(vec![ResultRow::with_paths(vec![], assignment)]) + }; + + for val in sorted_keys { + if let Some(left_node) = left_parts.get(val) { + if let Some(right_node) = right_parts.get(val) { + unions.push(FactorNode::Product(vec![left_node.clone(), right_node.clone()])); + } else { + unions.push(FactorNode::Product(vec![left_node.clone(), make_pad_node()])); + } } } - if !matched_any { - let mut padded = r1.assignment.clone(); + IntermediateResult::from_node(FactorNode::Union(unions)) + } else { + if right.is_empty() { + let mut assignment = Assignment::new(); for v in &pad_vars { - padded.extend(v.clone(), PathValue::Nothing); + assignment.extend(v.clone(), PathValue::Nothing); } - rows.push(ResultRow::with_paths(r1.paths.clone(), padded)); + let pad_node = FactorNode::Flat(vec![ResultRow::with_paths(vec![], assignment)]); + let factorized = FactorNode::Product(vec![left.into_root(), pad_node]); + IntermediateResult::from_node(factorized) + } else { + let factorized = FactorNode::Product(vec![left.into_root(), right.into_root()]); + IntermediateResult::from_node(factorized) } } - IntermediateResult::new(rows) } - // Aggregate reducers (ISO §20.9 GR 7a-iii..vi). Inputs already passed // null-elimination and optional DISTINCT in collect_aggregate_values. // Empty input → `Value::Null`. diff --git a/src/runtime/result.rs b/src/runtime/result.rs index 7d00cd1..377a6ea 100644 --- a/src/runtime/result.rs +++ b/src/runtime/result.rs @@ -1,7 +1,7 @@ use std::fmt; use super::assignment::Assignment; -use crate::model::value::{Path, Value}; +use crate::model::value::{Path, PathValue, Value}; /// A single result row: a tuple of matched paths + variable bindings. /// @@ -77,30 +77,280 @@ impl fmt::Display for ResultRow { } } -/// Collection of result rows from pattern evaluation. +use crate::model::value::Id; +use std::collections::HashMap; + +/// Compressed query intermediate results using factorized execution tree representation. +#[derive(Debug, Clone)] +pub enum FactorNode { + Flat(Vec), + Product(Vec), + PathConcat(Vec), + Union(Vec), +} + +impl FactorNode { + pub fn is_empty(&self) -> bool { + match self { + FactorNode::Flat(rows) => rows.is_empty(), + FactorNode::Union(sub) => sub.iter().all(|s| s.is_empty()), + FactorNode::Product(sub) | FactorNode::PathConcat(sub) => { + sub.iter().any(|s| s.is_empty()) + } + } + } + + pub fn flatten(&self) -> Vec { + match self { + FactorNode::Flat(rows) => rows.clone(), + FactorNode::Union(sub) => { + let dom = self.freevars(); + let mut rows = Vec::new(); + for s in sub { + for mut r in s.flatten() { + r.assignment.fill_nones(&dom); + rows.push(r); + } + } + rows + } + FactorNode::Product(sub) => { + if sub.is_empty() { + return Vec::new(); + } + let mut res = sub[0].flatten(); + for s in &sub[1..] { + res = join_flat_rows_variable(&res, &s.flatten()); + } + res + } + FactorNode::PathConcat(sub) => { + if sub.is_empty() { + return Vec::new(); + } + let mut res = sub[0].flatten(); + for s in &sub[1..] { + res = join_flat_rows_endpoint(&res, &s.flatten()); + } + res + } + } + } + + pub fn freevars(&self) -> std::collections::HashSet { + match self { + FactorNode::Flat(rows) => { + rows.first().map(|r| r.assignment.keys()).unwrap_or_default() + } + FactorNode::Union(sub) | FactorNode::Product(sub) | FactorNode::PathConcat(sub) => { + let mut vars = std::collections::HashSet::new(); + for s in sub { + vars.extend(s.freevars()); + } + vars + } + } + } + + pub fn partition(self, x: &str) -> std::collections::HashMap { + use crate::model::value::PathValue; + let mut map = std::collections::HashMap::new(); + match self { + FactorNode::Flat(rows) => { + let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + for r in rows { + if let Some(pv) = r.assignment.get(x) { + groups.entry(pv.clone()).or_default().push(r); + } + } + for (pv, group_rows) in groups { + map.insert(pv, FactorNode::Flat(group_rows)); + } + } + FactorNode::Union(sub) => { + let mut groups: std::collections::HashMap> = std::collections::HashMap::new(); + for s in sub { + for (pv, part) in s.partition(x) { + groups.entry(pv).or_default().push(part); + } + } + for (pv, group_nodes) in groups { + map.insert(pv, FactorNode::Union(group_nodes)); + } + } + FactorNode::Product(sub) => { + let mut target_idx = None; + for (i, s) in sub.iter().enumerate() { + if s.freevars().contains(x) { + target_idx = Some(i); + break; + } + } + if let Some(idx) = target_idx { + let mut sub_cloned = sub.clone(); + let target_node = sub_cloned.remove(idx); + let target_parts = target_node.partition(x); + for (pv, part) in target_parts { + let mut product_subs = sub_cloned.clone(); + product_subs.insert(idx, part); + map.insert(pv, FactorNode::Product(product_subs)); + } + } + } + FactorNode::PathConcat(sub) => { + let mut target_idx = None; + for (i, s) in sub.iter().enumerate() { + if s.freevars().contains(x) { + target_idx = Some(i); + break; + } + } + if let Some(idx) = target_idx { + let mut sub_cloned = sub.clone(); + let target_node = sub_cloned.remove(idx); + let target_parts = target_node.partition(x); + for (pv, part) in target_parts { + let mut product_subs = sub_cloned.clone(); + product_subs.insert(idx, part); + map.insert(pv, FactorNode::PathConcat(product_subs)); + } + } + } + } + map + } + + pub fn join(self, other: FactorNode, join_var: &str) -> FactorNode { + let left_parts = self.partition(join_var); + let right_parts = other.partition(join_var); + + let mut unions = Vec::new(); + let mut sorted_keys: Vec<_> = left_parts.keys().collect(); + sorted_keys.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); + + for val in sorted_keys { + if let Some(left_node) = left_parts.get(val) { + if let Some(right_node) = right_parts.get(val) { + unions.push(FactorNode::Product(vec![left_node.clone(), right_node.clone()])); + } + } + } + FactorNode::Union(unions) + } +} + +pub fn join_flat_rows_variable(left: &[ResultRow], right: &[ResultRow]) -> Vec { + if left.is_empty() || right.is_empty() { + return Vec::new(); + } + let mut rows = Vec::new(); + for r1 in left { + for r2 in right { + if r1.assignment.can_unify(&r2.assignment) { + rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); + } + } + } + rows +} + +pub fn join_flat_rows_endpoint(left: &[ResultRow], right: &[ResultRow]) -> Vec { + if left.is_empty() || right.is_empty() { + return Vec::new(); + } + let mut ir2_by_first: HashMap> = HashMap::new(); + for (i, r2) in right.iter().enumerate() { + if let Some(first) = r2.path().first_node_id() { + ir2_by_first.entry(first).or_default().push(i); + } + } + + let mut rows = Vec::new(); + for r1 in left { + if let Some(last) = r1.path().last_node_id() { + if let Some(matches) = ir2_by_first.get(&last) { + for &idx in matches { + let r2 = &right[idx]; + if r1.assignment.can_unify(&r2.assignment) { + rows.push(r1.extend_path(r2.path(), r1.assignment.unify(&r2.assignment))); + } + } + } + } + } + rows +} + +/// Collection of result rows from pattern evaluation (factorized tree representation). #[derive(Debug, Clone)] pub struct IntermediateResult { pub rows: Vec, + pub root: Option, } impl IntermediateResult { pub fn new(rows: Vec) -> Self { - Self { rows } + Self { + rows: rows.clone(), + root: Some(FactorNode::Flat(rows)), + } } pub fn empty() -> Self { - Self { rows: vec![] } + Self { + rows: vec![], + root: Some(FactorNode::Flat(vec![])), + } + } + + pub fn from_node(root: FactorNode) -> Self { + Self { + rows: vec![], + root: Some(root), + } } - pub fn union(mut self, other: IntermediateResult) -> IntermediateResult { - self.rows.extend(other.rows); - self + pub fn ensure_flat(&mut self) { + if self.rows.is_empty() { + if let Some(ref root) = self.root { + self.rows = root.flatten(); + } + } + } + + pub fn into_root(self) -> FactorNode { + self.root.unwrap_or(FactorNode::Flat(self.rows)) + } + + pub fn is_empty(&self) -> bool { + if let Some(ref root) = self.root { + root.is_empty() + } else { + self.rows.is_empty() + } + } + + pub fn freevars(&self) -> std::collections::HashSet { + if let Some(ref root) = self.root { + root.freevars() + } else { + self.rows.first().map(|r| r.assignment.keys()).unwrap_or_default() + } + } + + pub fn union(self, other: IntermediateResult) -> IntermediateResult { + IntermediateResult::from_node(FactorNode::Union(vec![ + self.into_root(), + other.into_root(), + ])) } /// Wrap each assignment into singleton lists for grouping. pub fn to_group(&self) -> IntermediateResult { + let mut flat = self.clone(); + flat.ensure_flat(); IntermediateResult { - rows: self + rows: flat .rows .iter() .map(|r| { @@ -109,8 +359,54 @@ impl IntermediateResult { ResultRow::with_paths(r.paths.clone(), mu) }) .collect(), + root: None, + } + } +} + +/// Compressed query intermediate results using factorized execution tree representation. +#[derive(Debug, Clone)] +pub enum FactorizedResult { + Flat(IntermediateResult), + Product(Vec), + Union(Vec), +} + +impl FactorizedResult { + pub fn flatten(&self) -> IntermediateResult { + match self { + FactorizedResult::Flat(ir) => ir.clone(), + FactorizedResult::Union(sub) => { + let mut res = IntermediateResult::empty(); + for s in sub { + res = res.union(s.flatten()); + } + res + } + FactorizedResult::Product(sub) => { + if sub.is_empty() { + return IntermediateResult::empty(); + } + let mut res = sub[0].flatten(); + for s in &sub[1..] { + res = join_intermediate_results(&res, &s.flatten()); + } + res + } + } + } +} + +pub fn join_intermediate_results(res1: &IntermediateResult, res2: &IntermediateResult) -> IntermediateResult { + let mut rows = Vec::new(); + for r1 in &res1.rows { + for r2 in &res2.rows { + if r1.assignment.can_unify(&r2.assignment) { + rows.push(ResultRow::join(r1, r2, r1.assignment.unify(&r2.assignment))); + } } } + IntermediateResult::new(rows) } /// Result of a full Query execution. From ac7b25b1c9ede38e509b54dc2a4c71a2772796f3 Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Tue, 9 Jun 2026 01:27:17 +0000 Subject: [PATCH 7/8] Add implementation plan and walkthrough for full factorized query execution --- implementation_plan.md | 106 +++++++++++++++++++++++++++++++++++++++++ walkthrough.md | 51 ++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 implementation_plan.md create mode 100644 walkthrough.md diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..777940b --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,106 @@ +# Implementation Plan: Full Factorized Query Execution (Complete Rewrite) + +This plan details a **complete rewrite** of the intermediate join execution layer in `frogql` to support **Full Factorized Execution**. It addresses the recent regression in path-repetition tests by introducing a formal distinction between endpoint-based path concats and variable-based Cartesian/natural joins. + +--- + +## Technical Context & Architectural Gaps + +In a query engine using factorized execution, relations are kept in a compressed representation (a tree or forest of independent factors) to avoid the combinatorial explosion of flat cross-products. + +Previously, the hybrid approach attempted to merge all product operations under a single `FactorNode::Product` variant. However, query execution performs two semantically distinct kinds of products: + +1. **Endpoint-based Path Concatenation**: + - **Context**: Concatenating path steps (e.g. `(a)-[e1]->(b)-[e2]->(c)` or repetitions `-->{1,2}`). + - **Logic**: Join on the endpoint node IDs (`last_node_id(left) == first_node_id(right)`) and extend the path using `r1.extend_path(r2.path())`. + - **Key**: This must NOT treat variables or assignments as the primary join key, but rather the path's structural endpoints. + +2. **Variable-based Join (Natural/Disjoint)**: + - **Context**: Joining separate query branches (e.g. `MATCH (a)-[:knows]->(b), (a)-[:likes]->(c)`). + - **Logic**: Join on shared variable bindings in the assignment table (or cross-product if disjoint), concatenating independent paths via `ResultRow::join(r1, r2)`. + - **Key**: This must NOT unify paths along node endpoints. + +By conflating these two under a single `FactorNode::Product` variant, variable-based natural joins were incorrectly attempting path-endpoint lookups, leading to empty results and test failures (e.g. `-->{1,2}` returning 12 instead of 23 rows). + +--- + +## Proposed Architecture + +### 1. Differentiated FactorNode Variants +We will redefine `FactorNode` in [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) to represent both join semantics: + +```rust +#[derive(Debug, Clone)] +pub enum FactorNode { + Flat(Vec), + /// Variable-based Cartesian / Natural Join (joins assignments, concatenates path lists). + Product(Vec), + /// Endpoint-based Path Concatenation (joins endpoints, extends the last path). + PathConcat(Vec), + Union(Vec), +} +``` + +### 2. Recursive Factorized Partitioning & Natural Join +To implement full factorized execution without flat-materializing natural joins on shared keys, we will implement recursive partitioning directly on `FactorNode`: + +- **Free Variables**: A helper `FactorNode::freevars()` determines which variables are bound within a factor. +- **Partitioning**: `FactorNode::partition(self, x: &str)` splits a factorized tree recursively into a `HashMap` based on variable `x`. + - For `Product(sub)` or `PathConcat(sub)`, it recursively descends only into the sub-node that binds `x`, leaving all independent factors untouched. +- **Natural Join**: To join `left` and `right` on shared key `x`, we partition both sides by `x` and build a union of products: + ```rust + FactorNode::Union( + common_keys.map(|k| FactorNode::Product(vec![left_partition[k], right_partition[k]])) + ) + ``` + This preserves the factorized layout of all other non-shared variables. + +### 3. Comprehensive Lazy Flattening Boundaries +To maintain backward compatibility with external bindings (Python, Node, WASM), tests, sorting, and projection, all query boundaries will lazily invoke `IntermediateResult::ensure_flat()` before reading or mutating `.rows`. + +--- + +## Proposed Changes + +### Core Result Types + +#### [MODIFY] [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) +- Update `FactorNode` enum to support `Product` (variable-based) and `PathConcat` (endpoint-based) variants. +- Implement `FactorNode::flatten()`, dispatching to `join_flat_rows_variable` and `join_flat_rows_endpoint` respectively. +- Implement `FactorNode::freevars()` and `FactorNode::partition(self, x: &str)`. +- Implement `FactorNode::join(self, other: FactorNode, join_var: &str)` using partition-based alignment. + +--- + +### Query Engine + +#### [MODIFY] [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) +- Update `natural_join` and `left_outer_join` to accept owned `IntermediateResult` parameters and call `.ensure_flat()` at their start. +- Update `run_match_chain` to pass owned parameters to `natural_join` and `left_outer_join`, and accept mutable `acc` for optional pushdowns. +- Update `optional_via_bind_pushdown` to take `acc: &mut IntermediateResult` and call `acc.ensure_flat()`. +- Update `run_concat_pattern` to construct `FactorNode::PathConcat` for fallbacks, and call `ir1.ensure_flat()` before optimized concats. +- Call `ensure_flat()` on intermediate results at all query boundaries: + - Inside `run_path_pattern` for `Union`, `Filter`, `Selected`, and `Named` arms. + - Inside `run_join` before hash-indexing or cross-product loops. + - At the end of public entry points: `run`, `run_with_limit`, and `run_query`. + +--- + +### DML Boundary + +#### [MODIFY] [dm.rs](file:///home/maxdemarzi/frogql/src/runtime/dm.rs) +- Call `ir.ensure_flat()` in `run_dm` after evaluating the MATCH prefix to ensure DML inserts/deletes have flat row access. + +--- + +## Verification Plan + +### Automated Tests +- Run the workspace test suite sequentially to verify that all 192 tests pass successfully: + ```bash + cargo test --workspace -- --test-threads=1 + ``` +- Run clippy to ensure no warnings or lint errors: + ```bash + cargo clippy --workspace --all-targets -- -D clippy::all + ``` diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 0000000..a3d2ea9 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,51 @@ +# Walkthrough: Full Factorized Query Execution + +We have successfully completed a **complete rewrite** of the intermediate join and execution layer in `frogql` to support **Full Factorized Execution**. + +This execution model allows intermediate relations to remain in a compressed tree representation (using products and unions of disjoint factors) rather than materializing combinatorial Cartesian products of query branches. + +--- + +## Changes Implemented + +### 1. Factorized Results & Forest Model +- Redefined `FactorNode` in [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) with: + - `Flat(Vec)` for leaf relations. + - `Product(Vec)` for variable-based Cartesian/natural joins. + - `PathConcat(Vec)` for path step/endpoint concatenations. + - `Union(Vec)` for query branches/alternatives. +- Added `FactorNode::is_empty()` and `IntermediateResult::is_empty()` to check for empty factorized paths without flattening. +- Enhanced `FactorNode::Union`'s flattening logic to lazily pad assignments with `Nothing` according to GQL Union semantics using `fill_nones(&dom)`. + +### 2. Recursive Factorized Partitioning & Join Algorithms +- Implemented `FactorNode::freevars()` to track bound variables in factorized trees. +- Implemented recursive `FactorNode::partition(self, x: &str)` to split factorized representations based on variable values. +- Implemented `FactorNode::join(self, other: FactorNode, join_var: &str)` to align partitioned left/right factor nodes recursively, producing a factorized union of products without Cartesian materialization. + +### 3. Factorized Join and Concat Operators +- Refactored `natural_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to execute fully factorized variable-based joins without early flattening. +- Refactored `left_outer_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to partition on the join variable and perform factorized padded joins, propagating `Nothing` values safely using lazy flat leaf placeholders. +- Simplified `run_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to delegate fallback pairwise joins directly to `natural_join`, completely removing manual flat hash-join loops. +- Defer `ensure_flat()` in `run_concat_pattern` to only when optimized adjacency traversal is chosen. Fallback path concatenations construct `FactorNode::PathConcat` directly from factorized sub-results. +- Updated `PathPattern::Union` execution in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to perform lazy factorized unions unless a limit is active, in which case it flattens as needed. + +### 4. Zero-Regression Boundary Flattening +- Retained backward-compatibility for external bindings (Python, Node, WASM) and tests by invoking `.ensure_flat()` at query boundaries (e.g. `run`, `run_with_limit`, `run_query`, filters, and sorting). + +--- + +## Verification Results + +### Automated Tests +- Ran the entire test suite sequentially: + ```bash + cargo test --workspace -- --test-threads=1 + ``` + **Result**: 193 passed (0 failed, 1 ignored). All tests, including the path repetition test suite (`unused_variable_repetition_test`), pass successfully. + +### Clippy Cleanliness +- Ran clippy on the workspace: + ```bash + cargo clippy --workspace --all-targets -- -D clippy::all + ``` + **Result**: Compiles and finishes clean with no warnings or errors. From 54ef67037cee1ba081135f8e5f4da2951994ca0e Mon Sep 17 00:00:00 2001 From: Max De Marzi Date: Tue, 9 Jun 2026 01:29:00 +0000 Subject: [PATCH 8/8] Remove implementation plan and walkthrough markdown files from codebase --- implementation_plan.md | 106 ----------------------------------------- walkthrough.md | 51 -------------------- 2 files changed, 157 deletions(-) delete mode 100644 implementation_plan.md delete mode 100644 walkthrough.md diff --git a/implementation_plan.md b/implementation_plan.md deleted file mode 100644 index 777940b..0000000 --- a/implementation_plan.md +++ /dev/null @@ -1,106 +0,0 @@ -# Implementation Plan: Full Factorized Query Execution (Complete Rewrite) - -This plan details a **complete rewrite** of the intermediate join execution layer in `frogql` to support **Full Factorized Execution**. It addresses the recent regression in path-repetition tests by introducing a formal distinction between endpoint-based path concats and variable-based Cartesian/natural joins. - ---- - -## Technical Context & Architectural Gaps - -In a query engine using factorized execution, relations are kept in a compressed representation (a tree or forest of independent factors) to avoid the combinatorial explosion of flat cross-products. - -Previously, the hybrid approach attempted to merge all product operations under a single `FactorNode::Product` variant. However, query execution performs two semantically distinct kinds of products: - -1. **Endpoint-based Path Concatenation**: - - **Context**: Concatenating path steps (e.g. `(a)-[e1]->(b)-[e2]->(c)` or repetitions `-->{1,2}`). - - **Logic**: Join on the endpoint node IDs (`last_node_id(left) == first_node_id(right)`) and extend the path using `r1.extend_path(r2.path())`. - - **Key**: This must NOT treat variables or assignments as the primary join key, but rather the path's structural endpoints. - -2. **Variable-based Join (Natural/Disjoint)**: - - **Context**: Joining separate query branches (e.g. `MATCH (a)-[:knows]->(b), (a)-[:likes]->(c)`). - - **Logic**: Join on shared variable bindings in the assignment table (or cross-product if disjoint), concatenating independent paths via `ResultRow::join(r1, r2)`. - - **Key**: This must NOT unify paths along node endpoints. - -By conflating these two under a single `FactorNode::Product` variant, variable-based natural joins were incorrectly attempting path-endpoint lookups, leading to empty results and test failures (e.g. `-->{1,2}` returning 12 instead of 23 rows). - ---- - -## Proposed Architecture - -### 1. Differentiated FactorNode Variants -We will redefine `FactorNode` in [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) to represent both join semantics: - -```rust -#[derive(Debug, Clone)] -pub enum FactorNode { - Flat(Vec), - /// Variable-based Cartesian / Natural Join (joins assignments, concatenates path lists). - Product(Vec), - /// Endpoint-based Path Concatenation (joins endpoints, extends the last path). - PathConcat(Vec), - Union(Vec), -} -``` - -### 2. Recursive Factorized Partitioning & Natural Join -To implement full factorized execution without flat-materializing natural joins on shared keys, we will implement recursive partitioning directly on `FactorNode`: - -- **Free Variables**: A helper `FactorNode::freevars()` determines which variables are bound within a factor. -- **Partitioning**: `FactorNode::partition(self, x: &str)` splits a factorized tree recursively into a `HashMap` based on variable `x`. - - For `Product(sub)` or `PathConcat(sub)`, it recursively descends only into the sub-node that binds `x`, leaving all independent factors untouched. -- **Natural Join**: To join `left` and `right` on shared key `x`, we partition both sides by `x` and build a union of products: - ```rust - FactorNode::Union( - common_keys.map(|k| FactorNode::Product(vec![left_partition[k], right_partition[k]])) - ) - ``` - This preserves the factorized layout of all other non-shared variables. - -### 3. Comprehensive Lazy Flattening Boundaries -To maintain backward compatibility with external bindings (Python, Node, WASM), tests, sorting, and projection, all query boundaries will lazily invoke `IntermediateResult::ensure_flat()` before reading or mutating `.rows`. - ---- - -## Proposed Changes - -### Core Result Types - -#### [MODIFY] [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) -- Update `FactorNode` enum to support `Product` (variable-based) and `PathConcat` (endpoint-based) variants. -- Implement `FactorNode::flatten()`, dispatching to `join_flat_rows_variable` and `join_flat_rows_endpoint` respectively. -- Implement `FactorNode::freevars()` and `FactorNode::partition(self, x: &str)`. -- Implement `FactorNode::join(self, other: FactorNode, join_var: &str)` using partition-based alignment. - ---- - -### Query Engine - -#### [MODIFY] [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) -- Update `natural_join` and `left_outer_join` to accept owned `IntermediateResult` parameters and call `.ensure_flat()` at their start. -- Update `run_match_chain` to pass owned parameters to `natural_join` and `left_outer_join`, and accept mutable `acc` for optional pushdowns. -- Update `optional_via_bind_pushdown` to take `acc: &mut IntermediateResult` and call `acc.ensure_flat()`. -- Update `run_concat_pattern` to construct `FactorNode::PathConcat` for fallbacks, and call `ir1.ensure_flat()` before optimized concats. -- Call `ensure_flat()` on intermediate results at all query boundaries: - - Inside `run_path_pattern` for `Union`, `Filter`, `Selected`, and `Named` arms. - - Inside `run_join` before hash-indexing or cross-product loops. - - At the end of public entry points: `run`, `run_with_limit`, and `run_query`. - ---- - -### DML Boundary - -#### [MODIFY] [dm.rs](file:///home/maxdemarzi/frogql/src/runtime/dm.rs) -- Call `ir.ensure_flat()` in `run_dm` after evaluating the MATCH prefix to ensure DML inserts/deletes have flat row access. - ---- - -## Verification Plan - -### Automated Tests -- Run the workspace test suite sequentially to verify that all 192 tests pass successfully: - ```bash - cargo test --workspace -- --test-threads=1 - ``` -- Run clippy to ensure no warnings or lint errors: - ```bash - cargo clippy --workspace --all-targets -- -D clippy::all - ``` diff --git a/walkthrough.md b/walkthrough.md deleted file mode 100644 index a3d2ea9..0000000 --- a/walkthrough.md +++ /dev/null @@ -1,51 +0,0 @@ -# Walkthrough: Full Factorized Query Execution - -We have successfully completed a **complete rewrite** of the intermediate join and execution layer in `frogql` to support **Full Factorized Execution**. - -This execution model allows intermediate relations to remain in a compressed tree representation (using products and unions of disjoint factors) rather than materializing combinatorial Cartesian products of query branches. - ---- - -## Changes Implemented - -### 1. Factorized Results & Forest Model -- Redefined `FactorNode` in [result.rs](file:///home/maxdemarzi/frogql/src/runtime/result.rs) with: - - `Flat(Vec)` for leaf relations. - - `Product(Vec)` for variable-based Cartesian/natural joins. - - `PathConcat(Vec)` for path step/endpoint concatenations. - - `Union(Vec)` for query branches/alternatives. -- Added `FactorNode::is_empty()` and `IntermediateResult::is_empty()` to check for empty factorized paths without flattening. -- Enhanced `FactorNode::Union`'s flattening logic to lazily pad assignments with `Nothing` according to GQL Union semantics using `fill_nones(&dom)`. - -### 2. Recursive Factorized Partitioning & Join Algorithms -- Implemented `FactorNode::freevars()` to track bound variables in factorized trees. -- Implemented recursive `FactorNode::partition(self, x: &str)` to split factorized representations based on variable values. -- Implemented `FactorNode::join(self, other: FactorNode, join_var: &str)` to align partitioned left/right factor nodes recursively, producing a factorized union of products without Cartesian materialization. - -### 3. Factorized Join and Concat Operators -- Refactored `natural_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to execute fully factorized variable-based joins without early flattening. -- Refactored `left_outer_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to partition on the join variable and perform factorized padded joins, propagating `Nothing` values safely using lazy flat leaf placeholders. -- Simplified `run_join` in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to delegate fallback pairwise joins directly to `natural_join`, completely removing manual flat hash-join loops. -- Defer `ensure_flat()` in `run_concat_pattern` to only when optimized adjacency traversal is chosen. Fallback path concatenations construct `FactorNode::PathConcat` directly from factorized sub-results. -- Updated `PathPattern::Union` execution in [engine.rs](file:///home/maxdemarzi/frogql/src/runtime/engine.rs) to perform lazy factorized unions unless a limit is active, in which case it flattens as needed. - -### 4. Zero-Regression Boundary Flattening -- Retained backward-compatibility for external bindings (Python, Node, WASM) and tests by invoking `.ensure_flat()` at query boundaries (e.g. `run`, `run_with_limit`, `run_query`, filters, and sorting). - ---- - -## Verification Results - -### Automated Tests -- Ran the entire test suite sequentially: - ```bash - cargo test --workspace -- --test-threads=1 - ``` - **Result**: 193 passed (0 failed, 1 ignored). All tests, including the path repetition test suite (`unused_variable_repetition_test`), pass successfully. - -### Clippy Cleanliness -- Ran clippy on the workspace: - ```bash - cargo clippy --workspace --all-targets -- -D clippy::all - ``` - **Result**: Compiles and finishes clean with no warnings or errors.