Skip to content

Commit bc1f194

Browse files
fix(planner): carry projections and early_limit into the physical plan (#207)
fix(planner): carry projections and early_limit into the physical plan `PlanNode` has four fields; `PlanStep` had six, and neither `projections` nor `early_limit` was among them. The optimizer computed both, then dropped them at the logical -> physical boundary, so the executor never saw either pushdown. That is worse than merely lossy, and this is the part worth flagging. `CostModel::estimate` already *discounts* a node carrying an early limit (`cost.rs:345-350`): if let Some(limit) = node.early_limit { let limit_factor = (limit as f64 / 1000.0).min(1.0); selectivity *= limit_factor; time_ms *= 0.5 + 0.5 * limit_factor; // At least 50% of base cost } So a plan was priced as cheap as half its base cost *because of* a limit pushdown that the physical plan had no way to perform. The planner was rewarding an optimisation it then discarded. Nothing failed loudly — plans simply cost less on paper than they cost to run, which is the kind of defect that shows up as unexplained latency rather than as an error. Measured before the change: `early_limit` had exactly one non-test read in the entire tree — the cost adjustment above. `projections` had **none**; nothing read it after `vcl_bridge.rs` built it. Both fields are now on `PlanStep` and populated from the node. They carry `#[serde(default)]`, so a `PhysicalPlan` serialised before this change still deserialises (absent -> `vec![]` / `None`, matching the old behaviour rather than inventing a limit). This surfaced while discharging `optimize_is_permutation` (#206). The Coq model abstracts a node as `(modality, list condition)` — precisely the projection that omits the two fields being lost. The abstraction was not wrong, but it was drawn around the bug, which is why proving the permutation property could not have caught it. Test: `test_pushdown_survives_optimization` asserts both fields survive, and matches steps by modality rather than index — asserting on `steps[0]` would test the sort order, not the pushdown. Verified as a real guard, not a vacuous one: with the population reverted it fails with `projection pushdown was dropped at the physical boundary`; with the fix it passes. Verified: `cargo test -p verisim-planner` 118 passed / 0 failed; `cargo clippy -p verisim-planner --all-targets` clean; `cargo check -p verisim-api --all-targets` clean (it maps `PlanStep` into its own GraphQL type); `reuse lint` compliant. Not addressed here, and deliberately so: `pushed_predicates` remains `Vec<String>` built by `format!("{:?}", c)`, so conditions still cross the boundary as Rust `Debug` output rather than as `ConditionKind`. Retyping it is a larger change with its own serialisation surface, and it is not needed to stop the cost model lying.
1 parent 2c29af4 commit bc1f194

6 files changed

Lines changed: 107 additions & 0 deletions

File tree

rust-core/verisim-planner/src/explain.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ mod tests {
254254
},
255255
optimization_hint: Some("HNSW ANN search (k=10)".to_string()),
256256
pushed_predicates: vec!["Similarity { k: 10 }".to_string()],
257+
projections: vec![],
258+
early_limit: None,
257259
},
258260
PlanStep {
259261
step: 2,
@@ -266,6 +268,8 @@ mod tests {
266268
io_cost: 135.0,
267269
cpu_cost: 90.0,
268270
},
271+
projections: vec![],
272+
early_limit: None,
269273
optimization_hint: Some("Graph traversal: relates_to (depth=2)".to_string()),
270274
pushed_predicates: vec!["Traversal { predicate: relates_to }".to_string()],
271275
},
@@ -334,6 +338,8 @@ mod tests {
334338
},
335339
optimization_hint: Some("ZKP verification — expensive".to_string()),
336340
pushed_predicates: vec![],
341+
projections: vec![],
342+
early_limit: None,
337343
}],
338344
strategy: ExecutionStrategy::Sequential,
339345
total_cost: CostEstimate {

rust-core/verisim-planner/src/optimizer.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ impl Planner {
125125
cost: cost.clone(),
126126
optimization_hint: hint.clone(),
127127
pushed_predicates,
128+
// Carry both through to the physical plan. Dropping them here
129+
// was not merely lossy: CostModel::estimate discounts a node
130+
// that has an early_limit, so the plan was priced for a
131+
// pushdown the executor could never see.
132+
projections: node.projections.clone(),
133+
early_limit: node.early_limit,
128134
});
129135

