Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions examples/iql/04_session/11_session_rule_overwrite.iql.out
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,29 @@ Session rules (1):
└───┴───┘
3 rows
> connected(X, Z) <- edge(X, Y), edge(Y, Z)
┌──────┬──────┬──────┐
│ col0 │ col1 │ col2 │
├──────┼──────┼──────┤
│ 2 │ 3 │ 1 │
│ 3 │ 4 │ 2 │
└──────┴──────┴──────┘
2 rows
┌───┬───┐
│ X │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 2 │ 3 │
│ 2 │ 4 │
│ 3 │ 4 │
└───┴───┘
5 rows
(session: 2 rule(s))
> .session
Session rules (2):
1. connected(X, Y) <- edge(X, Y)
2. connected(X, Z) <- edge(X, Y), edge(Y, Z)
> ?connected(1, X)
No results.
┌───┬───┐
│ 1 │ X │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
└───┴───┘
2 rows
> .kg use default
Switched to knowledge graph: default
(Cleared 2 session rule(s), 0 session fact(s))
Expand Down
14 changes: 12 additions & 2 deletions examples/iql/09_recursion/08_mutual_recursion.iql.out
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,19 @@ Rule 'is_odd' registered.
> +is_odd(N) <- succ(M, N), is_even(M)
Rule 'is_odd' registered.
> ?is_even(X)
No results.
┌───┐
│ X │
├───┤
│ 0 │
└───┘
1 rows
> ?is_odd(X)
No results.
┌───┐
│ X │
├───┤
│ 1 │
└───┘
1 rows
> .kg use default
Switched to knowledge graph: default
> .kg drop test_mutual_recursion_n09t08
Expand Down
22 changes: 19 additions & 3 deletions examples/iql/09_recursion/09_three_way_mutual.iql.out
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,27 @@ Rule 'two_mod' registered.
> +two_mod(N) <- succ(M, N), one_mod(M)
Rule 'two_mod' registered.
> ?zero_mod(X)
No results.
┌───┐
│ X │
├───┤
│ 0 │
│ 3 │
└───┘
2 rows
> ?one_mod(X)
No results.
┌───┐
│ X │
├───┤
│ 1 │
└───┘
1 rows
> ?two_mod(X)
No results.
┌───┐
│ X │
├───┤
│ 2 │
└───┘
1 rows
> .kg use default
Switched to knowledge graph: default
> .kg drop test_three_way_mutual_n09t09
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Test: Multi-clause rules whose clauses share a derived relation (issue #91)

.kg create test_multi_clause_n18t98
.kg use test_multi_clause_n18t98

+claim(id: string, entity: string, attr: string, val: string)
+claim_modality(id: string, modality: string)
+link(x: string, y: string)
+claim[("c1", "e1", "attr", "v1"), ("c2", "e2", "attr", "v2"), ("c5", "anna_1", "distinct_from", "anna_2")]
+claim_modality[("c1", "asserted"), ("c2", "asserted"), ("c5", "asserted")]
+link[("e1", "e2")]
+active(C, E, A, V) <- claim(C, E, A, V), claim_modality(C, "asserted")

// Clauses share variable names E1/E2 and a constant-filtered derived atom
+r("bridge", C1, C2) <- active(C1, E1, A1, V1), active(C2, E2, A2, V2), link(E1, E2)
+r("solo", C, C) <- active(C, E1, "distinct_from", E2)
?r(K, C1, C2)

// Self-join clause plus projection clause keeps head arity
+ca(c: string, e: string)
+ca[("c1", "e1"), ("c2", "e1")]
+a(C, E) <- ca(C, E)
+s("joined", C1, C2) <- a(C1, E), a(C2, E), C1 != C2
+s("solo", C, C) <- a(C, E)
?s(K, C1, C2)

.kg use default
.kg drop test_multi_clause_n18t98
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
Connecting to server at http://127.0.0.1:8080...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> .kg create test_multi_clause_n18t98
Knowledge graph 'test_multi_clause_n18t98' created.
Switched to knowledge graph: test_multi_clause_n18t98
> .kg use test_multi_clause_n18t98
Switched to knowledge graph: test_multi_clause_n18t98
> +claim(id: string, entity: string, attr: string, val: string)
Schema for 'claim' registered with 4 columns (persistent)
> +claim_modality(id: string, modality: string)
Schema for 'claim_modality' registered with 2 columns (persistent)
> +link(x: string, y: string)
Schema for 'link' registered with 2 columns (persistent)
> +claim[("c1", "e1", "attr", "v1"), ("c2", "e2", "attr", "v2"), ("c5", "anna_1", "distinct_from", "anna_2")]
Inserted 3 fact(s) into 'claim'.
> +claim_modality[("c1", "asserted"), ("c2", "asserted"), ("c5", "asserted")]
Inserted 3 fact(s) into 'claim_modality'.
> +link[("e1", "e2")]
Inserted 1 fact(s) into 'link'.
> +active(C, E, A, V) <- claim(C, E, A, V), claim_modality(C, "asserted")
Rule 'active' registered.
> +r("bridge", C1, C2) <- active(C1, E1, A1, V1), active(C2, E2, A2, V2), link(E1, E2)
Rule 'r' registered.
> +r("solo", C, C) <- active(C, E1, "distinct_from", E2)
Rule 'r' registered.
> ?r(K, C1, C2)
┌──────────┬──────┬──────┐
│ K │ C1 │ C2 │
├──────────┼──────┼──────┤
│ "bridge" │ "c1" │ "c2" │
│ "solo" │ "c5" │ "c5" │
└──────────┴──────┴──────┘
2 rows
> +ca(c: string, e: string)
Schema for 'ca' registered with 2 columns (persistent)
> +ca[("c1", "e1"), ("c2", "e1")]
Inserted 2 fact(s) into 'ca'.
> +a(C, E) <- ca(C, E)
Rule 'a' registered.
> +s("joined", C1, C2) <- a(C1, E), a(C2, E), C1 != C2
Rule 's' registered.
> +s("solo", C, C) <- a(C, E)
Rule 's' registered.
> ?s(K, C1, C2)
┌──────────┬──────┬──────┐
│ K │ C1 │ C2 │
├──────────┼──────┼──────┤
│ "joined" │ "c1" │ "c2" │
│ "joined" │ "c2" │ "c1" │
│ "solo" │ "c1" │ "c1" │
│ "solo" │ "c2" │ "c2" │
└──────────┴──────┴──────┘
4 rows
> .kg use default
Switched to knowledge graph: default
> .kg drop test_multi_clause_n18t98
Knowledge graph 'test_multi_clause_n18t98' dropped.
14 changes: 12 additions & 2 deletions examples/iql/28_docs_coverage/10_syntax_recursion.iql.out
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,19 @@ Rule 'odd' registered.
> +even(X) <- odd(Y), edge(Y, X)
Rule 'even' registered.
> ?odd(X)
No results.
┌───┐
│ X │
├───┤
│ 2 │
└───┘
1 rows
> ?even(X)
No results.
┌───┐
│ X │
├───┤
│ 3 │
└───┘
1 rows
> .kg use default
Switched to knowledge graph: default
> .kg drop test_syntax_recursion_n28t10
Expand Down
10 changes: 5 additions & 5 deletions examples/iql/33_meta/18_debug_recursive.iql.out
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,17 @@ Query Plan:
IR nodes: 7 -> 8 (0 eliminated)

Rule 1 Optimized IR:
Distinct
Join[L:[0, 2], R:[1, 0]] -> [Y, Z, X]
|- Left:
Union (2 inputs)
|- Input 1:
Scan(edge)[X, Y]
|- Input 2:
Map[[2, 1]] -> [X, Z]
Distinct
Join[L:[0], R:[1]] -> [Y, Z, X]
|- Left:
Scan(path)[Y, Z]
`- Right:
Scan(edge)[X, Y]
`- Right:
Scan(edge)[X, Y]

Rule 2 Optimized IR:
Scan(path)[X, Y]
Expand Down
111 changes: 75 additions & 36 deletions src/join_planning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,10 @@ impl JoinGraph {
/// Build join graph from IR nodes (extracts scans and analyzes joins)
pub fn from_ir(ir: &IRNode) -> Self {
let mut graph = JoinGraph::new();
let scans = Self::extract_scans(ir);

// Add nodes
for (_relation, schema, ir_node) in &scans {
let variables: HashSet<String> = schema.iter().cloned().collect();
graph.nodes.push(JoinGraphNode {
variables,
ir_node: ir_node.clone(),
});
for ir_node in Self::extract_scans(ir) {
let variables: HashSet<String> = ir_node.output_schema().into_iter().collect();
graph.nodes.push(JoinGraphNode { variables, ir_node });
}

// Add edges based on shared variables
Expand Down Expand Up @@ -128,30 +123,21 @@ impl JoinGraph {
self.edges.push(edge);
}

/// Extract all scans from an IR tree
fn extract_scans(ir: &IRNode) -> Vec<(String, Vec<String>, IRNode)> {
/// Extract the leaves of the join block rooted at `ir`
fn extract_scans(ir: &IRNode) -> Vec<IRNode> {
let mut scans = Vec::new();
Self::extract_scans_recursive(ir, &mut scans);
scans
}

fn extract_scans_recursive(ir: &IRNode, scans: &mut Vec<(String, Vec<String>, IRNode)>) {
fn extract_scans_recursive(ir: &IRNode, scans: &mut Vec<IRNode>) {
match ir {
IRNode::Scan { relation, schema } => {
scans.push((relation.clone(), schema.clone(), ir.clone()));
}
IRNode::Scan { .. } => scans.push(ir.clone()),
IRNode::Map { input, .. } => Self::extract_scans_recursive(input, scans),
// Preserve Filter chains wrapping Scan nodes as single leaf nodes
// so constant filters (e.g., ColumnEqStr for string constants in atoms)
// are not lost during join reordering.
IRNode::Filter { .. }
if Self::find_scan_relation(ir).is_some() && Self::is_filter_scan_chain(ir) =>
{
let schema = ir.output_schema();
let relation = Self::find_scan_relation(ir)
.expect("find_scan_relation guaranteed Some by guard condition above");
scans.push((relation, schema, ir.clone()));
}
IRNode::Filter { .. } if Self::is_filter_scan_chain(ir) => scans.push(ir.clone()),
IRNode::Filter { input, .. } => Self::extract_scans_recursive(input, scans),
IRNode::Join { left, right, .. } => {
Self::extract_scans_recursive(left, scans);
Expand All @@ -162,11 +148,9 @@ impl JoinGraph {
Self::extract_scans_recursive(right, scans);
}
IRNode::Distinct { input } => Self::extract_scans_recursive(input, scans),
IRNode::Union { inputs } => {
for input in inputs {
Self::extract_scans_recursive(input, scans);
}
}
// Union branches are separate rule bodies: their variables are
// scoped per branch, so a nested Union is one opaque leaf.
IRNode::Union { .. } => scans.push(ir.clone()),
IRNode::Aggregate { input, .. } => Self::extract_scans_recursive(input, scans),
IRNode::Compute { input, .. } => Self::extract_scans_recursive(input, scans),
IRNode::HnswScan { .. } => {} // HNSW scans are not part of join graph
Expand All @@ -178,15 +162,6 @@ impl JoinGraph {
}
}

/// Find the relation name from a (possibly Filter-wrapped) Scan node
fn find_scan_relation(ir: &IRNode) -> Option<String> {
match ir {
IRNode::Scan { relation, .. } => Some(relation.clone()),
IRNode::Filter { input, .. } => Self::find_scan_relation(input),
_ => None,
}
}

/// Check if an IR node is a chain of Filter nodes wrapping a Scan
fn is_filter_scan_chain(ir: &IRNode) -> bool {
match ir {
Expand Down Expand Up @@ -500,6 +475,13 @@ impl JoinPlanner {
return ir;
}

// Each branch of a multi-clause rule is an independent join block.
if let IRNode::Union { inputs } = ir {
return IRNode::Union {
inputs: inputs.into_iter().map(|b| self.plan_joins(b)).collect(),
};
}

// Only optimize if there are joins
if !Self::has_joins(&ir) {
return ir;
Expand Down Expand Up @@ -1697,4 +1679,61 @@ mod tests {
// Root has one child (node 1), join_order should contain both nodes
assert_eq!(jst.join_order.len(), 2);
}

/// Two clauses of one rule share variable names (`y`) but are separate
/// join blocks; planning must keep them as separate Union branches.
#[test]
fn test_plan_joins_union_branches_planned_independently() {
let planner = JoinPlanner::new();
let join_branch = IRNode::Map {
input: Box::new(make_join(
make_scan("a", &["x", "y"]),
make_scan("a", &["z", "y"]),
"y",
)),
projection: vec![0, 2],
output_schema: vec!["x".to_string(), "z".to_string()],
};
let solo_branch = IRNode::Map {
input: Box::new(make_scan("a", &["x", "y"])),
projection: vec![0, 0],
output_schema: vec!["x".to_string(), "x".to_string()],
};
let ir = IRNode::Union {
inputs: vec![join_branch, solo_branch.clone()],
};

let IRNode::Union { inputs } = planner.plan_joins(ir) else {
panic!("Union must survive join planning");
};
assert_eq!(inputs.len(), 2);
assert_eq!(inputs[0].output_schema(), vec!["x", "z"]);
assert_eq!(count_scans(&inputs[0]), 2);
assert_eq!(inputs[1], solo_branch);
}

#[test]
fn test_join_graph_nested_union_is_single_leaf() {
let union = IRNode::Union {
inputs: vec![make_scan("R", &["x", "y"]), make_scan("S", &["x", "y"])],
};
let ir = IRNode::Join {
left: Box::new(make_scan("T", &["y", "z"])),
right: Box::new(union),
left_keys: vec![0],
right_keys: vec![1],
output_schema: vec!["y".to_string(), "z".to_string(), "x".to_string()],
};
let graph = JoinGraph::from_ir(&ir);
assert_eq!(graph.nodes.len(), 2);
}

fn count_scans(ir: &IRNode) -> usize {
match ir {
IRNode::Scan { .. } => 1,
IRNode::Join { left, right, .. } => count_scans(left) + count_scans(right),
IRNode::Map { input, .. } => count_scans(input),
other => panic!("unexpected node in planned branch: {other:?}"),
}
}
}
Loading
Loading