130136
cost_estimates.push(cost.clone());
@@ -352,4 +358,71 @@ mod tests {
352358
assert!(explain.text_output.contains("Strategy"));
353359
assert!(explain.text_output.contains("vector"));
354360
}
361+
362+
/// Projection and early-limit pushdown must survive optimization.
363+
///
364+
/// Before 2026-07-28 both were computed on the logical node and dropped at
365+
/// the physical boundary. That was worse than lossy: `CostModel::estimate`
366+
/// discounts a node carrying an `early_limit` (scaling `selectivity` and
367+
/// taking `time_ms` to as low as 50% of base), so plans were priced for a
368+
/// pushdown the executor could not see. Nothing failed loudly — the plan
369+
/// just quietly cost less than it ran.
370+
#[test]
371+
fn test_pushdown_survives_optimization() {
372+
let logical = LogicalPlan {
373+
source: QuerySource::Octad,
374+
nodes: vec![
375+
PlanNode {
376+
modality: Modality::Vector,
377+
conditions: vec![ConditionKind::Similarity { k: 10 }],
378+
projections: vec!["id".to_string(), "label".to_string()],
379+
early_limit: Some(25),
380+
},
381+
PlanNode {
382+
modality: Modality::Graph,
383+
conditions: vec![ConditionKind::Traversal {
384+
predicate: "relates_to".to_string(),
385+
depth: Some(2),
386+
}],
387+
projections: vec!["id".to_string()],
388+
early_limit: None,
389+
},
390+
],
391+
post_processing: vec![],
392+
};
393+
394+
let planner = Planner::new(PlannerConfig::default());
395+
let physical = planner.optimize(&logical).expect("optimize");
396+
397+
// Match on modality rather than position: the optimizer reorders by
398+
// execution priority, so asserting on steps[0] would be asserting on
399+
// the sort order, not on the pushdown.
400+
let vector_step = physical
401+
.steps
402+
.iter()
403+
.find(|s| s.modality == Modality::Vector)
404+
.expect("vector step present");
405+
assert_eq!(
406+
vector_step.projections,
407+
vec!["id".to_string(), "label".to_string()],
408+
"projection pushdown was dropped at the physical boundary"
409+
);
410+
assert_eq!(
411+
vector_step.early_limit,
412+
Some(25),
413+
"early-limit pushdown was dropped, but the cost model already \
414+
discounted the plan for it"
415+
);
416+
417+
let graph_step = physical
418+
.steps
419+
.iter()
420+
.find(|s| s.modality == Modality::Graph)
421+
.expect("graph step present");
422+
assert_eq!(graph_step.projections, vec!["id".to_string()]);
423+
assert_eq!(
424+
graph_step.early_limit, None,
425+
"a node without an early limit must not acquire one"
426+
);
427+
}
355428
}

rust-core/verisim-planner/src/plan.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ pub struct PlanStep {
113113
pub optimization_hint: Option<String>,
114114
/// Pushed-down predicates.
115115
pub pushed_predicates: Vec<String>,
116+
/// Fields to project, carried through from [`PlanNode::projections`]
117+
/// (empty = all fields).
118+
///
119+
/// Until 2026-07-28 this was absent, so projection pushdown was computed
120+
/// on the logical node and then discarded at the physical boundary — the
121+
/// executor had no way to see it.
122+
#[serde(default)]
123+
pub projections: Vec<String>,
124+
/// Early row limit pushed down to the store, carried through from
125+
/// [`PlanNode::early_limit`].
126+
///
127+
/// Also absent until 2026-07-28, which was the more consequential half of
128+
/// the same gap: [`crate::cost::CostModel::estimate`] *already* discounts a node
129+
/// carrying an early limit (it scales `selectivity` and takes `time_ms`
130+
/// down to as low as 50% of base), so a plan was scored cheaper for a
131+
/// pushdown the physical plan could not actually perform.
132+
#[serde(default)]
133+
pub early_limit: Option<usize>,
116134
}
117135

118136
/// An optimized physical plan ready for execution.
@@ -182,6 +200,8 @@ mod tests {
182200
},
183201
optimization_hint: Some("HNSW ANN".to_string()),
184202
pushed_predicates: vec!["k=10".to_string()],
203+
projections: vec![],
204+
early_limit: None,
185205
}],
186206
strategy: ExecutionStrategy::Sequential,
187207
total_cost: CostEstimate {

rust-core/verisim-planner/src/prepared.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,8 @@ mod tests {
755755
},
756756
optimization_hint: Some("depth-limited BFS".to_string()),
757757
pushed_predicates: vec!["relates_to".to_string()],
758+
projections: vec![],
759+
early_limit: None,
758760
}],
759761
strategy: ExecutionStrategy::Sequential,
760762
total_cost: CostEstimate {

rust-core/verisim-planner/src/profiler.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,8 @@ mod tests {
463463
},
464464
optimization_hint: Some("HNSW ANN search (k=10)".to_string()),
465465
pushed_predicates: vec!["Similarity { k: 10 }".to_string()],
466+
projections: vec![],
467+
early_limit: None,
466468
},
467469
PlanStep {
468470
step: 2,
@@ -475,6 +477,8 @@ mod tests {
475477
io_cost: 135.0,
476478
cpu_cost: 90.0,
477479
},
480+
projections: vec![],
481+
early_limit: None,
478482
optimization_hint: Some("Graph traversal: relates_to (depth=2)".to_string()),
479483
pushed_predicates: vec!["Traversal { predicate: relates_to }".to_string()],
480484
},

rust-core/verisim-planner/src/slow_query.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ mod tests {
321321
},
322322
optimization_hint: None,
323323
pushed_predicates: vec![],
324+
projections: vec![],
325+
early_limit: None,
324326
})
325327
.collect(),
326328
strategy: if steps.len() >= 2 {

0 commit comments

Comments
 (0)