diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 5d2f44bfb..a5132cd9e 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -73,11 +73,10 @@ // `guarantee_check_rejection`, the sketches read and a one-line summary), // `pruned_candidates` and `search_report` (`SearchReport`: `status`, // `cost_lower_bound_per_second`, MILP size and timings, and -// `inexact_objective` when the new search's objective and `plan_cost` +// `inexact_objective` when the selection MILP's objective and `plan_cost` // differ over terms the MILP encodes inexactly). Stage 3 runs #1095's // selection MILP, which supports cost model 1 only (cost models 2 and 3 -// are an `UnsupportedCostModel` error); `ASAP_STAGE3_SEARCH=old` runs the -// old engine instead; `error` when no +// are an `UnsupportedCostModel` error, #888); `error` when no // plan was selected. `tumbling_window_retention_ms` maps each pane chain the // selected plan keeps beyond one evaluation (by node id in `P1`) to how // far back the runtime must keep its tumbling windows (`RealizedPlan::tumbling_window_retention_ms`). diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index dbfefde55..a9bd69ad3 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -2553,8 +2553,8 @@ fn memo_section_lists_each_step_the_selected_choice_and_the_enumeration() { assert!(document.get("memo").is_none()); } -/// `stage_pipeline`'s document with Stage 3's new search forced on -/// (`ASAP_STAGE3_SEARCH=milp`, #1095), set on the child process only. +/// `stage_pipeline`'s document for `args`, run with Stage 3's group +/// selection MILP (#1095). fn document_with_milp_search(args: &[&str]) -> Value { static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let call = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -2563,7 +2563,6 @@ fn document_with_milp_search(args: &[&str]) -> Value { std::process::id(), )); let status = Command::new(env!("CARGO_BIN_EXE_stage_pipeline")) - .env("ASAP_STAGE3_SEARCH", "milp") .args(args) .arg("--out") .arg(&out) @@ -2575,7 +2574,7 @@ fn document_with_milp_search(args: &[&str]) -> Value { document } -/// #1095 C5: with the new search on, example 1 and the RangeHeavy T5 +/// #1095 C5: with the group selection MILP, example 1 and the RangeHeavy T5 /// q0.5 + q0.9 workload select a plan through the real pipeline, solved to /// optimality or stopped at the deadline, whose `plan_cost` equals the MILP's objective exactly (the /// search errors on a difference and reports `inexact_objective` when the @@ -2627,7 +2626,7 @@ fn milp_search_objective_equals_plan_cost_on_example1_and_rangeheavy() { } } -/// #1127 regressions: with the new search on and guarantees enforced, the +/// #1127 regressions: with the group selection MILP and guarantees enforced, the /// memo examples on which the A1 rows admitted a plan that `satisfies` /// rejects now select a plan that passes every query's accuracy check: /// ratio-of-quantiles (KLL rank errors compose no guarantee through the @@ -2691,7 +2690,7 @@ fn milp_search_selects_checked_plans_on_ratio_and_rollupheavy() { } /// #1095 C5: on RangeHeavy T8+T5 (`q0.9 / q0.5` with both operands also -/// queried alone), the new search selects a DDSketch plan that passes every +/// queried alone), the group selection MILP selects a DDSketch plan that passes every /// query's accuracy check (the A1 rows admitted KLL there, which the check /// rejects). Which chains it shares depends on the deadline; the weights /// that let one chain serve all three queries are checked in diff --git a/crates/plan-selection/src/groups/tests.rs b/crates/plan-selection/src/groups/tests.rs index cb1e1765b..1ae32f30b 100644 --- a/crates/plan-selection/src/groups/tests.rs +++ b/crates/plan-selection/src/groups/tests.rs @@ -777,8 +777,8 @@ fn ratio_weights_come_from_the_composition() { // RangeHeavy T8+T5's shape: `q0.9 / q0.5` with both operands also queried // alone. One DDSketch pane chain sized for two shares of the budget serves -// both quotient operands and both lone quantiles, as today's engine plans -// it: the check accepts the quotient over it (`(α + α)/(1 − α) ≤ ε` at +// both quotient operands and both lone quantiles, as the old Stage 3 +// engine planned it: the check accepts the quotient over it (`(α + α)/(1 − α) ≤ ε` at // `α = 1 − (1 + ε)^{−1/2}`), so it weighs 1 for every query. A chain sized // for the whole budget (`α = ε`) never serves the quotient's denominator. #[test] diff --git a/crates/plan-selection/src/lib.rs b/crates/plan-selection/src/lib.rs index 04c7f6c05..2b4d82088 100644 --- a/crates/plan-selection/src/lib.rs +++ b/crates/plan-selection/src/lib.rs @@ -25,9 +25,9 @@ //! physical DAG ([`ResourceUsage`](asap_cost::ResourceUsage)), and the deployment's [`CostModel`](asap_cost::CostModel) //! chooses among them ("Cost models" below). The rest are reported valid //! but costlier (or slower, or larger). -//! Candidate-connection MILP search selects and validates workload DAGs -//! without constructing the Cartesian product of local candidates. -//! Dynamic-programming compression is outside the current implementation. +//! The group selection MILP ([`select_plan`], #1095) selects one candidate +//! per memo group and execution context without constructing the Cartesian +//! product of local candidates; it prices cost model 1 only (#888). //! //! ## Cost model: cost per second of wall time //! @@ -181,7 +181,10 @@ //! //! [`CostInputs::cost_model`](asap_cost::CostInputs::cost_model) (the cost inputs [`select_plan`] receives) chooses how //! usage becomes a choice; the default is cost model 1 with -//! [`ResourceUnitCost::illustrative`](asap_cost::ResourceUnitCost::illustrative): +//! [`ResourceUnitCost::illustrative`](asap_cost::ResourceUnitCost::illustrative). +//! [`select_plan`] searches under cost model 1 only; cost models 2 and 3 +//! are an `UnsupportedCostModel` error until #888 (the whole-plan reference +//! pricer still ranks plans under all three): //! //! * [`CostModel::UsageOverTime`](asap_cost::CostModel::UsageOverTime) (cost model 1, pay-as-you-go): each //! node's CPU operations, scanned bytes and memory byte-seconds per second @@ -241,9 +244,6 @@ #[cfg(test)] use asap_types::workload::RepeatedDemand; -use asap_cost::analytical::AnalyticalCostError; -use asap_cost::state_bytes::{sketch_shape, summary_shape, StateInputRows}; - mod accuracy_binding; mod checks; mod models; @@ -253,23 +253,20 @@ mod groups; // #1095 §2.0: the group selection MILP and the decode of its solution. mod milp; mod report; -mod search; -// #1095 C5: the new search behind `select_plan`'s internal switch. +// Stage 3's entry, `select_plan`, over the group selection MILP (#1095). mod select; +// The whole-plan selector behind `reference_pricer` and the tests. +#[cfg(any(test, feature = "reference-pricer"))] mod selector; -pub(crate) use accuracy_binding::bound_root_guarantee; -pub(crate) use checks::{accuracy_targets, guarantee_check}; pub use models::AccuracyCheckInputs; pub(crate) use models::SelectionModels; pub use report::{ large_peak_memory_warning, AcceptedAccuracy, ErrorSelectingPlan, InexactObjective, RejectionCause, SearchReport, SearchStatus, Selection, }; -pub(crate) use report::{Rejection, WholePlanSelection}; -pub use search::select::select_plan; +pub use select::select_plan; #[cfg(test)] pub(crate) use selector::select_among_plans; -pub(crate) use selector::PlanSelector; /// The whole-workload reference pricer (feature `reference-pricer`): the /// selector Stage 3 used before the memo search, over whole physical plans @@ -318,18 +315,23 @@ pub(crate) fn sized_as_pass1( .expect("sized summaries rebuild") } -use asap_cost::{lookup, StateFamily}; - #[cfg(test)] mod tests { use super::*; + use crate::accuracy_binding::bound_root_guarantee; use crate::accuracy_binding::{bind_statistics, root_operators, workload_statistic}; + use crate::checks::guarantee_check; use crate::checks::{accuracy_check, CheckFailure}; + use crate::report::WholePlanSelection; + use crate::selector::PlanSelector; use asap_accuracy::composition::INPUT_ROW_COUNT; use asap_accuracy::estimators::hll; use asap_accuracy::propagation::root_guarantee; + use asap_cost::analytical::AnalyticalCostError; use asap_cost::execution_demand::evaluation_rate; + use asap_cost::state_bytes::{sketch_shape, summary_shape, StateInputRows}; use asap_cost::{kept_tumbling_window_chains, node_map, plan_cost, NodeMap}; + use asap_cost::{lookup, StateFamily}; use asap_cost::{ CalibrationFit, ClusterLimits, ClusterObjective, CostInputs, CostModel, NodePriceSource, OperatorKind, PlanCost, ResourceUsage, COST_SOURCE, MEASURED_COST_SOURCE, diff --git a/crates/plan-selection/src/report.rs b/crates/plan-selection/src/report.rs index b06ec740a..09a4f973c 100644 --- a/crates/plan-selection/src/report.rs +++ b/crates/plan-selection/src/report.rs @@ -7,6 +7,7 @@ use asap_cost::analytical::AnalyticalCostError; use asap_cost::{CostModel, PlanCost}; use asap_types::ir::physical_export::PhysicalASAPNodeId; use asap_types::ir::properties::AccuracyMetric; +#[cfg(any(test, feature = "reference-pricer"))] use asap_types::ir::QueryRoot; use asap_types::stages::{ DagOfSelectedCandidates, PhysicalCandidateId, PrunedCandidate, SelectedNodeId, @@ -180,12 +181,12 @@ impl std::fmt::Display for AcceptedAccuracy { /// The whole-plan selector's result (`PlanSelector`): /// the selected candidate among whole physical plans and the plan to run, -/// with what it kept of the other candidates. Today's engine evaluates each -/// proposed plan with it, and the reference pricer (feature +/// with what it kept of the other candidates. The reference pricer (feature /// `reference-pricer`) selects among enumerated plans with it. Keeping /// every candidate, `costs` and `accepted` list every valid candidate and /// `rejected` every other one; otherwise only the selected candidate is /// kept, and `rejected` holds the first rejection of each cause. +#[cfg(any(test, feature = "reference-pricer"))] #[derive(Debug, Clone, PartialEq)] pub struct WholePlanSelection { pub selected: String, @@ -220,8 +221,8 @@ pub struct Selection { pub cost: PlanCost, /// Per query, the accuracy Stage 3 accepted for the selected plan. pub accepted_accuracy_per_query: Vec, - /// The candidates Stage 3 ruled out before or during the search (none - /// recorded by today's engine; #1095 fills it). + /// The candidates Stage 3 ruled out before the search: dominated ones and + /// those whose usage could not be costed. pub pruned_candidates: Vec>, pub search_report: SearchReport, } @@ -231,7 +232,7 @@ pub struct Selection { pub struct SearchReport { pub status: SearchStatus, /// The solver's lower bound on the selected plan's cost per second; - /// `None` when the search proves none (e.g. under cost model 3). + /// `None` when the solver's dual bound is not finite. pub cost_lower_bound_per_second: Option, /// The largest MILP the search built. pub milp_variables: usize, @@ -243,7 +244,7 @@ pub struct SearchReport { /// the selection MILP's objective and the model encoded some cost terms /// inexactly (open questions on #1095, e.g. the working-memory term), so /// the plan is returned with its cost equality unproven. `None` when - /// the two are equal (within a relative 10⁻⁶) or the old search ran. A + /// the two are equal (within a relative 10⁻⁶). A /// difference with no inexact term is an error /// ([`ErrorSelectingPlan::ObjectiveDiffersFromPlanCost`]). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -280,11 +281,8 @@ impl SearchReport { pub enum SearchStatus { /// The search proved the plan optimal over the catalog it was given. Optimal, - /// The search stopped at the deadline (or at its internal limits) with - /// a validated plan; the gap to the lower bound may be above zero. - /// Until #1095 replaces today's engine, this also covers the engine's - /// stop at its internal limit of 128 evaluations, and any other - /// non-optimal stop that still validated a plan. + /// The search stopped at the deadline (or another solver limit) with a + /// validated plan; the gap to the lower bound may be above zero. DeadlineReached, } diff --git a/crates/plan-selection/src/search/candidates/configurations.rs b/crates/plan-selection/src/search/candidates/configurations.rs deleted file mode 100644 index 1525b2bd3..000000000 --- a/crates/plan-selection/src/search/candidates/configurations.rs +++ /dev/null @@ -1,660 +0,0 @@ -//! Unit tests of Stage 2's configuration enumeration -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use std::rc::Rc; - -use asap_logical_asap_optimization::DeploymentCapabilities; -use asap_physical_asap_optimization::test_support::catalog::{ - compatible_timing, enumerate_candidate_configurations_before, - may_keep_across_evaluations_per_sole_reader_query, CandidateInventory, -}; -use asap_physical_asap_optimization::ConfigurationError; -use asap_types::ir::properties::ExecutionTiming; -use asap_types::ir::{ASAPOp, OperatorNode, QueryRoot, ScalarExpr}; -use asap_types::physical::{Materialization, Medium}; -use asap_types::stages::{ - CandidateForMemoGroupId, MemoGroupId, OperatorNodeOfCandidateForMemoGroup, PhysicalCandidateId, -}; -use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; - -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; - -/// [`enumerate_candidate_configurations_before`] with no deadline, its -/// output references those of `source`, owned by the queries -/// `output_queries[reference]` ([`query_roots_for_tests`]), with the -/// engine's execution-scope copies and an unpriced DAG. Multiple references of one scalar query count once for shared-query -/// materialization. -#[cfg(test)] -pub(in crate::search) fn enumerate_candidate_configurations( - source: &CandidateInventory, - output_queries: &[usize], - entries: &[QueryWorkloadEntry], - data: &DataWorkload, - deployment: &DeploymentCapabilities, -) -> Result { - enumerate_candidate_configurations_before( - source, - &query_roots_for_tests(source, output_queries, entries.len()), - entries, - data, - deployment.query_time_results_retained, - deployment.raw_data_retained, - None, - ) - .map(|physical| { - // As `select_plan` does: the engine's execution-scope copies. - crate::search::candidates::scopes::expand_execution_scopes(&physical, entries, None) - .expect("expansion without a deadline") - .configured - }) -} - -/// A hand-built inventory: node `i` runs `nodes[i].0` and reads, per input, -/// the nodes `nodes[i].1`; output reference `r` may be produced by the nodes -/// `query_output_nodes[r]`. The nodes each distinct output list names are the -/// implementation roots of one group (1, 2, … in order of first appearance); -/// every other node is a root of group 0. -#[cfg(test)] -pub(in crate::search) fn inventory_for_tests( - nodes: Vec<(std::rc::Rc, Vec>)>, - query_output_nodes: Vec>, -) -> CandidateInventory { - let mut lists: Vec> = Vec::new(); - let mut group_of = vec![0; nodes.len()]; - for list in &query_output_nodes { - let mut sorted = list.clone(); - sorted.sort(); - if !lists.contains(&sorted) { - lists.push(sorted.clone()); - } - let group = lists.iter().position(|known| *known == sorted).unwrap() + 1; - for id in list { - assert!( - group_of[id.0] == 0 || group_of[id.0] == group, - "test inventory gives node {} two different output lists", - id.0 - ); - group_of[id.0] = group; - } - } - let mut implementation_roots_of_group = vec![Vec::new(); lists.len() + 1]; - for (id, &group) in group_of.iter().enumerate() { - implementation_roots_of_group[group].push(PhysicalCandidateId(id)); - } - let (operator_nodes, suppliers): (Vec<_>, Vec<_>) = nodes - .into_iter() - .enumerate() - .map(|(id, (node, inputs))| { - ( - OperatorNodeOfCandidateForMemoGroup { - candidates_for_memo_group: vec![CandidateForMemoGroupId { - group: MemoGroupId(group_of[id] as u32), - index: id, - }], - is_implementation_root: true, - accuracy_budget_parts: 1, - physical_node: node, - }, - inputs.into_iter().map(Into::into).collect(), - ) - }) - .unzip(); - CandidateInventory { - operator_nodes_of_candidates_for_memo_groups: operator_nodes, - producer_nodes_per_input: suppliers, - query_output_nodes, - implementation_roots_of_group, - } -} - -/// The roots of `query_count` queries over `source`'s groups: query `q` -/// reads, in order, the group of each output reference `r` with -/// `output_queries[r] == q` (a scalar root unless it reads exactly one); an -/// output no node may produce reads a group with no implementation roots. -#[cfg(test)] -pub(in crate::search) fn query_roots_for_tests( - source: &CandidateInventory, - output_queries: &[usize], - query_count: usize, -) -> Vec> { - let mut groups = vec![Vec::new(); query_count]; - for (output, &query) in output_queries.iter().enumerate() { - groups[query].push( - source.query_output_nodes[output] - .first() - .map_or(MemoGroupId(u32::MAX), |node| { - source.operator_nodes_of_candidates_for_memo_groups[node.0].group() - }), - ); - } - groups - .into_iter() - .map(|groups| match groups.as_slice() { - [group] => QueryRoot::Operator(*group), - _ => QueryRoot::Scalar(ScalarExpr::FunctionCall { - name: "refs".into(), - args: groups - .into_iter() - .map(ScalarExpr::PromqlScalarFromVector) - .collect(), - }), - }) - .collect() -} - -/// Test fixtures build candidates by hand; this fills each one's -/// `may_keep_across_evaluations_per_sole_reader_query` from its operator -/// node as [`enumerate_candidate_configurations_before`] does. -#[cfg(test)] -pub(in crate::search) fn fill_may_keep_across_evaluations_per_sole_reader_query( - configured: &mut PhysicalCandidatesWithCosts, - entries: &[QueryWorkloadEntry], - data: &DataWorkload, - query_time_results_retained: bool, -) { - let nodes = configured.nodes_for_tests(); - for (candidate, node) in configured.physical.candidates.iter_mut().zip(&nodes) { - candidate.may_keep_across_evaluations_per_sole_reader_query = - may_keep_across_evaluations_per_sole_reader_query( - node, - candidate.materialization, - entries, - data, - query_time_results_retained, - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::inventory::{build_candidate_inventory, InventoryForTests}; - use crate::search::milp::model::select_candidates; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::lower_promql; - use asap_logical_asap_optimization::test_support::sharing_rules_and_shared_state_groups; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::TumblingWindowWidthDomain; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::QueryRoot; - - use asap_types::workload::{ - DataArrival, Predictability, Query, QueryRecurrence, QueryRequirements, RepeatedDemand, - RepetitionInterval, - }; - - // Configuration expansion must stop before returning an incomplete catalog. - #[test] - fn configuration_deadline_returns_typed_timeout() { - let (source, entries, data) = fixture(); - assert!(matches!( - enumerate_candidate_configurations_before( - &source, - &query_roots_for_tests(&source, &[0, 1], entries.len()), - &entries, - &data, - DeploymentCapabilities::default().query_time_results_retained, - DeploymentCapabilities::default().raw_data_retained, - Some(std::time::Instant::now()) - ), - Err(ConfigurationError::DeadlineExceeded(_)) - )); - } - - fn fixture() -> (InventoryForTests, Vec, DataWorkload) { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let queries = [ - "quantile_over_time(0.95, latency[5m])", - "quantile_over_time(0.99, latency[5m])", - ]; - let roots = queries - .iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect::>(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs = (0..2) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect::>(); - let candidates = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(); - let memo = sharing_rules_and_shared_state_groups( - try_populate_pass1(initial, &candidates).unwrap(), - TumblingWindowWidthDomain::Complete, - ) - .unwrap(); - let source = build_candidate_inventory(&memo, &outputs, |_| Ok(7.0)).unwrap(); - let entries = queries - .iter() - .map(|query| QueryWorkloadEntry { - query: Query((*query).into()), - requirements: QueryRequirements { - accuracy: target.clone(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(300_000), - )), - range_evaluation: None, - }) - .collect(); - ( - source, - entries, - DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - ..Default::default() - }, - ) - } - - fn kll(node: &OperatorNode) -> bool { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if *kind.algorithm() == SketchAlgorithm::Kll) - } - - // Indexed compatibility must reproduce every original ordered edge while - // storing repeated producer lists once, including query/ingestion modes. - #[test] - fn producer_index_preserves_all_edges_and_shares_storage() { - let (source, entries, data) = fixture(); - let expanded = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &DeploymentCapabilities::default(), - ) - .unwrap(); - for (consumer, configuration) in expanded.physical.candidates.iter().enumerate() { - let original = &source.graph.candidates[configuration.operator_node.0]; - let generated = &expanded.dag.candidates[consumer]; - assert_eq!( - generated.producer_candidates_per_input.len(), - original.producer_candidates_per_input.len() - ); - for (original, generated) in original - .producer_candidates_per_input - .iter() - .zip(&generated.producer_candidates_per_input) - { - let expected: Vec<_> = original - .candidates - .iter() - .flat_map(|source| { - expanded.physical.candidates.iter().enumerate().filter_map( - move |(id, producer)| { - (producer.operator_node.0 == source.0 - && (configuration.timing == producer.timing - || (configuration.timing == ExecutionTiming::QueryTime - && matches!( - producer.materialization, - Materialization::MatAtIngestion(_) - )))) - .then_some(PhysicalCandidateId(id)) - }, - ) - }) - .collect(); - assert_eq!(generated.candidates.as_slice(), expected.as_slice()); - } - } - let metrics = asap_types::stages::producer_storage_metrics(&expanded.physical); - let requirements = expanded - .dag - .candidates - .iter() - .map(|candidate| candidate.producer_candidates_per_input.len()) - .sum::(); - assert!(metrics.unique_producer_sets < requirements, "{metrics:?}"); - assert!( - metrics.stored_producer_ids < metrics.logical_connections, - "{metrics:?}" - ); - } - - // Configuration expansion is bounded per node, and never crosses ephemeral timing. - #[test] - fn real_quantiles_offer_local_and_shared_timing_configurations() { - let (source, entries, data) = fixture(); - let expanded = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &DeploymentCapabilities::default(), - ) - .unwrap(); - let original_positions = source.graph.query_roots.len() - + source - .graph - .candidates - .iter() - .map(|candidate| candidate.producer_candidates_per_input.len()) - .sum::(); - assert!( - expanded.physical.candidates.len() - <= 4 * source.graph.candidates.len() * original_positions.max(1) - ); - assert!(expanded - .dag - .candidates - .iter() - .all(|candidate| candidate.local_cost == 0.0)); - let builds = source - .templates - .iter() - .enumerate() - .filter_map(|(id, template)| kll(template).then_some(id)) - .collect::>(); - assert!( - builds.len() >= 3, - "both local builds and explicit shared state survive" - ); - for source in builds { - let modes = expanded - .physical - .candidates - .iter() - .filter(|configuration| configuration.operator_node.0 == source) - .map(|configuration| configuration.materialization) - .collect::>(); - assert!(modes.contains(&Materialization::NotMaterialized)); - assert!(modes.contains(&Materialization::MatAtQuery(Medium::Memory))); - assert!(modes.contains(&Materialization::MatAtIngestion(Medium::Memory))); - assert!(!expanded - .physical - .candidates - .iter() - .any(|configuration| configuration.operator_node.0 == source - && configuration.materialization == Materialization::NotMaterialized - && configuration.timing == ExecutionTiming::IngestionTime)); - } - for (consumer, candidate) in expanded.dag.candidates.iter().enumerate() { - for producer in candidate - .producer_candidates_per_input - .iter() - .flat_map(|input| &input.candidates) - { - assert!(compatible_timing( - expanded.physical.candidates[consumer].timing, - &expanded.physical.candidates[producer.0] - )); - } - } - assert!(expanded - .dag - .query_roots - .iter() - .flat_map(|root| &root.candidates) - .all(|id| expanded.physical.candidates[id.0].timing == ExecutionTiming::QueryTime)); - } - - // Identical logical readers need separate bindings when their whole-window - // ingestion phases disagree. Every copy still passes the existing Stage 2. - #[test] - fn identical_quantiles_with_distinct_phases_have_separate_execution_scopes() { - let (catalog, mut entries, data) = fixture(); - let root = catalog.graph.query_roots[0] - .candidates - .iter() - .map(|id| &catalog.templates[id.0]) - .find(|node| OperatorNode::reachable(node).iter().any(|child| kll(child))) - .unwrap() - .clone(); - fn append( - node: &Rc, - nodes: &mut Vec<(Rc, Vec>)>, - ) -> PhysicalCandidateId { - let mut inputs = Vec::new(); - node.operator.map_children(|child| { - inputs.push(vec![append(child, nodes)]); - child.clone() - }); - nodes.push((node.clone(), inputs)); - PhysicalCandidateId(nodes.len() - 1) - } - let mut nodes = Vec::new(); - let output = append(&root, &mut nodes); - let source = inventory_for_tests(nodes, vec![vec![output]; 2]); - for (index, entry) in entries.iter_mut().enumerate() { - entry.recurrence = QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { - interval: RepetitionInterval(600_000), - evaluation_phase: asap_types::workload::TimestampMs(index as u64 * 300_000), - }); - } - let deployment = DeploymentCapabilities { - raw_data_retained: false, - ..Default::default() - }; - let expanded = - enumerate_candidate_configurations(&source, &[0, 1], &entries, &data, &deployment) - .unwrap(); - let selected = select_candidates(&expanded.dag) - .unwrap() - .expect("both incompatible phases must be covered"); - let builds: Vec<_> = selected - .selected_candidates - .iter() - .filter(|id| kll(expanded.node(id.0))) - .collect(); - assert_eq!(builds.len(), 2); - assert!(builds - .iter() - .all(|id| expanded.physical.candidates[id.0].materialization - == Materialization::MatAtIngestion(Medium::Memory))); - let mut modes = vec![None; expanded.physical.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(expanded.physical.candidates[id.0].materialization); - } - let query_roots = vec![QueryRoot::Operator(root); 2]; - let physical = asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates( - &expanded.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &query_roots, - &entries, - &data, - deployment.query_time_results_retained, - ) - .unwrap(); - physical.plan.dag.validate().unwrap(); - assert_eq!(physical.plan.roots.len(), 2); - } - - // Repeating the same workload entry adds output references, not a quadratic - // family of identical execution scopes and producer connections. - #[test] - fn repeated_identical_queries_keep_scope_and_connection_counts_constant() { - let (mut source, entries, data) = fixture(); - let root = source.inventory.query_output_nodes[0].clone(); - let mut counts = Vec::new(); - for count in [2, 32] { - source.inventory.query_output_nodes = vec![root.clone(); count]; - let entries = vec![entries[0].clone(); count]; - let outputs: Vec<_> = (0..count).collect(); - let expanded = enumerate_candidate_configurations( - &source, - &outputs, - &entries, - &data, - &DeploymentCapabilities::default(), - ) - .unwrap(); - let edges: usize = expanded - .dag - .candidates - .iter() - .flat_map(|candidate| &candidate.producer_candidates_per_input) - .map(|input| input.candidates.len()) - .sum(); - counts.push((expanded.physical.candidates.len(), edges)); - } - assert_eq!(counts[0], counts[1]); - } - - // A deployment without raw retention cannot execute a query-time scan; - // reject those configurations before spending refinement rounds on them. - #[test] - fn no_raw_retention_excludes_query_time_scan_configurations() { - let (source, entries, data) = fixture(); - let expanded = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &DeploymentCapabilities { - raw_data_retained: false, - ..Default::default() - }, - ) - .unwrap(); - let scans = expanded - .physical - .candidates - .iter() - .enumerate() - .filter(|(id, _)| { - matches!( - expanded.node(*id).operator, - asap_types::ir::Operator::NonASAP(asap_types::ir::NonASAPOp::Scan { .. }) - ) - }) - .collect::>(); - assert!(!scans.is_empty()); - assert!(scans - .iter() - .all(|(_, config)| config.timing == ExecutionTiming::IngestionTime)); - assert!(select_candidates(&expanded.dag).unwrap().is_some()); - } - - // Two references belonging to one scalar query cannot justify shared-query materialization. - #[test] - fn repeated_scalar_references_are_not_two_query_readers() { - let (source, entries, data) = fixture(); - let expanded = enumerate_candidate_configurations( - &source, - &[0, 0], - &entries, - &data, - &DeploymentCapabilities::default(), - ) - .unwrap(); - let selected = select_candidates(&expanded.dag).unwrap().unwrap(); - assert!(selected - .selected_candidates - .iter() - .all(|id| expanded.physical.candidates[id.0].materialization - != Materialization::MatAtQuery(Medium::Memory))); - } - - // Requiring ingestion for output zero remains feasible beside an incompatible - // optional reader: output one can use another configuration of the same state. - #[test] - fn unpredictable_consumer_does_not_remove_predictable_ingestion_configuration() { - let (source, mut entries, data) = fixture(); - entries[1].predictability = Predictability::AdHoc; - let mut expanded = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &DeploymentCapabilities::default(), - ) - .unwrap(); - let shared_build = source - .group_candidates - .iter() - .flatten() - .find(|id| kll(&source.templates[id.0])) - .unwrap(); - let ingestion = expanded - .physical - .candidates - .iter() - .position(|configuration| { - configuration.operator_node.0 == shared_build.0 - && configuration.materialization - == Materialization::MatAtIngestion(Medium::Memory) - }) - .unwrap(); - // Add a required output reference to this producer via a query-time - // reader root that already depends on its state group, preserving valid roots. - let reader_sources = source - .graph - .candidates - .iter() - .enumerate() - .filter(|(_, candidate)| { - candidate - .producer_candidates_per_input - .iter() - .any(|input| input.candidates.contains(shared_build)) - }) - .map(|(id, _)| id) - .collect::>(); - assert!(reader_sources.len() >= 2); - for (id, configuration) in expanded.physical.candidates.iter().enumerate() { - if reader_sources.contains(&configuration.operator_node.0) - && configuration.timing == ExecutionTiming::QueryTime - { - for input in &mut expanded.dag.candidates[id].producer_candidates_per_input { - if input.candidates.iter().any(|producer| { - expanded.physical.candidates[producer.0].operator_node.0 == shared_build.0 - }) { - // Query-specific readers have their original logical owner. - let group = |node: usize| { - source.operator_nodes_of_candidates_for_memo_groups[node].group() - }; - let source_group = group(configuration.operator_node.0); - let is_first = source.graph.query_roots[0] - .candidates - .iter() - .any(|root| group(root.0) == source_group); - input.candidates.retain(|producer| { - if is_first { - producer.0 == ingestion - } else { - producer.0 != ingestion - } - }); - } - } - } - } - // Make complete exact/local choices expensive, so the exercised solution - // uses the shared-state reader path. These are test-only coefficients. - for (id, candidate) in expanded.dag.candidates.iter_mut().enumerate() { - let source_id = expanded.physical.candidates[id].operator_node; - candidate.local_cost = if source_id.0 == shared_build.0 { - 1.0 - } else if kll(&source.templates[source_id.0]) { - 50.0 - } else if matches!( - &source.templates[source_id.0].asap(), - Some(ASAPOp::SummaryAgg { .. }) - ) || matches!( - &source.templates[source_id.0].non_asap(), - Some(asap_types::ir::NonASAPOp::Aggregate { .. }) - ) { - 100.0 - } else { - 0.0 - }; - } - let selected = select_candidates(&expanded.dag).unwrap().unwrap(); - assert!(selected - .selected_candidates - .contains(&PhysicalCandidateId(ingestion))); - assert!(selected - .selected_candidates - .iter() - .any( - |id| expanded.physical.candidates[id.0].operator_node.0 == shared_build.0 - && expanded.physical.candidates[id.0].timing == ExecutionTiming::QueryTime - )); - } -} diff --git a/crates/plan-selection/src/search/candidates/dag.rs b/crates/plan-selection/src/search/candidates/dag.rs deleted file mode 100644 index 62cc4a958..000000000 --- a/crates/plan-selection/src/search/candidates/dag.rs +++ /dev/null @@ -1,965 +0,0 @@ -//! Stage 3's DAG of Stage 2's physical candidates with their costs: what the -//! MILP selects over. Stage 2's output carries no cost; lower bounds build -//! this from it ([`PhysicalCandidatesWithCosts::new`]) and fill the costs. -use std::collections::HashSet; -use std::rc::Rc; - -use crate::search::candidates::graph::SelectedCandidateGraph; -use asap_types::ir::OperatorNode; -use asap_types::stages::{PhysicalASAPMemoCandidates, PhysicalCandidateId, ProducerCandidates}; - -/// Today's engine's reader constraints: Stage 2's -/// ([`asap_types::stages::ReaderConstraint`]), plus the activation order of -/// the execution-scope copies the engine expands -/// ([`crate::search::candidates::scopes`]). Deleted with the engine (#1095). -#[derive(Debug, Clone)] -pub(crate) enum ReaderConstraint { - /// Symmetry only: interchangeable execution-scope copies activate in - /// order. Both copies still need genuine consumers; this does not - /// create a DAG edge. - ActivationRequires { - candidate: PhysicalCandidateId, - required_candidate: PhysicalCandidateId, - }, - /// As Stage 2's, on each selected copy. - MinimumQueryReaders { - candidate: PhysicalCandidateId, - query_outputs: Vec>, - minimum: usize, - }, - /// As Stage 2's, on each selected copy. - HighestSharedQueryMaterialization { - candidate: PhysicalCandidateId, - query_outputs: Vec>, - }, - /// As Stage 2's, on each selected copy. - AllowedOutputs { - candidate: PhysicalCandidateId, - outputs: Vec, - }, - /// As Stage 2's, on each selected copy. - IncompatibleOutputs { - candidate: PhysicalCandidateId, - left: usize, - right: usize, - }, -} - -impl From for ReaderConstraint { - fn from(constraint: asap_types::stages::ReaderConstraint) -> Self { - use asap_types::stages::ReaderConstraint as Stage2; - match constraint { - Stage2::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => Self::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - }, - Stage2::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => Self::HighestSharedQueryMaterialization { - candidate, - query_outputs, - }, - Stage2::AllowedOutputs { candidate, outputs } => { - Self::AllowedOutputs { candidate, outputs } - } - Stage2::IncompatibleOutputs { - candidate, - left, - right, - } => Self::IncompatibleOutputs { - candidate, - left, - right, - }, - } - } -} - -impl ReaderConstraint { - #[cfg(test)] - /// Stage 2's constraint it is; `None` for `ActivationRequires`. - pub(crate) fn to_stage2(&self) -> Option { - use asap_types::stages::ReaderConstraint as Stage2; - Some(match self.clone() { - Self::ActivationRequires { .. } => return None, - Self::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => Stage2::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - }, - Self::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => Stage2::HighestSharedQueryMaterialization { - candidate, - query_outputs, - }, - Self::AllowedOutputs { candidate, outputs } => { - Stage2::AllowedOutputs { candidate, outputs } - } - Self::IncompatibleOutputs { - candidate, - left, - right, - } => Stage2::IncompatibleOutputs { - candidate, - left, - right, - }, - }) - } -} - -/// One required input, distinct even when another input accepts the same -/// producers. -#[derive(Debug, Clone)] -pub(crate) struct InputProducerCandidates { - /// The candidates that may produce it. - pub candidates: ProducerCandidates, -} - -/// One physical candidate, indexed like -/// [`PhysicalASAPMemoCandidates::candidates`], with its cost over the same -/// fixed horizon. -#[derive(Debug, Clone)] -pub(crate) struct PhysicalCandidateWithCost { - /// Local work and state only; producer costs must not appear here again. - pub local_cost: f64, - /// Per input, the candidates that may produce it (as Stage 2's - /// `PhysicalCandidate::producer_candidates_per_input`). - pub producer_candidates_per_input: Vec, -} - -/// One output reference of the query roots. -#[derive(Debug, Clone)] -pub(crate) struct QueryOutputCandidates { - /// The candidates allowed to produce it. - pub candidates: Vec, -} - -/// Additive usage over a fixed accounting context, owned once per candidate. -/// Peak temporaries and connection-dependent demand require a different model. -#[derive(Debug, Clone)] -pub(crate) struct ResourceBudget { - pub name: String, - pub capacity: f64, - /// Same index order as the graph's candidates. - pub candidate_usage: Vec, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct DagOfPhysicalCandidatesWithCosts { - /// By [`PhysicalCandidateId`]. - pub candidates: Vec, - /// One per output reference of the query roots, in order. - pub query_roots: Vec, - /// Resource limits the selection must respect. - pub resource_budgets: Vec, - /// Legality that depends on the selected readers. - pub reader_constraints: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum CandidateSelectionError { - #[error("direct cost model is not representable: {0}")] - UnsupportedDirectCost(String), - #[error("invalid DAG of physical candidates with costs: {0}")] - InvalidGraph(String), - #[error("candidate selection solver: {0}")] - Solver(String), -} - -// Positive costs must fit within the supported ratio before scaling the -// smallest to unit magnitude; rejecting a wider range is an -// explicit model restriction, not proof of infeasibility or a completeness claim. -// Resource rows use the same conservative positive coefficient range. -pub(crate) const MINIMUM_RELATIVE_COEFFICIENT: f64 = 1e-9; - -impl DagOfPhysicalCandidatesWithCosts { - pub(crate) fn validate(&self) -> Result<(), CandidateSelectionError> { - let validate_ids = |ids: &[PhysicalCandidateId], context: &str| { - let mut seen = HashSet::new(); - for id in ids { - if id.0 >= self.candidates.len() { - return Err(CandidateSelectionError::InvalidGraph(format!( - "{context} references missing candidate {}", - id.0 - ))); - } - if !seen.insert(*id) { - return Err(CandidateSelectionError::InvalidGraph(format!( - "{context} repeats candidate {}", - id.0 - ))); - } - } - Ok(()) - }; - for budget in &self.resource_budgets { - if budget.name.is_empty() - || !budget.capacity.is_finite() - || budget.capacity < 0.0 - || budget.candidate_usage.len() != self.candidates.len() - || budget - .candidate_usage - .iter() - .any(|usage| !usage.is_finite() || *usage < 0.0) - || !budget.candidate_usage.iter().sum::().is_finite() - { - return Err(CandidateSelectionError::InvalidGraph(format!( - "resource budget {:?} needs finite nonnegative capacity and one finite nonnegative usage per candidate", budget.name - ))); - } - } - for budget in &self.resource_budgets { - let scale = budget - .candidate_usage - .iter() - .copied() - .fold(budget.capacity, f64::max); - if scale > 0.0 - && budget - .candidate_usage - .iter() - .chain(std::iter::once(&budget.capacity)) - .any(|value| *value > 0.0 && *value / scale < MINIMUM_RELATIVE_COEFFICIENT) - { - return Err(CandidateSelectionError::InvalidGraph(format!( - "resource budget {:?} exceeds the supported positive coefficient range ({MINIMUM_RELATIVE_COEFFICIENT}); restrict or reformulate the resource model", budget.name - ))); - } - } - for requirement in &self.reader_constraints { - let (candidate, outputs) = match requirement { - ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } => { - if required_candidate.0 >= self.candidates.len() - || candidate == required_candidate - { - return Err(CandidateSelectionError::InvalidGraph( - "activation constraint references missing or identical candidate" - .into(), - )); - } - (*candidate, Vec::new()) - } - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - .. - } - | ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => { - let outputs: Vec<_> = query_outputs.iter().flatten().copied().collect(); - let unique: HashSet<_> = outputs.iter().copied().collect(); - if outputs.len() != self.query_roots.len() || unique.len() != outputs.len() { - return Err(CandidateSelectionError::InvalidGraph( - "query reader buckets must partition every output exactly once".into(), - )); - } - (*candidate, outputs) - } - ReaderConstraint::AllowedOutputs { candidate, outputs } => { - (*candidate, outputs.clone()) - } - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => (*candidate, vec![*left, *right]), - }; - if candidate.0 >= self.candidates.len() - || outputs - .iter() - .any(|&output| output >= self.query_roots.len()) - { - return Err(CandidateSelectionError::InvalidGraph( - "reader constraint references missing candidate or output".into(), - )); - } - } - let mut total_cost = 0.0; - for (index, candidate) in self.candidates.iter().enumerate() { - if !candidate.local_cost.is_finite() || candidate.local_cost < 0.0 { - return Err(CandidateSelectionError::InvalidGraph(format!( - "candidate {index} needs a finite nonnegative local cost" - ))); - } - total_cost += candidate.local_cost; - for (requirement, input) in candidate.producer_candidates_per_input.iter().enumerate() { - validate_ids( - &input.candidates, - &format!("candidate {index}, requirement {requirement}"), - )?; - } - } - if !total_cost.is_finite() { - return Err(CandidateSelectionError::InvalidGraph( - "total candidate cost overflows".into(), - )); - } - let maximum_cost = self - .candidates - .iter() - .map(|c| c.local_cost) - .fold(0.0, f64::max); - if maximum_cost > 0.0 - && self.candidates.iter().any(|candidate| { - candidate.local_cost > 0.0 - && candidate.local_cost / maximum_cost < MINIMUM_RELATIVE_COEFFICIENT - }) - { - return Err(CandidateSelectionError::InvalidGraph(format!( - "positive local costs must be at least {MINIMUM_RELATIVE_COEFFICIENT} of the largest cost; wider ranges require a different numerical policy" - ))); - } - for (index, root) in self.query_roots.iter().enumerate() { - validate_ids(&root.candidates, &format!("query root {index}"))?; - } - Ok(()) - } -} - -/// Check the decoded integral graph instead of trusting rounded solver values: -/// its structure (`validate_selected_connections`), resource budgets and -/// modeled cost. -pub(crate) fn validate_selected_graph( - graph: &DagOfPhysicalCandidatesWithCosts, - selected: &SelectedCandidateGraph, -) -> Result<(), String> { - let query_output_candidates: Vec<&[PhysicalCandidateId]> = graph - .query_roots - .iter() - .map(|root| root.candidates.as_slice()) - .collect(); - // `to_selected_candidates` orders the roots by output index, so each - // output must be assigned exactly once before that order means anything. - let mut assigned_outputs: Vec = selected - .query_roots - .iter() - .map(|root| root.query_root) - .collect(); - assigned_outputs.sort_unstable(); - if !assigned_outputs - .iter() - .copied() - .eq(0..graph.query_roots.len()) - { - return Err("decoded candidate selection violates dependency or root constraints".into()); - } - validate_selected_connections( - graph.candidates.len(), - |candidate| { - graph.candidates[candidate.0] - .producer_candidates_per_input - .len() - }, - |candidate, input| { - graph.candidates[candidate.0] - .producer_candidates_per_input - .get(input) - .map(|requirement| &requirement.candidates) - }, - &query_output_candidates, - &graph.reader_constraints, - selected, - )?; - let active: HashSet<_> = selected.selected_candidates.iter().copied().collect(); - for budget in &graph.resource_budgets { - let usage: f64 = active.iter().map(|c| budget.candidate_usage[c.0]).sum(); - if usage > budget.capacity { - return Err(format!( - "decoded selection exceeds resource budget {:?}", - budget.name - )); - } - } - let expected_cost: f64 = active - .iter() - .map(|c| graph.candidates[c.0].local_cost) - .sum(); - if !selected.modeled_cost.is_finite() - || (selected.modeled_cost - expected_cost).abs() > 1e-9 * expected_cost.abs() - { - return Err("decoded candidate cost disagrees with selected candidates".into()); - } - Ok(()) -} - -/// Today's engine's structural check of a selection over its graph (one -/// run per candidate): every selected candidate exists once, each output -/// reference has one allowed root, each selected input has exactly one -/// allowed selected producer, the selection is acyclic, reaches only what -/// the roots read, and keeps every reader constraint. -fn validate_selected_connections<'a>( - candidate_count: usize, - input_count: impl Fn(PhysicalCandidateId) -> usize, - producers_of_input: impl Fn(PhysicalCandidateId, usize) -> Option<&'a ProducerCandidates>, - query_output_candidates: &[&[PhysicalCandidateId]], - reader_constraints: &[ReaderConstraint], - selected: &SelectedCandidateGraph, -) -> Result<(), String> { - let invalid = - || "decoded candidate selection violates dependency or root constraints".to_string(); - let active: HashSet<_> = selected.selected_candidates.iter().copied().collect(); - if active.len() != selected.selected_candidates.len() - || active.iter().any(|c| c.0 >= candidate_count) - || selected.query_roots.len() != query_output_candidates.len() - { - return Err(invalid()); - } - let query_output_candidates_selected = selected.output_candidates(); - for (candidate, allowed) in query_output_candidates_selected - .iter() - .zip(query_output_candidates) - { - if !active.contains(candidate) || !allowed.contains(candidate) { - return Err(invalid()); - } - } - let mut producers: Vec>> = (0..candidate_count) - .map(|c| vec![None; input_count(PhysicalCandidateId(c))]) - .collect(); - for connection in &selected.connections { - if !active.contains(&connection.producer) || !active.contains(&connection.consumer) { - return Err(invalid()); - } - let Some(producer_candidates) = - producers_of_input(connection.consumer, connection.requirement) - else { - return Err(invalid()); - }; - let target = &mut producers[connection.consumer.0][connection.requirement]; - if target.is_some() || !producer_candidates.contains(&connection.producer) { - return Err(invalid()); - } - *target = Some(connection.producer); - } - if active - .iter() - .any(|c| producers[c.0].iter().any(Option::is_none)) - { - return Err(invalid()); - } - // Kahn's algorithm checks cycles without recursion on a potentially deep DAG. - let mut outstanding: Vec<_> = producers.iter().map(Vec::len).collect(); - let mut consumers = vec![Vec::new(); candidate_count]; - for connection in &selected.connections { - consumers[connection.producer.0].push(connection.consumer.0); - } - let mut ready: Vec<_> = active - .iter() - .filter(|c| outstanding[c.0] == 0) - .map(|c| c.0) - .collect(); - let mut visited = 0; - while let Some(producer) = ready.pop() { - visited += 1; - for &consumer in &consumers[producer] { - outstanding[consumer] -= 1; - if outstanding[consumer] == 0 { - ready.push(consumer); - } - } - } - if visited != active.len() { - return Err(invalid()); - } - let mut reachable = HashSet::new(); - let mut pending: Vec<_> = query_output_candidates_selected.clone(); - while let Some(candidate) = pending.pop() { - if reachable.insert(candidate) { - pending.extend(producers[candidate.0].iter().flatten().copied()); - } - } - // Reuse one traversal per output across all reader-compatibility checks. - let mut output_reach = Vec::new(); - if !reader_constraints.is_empty() { - output_reach = vec![HashSet::new(); query_output_candidates.len()]; - for (output, &root) in query_output_candidates_selected.iter().enumerate() { - let reached = &mut output_reach[output]; - let mut pending = vec![root]; - while let Some(candidate) = pending.pop() { - if reached.insert(candidate) { - pending.extend(producers[candidate.0].iter().flatten().copied()); - } - } - } - } - let output_reaches = - |output: usize, target: PhysicalCandidateId| output_reach[output].contains(&target); - for requirement in reader_constraints { - let violated = match requirement { - ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } => active.contains(candidate) && !active.contains(required_candidate), - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => { - active.contains(candidate) - && query_outputs - .iter() - .filter(|outputs| { - outputs - .iter() - .any(|&output| output_reaches(output, *candidate)) - }) - .count() - < *minimum - } - ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => { - active.contains(candidate) - && !query_output_candidates_selected.contains(candidate) - && !selected.connections.iter().any(|edge| { - edge.producer == *candidate - && query_outputs - .iter() - .filter(|outputs| { - outputs - .iter() - .any(|output| output_reaches(*output, edge.consumer)) - }) - .count() - <= 1 - }) - } - ReaderConstraint::AllowedOutputs { candidate, outputs } => (0..query_output_candidates - .len()) - .any(|output| !outputs.contains(&output) && output_reaches(output, *candidate)), - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => output_reaches(*left, *candidate) && output_reaches(*right, *candidate), - }; - if violated { - return Err("decoded selection violates selected-reader compatibility".into()); - } - } - if reachable != active { - return Err(invalid()); - } - Ok(()) -} - -impl DagOfPhysicalCandidatesWithCosts { - /// `physical`'s candidates, producers, query outputs and reader - /// constraints, at zero cost and with no resource budgets. - pub(crate) fn unpriced(physical: &PhysicalASAPMemoCandidates) -> Self { - Self { - candidates: physical - .candidates - .iter() - .map(|candidate| PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: candidate - .producer_candidates_per_input - .iter() - .map(|suppliers| InputProducerCandidates { - candidates: suppliers.clone(), - }) - .collect(), - }) - .collect(), - query_roots: (0..physical.output_reference_count()) - .map(|output| QueryOutputCandidates { - candidates: physical.query_output_candidates(output).collect(), - }) - .collect(), - resource_budgets: Vec::new(), - reader_constraints: physical - .reader_constraints - .iter() - .cloned() - .map(ReaderConstraint::from) - .collect(), - } - } -} - -/// Stage 3's search input: Stage 2's candidates, and their DAG with costs, -/// indexed alike by [`PhysicalCandidateId`]. -#[derive(Debug, Clone)] -pub(crate) struct PhysicalCandidatesWithCosts { - /// Stage 2's output: each candidate's operator node, timing and - /// materialization. - pub physical: PhysicalASAPMemoCandidates, - /// What the MILP selects over: the same candidates with their costs. - pub dag: DagOfPhysicalCandidatesWithCosts, -} - -/// The workload's query roots as Stage 3 sees them, from Stage 2's output -/// alone: each output reference of `physical.query_roots` is the operator -/// tree of its group's first implementation root (its -/// `physical_node`). The engine's heuristics read them instead of the -/// frontends' roots (#1047 P7); `None` when a referenced group has no -/// implementation root. -pub(crate) fn query_roots_of( - physical: &PhysicalASAPMemoCandidates, -) -> Option> { - use asap_types::ir::QueryRoot; - let tree = |group: &asap_types::stages::MemoGroupId| { - let &root = physical.implementation_roots(*group).first()?; - let candidate = &physical.candidates[root.0]; - Some(Rc::clone( - &physical.operator_nodes_of_candidates_for_memo_groups[candidate.operator_node.0] - .physical_node, - )) - }; - if !physical - .query_roots - .iter() - .flat_map(QueryRoot::operator_refs) - .all(|group| tree(group).is_some()) - { - return None; - } - let tree = |group: &asap_types::stages::MemoGroupId| tree(group).expect("checked above"); - Some( - physical - .query_roots - .iter() - .map(|root| match root { - QueryRoot::Operator(group) => QueryRoot::Operator(tree(group)), - QueryRoot::Scalar(expression) => { - QueryRoot::Scalar(expression.map_operator_refs(&mut |group| tree(group))) - } - }) - .collect(), - ) -} - -impl PhysicalCandidatesWithCosts { - /// `physical` with an unpriced DAG ([`DagOfPhysicalCandidatesWithCosts::unpriced`]). - pub(crate) fn new(physical: PhysicalASAPMemoCandidates) -> Self { - let dag = DagOfPhysicalCandidatesWithCosts::unpriced(&physical); - Self { physical, dag } - } - - /// `selected` against the DAG: its structure, resource budgets and cost - /// ([`validate_selected_graph`]), which Stage 2's reconstruction no - /// longer checks. - pub(crate) fn validate_selected( - &self, - selected: &SelectedCandidateGraph, - ) -> Result<(), String> { - self.dag.validate().map_err(|error| error.to_string())?; - validate_selected_graph(&self.dag, selected) - } - - /// The operator node candidate `id` runs. - pub(crate) fn node(&self, id: usize) -> &Rc { - self.physical.physical_node(PhysicalCandidateId(id)) - } -} - -#[cfg(test)] -impl PhysicalCandidatesWithCosts { - /// No candidates; tests append them with [`Self::push_for_tests`]. - pub(crate) fn empty_for_tests() -> Self { - Self::from_dag_for_tests(DagOfPhysicalCandidatesWithCosts::default(), Vec::new()) - } - - /// `dag` with candidate `i` running `nodes[i].0` at timing and - /// materialization `nodes[i].1`, each on its own operator node `i` - /// ([`Self::sync_physical_for_tests`]). - pub(crate) fn from_dag_for_tests( - dag: DagOfPhysicalCandidatesWithCosts, - nodes: Vec<( - Rc, - ( - asap_types::ir::properties::ExecutionTiming, - asap_types::physical::Materialization, - ), - )>, - ) -> Self { - assert_eq!(dag.candidates.len(), nodes.len()); - let candidates = nodes - .iter() - .enumerate() - .map( - |(id, (_, (timing, materialization)))| asap_types::stages::PhysicalCandidate { - operator_node: asap_types::stages::OperatorNodeOfCandidateForMemoGroupId(id), - timing: *timing, - materialization: *materialization, - producer_candidates_per_input: Vec::new(), - may_produce_query_output: false, - may_keep_across_evaluations_per_sole_reader_query: Vec::new(), - }, - ) - .collect(); - let operator_nodes = nodes - .into_iter() - .enumerate() - .map( - |(id, (node, _))| asap_types::stages::OperatorNodeOfCandidateForMemoGroup { - candidates_for_memo_group: vec![asap_types::stages::CandidateForMemoGroupId { - group: asap_types::stages::MemoGroupId(0), - index: id, - }], - is_implementation_root: false, - accuracy_budget_parts: 1, - physical_node: node, - }, - ) - .collect(); - let mut configured = Self { - physical: PhysicalASAPMemoCandidates::new( - operator_nodes, - candidates, - Vec::new(), - Vec::new(), - ), - dag, - }; - configured.sync_physical_for_tests(); - configured - } - - /// Appends a candidate running `node` at `timing` and `materialization` - /// on its own operator node, at `local_cost`, reading `inputs`; returns - /// its ID. Call [`Self::sync_physical_for_tests`] once the DAG is complete. - pub(crate) fn push_for_tests( - &mut self, - node: Rc, - timing: asap_types::ir::properties::ExecutionTiming, - materialization: asap_types::physical::Materialization, - local_cost: f64, - inputs: Vec, - ) -> PhysicalCandidateId { - let id = PhysicalCandidateId(self.physical.candidates.len()); - let operator_node = asap_types::stages::OperatorNodeOfCandidateForMemoGroupId( - self.physical - .operator_nodes_of_candidates_for_memo_groups - .len(), - ); - self.physical - .operator_nodes_of_candidates_for_memo_groups - .push(asap_types::stages::OperatorNodeOfCandidateForMemoGroup { - candidates_for_memo_group: vec![asap_types::stages::CandidateForMemoGroupId { - group: asap_types::stages::MemoGroupId(0), - index: operator_node.0, - }], - is_implementation_root: false, - accuracy_budget_parts: 1, - physical_node: node, - }); - self.physical - .candidates - .push(asap_types::stages::PhysicalCandidate { - operator_node, - timing, - materialization, - producer_candidates_per_input: Vec::new(), - may_produce_query_output: false, - may_keep_across_evaluations_per_sole_reader_query: Vec::new(), - }); - self.dag.candidates.push(PhysicalCandidateWithCost { - local_cost, - producer_candidates_per_input: inputs, - }); - self.sync_physical_for_tests(); - id - } - - /// Makes `physical` agree with a DAG a test built or edited directly: - /// producers and reader constraints are the DAG's, and the candidates - /// each distinct DAG query-root list names become the implementation - /// roots, with `may_produce_query_output`, of one group (1, 2, … in - /// order of first appearance) that output references; other candidates' - /// nodes are private nodes of group 0. Each candidate keeps its operator - /// node, timing and materialization. - pub(crate) fn sync_physical_for_tests(&mut self) { - use asap_types::stages::{CandidateForMemoGroupId, MemoGroupId}; - let mut lists: Vec> = Vec::new(); - let mut query_roots = Vec::new(); - for root in &self.dag.query_roots { - let mut list = root.candidates.clone(); - list.sort(); - let group = match lists.iter().position(|known| *known == list) { - Some(position) => position + 1, - None => { - lists.push(list); - lists.len() - } - }; - query_roots.push(asap_types::ir::QueryRoot::Operator(MemoGroupId( - group as u32, - ))); - } - let mut group_of = vec![None; self.physical.candidates.len()]; - for (position, list) in lists.iter().enumerate() { - for id in list { - assert!( - group_of[id.0] - .replace(position + 1) - .is_none_or(|group| group == position + 1), - "test DAG gives candidate {} two different query-root lists", - id.0 - ); - } - } - for (id, candidate) in self.physical.candidates.iter_mut().enumerate() { - candidate.producer_candidates_per_input = self.dag.candidates[id] - .producer_candidates_per_input - .iter() - .map(|input| input.candidates.clone()) - .collect(); - candidate.may_produce_query_output = group_of[id].is_some(); - let node = &mut self.physical.operator_nodes_of_candidates_for_memo_groups - [candidate.operator_node.0]; - node.is_implementation_root = group_of[id].is_some(); - node.candidates_for_memo_group = vec![CandidateForMemoGroupId { - group: MemoGroupId(group_of[id].unwrap_or(0) as u32), - index: candidate.operator_node.0, - }]; - } - let physical = std::mem::replace( - &mut self.physical, - PhysicalASAPMemoCandidates::new(Vec::new(), Vec::new(), Vec::new(), Vec::new()), - ); - self.physical = PhysicalASAPMemoCandidates::new( - physical.operator_nodes_of_candidates_for_memo_groups, - physical.candidates, - query_roots, - self.dag - .reader_constraints - .iter() - .filter_map(ReaderConstraint::to_stage2) - .collect(), - ); - } - - /// Stage 2's structural pruning of `physical`, with the DAG restricted to - /// the kept candidates at their costs and resource usage. Each candidate - /// must run its own operator node (as the test constructors make them), - /// which then names its index before pruning. Returns how many - /// candidates were pruned. - pub(crate) fn prune_for_tests(&mut self) -> usize { - let physical = std::mem::replace( - &mut self.physical, - PhysicalASAPMemoCandidates::new(Vec::new(), Vec::new(), Vec::new(), Vec::new()), - ); - self.physical = - asap_physical_asap_optimization::test_support::catalog::prune(physical).unwrap(); - let kept: Vec<_> = self - .physical - .candidates - .iter() - .map(|candidate| candidate.operator_node.0) - .collect(); - let mut dag = DagOfPhysicalCandidatesWithCosts::unpriced(&self.physical); - for (candidate, &original) in dag.candidates.iter_mut().zip(&kept) { - candidate.local_cost = self.dag.candidates[original].local_cost; - } - dag.resource_budgets = self - .dag - .resource_budgets - .iter() - .map(|budget| ResourceBudget { - candidate_usage: kept.iter().map(|&id| budget.candidate_usage[id]).collect(), - ..budget.clone() - }) - .collect(); - self.dag = dag; - self.physical.pruned_candidates.len() - } -} - -/// An operator node for tests whose candidates' operators do not matter. -#[cfg(test)] -pub(crate) fn placeholder_node_for_tests() -> Rc { - use asap_types::ir::scalar::expr_ir::ScalarValue; - use asap_types::ir::schema::{DataType, Field, Schema}; - use asap_types::ir::{NonASAPOp, Operator, ScalarExpr}; - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Values { - rows: vec![vec![ScalarExpr::Literal(ScalarValue::Float64(1.0))]], - schema: Schema::lifted(vec![Field::plain("value", DataType::Float64, false)], None), - })) - .unwrap() -} - -#[cfg(test)] -impl PhysicalCandidatesWithCosts { - /// Each candidate's operator node, by candidate ID. - pub(crate) fn nodes_for_tests(&self) -> Vec> { - (0..self.physical.candidates.len()) - .map(|id| Rc::clone(self.node(id))) - .collect() - } - - /// Makes candidate `id` alone run `node`: it gets its own copy of its - /// operator node (same group and root flag) with `node` as its physical - /// node; other candidates of its old operator node are unchanged. - pub(crate) fn set_node_for_tests(&mut self, id: usize, node: Rc) { - let nodes = &mut self.physical.operator_nodes_of_candidates_for_memo_groups; - let mut own = nodes[self.physical.candidates[id].operator_node.0].clone(); - own.physical_node = node; - nodes.push(own); - self.physical.candidates[id].operator_node = - asap_types::stages::OperatorNodeOfCandidateForMemoGroupId(nodes.len() - 1); - } -} - -#[cfg(test)] -impl PhysicalCandidatesWithCosts { - /// Appends a candidate running `node` as another implementation root of - /// candidate `like`'s group, with `like`'s timing, materialization and - /// cost, reading `inputs`; returns its ID. - pub(crate) fn push_root_like_for_tests( - &mut self, - node: Rc, - like: PhysicalCandidateId, - inputs: Vec, - ) -> PhysicalCandidateId { - let id = PhysicalCandidateId(self.physical.candidates.len()); - let mut operator_nodes = self - .physical - .operator_nodes_of_candidates_for_memo_groups - .clone(); - let mut root = operator_nodes[self.physical.candidates[like.0].operator_node.0].clone(); - root.is_implementation_root = true; - root.physical_node = node; - operator_nodes.push(root); - let mut candidates = self.physical.candidates.clone(); - let mut candidate = candidates[like.0].clone(); - candidate.operator_node = - asap_types::stages::OperatorNodeOfCandidateForMemoGroupId(operator_nodes.len() - 1); - candidate.producer_candidates_per_input = inputs - .iter() - .map(|input| input.candidates.clone()) - .collect(); - candidates.push(candidate); - self.physical = PhysicalASAPMemoCandidates::new( - operator_nodes, - candidates, - self.physical.query_roots.clone(), - self.physical.reader_constraints.clone(), - ); - self.dag.candidates.push(PhysicalCandidateWithCost { - local_cost: self.dag.candidates[like.0].local_cost, - producer_candidates_per_input: inputs, - }); - id - } -} diff --git a/crates/plan-selection/src/search/candidates/factoring.rs b/crates/plan-selection/src/search/candidates/factoring.rs deleted file mode 100644 index 86017699e..000000000 --- a/crates/plan-selection/src/search/candidates/factoring.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Unit tests of Stage 2's terminal-reader factoring -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use asap_physical_asap_optimization::test_support::catalog::{ - factor_terminal_readers, CandidateInventory, MemoGroupOutputKey, - MemoGroupOutputToCompatibleProducersCache, -}; -use asap_types::stages::MemoGroupId; -use asap_types::stages::PhysicalCandidateId; - -#[cfg(test)] -mod tests { - use super::*; - use asap_logical_asap_optimization::test_support::lower_promql; - use asap_types::ir::properties::AccuracyTarget; - - /// Node costs: states 0 and 1 cost 8 and 12, their readers 1 each (the - /// factored reader keeps index 2). - const NODE_COSTS: [f64; 4] = [8.0, 12.0, 1.0, 1.0]; - - fn terminal_fixture() -> CandidateInventory { - use asap_logical_asap_optimization::test_support::{cms_summary, estimate_over}; - use asap_types::ir::properties::AccuracyGuarantee; - use std::rc::Rc; - let state = Rc::new(cms_summary("count(m)")); - let reader = estimate_over(state.clone(), AccuracyGuarantee::exact("fixture")); - crate::search::candidates::configurations::inventory_for_tests( - vec![ - (state.clone(), vec![]), - (state, vec![]), - (reader.clone(), vec![vec![PhysicalCandidateId(0)]]), - (reader, vec![vec![PhysicalCandidateId(1)]]), - ], - vec![vec![PhysicalCandidateId(2), PhysicalCandidateId(3)]; 2], - ) - } - - /// `inventory`'s nodes as candidates (states materialized at ingestion, - /// readers at query time) at [`NODE_COSTS`], with execution scopes for - /// two output references of one query. - fn with_scopes( - inventory: CandidateInventory, - ) -> crate::search::candidates::dag::PhysicalCandidatesWithCosts { - use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, InputProducerCandidates, PhysicalCandidateWithCost, - PhysicalCandidatesWithCosts, QueryOutputCandidates, - }; - use asap_types::ir::properties::ExecutionTiming; - use asap_types::physical::{Materialization, Medium}; - let count = inventory.operator_nodes_of_candidates_for_memo_groups.len(); - let dag = DagOfPhysicalCandidatesWithCosts { - candidates: (0..count) - .map(|id| PhysicalCandidateWithCost { - local_cost: NODE_COSTS[id], - producer_candidates_per_input: inventory.producer_nodes_per_input[id] - .iter() - .map(|suppliers| InputProducerCandidates { - candidates: suppliers.clone(), - }) - .collect(), - }) - .collect(), - query_roots: inventory - .query_output_nodes - .iter() - .map(|nodes| QueryOutputCandidates { - candidates: nodes.clone(), - }) - .collect(), - ..Default::default() - }; - let nodes = (0..count) - .map(|id| { - ( - inventory.operator_nodes_of_candidates_for_memo_groups[id] - .physical_node - .clone(), - if id < 2 { - ( - ExecutionTiming::IngestionTime, - Materialization::MatAtIngestion(Medium::Memory), - ) - } else { - (ExecutionTiming::QueryTime, Materialization::NotMaterialized) - }, - ) - }) - .collect(); - crate::search::candidates::scopes::expand_execution_scopes_for_tests( - PhysicalCandidatesWithCosts::from_dag_for_tests(dag, nodes), - &vec![1; count], - &[0, 0], - ) - .unwrap() - } - - // Enumerate every output-to-state assignment, including split bindings for - // two references of one query. State 1 models a higher-retention producer: - // its different cost and identity must survive despite equal reader types. - #[test] - fn terminal_quotient_preserves_all_bindings_and_pay_once_costs() { - use crate::search::candidates::dag::ReaderConstraint; - use crate::search::milp::model::select_candidates; - for assignment in [[0, 0], [0, 1], [1, 0], [1, 1]] { - let original = with_scopes(terminal_fixture()); - let mut factored = terminal_fixture(); - assert_eq!(factor_terminal_readers(&mut factored, None).unwrap(), 1); - assert_eq!( - factored.operator_nodes_of_candidates_for_memo_groups.len(), - 3 - ); - let compressed = with_scopes(factored); - assert!(compressed.dag.candidates.len() < original.dag.candidates.len()); - for mut configured in [original, compressed] { - for state in 0..2 { - configured - .dag - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(state), - outputs: assignment - .iter() - .enumerate() - .filter_map(|(output, &producer)| { - (producer == state).then_some(output) - }) - .collect(), - }); - } - let selected = select_candidates(&configured.dag) - .unwrap() - .expect("every original assignment remains feasible"); - let expected = if assignment[0] == assignment[1] { - [8.0, 12.0][assignment[0]] + 1.0 - } else { - 22.0 - }; - assert_eq!(selected.modeled_cost, expected, "assignment {assignment:?}"); - } - } - } - - #[test] - fn duplicate_terminal_interfaces_reduce_scoped_candidates_and_connections() { - let build = || { - let mut inventory = terminal_fixture(); - for reader in &mut inventory.producer_nodes_per_input[2..] { - reader[0] = vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - } - inventory - }; - let original = with_scopes(build()); - let mut compact = build(); - assert_eq!(factor_terminal_readers(&mut compact, None).unwrap(), 1); - let compact = with_scopes(compact); - let counts = |graph: &crate::search::candidates::dag::DagOfPhysicalCandidatesWithCosts| { - ( - graph.candidates.len(), - graph - .candidates - .iter() - .flat_map(|candidate| &candidate.producer_candidates_per_input) - .map(|input| input.candidates.len()) - .sum::(), - ) - }; - assert_eq!(counts(&original.dag), (6, 8)); - assert_eq!(counts(&compact.dag), (4, 4)); - } - - #[test] - fn terminal_quotient_preserves_interfaces_and_internal_consumers() { - use asap_types::ir::properties::AccuracyGuarantee; - use std::rc::Rc; - let mut accuracy = terminal_fixture(); - let reader = &mut accuracy.operator_nodes_of_candidates_for_memo_groups[3].physical_node; - let mut other = (**reader).clone(); - other.guarantee = Some(AccuracyGuarantee::exact("different proof contract")); - *reader = Rc::new(other); - assert_eq!(factor_terminal_readers(&mut accuracy, None).unwrap(), 0); - let mut eligibility = terminal_fixture(); - eligibility.query_output_nodes[1] = vec![PhysicalCandidateId(3)]; - assert_eq!(factor_terminal_readers(&mut eligibility, None).unwrap(), 0); - let mut internal = terminal_fixture(); - internal.producer_nodes_per_input[0].push(vec![PhysicalCandidateId(2)].into()); - assert_eq!(factor_terminal_readers(&mut internal, None).unwrap(), 0); - } - - // Logical ownership (including window semantics), closedness and required - // keys remain distinct even when their fields have identical types. - #[test] - fn compatibility_cache_keeps_every_expected_output_requirement() { - let expected = lower_promql("latency", AccuracyTarget::exact()); - let group = MemoGroupId(1); - let key = MemoGroupOutputKey::new(group, &expected).unwrap(); - let mut cache = MemoGroupOutputToCompatibleProducersCache::default(); - let suppliers = vec![PhysicalCandidateId(3), PhysicalCandidateId(7)]; - cache.remember(key, suppliers.clone()); - assert_eq!( - &*cache - .get(&MemoGroupOutputKey::new(group, &expected).unwrap()) - .unwrap(), - &suppliers - ); - assert!(cache - .get(&MemoGroupOutputKey::new(MemoGroupId(2), &expected).unwrap()) - .is_none()); - let mut closed = (*expected).clone(); - closed.schema.closed = !closed.schema.closed; - assert!(cache - .get(&MemoGroupOutputKey::new(group, &closed).unwrap()) - .is_none()); - let mut keys = (*expected).clone(); - keys.schema.unique_keys.push(vec![]); - assert!(cache - .get(&MemoGroupOutputKey::new(group, &keys).unwrap()) - .is_none()); - } - - // Storage sharing never couples the two consumers' producer decisions. - #[test] - fn two_reader_bindings_keep_independent_producer_lists() { - let expected = lower_promql("latency", AccuracyTarget::exact()); - let key = || MemoGroupOutputKey::new(MemoGroupId(1), &expected).unwrap(); - let mut cache = MemoGroupOutputToCompatibleProducersCache::default(); - cache.remember(key(), vec![PhysicalCandidateId(1), PhysicalCandidateId(2)]); - let mut first = cache.get(&key()).unwrap(); - let second = cache.get(&key()).unwrap(); - first.retain(|id| id.0 == 1); - assert_eq!(&*first, &[PhysicalCandidateId(1)]); - assert_eq!(&*second, &[PhysicalCandidateId(1), PhysicalCandidateId(2)]); - assert_eq!(cache.get(&key()).unwrap(), second); - } - // Exhaust every two-reader binding, charging each retained producer once. - // A longer retention requirement has its own logical group and cannot - // accidentally inherit the shorter reader's producer set from the cache. - #[test] - fn cached_interfaces_preserve_exhaustive_sharing_and_retention_choices() { - let expected = lower_promql("latency", AccuracyTarget::exact()); - let short = vec![PhysicalCandidateId(0), PhysicalCandidateId(1)]; - let long = vec![PhysicalCandidateId(1)]; - let mut cache = MemoGroupOutputToCompatibleProducersCache::default(); - let key = |group| MemoGroupOutputKey::new(MemoGroupId(group), &expected).unwrap(); - cache.remember(key(0), short.clone()); - cache.remember(key(1), long.clone()); - let enumerate = |left: &[PhysicalCandidateId], right: &[PhysicalCandidateId]| { - let costs = [8, 12]; - left.iter() - .flat_map(|a| { - right.iter().map(move |b| { - let cost = costs[a.0] + if a == b { 0 } else { costs[b.0] } + 2; - ((a.0, b.0), cost) - }) - }) - .collect::>() - }; - for (right, group, optimum) in [(&short, 0, 10), (&long, 1, 14)] { - let reference = enumerate(&short, right); - let factored = enumerate( - &cache.get(&key(0)).unwrap(), - &cache.get(&key(group)).unwrap(), - ); - assert_eq!(factored, reference); - assert_eq!(factored.values().min(), Some(&optimum)); - assert_eq!( - factored.get(&(0, 1)), - Some(&22), - "two distinct producer bindings remain feasible" - ); - } - } -} diff --git a/crates/plan-selection/src/search/candidates/graph.rs b/crates/plan-selection/src/search/candidates/graph.rs deleted file mode 100644 index 945d746bb..000000000 --- a/crates/plan-selection/src/search/candidates/graph.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! The Stage 3 part of the candidate graph: structural witnesses of rejected -//! selections, checked against the decoded selected graph. -use asap_types::stages::{ - DagOfSelectedCandidates, PhysicalCandidateId, SelectedEdge, SelectedNodeId, -}; - -/// One output reference's producing candidate in a decoded selection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct QueryRootAssignment { - /// The output reference (index over the group references of the query roots). - pub query_root: usize, - pub candidate: PhysicalCandidateId, -} - -/// One selected input in today's engine, which runs each candidate at most -/// once: `producer`'s output is input `requirement` of `consumer`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ProducerConsumerConnection { - pub producer: PhysicalCandidateId, - pub consumer: PhysicalCandidateId, - /// Index in the consumer's inputs (`producer_candidates_per_input`). - pub requirement: usize, -} - -/// Today's engine's decoded selection, each candidate run at most once, -/// with the cost the MILP modeled for it, which the engine compares with -/// the cost it validates. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct SelectedCandidateGraph { - pub selected_candidates: Vec, - pub connections: Vec, - pub query_roots: Vec, - pub modeled_cost: f64, -} - -impl SelectedCandidateGraph { - /// The candidate producing each output reference, in output order. - pub(crate) fn output_candidates(&self) -> Vec { - let mut roots = self.query_roots.clone(); - roots.sort_by_key(|root| root.query_root); - roots.into_iter().map(|root| root.candidate).collect() - } - - /// The node running `candidate` in [`Self::to_selected_dag`]: its - /// position in `selected_candidates`. - pub(crate) fn node_of(&self, candidate: PhysicalCandidateId) -> Option { - self.selected_candidates - .iter() - .position(|selected| *selected == candidate) - .map(SelectedNodeId) - } - - /// The selection as Stage 2 realizes it: one node per selected - /// candidate, in `selected_candidates` order. - pub(crate) fn to_selected_dag(&self) -> DagOfSelectedCandidates { - let node_of = |candidate: PhysicalCandidateId| { - self.node_of(candidate) - .unwrap_or(SelectedNodeId(usize::MAX)) - }; - DagOfSelectedCandidates { - candidate_of_node: self.selected_candidates.clone(), - edges: self - .connections - .iter() - .map(|connection| SelectedEdge { - producer: node_of(connection.producer), - consumer: node_of(connection.consumer), - input: connection.requirement, - }) - .collect(), - node_of_output_reference: self.output_candidates().into_iter().map(node_of).collect(), - } - } - - #[cfg(test)] - /// `materializations` (indexed by candidate; `Some` for the selected - /// ones) per node of [`Self::to_selected_dag`]. - pub(crate) fn materializations_by_node( - &self, - materializations: &[Option], - ) -> Vec { - self.selected_candidates - .iter() - .map(|candidate| { - materializations[candidate.0] - .unwrap_or(asap_types::physical::Materialization::NotMaterialized) - }) - .collect() - } -} - -/// A certified size-independent failure of a complete query dependency closure. -/// The evaluator certifies semantics; this module verifies the structural witness. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RejectedQuerySubgraph { - pub query_roots: Vec, - pub connections: Vec, -} - -impl RejectedQuerySubgraph { - pub fn from_query_outputs( - selected: &SelectedCandidateGraph, - outputs: &[usize], - ) -> Result { - if outputs.is_empty() - || outputs - .iter() - .enumerate() - .any(|(i, id)| outputs[..i].contains(id)) - { - return Err("query cut requires distinct, nonempty output references".into()); - } - let query_roots = outputs - .iter() - .map(|output| { - selected - .query_roots - .iter() - .find(|root| root.query_root == *output) - .copied() - .ok_or_else(|| format!("query cut references missing output {output}")) - }) - .collect::, _>>()?; - let mut pending: Vec<_> = query_roots.iter().map(|root| root.candidate).collect(); - let mut visited = Vec::new(); - let mut connections = Vec::new(); - while let Some(candidate) = pending.pop() { - if visited.contains(&candidate) { - continue; - } - visited.push(candidate); - for edge in selected - .connections - .iter() - .filter(|edge| edge.consumer == candidate) - { - connections.push(*edge); - pending.push(edge.producer); - } - } - Ok(Self { - query_roots, - connections, - }) - } - - pub(crate) fn validate(&self, selected: &SelectedCandidateGraph) -> Result<(), String> { - let outputs: Vec<_> = self - .query_roots - .iter() - .map(|root| root.query_root) - .collect(); - let expected = Self::from_query_outputs(selected, &outputs)?; - if self.query_roots != expected.query_roots - || self.connections.len() != expected.connections.len() - || self.connections.iter().enumerate().any(|(i, edge)| { - self.connections[..i].contains(edge) || !expected.connections.contains(edge) - }) - { - return Err("query cut must contain the complete selected upstream closure".into()); - } - Ok(()) - } -} diff --git a/crates/plan-selection/src/search/candidates/inventory.rs b/crates/plan-selection/src/search/candidates/inventory.rs deleted file mode 100644 index 29304de23..000000000 --- a/crates/plan-selection/src/search/candidates/inventory.rs +++ /dev/null @@ -1,1236 +0,0 @@ -//! Unit tests of Stage 2's candidate inventory -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use std::collections::HashMap; -use std::rc::Rc; - -use asap_physical_asap_optimization::test_support::catalog::{ - build_candidate_inventory_before, build_with_compatible_producers_cache, CandidateInventory, - MemoGroupOutputToCompatibleProducersCache, -}; -use asap_physical_asap_optimization::CandidateInventoryError; -use asap_types::ir::OperatorNode; -use asap_types::physical::Materialization; -use asap_types::stages::{Memo, MemoGroupId, PhysicalCandidateId}; - -use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, InputProducerCandidates, PhysicalCandidateWithCost, - QueryOutputCandidates, -}; - -/// A Stage 2 inventory, and its operator nodes as a DAG with test-chosen -/// costs, for tests that solve the inventory before configuration. -#[cfg(test)] -pub(in crate::search) struct InventoryForTests { - pub inventory: CandidateInventory, - /// Node `i` as candidate `i`, priced by the test's node cost, reading the - /// inventory's producers; query roots are its output references. - pub graph: DagOfPhysicalCandidatesWithCosts, - /// Node `i`'s operator node. - pub templates: Vec>, - /// The inventory's implementation roots per group. - pub group_candidates: Vec>, -} - -#[cfg(test)] -impl InventoryForTests { - pub(in crate::search) fn new( - inventory: CandidateInventory, - mut node_local_cost: impl FnMut(&OperatorNode) -> Result, - ) -> Self { - let templates: Vec<_> = inventory - .operator_nodes_of_candidates_for_memo_groups - .iter() - .map(|node| Rc::clone(&node.physical_node)) - .collect(); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: templates - .iter() - .zip(&inventory.producer_nodes_per_input) - .map(|(node, inputs)| PhysicalCandidateWithCost { - local_cost: node_local_cost(node).unwrap(), - producer_candidates_per_input: inputs - .iter() - .map(|suppliers| InputProducerCandidates { - candidates: suppliers.clone(), - }) - .collect(), - }) - .collect(), - query_roots: inventory - .query_output_nodes - .iter() - .map(|nodes| QueryOutputCandidates { - candidates: nodes.clone(), - }) - .collect(), - ..Default::default() - }; - let group_candidates = inventory.implementation_roots_of_group.clone(); - Self { - inventory, - graph, - templates, - group_candidates, - } - } - - /// Each node as one query-time, unmaterialized candidate with the DAG's - /// costs. - pub(in crate::search) fn configured_for_tests( - &self, - ) -> crate::search::candidates::dag::PhysicalCandidatesWithCosts { - use asap_types::ir::properties::ExecutionTiming; - crate::search::candidates::dag::PhysicalCandidatesWithCosts::from_dag_for_tests( - self.graph.clone(), - self.templates - .iter() - .map(|node| { - ( - Rc::clone(node), - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ) - } -} - -#[cfg(test)] -impl std::ops::Deref for InventoryForTests { - type Target = CandidateInventory; - fn deref(&self) -> &CandidateInventory { - &self.inventory - } -} - -/// Each output reference is an operator group, not necessarily an entire query. -/// Scalar query assembly and Stage 2/accuracy validation remain with the caller. -#[cfg(test)] -pub(in crate::search) fn build_candidate_inventory( - memo: &Memo, - output_groups: &[MemoGroupId], - node_local_cost: impl FnMut(&OperatorNode) -> Result, -) -> Result { - build_candidate_inventory_before(memo, output_groups, None) - .map(|inventory| InventoryForTests::new(inventory, node_local_cost)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::reconstruction::reconstruct_candidate_outputs; - use crate::search::milp::model::select_candidates; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::lower_promql; - use asap_logical_asap_optimization::test_support::sharing_rules_and_shared_state_groups; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::TumblingWindowWidthDomain; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::{ASAPOp, NonASAPOp, QueryRoot}; - - fn quantile_memo() -> (Memo, Vec) { - let roots = [0.95, 0.99] - .into_iter() - .map(|quantile| { - QueryRoot::Operator(lower_promql( - &format!("quantile_over_time({quantile}, latency[5m])"), - AccuracyTarget::max_error(0.01).failure_probability(0.001), - )) - }) - .collect::>(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let output_groups = (0..2) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let candidates = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(); - let memo = try_populate_pass1(initial, &candidates).unwrap(); - ( - sharing_rules_and_shared_state_groups(memo, TumblingWindowWidthDomain::Complete) - .unwrap(), - output_groups, - ) - } - - // Reusing a typed compatibility result must leave every activation and - // connection decision unchanged, including shared-producer cost ownership. - #[test] - fn compatibility_memo_preserves_complete_candidate_graph_and_optimum() { - let (memo, outputs) = quantile_memo(); - let cost = |node: &OperatorNode| Ok(if is_kll(node) { 8.0 } else { 1.0 }); - let mut compatible_producers_cache = MemoGroupOutputToCompatibleProducersCache::default(); - let cached = InventoryForTests::new( - build_with_compatible_producers_cache( - &memo, - &outputs, - None, - &mut compatible_producers_cache, - ) - .unwrap(), - cost, - ); - assert!( - compatible_producers_cache.hits() > 0, - "repeated typed supplier checks were actually reused" - ); - let uncached = InventoryForTests::new( - build_with_compatible_producers_cache( - &memo, - &outputs, - None, - &mut MemoGroupOutputToCompatibleProducersCache::disabled(), - ) - .unwrap(), - cost, - ); - assert_eq!( - format!("{:?}", cached.graph), - format!("{:?}", uncached.graph) - ); - assert_eq!(cached.group_candidates, uncached.group_candidates); - assert_eq!(cached.templates.len(), uncached.templates.len()); - for (cached, uncached) in cached.templates.iter().zip(&uncached.templates) { - assert!(Rc::ptr_eq(cached, uncached) || cached == uncached); - } - let cached_plan = select_candidates(&cached.graph).unwrap().unwrap(); - let uncached_plan = select_candidates(&uncached.graph).unwrap().unwrap(); - assert_eq!(cached_plan.modeled_cost, uncached_plan.modeled_cost); - } - - fn is_kll(node: &OperatorNode) -> bool { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if *kind.algorithm() == SketchAlgorithm::Kll) - } - - // Synthetic fixed node costs favor one KLL producer; no runtime cost claim. - #[test] - fn expanded_quantiles_select_and_reconstruct_one_shared_kll() { - let (memo, outputs) = quantile_memo(); - let inventory = build_candidate_inventory(&memo, &outputs, |node| { - Ok(if is_kll(node) { - 8.0 - } else if matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) { - 80.0 - } else if matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })) { - 1.0 - } else if matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })) { - 100.0 - } else { - 0.0 - }) - }) - .unwrap(); - for (group, candidates) in memo.candidates.iter().enumerate() { - assert_eq!(inventory.group_candidates[group].len(), candidates.len()); - } - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - assert!((selected.modeled_cost - 10.0).abs() < 1e-8); - let reconstructed = - reconstruct_candidate_outputs(&inventory.graph, &selected, &inventory.templates) - .unwrap(); - let builds: Vec<_> = reconstructed - .iter() - .map(|root| { - OperatorNode::reachable(root) - .into_iter() - .find(|node| is_kll(node)) - .unwrap() - }) - .collect(); - assert!(Rc::ptr_eq(&builds[0], &builds[1])); - // Legality is independently checked by the real physical stage; the - // synthetic objective above is deliberately not an evaluator estimate. - stage2_smoke(&reconstructed); - assert_eq!( - selected - .selected_candidates - .iter() - .filter(|id| is_kll(&inventory.templates[id.0])) - .count(), - 1 - ); - } - - fn stage2_smoke(outputs: &[Rc]) { - use asap_physical_asap_optimization::test_support::optimize_physical_asap_chosen; - use asap_types::physical::Medium; - use asap_types::workload::{ - DataArrival, DataWorkload, DurationMs, Predictability, PrometheusMetricType, Query, - QueryRecurrence, QueryRequirements, QueryWorkloadEntry, RepeatedDemand, - RepetitionInterval, SourceFacts, - }; - let entries: Vec<_> = [0.95, 0.99] - .into_iter() - .map(|quantile| QueryWorkloadEntry { - query: Query(format!("quantile_over_time({quantile}, latency[5m])")), - requirements: QueryRequirements { - accuracy: AccuracyTarget::max_error(0.01).failure_probability(0.001), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - range_evaluation: None, - }) - .collect(); - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 2_000.0 / 15.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(1_000), - metric_type: Some(PrometheusMetricType::Gauge), - sample_interval: Some(DurationMs(15_000)), - ..SourceFacts::default() - }, - )] - .into(), - }; - let roots: Vec<_> = outputs.iter().cloned().map(QueryRoot::Operator).collect(); - let physical = optimize_physical_asap_chosen( - "candidate-inventory-smoke", - &roots, - &entries, - &data, - false, - &mut |_, _, options| { - let query_materialization = Materialization::MatAtQuery(Medium::Memory); - if options.contains(&query_materialization) { - query_materialization - } else { - options - .first() - .copied() - .unwrap_or(Materialization::NotMaterialized) - } - }, - ) - .unwrap() - .expect("reconstructed logical connections admit a legal Stage 2 plan"); - physical.dag.validate().unwrap(); - assert_eq!(physical.roots.len(), 2); - } - - // Union-label tumbling window sharing survives typed connection selection and real - // Stage 2, while each query receives only its original output columns. - #[test] - fn widened_label_readers_materialize_one_shared_tumbling_window_chain() { - check_shared_tumbling_window_plan( - [ - "avg by (job) (quantile_over_time(0.95, latency[5m]))", - "avg by (region) (quantile_over_time(0.99, latency[5m]))", - ], - [0.05, 0.05], - &["job", "region"], - None, - ); - } - - // Different capacities and windows can share a larger chain while the short - // query reads only its newest tumbling windows; Stage 2, accuracy and pricing all validate. - #[test] - fn different_capacity_and_horizon_readers_share_one_validated_tumbling_window_chain() { - check_shared_tumbling_window_plan( - [ - "avg by (job) (quantile_over_time(0.95, latency[5m]))", - "avg by (job) (quantile_over_time(0.99, latency[15m]))", - ], - [0.03, 0.05], - &["job"], - Some(300_000), - ); - } - - fn check_shared_tumbling_window_plan( - queries: [&str; 2], - errors: [f64; 2], - labels: &[&str], - newest_window_ms: Option, - ) { - use crate::search::candidates::configurations::enumerate_candidate_configurations; - use asap_logical_asap_optimization::test_support::entry_with; - use asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates; - use asap_types::workload::{ - DataArrival, DataWorkload, Predictability, QueryRecurrence, RepeatedDemand, - RepetitionInterval, SourceFacts, - }; - let targets: Vec<_> = errors - .into_iter() - .map(|error| AccuracyTarget::max_error(error).failure_probability(0.3)) - .collect(); - let roots: Vec<_> = queries - .into_iter() - .zip(&targets) - .map(|(query, target)| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs: Vec<_> = (0..roots.len()) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let generated = generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &roots.clone(), - )) - .unwrap(); - let mut memo = try_populate_pass1(initial, &generated).unwrap(); - let entries: Vec<_> = (0..roots.len()) - .map(|query| { - let mut entry = entry_with( - targets[query].clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 1000, - ))), - ); - entry.predictability = Predictability::Predictable { known_at: None }; - entry - }) - .collect(); - asap_logical_asap_optimization::test_support::add_local_window_composition( - &mut memo, &entries, - ); - let memo = sharing_rules_and_shared_state_groups(memo, TumblingWindowWidthDomain::Complete) - .unwrap(); - let source = build_candidate_inventory(&memo, &outputs, |_| Ok(0.0)).unwrap(); - let deployment = asap_logical_asap_optimization::DeploymentCapabilities { - raw_data_retained: false, - ..Default::default() - }; - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(10), - values_non_negative: true, - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let mut configured = - enumerate_candidate_configurations(&source, &[0, 1], &entries, &data, &deployment) - .unwrap(); - // Synthetic activation objective exposes reuse; it is not runtime pricing. - let nodes = configured.nodes_for_tests(); - for (id, candidate) in configured.dag.candidates.iter_mut().enumerate() { - let node = &nodes[id]; - candidate.local_cost = if node.guarantee.as_ref().is_some_and(|guarantee| { - !guarantee.is_exact() - && guarantee.spec.accuracy_metric - == asap_types::ir::properties::AccuracyMetric::Rank - }) { - 100_000.0 - } else if matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) { - 1000.0 - } else { - 1.0 - }; - } - let selected = - crate::search::milp::model::solve_candidate_proposal(&configured.dag, &[], None) - .unwrap() - .unwrap() - .selected; - let chains: Vec<_> = selected - .selected_candidates - .iter() - .filter(|id| { - matches!( - configured.node(id.0).asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) - }) - .collect(); - assert_eq!(chains.len(), 1); - let chain = configured.node(chains[0].0); - assert!(labels.iter().all(|name| chain - .schema - .fields - .iter() - .any(|field| field.name == *name))); - let mut modes = vec![None; configured.physical.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(configured.physical.candidates[id.0].materialization); - } - let mut physical = materialize_selected_candidates( - &configured.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - deployment.query_time_results_retained, - ) - .unwrap(); - physical.plan.dag.validate().unwrap(); - if let Some(window_ms) = newest_window_ms { - let Some(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - tumbling_window_count, - .. - }) = chain.asap() - else { - unreachable!("selected chain checked above") - }; - // The complete width domain permits different tumbling-window counts. Verify - // the short reader's coverage, not one traversal-dependent width. - assert!(physical.plan.dag.nodes.iter().any(|node| matches!( - node.payload, asap_types::ir::Operator::ASAP(ASAPOp::SummaryMerge {newest_tumbling_windows: Some(count), ..}) - if u64::from(count) * tumbling_window_width_ms == window_ms && count < *tumbling_window_count - ))); - } - - assert!(selected.node_of(*chains[0]).is_some()); - // Validate this exact selected shared graph. Bounded search incumbents - // need not choose it, and synthetic activation prices are never bills. - physical.plan.id = "shared-tumbling-window-regression".into(); - let mut evaluator = crate::PlanSelector::new( - &entries, - &data, - crate::SelectionModels { - ..crate::SelectionModels::builtin() - }, - false, - ) - .unwrap(); - evaluator.offer(&physical.plan).unwrap(); - let (selection, physical) = evaluator - .finish() - .expect("shared tumbling window graph must pass accuracy and cost validation"); - assert!(selection.costs[&selection.selected] - .cost_per_second - .unwrap() - .is_finite()); - assert_eq!( - physical - .dag - .nodes - .iter() - .filter(|node| matches!( - node.payload, - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) - )) - .count(), - 1 - ); - } - - // Producer-level rollup and ranking candidates survive concrete selection, - // Stage 2, end-to-end accuracy checks and the actual billing evaluator. - #[test] - fn producer_reuse_rules_produce_validated_shared_plans() { - use asap_logical_asap_optimization::test_support::entry_with; - use asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates; - use asap_types::workload::{ - DataWorkload, QueryRecurrence, RepeatedDemand, RepetitionInterval, - }; - // A summary build over rows, as opposed to a rollup over an already - // aggregated result: a finalized accumulator, or (since an exact - // rollup reads the finer query's own result) the finer query's - // aggregate. - let builds_over_rows = |node: &OperatorNode| { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { child, .. }) - if !matches!(child.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })) - && !matches!(child.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - }; - for (queries, kind) in [ - (["avg(m)", "sum(m)"], "partial"), - (["sum by (job) (m)", "sum by (job, instance) (m)"], "rollup"), - ( - [ - "topk by (job) (3, sum_over_time(m[1m]))", - "topk by (job) (5, sum_over_time(m[1m]))", - ], - "ranking", - ), - (["quantile(0.9, m)", "quantile by (job) (0.9, m)"], "sketch"), - ] { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let roots: Vec<_> = queries - .into_iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs: Vec<_> = (0..2) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let generated = generate_per_computation_candidates( - &generate_dag_of_logical_memo_groups(&roots.clone()), - ) - .unwrap(); - let memo = sharing_rules_and_shared_state_groups( - try_populate_pass1(initial, &generated).unwrap(), - TumblingWindowWidthDomain::Complete, - ) - .unwrap(); - let mut inventory = build_candidate_inventory(&memo, &outputs, |node| { - // Structural selection forces the reuse being tested. Actual - // prices are obtained only from PlanSelector below. - if node.guarantee.as_ref().is_some_and(|g| { - !g.is_exact() - && g.spec.accuracy_metric - == asap_types::ir::properties::AccuracyMetric::Rank - }) { - return Ok(100_000.0); - } - Ok( - if builds_over_rows(node) - || matches!(node.non_asap(), Some(NonASAPOp::Sort { .. })) - { - 1000.0 - } else { - 1.0 - }, - ) - }) - .unwrap(); - if matches!(kind, "ranking" | "sketch") { - for (output, group) in inventory.graph.query_roots.iter_mut().zip(&outputs) { - output.candidates.retain(|id| inventory.group_candidates[group.0 as usize].iter().position(|candidate| candidate == id).is_some_and(|index| memo.candidates[group.0 as usize][index].origin == if kind == "ranking" { asap_types::stages::OriginOfCandidateForMemoGroup::TopKReuse } else { asap_types::stages::OriginOfCandidateForMemoGroup::SharedBuild })); - assert!(!output.candidates.is_empty(), "{kind} reader generated"); - } - } - let selected = select_candidates(&inventory.graph).unwrap().expect(kind); - let chosen: Vec<_> = selected - .selected_candidates - .iter() - .map(|id| &inventory.templates[id.0]) - .collect(); - if kind == "ranking" { - assert_eq!( - chosen - .iter() - .filter(|node| matches!(node.non_asap(), Some(NonASAPOp::Sort { .. }))) - .count(), - 1 - ); - } else { - assert_eq!( - chosen.iter().filter(|node| builds_over_rows(node)).count(), - if kind == "partial" { 2 } else { 1 }, - "{kind}" - ); - } - let entries: Vec<_> = (0..2) - .map(|_| { - entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - ) - }) - .collect(); - let mut modes = vec![None; inventory.graph.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(Materialization::NotMaterialized); - } - let mut data = DataWorkload { - arrival: asap_types::workload::DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - ..DataWorkload::default() - }; - data.sources.entry("m".into()).or_default().series_count = Some(10); - data.sources - .entry("m".into()) - .or_default() - .values_non_negative = true; - let mut realized = materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - realized.plan.id = format!("shared-{kind}"); - let mut evaluator = - crate::PlanSelector::new(&entries, &data, crate::SelectionModels::builtin(), false) - .unwrap(); - evaluator.offer(&realized.plan).unwrap(); - let (selection, physical) = evaluator - .finish() - .unwrap_or_else(|error| panic!("{kind}: {error:?}")); - physical.dag.validate().unwrap(); - assert!(selection.costs[&selection.selected] - .cost_per_second - .unwrap() - .is_finite()); - } - } - - // A fused two-sketch computation chooses each readout's capacity separately: - // one or both states can serve external queries without a configuration product. - #[test] - fn factored_summary_results_choose_independent_capacities_and_sharing() { - use asap_logical_asap_optimization::test_support::entry_with; - use asap_types::ir::schema::sketch::SketchParams; - use asap_types::stages::OriginOfCandidateForMemoGroup; - use asap_types::workload::{ - DataWorkload, QueryRecurrence, RepeatedDemand, RepetitionInterval, - }; - for share_both in [false, true] { - let mut queries = vec![ - ( - "quantile_over_time(0.5, a[5m]) * quantile_over_time(0.99, b[5m])", - 0.03, - ), - ("quantile_over_time(0.95, a[5m])", 0.005), - ]; - if share_both { - queries.push(("quantile_over_time(0.9, b[5m])", 0.008)); - } - let targets: Vec<_> = queries - .iter() - .map(|(_, error)| AccuracyTarget::max_error(*error).failure_probability(0.001)) - .collect(); - let roots: Vec<_> = queries - .iter() - .zip(&targets) - .map(|((query, _), target)| { - QueryRoot::Operator(lower_promql(query, target.clone())) - }) - .collect(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs: Vec<_> = (0..roots.len()) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let generated = generate_per_computation_candidates( - &generate_dag_of_logical_memo_groups(&roots.clone()), - ) - .unwrap(); - let mut memo = try_populate_pass1(initial, &generated).unwrap(); - let owner = outputs[0].0 as usize; - let mut fused = memo.candidates[owner][0].clone(); - let original_inputs = fused.input_groups.clone(); - assert_eq!(original_inputs.len(), 2); - let mut replacements = HashMap::new(); - let mut raw_inputs = Vec::new(); - for input in &original_inputs { - let selected = memo.candidates[input.0 as usize].iter().filter(|candidate| OperatorNode::reachable(&candidate.root_node).iter().any(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg {family: FieldDataType::Sketch(kind, _), ..}) if kind.algorithm() == &SketchAlgorithm::DDSketch))).max_by_key(|candidate| candidate.accuracy_budget_parts).unwrap(); - replacements.insert( - Rc::as_ptr(&memo.groups[input.0 as usize].logical_node), - Rc::clone(&selected.root_node), - ); - for raw in &selected.input_groups { - if !raw_inputs.contains(raw) { - raw_inputs.push(*raw); - } - } - } - fused.root_node = Rc::new( - fused - .root_node - .with_new_children(|child| { - replacements - .get(&Rc::as_ptr(child)) - .cloned() - .unwrap_or_else(|| Rc::clone(child)) - }) - .unwrap(), - ); - fused.input_groups = raw_inputs; - fused.fused_groups.extend(original_inputs); - fused.accuracy_budget_parts = 2; - memo.candidates[owner].push(fused); - let memo = - sharing_rules_and_shared_state_groups(memo, TumblingWindowWidthDomain::Complete) - .unwrap(); - let mut inventory = build_candidate_inventory(&memo, &outputs, |node| { - Ok(if matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) { - 1000.0 - } else { - 1.0 - }) - }) - .unwrap(); - for (query, (output, group)) in inventory - .graph - .query_roots - .iter_mut() - .zip(&outputs) - .enumerate() - { - output.candidates.retain(|id| inventory.group_candidates[group.0 as usize].iter().position(|candidate| candidate == id).is_some_and(|index| { - let candidate = &memo.candidates[group.0 as usize][index]; - if query == 0 { candidate.origin == OriginOfCandidateForMemoGroup::SummaryResult } else { - candidate.origin == OriginOfCandidateForMemoGroup::SharedBuild && OperatorNode::reachable(&candidate.root_node).iter().any(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg {family: FieldDataType::Sketch(kind, _), ..}) if kind.algorithm() == &SketchAlgorithm::DDSketch)) - } - })); - assert!( - !output.candidates.is_empty(), - "factored/shared output {query}" - ); - } - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let alphas: Vec<_> = selected - .selected_candidates - .iter() - .filter_map(|id| match &inventory.templates[id.0].asap() { - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) => match kind.params() { - SketchParams::DDSketch { alpha } => Some(*alpha), - _ => None, - }, - _ => None, - }) - .collect(); - assert_eq!( - alphas.len(), - 2, - "two independent sources, shared when requested" - ); - assert_ne!(alphas[0], alphas[1], "capacities selected independently"); - let mut modes = vec![None; inventory.graph.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(Materialization::NotMaterialized); - } - let entries: Vec<_> = targets - .iter() - .map(|target| { - entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - ) - }) - .collect(); - let mut data = DataWorkload { - arrival: asap_types::workload::DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - ..DataWorkload::default() - }; - for source in ["a", "b"] { - let facts = data.sources.entry(source.into()).or_default(); - facts.series_count = Some(10); - facts.values_non_negative = true; - } - let mut realized = - asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - realized.plan.id = format!("independent-capacities-{share_both}"); - let mut evaluator = - crate::PlanSelector::new(&entries, &data, crate::SelectionModels::builtin(), false) - .unwrap(); - evaluator.offer(&realized.plan).unwrap(); - let (selection, physical) = evaluator.finish().unwrap(); - physical.dag.validate().unwrap(); - assert!(selection.costs[&selection.selected] - .cost_per_second - .unwrap() - .is_finite()); - } - } - - // Exact top-k's executable Sort/Limit/Project nodes are selected and charged - // explicitly, so final Stage 2 does not introduce unmodeled identities. - #[test] - fn exact_topk_is_lowered_before_decomposition_and_survives_stage2() { - use asap_logical_asap_optimization::test_support::entry_with; - use asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates; - use asap_types::ir::operator::AggIntent; - use asap_types::workload::{ - DataWorkload, QueryRecurrence, RepeatedDemand, RepetitionInterval, - }; - let query = "topk by (job) (3, sum_over_time(m[1m]))"; - let root = lower_promql(query, AccuracyTarget::exact()); - // Top-k returns several ranked rows per partition, with numeric values. - assert_eq!(root.schema.fields[1].name, "topk_3"); - assert_eq!( - root.schema.fields[1].dtype, - asap_types::ir::schema::FieldDataType::Plain(asap_types::ir::schema::DataType::Float64) - ); - assert!(root.schema.unique_keys.is_empty()); - let roots = vec![QueryRoot::Operator(Rc::clone(&root))]; - let initial = generate_dag_of_logical_memo_groups(&roots); - let output = initial.group_for_node(&root).unwrap(); - let original_input = Rc::clone( - &initial.groups[initial.groups[output.0 as usize].child_groups[0].0 as usize] - .logical_node, - ); - let generated = generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &roots.clone(), - )) - .unwrap(); - let memo = try_populate_pass1(initial, &generated).unwrap(); - let mut priced_operators = Vec::new(); - let inventory = build_candidate_inventory(&memo, &[output], |node| { - priced_operators.push(node.operator.kind_name()); - Ok(1.0) - }) - .unwrap(); - assert!(priced_operators.contains(&"Sort")); - assert!(priced_operators.contains(&"Limit")); - let exact_root = inventory.group_candidates[output.0 as usize][0]; - let root_template = &inventory.templates[exact_root.0]; - assert!( - OperatorNode::reachable(root_template) - .iter() - .any(|node| Rc::ptr_eq(node, &original_input)), - "lowering preserves canonical input boundary" - ); - assert!(inventory.templates.iter().all(|node| !matches!(node.non_asap(), Some(NonASAPOp::Aggregate { measures, .. }) if measures.iter().any(|measure| matches!(measure, AggIntent::TopK { .. }))))); - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let mut modes = vec![None; inventory.graph.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(Materialization::NotMaterialized); - } - let entries = vec![entry_with( - AccuracyTarget::exact(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))), - )]; - let physical = materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &DataWorkload::default(), - false, - ) - .unwrap(); - physical.plan.dag.validate().unwrap(); - assert_eq!( - physical.operator_node_of_selected_node.len(), - selected.selected_candidates.len() - ); - assert_eq!( - selected.modeled_cost, - selected.selected_candidates.len() as f64 - ); - } - - fn nested_sum_memo(external_reader: bool) -> (Memo, Vec) { - let mut queries = vec!["sum by (region) (sum by (region, job) (m))"]; - if external_reader { - queries.push("sum by (region, job) (m)"); - } - let roots = queries - .iter() - .map(|text| QueryRoot::Operator(lower_promql(text, AccuracyTarget::exact()))) - .collect::>(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs = (0..queries.len()) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let generated = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(); - (try_populate_pass1(initial, &generated).unwrap(), outputs) - } - - #[test] - fn terminal_reader_factoring_reconstructs_the_same_minimum_cost_computation() { - let (memo, outputs) = nested_sum_memo(false); - let original = InventoryForTests::new( - build_with_compatible_producers_cache( - &memo, - &outputs, - None, - &mut MemoGroupOutputToCompatibleProducersCache::default(), - ) - .unwrap(), - |_| Ok(1.0), - ); - let compact = build_candidate_inventory(&memo, &outputs, |_| Ok(1.0)).unwrap(); - assert!(compact.graph.candidates.len() < original.graph.candidates.len()); - let before = select_candidates(&original.graph).unwrap().unwrap(); - let after = select_candidates(&compact.graph).unwrap().unwrap(); - assert_eq!(before.modeled_cost, after.modeled_cost); - for (inventory, selected) in [(&original, before), (&compact, after)] { - let roots = - reconstruct_candidate_outputs(&inventory.graph, &selected, &inventory.templates) - .unwrap(); - assert_eq!(roots.len(), 1); - roots[0].validate_structure().unwrap(); - assert!( - asap_physical_asap_optimization::test_support::catalog::output_contract_satisfied( - &memo.groups[outputs[0].0 as usize].logical_node, - &roots[0] - ) - .unwrap() - ); - } - } - - // Fusion owns its private work but does not publish another logical result. - // Inspect the uncompressed catalog here because the fixture identifies its - // particular fusion rule by its original memo ordinal. - #[test] - fn fused_nested_sum_activates_only_its_actual_input_groups() { - let (memo, outputs) = nested_sum_memo(false); - let owner = outputs[0].0 as usize; - let fused_index = memo.candidates[owner] - .iter() - .position(|candidate| !candidate.fused_groups.is_empty()) - .expect("nested sum has an absorbed computation"); - let absorbed = memo.candidates[owner][fused_index].fused_groups[0]; - let mut inventory = InventoryForTests::new( - build_with_compatible_producers_cache( - &memo, - &outputs, - None, - &mut MemoGroupOutputToCompatibleProducersCache::default(), - ) - .unwrap(), - |_| Ok(1.0), - ); - let fused = inventory.group_candidates[owner][fused_index]; - assert!(!inventory.group_candidates[absorbed.0 as usize].contains(&fused)); - assert!( - inventory.graph.query_roots[0].candidates.contains(&fused), - "fused output satisfies its owning group's contract" - ); - inventory.graph.query_roots[0].candidates = vec![fused]; - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - assert!(inventory.group_candidates[absorbed.0 as usize] - .iter() - .all(|id| !selected.selected_candidates.contains(id))); - let rebuilt = - reconstruct_candidate_outputs(&inventory.graph, &selected, &inventory.templates) - .unwrap(); - rebuilt[0].validate_structure().unwrap(); - assert_eq!( - OperatorNode::reachable(&rebuilt[0]) - .iter() - .filter(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .count(), - 1 - ); - } - - // A separately requested inner result is computed independently of fusion. - #[test] - fn fused_nested_sum_preserves_an_external_inner_query() { - let (memo, outputs) = nested_sum_memo(true); - let owner = outputs[0].0 as usize; - let inner = outputs[1]; - assert_eq!(memo.groups[owner].child_groups, vec![inner]); - let fused_index = memo.candidates[owner] - .iter() - .position(|candidate| { - candidate.root_node.contains_asap() && !candidate.input_groups.contains(&inner) - }) - .expect("fusion can privately recompute the shared inner work"); - let mut inventory = InventoryForTests::new( - build_with_compatible_producers_cache( - &memo, - &outputs, - None, - &mut MemoGroupOutputToCompatibleProducersCache::default(), - ) - .unwrap(), - |_| Ok(1.0), - ); - let fused = inventory.group_candidates[owner][fused_index]; - inventory.graph.query_roots[0].candidates = vec![fused]; - assert!(!inventory.graph.query_roots[1].candidates.contains(&fused)); - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - assert!(inventory.group_candidates[inner.0 as usize] - .iter() - .any(|id| selected.selected_candidates.contains(id))); - let rebuilt = - reconstruct_candidate_outputs(&inventory.graph, &selected, &inventory.templates) - .unwrap(); - assert_eq!(rebuilt.len(), 2); - for root in &rebuilt { - root.validate_structure().unwrap(); - } - assert!(!Rc::ptr_eq(&rebuilt[0], &rebuilt[1])); - assert_eq!( - rebuilt[0].schema.fields.len(), - memo.groups[owner].logical_node.schema.fields.len() - ); - assert_eq!( - rebuilt[1].schema.fields.len(), - memo.groups[inner.0 as usize] - .logical_node - .schema - .fields - .len() - ); - } - - // A boundary-only alias has no immediate operator for the decoder to rebuild. - #[test] - fn rejects_root_alias_instead_of_duplicating_boundary_work() { - let (mut memo, outputs) = quantile_memo(); - let group = outputs[0].0 as usize; - let boundary = memo.candidates[group][0].input_groups[0]; - memo.candidates[group][0].root_node = - Rc::clone(&memo.groups[boundary.0 as usize].logical_node); - assert!(matches!( - build_candidate_inventory(&memo, &outputs, |_| Ok(1.0)), - Err(CandidateInventoryError::Unsupported { - reason: "root aliases an input group without an operator", - .. - }) - )); - } - - // An expired generation budget returns no partially constructed inventory. - #[test] - fn inventory_deadline_returns_typed_timeout() { - let (memo, outputs) = quantile_memo(); - assert!(matches!( - build_candidate_inventory_before(&memo, &outputs, Some(std::time::Instant::now())), - Err(CandidateInventoryError::DeadlineExceeded(_)) - )); - } - - // Unsupported decisions must fail adaptation, rather than disappear. - #[test] - fn rejects_zero_accuracy_share() { - let (mut memo, outputs) = quantile_memo(); - memo.candidates[0][0].accuracy_budget_parts = 0; - assert!(matches!( - build_candidate_inventory(&memo, &outputs, |_| Ok(1.0)), - Err(CandidateInventoryError::Unsupported { - reason: "zero accuracy budget parts", - .. - }) - )); - } - // Budget shares are concrete sizes: composed root accuracy, not the share - // label, decides feasibility. Oversizing one remaining estimate stays legal. - #[test] - fn ratio_accuracy_is_validated_after_selecting_concrete_accuracy_budget_parts() { - use crate::{ErrorSelectingPlan, PlanSelector, SelectionModels}; - use asap_accuracy::test_support::satisfies; - use asap_cost::CalibrationFit; - use asap_logical_asap_optimization::test_support::entry_with; - use asap_physical_asap_optimization::test_support::optimize_physical_asap_chosen; - use asap_types::ir::properties::AccuracyMetric; - use asap_types::workload::{ - DataArrival, DataWorkload, QueryRecurrence, RepeatedDemand, RepetitionInterval, - SourceFacts, - }; - - let target = AccuracyTarget::max_error(0.01).accuracy_metric(AccuracyMetric::Relative); - let query = "quantile_over_time(0.99, m[1m]) / quantile_over_time(0.5, m[1m])"; - let roots = vec![QueryRoot::Operator(lower_promql(query, target.clone()))]; - let logical = generate_dag_of_logical_memo_groups(&roots); - let output = *logical.query_roots[0].as_operator().unwrap(); - let generated = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(); - let original = try_populate_pass1(logical, &generated).unwrap(); - assert!(original - .candidates - .iter() - .flatten() - .any(|candidate| candidate.accuracy_budget_parts == 2)); - // The complete generated inventory must no longer fail merely because - // it includes composed-budget sizes alongside whole-budget sizes. - build_candidate_inventory(&original, &[output], |_| Ok(0.0)).unwrap(); - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: [( - "m".into(), - SourceFacts { - series_count: Some(1), - values_non_negative: true, - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let entries = [entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))), - )]; - let models = SelectionModels::builtin(); - for (share, one_exact, valid) in [(1, false, false), (2, false, true), (2, true, true)] { - let mut memo = original.clone(); - let mut made_exact = false; - let mut approximate_groups = 0; - for candidates in &mut memo.candidates { - let sized: Vec<_> = candidates.iter().filter(|candidate| candidate.accuracy_budget_parts == share - && OperatorNode::reachable(&candidate.root_node).iter().any(|node| - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if *kind.algorithm() == SketchAlgorithm::DDSketch))) - .cloned().collect(); - if sized.is_empty() { - continue; - } - approximate_groups += 1; - if one_exact && !made_exact { - candidates.retain(|candidate| !candidate.root_node.contains_asap()); - assert!(!candidates.is_empty()); - made_exact = true; - } else { - *candidates = sized; - } - } - assert_eq!(approximate_groups, 2); - let inventory = build_candidate_inventory(&memo, &[output], |_| Ok(1.0)).unwrap(); - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let outputs = - reconstruct_candidate_outputs(&inventory.graph, &selected, &inventory.templates) - .unwrap(); - let roots = vec![QueryRoot::Operator(Rc::clone(&outputs[0]))]; - let guarantee = crate::bound_root_guarantee(&roots[0], Some(1), &data, &models) - .expect("proven nonnegative relative ratio has a composed guarantee"); - assert_eq!( - satisfies(&guarantee, &target), - valid, - "share={share}, one_exact={one_exact}, {guarantee:?}" - ); - let mut unproved_data = data.clone(); - unproved_data - .sources - .get_mut("m") - .unwrap() - .values_non_negative = false; - assert!( - crate::bound_root_guarantee(&roots[0], Some(1), &unproved_data, &models) - .is_none_or(|guarantee| !satisfies(&guarantee, &target)), - "capacity sizing must not establish an unproved source-sign contract" - ); - let physical = optimize_physical_asap_chosen( - "ratio-shares", - &roots, - &entries, - &data, - false, - &mut |_, _, _| Materialization::NotMaterialized, - ) - .unwrap() - .unwrap(); - let mut selector = PlanSelector::new(&entries, &data, models, false).unwrap(); - selector.set_calibration(CalibrationFit::default()); - selector.offer(&physical).unwrap(); - match selector.finish() { - Ok(_) => assert!(valid, "undersized ratio must fail end-to-end accuracy"), - Err(ErrorSelectingPlan::NoValidCandidate(_)) => { - assert!(!valid, "properly sized ratio must be accepted") - } - Err(error) => panic!("unexpected evaluation error: {error}"), - } - } - } -} diff --git a/crates/plan-selection/src/search/candidates/mod.rs b/crates/plan-selection/src/search/candidates/mod.rs deleted file mode 100644 index 8188940e5..000000000 --- a/crates/plan-selection/src/search/candidates/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! The Stage 3 part of the candidate graph (its DAG with costs, the -//! execution-scope copies the engine selects over, and rejected-subgraph -//! witnesses), and unit tests of Stage 2's catalog -//! (`asap_physical_asap_optimization::test_support::catalog`) that select -//! with the solver. -#[cfg(test)] -pub(crate) mod configurations; -pub(crate) mod dag; -#[cfg(test)] -mod factoring; -pub(crate) mod graph; -#[cfg(test)] -pub(crate) mod inventory; -#[cfg(test)] -pub(crate) mod physical; -#[cfg(test)] -pub(crate) mod reconstruction; -pub(crate) mod scopes; diff --git a/crates/plan-selection/src/search/candidates/physical.rs b/crates/plan-selection/src/search/candidates/physical.rs deleted file mode 100644 index 0a4a7c844..000000000 --- a/crates/plan-selection/src/search/candidates/physical.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Unit tests of Stage 2's realization of a selected assignment -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use std::rc::Rc; - -use asap_physical_asap_optimization::test_support::catalog::{ - materialize_selected_candidates, ordered_output_references, output_contract_satisfied, - restore_query_roots, -}; -use asap_types::ir::{OperatorNode, QueryRoot}; -use asap_types::physical::Materialization; -use asap_types::stages::PhysicalCandidateError; - -use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::inventory::{build_candidate_inventory, InventoryForTests}; - use crate::search::milp::model::select_candidates; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::lower_promql; - use asap_logical_asap_optimization::test_support::sharing_rules_and_shared_state_groups; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::DeploymentCapabilities; - use asap_logical_asap_optimization::TumblingWindowWidthDomain; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::{ASAPOp, NonASAPOp}; - use asap_types::physical::Medium; - use asap_types::stages::*; - - use asap_types::workload::{ - DataArrival, Predictability, Query, QueryRecurrence, QueryRequirements, RepeatedDemand, - RepetitionInterval, - }; - - fn is_kll(node: &OperatorNode) -> bool { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if *kind.algorithm() == SketchAlgorithm::Kll) - } - - fn fixture() -> ( - InventoryForTests, - Vec, - Vec, - DataWorkload, - ) { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let queries = [ - "quantile_over_time(0.95, latency[5m])", - "quantile_over_time(0.99, latency[5m])", - ]; - let roots = queries - .iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect::>(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let output_groups = (0..2) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect::>(); - let candidates = generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &roots.clone(), - )) - .unwrap(); - let memo = sharing_rules_and_shared_state_groups( - try_populate_pass1(initial, &candidates).unwrap(), - TumblingWindowWidthDomain::Complete, - ) - .unwrap(); - let inventory = build_candidate_inventory(&memo, &output_groups, |node| { - Ok(if is_kll(node) { - 8.0 - } else if matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) { - 80.0 - } else if matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })) { - 1.0 - } else if matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. })) { - 100.0 - } else { - 0.0 - }) - }) - .unwrap(); - let entries = queries - .iter() - .map(|query| QueryWorkloadEntry { - query: Query((*query).into()), - requirements: QueryRequirements { - accuracy: target.clone(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(300_000), - )), - range_evaluation: None, - }) - .collect(); - ( - inventory, - roots, - entries, - DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - ..Default::default() - }, - ) - } - - fn shared_non_summary_fixture( - with_shared_parent: bool, - ) -> (InventoryForTests, Vec, Vec) { - use asap_types::stages::PhysicalCandidateId; - let logical = lower_promql("latency", AccuracyTarget::exact()); - let scan = OperatorNode::reachable(&logical) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap(); - let mut nodes = vec![(Rc::clone(&scan), Vec::new())]; - if with_shared_parent { - let range = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - range: std::time::Duration::from_secs(60), - kind: asap_types::ir::TimeRangeKind::Range, - child: scan, - })) - .unwrap(); - nodes.push((range, vec![vec![PhysicalCandidateId(0)]])); - } - let root_id = nodes.len() - 1; - let roots = vec![QueryRoot::Operator(Rc::clone(&nodes[root_id].0)); 2]; - let inventory = InventoryForTests::new( - crate::search::candidates::configurations::inventory_for_tests( - nodes, - vec![vec![PhysicalCandidateId(root_id)]; 2], - ), - |_| Ok(0.0), - ); - let entries = (0..2) - .map(|_| QueryWorkloadEntry { - query: Query("latency".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::AdHoc, - recurrence: QueryRecurrence::Unknown, - range_evaluation: None, - }) - .collect(); - (inventory, roots, entries) - } - - // A real shared scan can be built once per evaluation for two distinct queries. - #[test] - fn shared_scan_enumerates_and_executes_query_materialization() { - use crate::search::candidates::configurations::enumerate_candidate_configurations; - let (source, roots, entries) = shared_non_summary_fixture(false); - let data = DataWorkload::default(); - let deployment = DeploymentCapabilities::default(); - let mut configured = - enumerate_candidate_configurations(&source, &[0, 1], &entries, &data, &deployment) - .unwrap(); - let mode = Materialization::MatAtQuery(Medium::Memory); - assert!(configured - .physical - .candidates - .iter() - .any(|configuration| configuration.materialization == mode)); - for (candidate, configuration) in configured - .dag - .candidates - .iter_mut() - .zip(&configured.physical.candidates) - { - candidate.local_cost = if configuration.materialization == mode { - 0.0 - } else { - 1.0 - }; - } - let selected = select_candidates(&configured.dag).unwrap().unwrap(); - let mut assignments = vec![None; configured.physical.candidates.len()]; - for id in &selected.selected_candidates { - assignments[id.0] = Some(configured.physical.candidates[id.0].materialization); - } - let physical = materialize_selected_candidates( - &configured.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&assignments), - &roots, - &entries, - &data, - deployment.query_time_results_retained, - ) - .unwrap(); - assert_eq!(physical.plan.dag.nodes.len(), 1); - assert_eq!(physical.plan.dag.nodes[0].materialization, mode); - let same_query = - enumerate_candidate_configurations(&source, &[0, 0], &entries[..1], &data, &deployment) - .unwrap(); - assert!(!same_query - .physical - .candidates - .iter() - .any(|configuration| configuration.materialization == mode)); - } - - // A shared scan below an already shared range is not a highest shared node; - // reject that selected assignment using Stage 2's exact eligibility result. - #[test] - fn query_materialization_below_shared_parent_is_rejected_assignment() { - let (inventory, roots, entries) = shared_non_summary_fixture(true); - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let modes = vec![ - Some(Materialization::MatAtQuery(Medium::Memory)), - Some(Materialization::NotMaterialized), - ]; - assert!(matches!( - materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &DataWorkload::default(), - false - ), - Err(PhysicalCandidateError::RejectedAssignment(_)) - )); - } - - // Exact selected modes survive Stage 2 and retain one producer identity. - #[test] - fn shared_kll_keeps_candidate_identity_at_query_and_ingestion() { - let (inventory, roots, entries, data) = fixture(); - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let build = selected - .selected_candidates - .iter() - .find(|id| is_kll(&inventory.templates[id.0])) - .unwrap() - .0; - for mode in [ - Materialization::MatAtQuery(Medium::Memory), - Materialization::MatAtIngestion(Medium::Memory), - ] { - let mut modes = vec![None; inventory.graph.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(Materialization::NotMaterialized); - } - modes[build] = Some(mode); - let result = materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - assert_eq!( - result.operator_node_of_selected_node - [selected.node_of(PhysicalCandidateId(build)).unwrap().0] - .materialization, - mode - ); - let outputs = ordered_output_references(&result.plan.roots); - let states = outputs - .iter() - .map(|root| { - OperatorNode::reachable(root) - .into_iter() - .find(|node| is_kll(node)) - .unwrap() - }) - .collect::>(); - assert!(Rc::ptr_eq(&states[0], &states[1])); - assert!(Rc::ptr_eq( - &states[0], - &result.operator_node_of_selected_node - [selected.node_of(PhysicalCandidateId(build)).unwrap().0] - )); - } - } - - // An inadmissible reader must fail the requested mode, not trigger fallback. - #[test] - fn ingestion_assignment_rejects_unpredictable_selected_reader() { - let (inventory, roots, mut entries, data) = fixture(); - entries[1].predictability = Predictability::AdHoc; - let selected = select_candidates(&inventory.graph).unwrap().unwrap(); - let mut modes = vec![None; inventory.graph.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(if is_kll(&inventory.templates[id.0]) { - Materialization::MatAtIngestion(Medium::Memory) - } else { - Materialization::NotMaterialized - }); - } - let result = materialize_selected_candidates( - &inventory.configured_for_tests().physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ); - assert!( - matches!(result, Err(PhysicalCandidateError::RejectedAssignment(error)) if error.contains("not offered")) - ); - } - - // Only stronger completeness evidence may differ from the declared output. - #[test] - fn output_contract_allows_completeness_refinement_but_preserves_fields_and_keys() { - let expected = lower_promql("latency", AccuracyTarget::exact()); - assert!(!expected.schema.closed); - let mut supplied = (*expected).clone(); - supplied.schema.closed = true; - assert!(output_contract_satisfied(&expected, &supplied).unwrap()); - assert!(!output_contract_satisfied(&supplied, &expected).unwrap()); - supplied.schema.fields[0].nullable = !supplied.schema.fields[0].nullable; - assert!(!output_contract_satisfied(&expected, &supplied).unwrap()); - supplied = (*expected).clone(); - supplied.schema.time_index = None; - assert!(!output_contract_satisfied(&expected, &supplied).unwrap()); - supplied = (*expected).clone(); - supplied.schema.unique_keys.push(vec![0]); - assert!(output_contract_satisfied(&expected, &supplied).unwrap()); - assert!(!output_contract_satisfied(&supplied, &expected).unwrap()); - supplied = (*expected).clone(); - supplied.schema.fields[0].name.push_str("_different"); - assert!(!output_contract_satisfied(&expected, &supplied).unwrap()); - } - - // Scalar restoration uses mapper order and preserves repeated and literal-only references. - #[test] - fn scalar_output_mapping_preserves_positions() { - use asap_types::ir::scalar::{ArithmeticOpKind, ScalarValue}; - use asap_types::ir::{ExprSemantics, ScalarExpr}; - let root = lower_promql("latency", AccuracyTarget::exact()); - let reference = ScalarExpr::PromqlScalarFromVector(Rc::clone(&root)); - let original = vec![ - QueryRoot::Scalar(ScalarExpr::Arithmetic { - op: ArithmeticOpKind::Add, - left: Box::new(reference.clone()), - right: Box::new(reference), - semantics: ExprSemantics::Promql, - }), - QueryRoot::Scalar(ScalarExpr::Literal(ScalarValue::Float64(7.0))), - ]; - let first = lower_promql("latency", AccuracyTarget::exact()); - let second = lower_promql("latency", AccuracyTarget::exact()); - let restored = - restore_query_roots(&original, &[Rc::clone(&first), Rc::clone(&second)]).unwrap(); - let references = ordered_output_references(&restored); - assert_eq!(references.len(), 2); - assert!(Rc::ptr_eq(&references[0], &first)); - assert!(Rc::ptr_eq(&references[1], &second)); - assert_eq!(restored[1], original[1]); - assert!(restore_query_roots(&original, &[first]).is_err()); - } -} diff --git a/crates/plan-selection/src/search/candidates/reconstruction.rs b/crates/plan-selection/src/search/candidates/reconstruction.rs deleted file mode 100644 index 9279874cb..000000000 --- a/crates/plan-selection/src/search/candidates/reconstruction.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Unit tests of Stage 2's reconstruction of a selection -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use std::rc::Rc; - -use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, PhysicalCandidatesWithCosts, -}; -use crate::search::candidates::graph::SelectedCandidateGraph; -use asap_physical_asap_optimization::test_support::catalog::reconstruct_selected_candidates; -use asap_physical_asap_optimization::test_support::catalog::{ - ordered_inputs, output_contract_satisfied, paired_node_identities, -}; -use asap_types::ir::OperatorNode; -use asap_types::stages::PhysicalCandidateId; - -/// Operator output references in `graph.query_roots` order, candidate `i` -/// running `nodes[i]`, after Stage 3 validated `selected` against `graph`. A -/// caller with scalar roots must map these references back into its original -/// scalar expressions (and preserve literal-only roots); an output reference -/// is not a whole query. Nodes' timing/materialization are deliberately -/// discarded: Stage 2 must apply the selected materialization policy to the -/// reconstructed connections. -#[cfg(test)] -pub(in crate::search) fn reconstruct_candidate_outputs( - graph: &DagOfPhysicalCandidatesWithCosts, - selected: &SelectedCandidateGraph, - nodes: &[Rc], -) -> Result>, String> { - use asap_types::ir::properties::ExecutionTiming; - use asap_types::physical::Materialization; - let configured = PhysicalCandidatesWithCosts::from_dag_for_tests( - graph.clone(), - nodes - .iter() - .map(|node| { - ( - Rc::clone(node), - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ); - configured.validate_selected(selected)?; - Ok(reconstruct_selected_candidates(&configured.physical, &selected.to_selected_dag())?.outputs) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::dag::{ - InputProducerCandidates, PhysicalCandidateWithCost, QueryOutputCandidates, - }; - use crate::search::milp::model::select_candidates; - use asap_logical_asap_optimization::test_support::lower_promql; - - // Physical identity is reflexive for NaN, without equating different - // non-finite quantile parameters that JSON would serialize as null. - #[test] - fn unchanged_nan_parameter_preserves_identity() { - let lower = - |query| lower_promql(query, asap_types::ir::properties::AccuracyTarget::exact()); - let before = lower("quantile by(job)(NaN, data)"); - let unchanged = lower("quantile by(job)(NaN, data)"); - assert!(paired_node_identities(std::slice::from_ref(&before), &[unchanged]).is_ok()); - let changed = lower("quantile by(job)(Inf, data)"); - assert!(paired_node_identities(&[before], &[changed]).is_err()); - } - - // A producer may prove stronger uniqueness, but cannot drop a key that - // its consumer relies on when interpreting or combining rows. - #[test] - fn input_contract_accepts_only_sufficient_unique_keys() { - let root = lower_promql( - "sum by(job)(data)", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - let mut expected = (*root).clone(); - let mut supplied = (*root).clone(); - expected.schema.unique_keys = vec![vec![0, 1]]; - supplied.schema.unique_keys = vec![vec![0]]; - assert!(output_contract_satisfied(&expected, &supplied).unwrap()); - expected.schema.unique_keys = vec![vec![0]]; - supplied.schema.unique_keys = vec![vec![0, 1]]; - assert!(!output_contract_satisfied(&expected, &supplied).unwrap()); - supplied.schema.unique_keys.clear(); - assert!(!output_contract_satisfied(&expected, &supplied).unwrap()); - expected.schema.unique_keys.clear(); - supplied.schema.unique_keys = vec![vec![0]]; - assert!(output_contract_satisfied(&expected, &supplied).unwrap()); - } - - fn catalog( - root: Rc, - ) -> (DagOfPhysicalCandidatesWithCosts, Vec>) { - fn add( - node: &Rc, - graph: &mut DagOfPhysicalCandidatesWithCosts, - templates: &mut Vec>, - ) -> PhysicalCandidateId { - if let Some(id) = templates.iter().position(|t| Rc::ptr_eq(t, node)) { - return PhysicalCandidateId(id); - } - let producer_candidates_per_input = ordered_inputs(node) - .iter() - .map(|child| InputProducerCandidates { - candidates: vec![add(child, graph, templates)].into(), - }) - .collect(); - let id = templates.len(); - templates.push(Rc::clone(node)); - graph.candidates.push(PhysicalCandidateWithCost { - local_cost: 1.0, - producer_candidates_per_input, - }); - PhysicalCandidateId(id) - } - let mut graph = DagOfPhysicalCandidatesWithCosts::default(); - let mut templates = Vec::new(); - let root = add(&root, &mut graph, &mut templates); - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![root], - }); - (graph, templates) - } - - // Shared producers retain one identity; separately selected readers do not merge. - #[test] - fn two_readers_share_one_build_and_distinct_candidates_keep_identity() { - let (mut graph, mut templates) = catalog(lower_promql( - "sum(sum(metric))", - asap_types::ir::properties::AccuracyTarget::exact(), - )); - let root = templates.len() - 1; - assert_eq!( - graph.candidates[root].producer_candidates_per_input.len(), - 1 - ); - // Same group and same template, deliberately distinct physical candidates. - graph.candidates.push(graph.candidates[root].clone()); - templates.push(templates[root].clone()); - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(root + 1)], - }); - let selected = select_candidates(&graph).unwrap().unwrap(); - let outputs = reconstruct_candidate_outputs(&graph, &selected, &templates).unwrap(); - assert!(!Rc::ptr_eq(&outputs[0], &outputs[1])); - assert!(Rc::ptr_eq( - outputs[0].children()[0], - outputs[1].children()[0] - )); - assert!(outputs.iter().all(|node| node.timing.is_none())); - } - - // Input positions remain independent even when template references are identical. - #[test] - fn repeated_input_positions_can_select_distinct_producers() { - let root = lower_promql( - "metric + metric", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - let (mut graph, mut templates) = catalog(root); - let root = templates.len() - 1; - assert_eq!( - graph.candidates[root].producer_candidates_per_input.len(), - 2 - ); - let first = graph.candidates[root].producer_candidates_per_input[0].candidates[0]; - let duplicate = PhysicalCandidateId(templates.len()); - graph.candidates.push(graph.candidates[first.0].clone()); - templates.push(templates[first.0].clone()); - // Both template positions name the same expected child. Connections may differ. - let template = &templates[root]; - let child = Rc::clone(template.children()[0]); - templates[root] = Rc::new(OperatorNode::with_schema( - template.operator.map_children(|_| Rc::clone(&child)), - template.schema.clone(), - )); - graph.candidates[root].producer_candidates_per_input[0].candidates = vec![first].into(); - graph.candidates[root].producer_candidates_per_input[1].candidates = vec![duplicate].into(); - let selected = select_candidates(&graph).unwrap().unwrap(); - let outputs = reconstruct_candidate_outputs(&graph, &selected, &templates).unwrap(); - let children = outputs[0].children(); - assert!(!Rc::ptr_eq(children[0], children[1])); - } - - // Invalid node metadata must fail before returning reconstructed roots. - #[test] - fn incompatible_input_schema_is_rejected() { - let (graph, mut templates) = catalog(lower_promql( - "sum(metric)", - asap_types::ir::properties::AccuracyTarget::exact(), - )); - let selected = select_candidates(&graph).unwrap().unwrap(); - let root = templates.len() - 1; - let template = &templates[root]; - let wrong = lower_promql( - "sum(other)", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - templates[root] = Rc::new(OperatorNode::with_schema( - template.operator.map_children(|_| Rc::clone(&wrong)), - template.schema.clone(), - )); - assert!(reconstruct_candidate_outputs(&graph, &selected, &templates) - .unwrap_err() - .contains("incompatible schema")); - } - // Scalar references precede direct inputs during rebinding, unlike children(). - // Verify identities and scalar contents, so equal schemas cannot hide swaps. - #[test] - fn scalar_reference_and_direct_input_preserve_their_distinct_positions() { - use asap_types::ir::scalar::expr_ir::ScalarValue; - use asap_types::ir::scalar::ProjectItem; - use asap_types::ir::{NonASAPOp, Operator, ScalarExpr}; - - let direct = lower_promql( - "sum(direct_metric)", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - let scalar = lower_promql( - "sum(scalar_metric)", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - assert_eq!(direct.schema, scalar.schema); - let root = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: vec![ - ProjectItem { - alias: Some("scalar_result".into()), - expr: ScalarExpr::PromqlScalarFromVector(Rc::clone(&scalar)), - }, - ProjectItem { - alias: Some("constant".into()), - expr: ScalarExpr::Literal(ScalarValue::Float64(7.0)), - }, - ], - qualifier: None, - child: Rc::clone(&direct), - })) - .unwrap(); - assert!(Rc::ptr_eq(root.children()[0], &direct)); - assert!(Rc::ptr_eq(&ordered_inputs(&root)[0], &scalar)); - let (mut graph, templates) = catalog(Rc::clone(&root)); - let find = |node: &Rc| { - PhysicalCandidateId( - templates - .iter() - .position(|template| Rc::ptr_eq(template, node)) - .unwrap(), - ) - }; - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![find(&direct)], - }); - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![find(&scalar)], - }); - let selected = select_candidates(&graph).unwrap().unwrap(); - let outputs = reconstruct_candidate_outputs(&graph, &selected, &templates).unwrap(); - let Some(NonASAPOp::Project { - cols, - qualifier, - child, - }) = outputs[0].non_asap() - else { - panic!("expected reconstructed projection"); - }; - assert!(Rc::ptr_eq(child, &outputs[1])); - let ScalarExpr::PromqlScalarFromVector(scalar) = &cols[0].expr else { - panic!("scalar conversion must be preserved"); - }; - assert!(Rc::ptr_eq(scalar, &outputs[2])); - assert!(!Rc::ptr_eq(child, scalar)); - assert_eq!(cols[0].alias.as_deref(), Some("scalar_result")); - assert_eq!(cols[1].alias.as_deref(), Some("constant")); - assert_eq!(cols[1].expr, ScalarExpr::Literal(ScalarValue::Float64(7.0))); - assert!(qualifier.is_none()); - } - // Completeness and uniqueness may strengthen; field and time semantics cannot drift. - #[test] - fn output_contract_allows_only_open_to_closed_refinement() { - let node = lower_promql( - "metric", - asap_types::ir::properties::AccuracyTarget::exact(), - ); - let mut expected = (*node).clone(); - expected.schema.closed = false; - let mut supplied = expected.clone(); - supplied.schema.closed = true; - assert!(output_contract_satisfied(&expected, &supplied).unwrap()); - assert!(!output_contract_satisfied(&supplied, &expected).unwrap()); - let mut renamed = supplied.clone(); - renamed.schema.fields[0].name.push_str("_changed"); - assert!(!output_contract_satisfied(&expected, &renamed).unwrap()); - let mut nullable = supplied.clone(); - nullable.schema.fields[0].nullable = !nullable.schema.fields[0].nullable; - assert!(!output_contract_satisfied(&expected, &nullable).unwrap()); - let mut timeless = supplied.clone(); - timeless.schema.time_index = None; - assert!(!output_contract_satisfied(&expected, &timeless).unwrap()); - let mut keyed = supplied.clone(); - keyed.schema.unique_keys.push(vec![0]); - assert!(output_contract_satisfied(&expected, &keyed).unwrap()); - assert!(!output_contract_satisfied(&keyed, &expected).unwrap()); - } -} diff --git a/crates/plan-selection/src/search/candidates/scopes.rs b/crates/plan-selection/src/search/candidates/scopes.rs deleted file mode 100644 index 1982758f0..000000000 --- a/crates/plan-selection/src/search/candidates/scopes.rs +++ /dev/null @@ -1,703 +0,0 @@ -//! Today's engine's execution-scope copies (#1095 §2.0, "Stage 2 emits no -//! copies"): the engine's MILP runs each candidate at most once, so before it -//! solves, Stage 3 copies a candidate once per execution scope that may need -//! its own run (was Stage 2's `catalog/scopes.rs`), and decodes each selected -//! copy into a node of the [`DagOfSelectedCandidates`] over Stage 2's -//! candidates. Copies are interchangeable, not assigned to disjoint reader -//! sets: one query may consume several copies while different subsets share -//! other inputs. Activation-order constraints remove permutations of unused -//! copies only. Deleted with the engine. -use std::collections::{HashMap, HashSet}; - -use asap_types::ir::properties::ExecutionTiming; -use asap_types::physical::Materialization; -use asap_types::stages::{ - output_references, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, PhysicalCandidateId, - ProducerCandidates, ProducerSetInterner, -}; -use asap_types::workload::QueryWorkloadEntry; - -use crate::search::candidates::dag::{PhysicalCandidatesWithCosts, ReaderConstraint}; -use crate::search::candidates::graph::SelectedCandidateGraph; - -/// Stage 2's candidates with the execution-scope copies the engine selects -/// over. -#[derive(Debug, Clone)] -pub(crate) struct ExpandedCandidates { - /// The copies, each a candidate of its own, with Stage 2's reader - /// constraints on every copy and the copies' activation order in the - /// DAG's reader constraints. - pub configured: PhysicalCandidatesWithCosts, - /// Indexed by copy: the Stage 2 candidate it runs. - pub candidate_of_copy: Vec, -} - -impl ExpandedCandidates { - /// `selected` (over the copies) as a DAG over Stage 2's candidates: one - /// node per selected copy, in `selected_candidates` order. - pub(crate) fn decode(&self, selected: &SelectedCandidateGraph) -> DagOfSelectedCandidates { - let dag = selected.to_selected_dag(); - DagOfSelectedCandidates { - candidate_of_node: dag - .candidate_of_node - .iter() - .map(|copy| self.candidate_of_copy[copy.0]) - .collect(), - edges: dag.edges, - node_of_output_reference: dag.node_of_output_reference, - } - } -} - -/// `physical`'s candidates with their execution-scope copies, sized as -/// Stage 2 sized them: per candidate, the larger of the distinct output -/// contexts reaching it (an output reference's workload entry and root -/// operator nodes; identical outputs share one) and the distinct -/// (consumer memo group, input) uses of its operator node, or one copy when -/// at most one context reaches it or it is a shared ingestion-time leaf; -/// a terminal unary summary reader gets at least one copy per output -/// reference reaching it. -pub(crate) fn expand_execution_scopes( - physical: &PhysicalASAPMemoCandidates, - entries: &[QueryWorkloadEntry], - deadline: Option, -) -> Result { - let node_count = physical.operator_nodes_of_candidates_for_memo_groups.len(); - let output_queries: Vec = output_references(&physical.query_roots) - .into_iter() - .map(|(query, _)| query) - .collect(); - let mut context_ids = HashMap::new(); - let mut output_contexts = Vec::new(); - for (output, &query) in output_queries.iter().enumerate() { - let mut root_nodes: Vec = physical - .query_output_candidates(output) - .map(|id| physical.candidates[id.0].operator_node.0) - .collect(); - root_nodes.sort_unstable(); - root_nodes.dedup(); - let entry = entries - .get(query) - .ok_or("execution-scope output contexts are incomplete")?; - let key = serde_json::to_string(&(entry, root_nodes)).map_err(|e| e.to_string())?; - let next = context_ids.len(); - output_contexts.push(*context_ids.entry(key).or_insert(next)); - } - let mut uses = vec![HashSet::new(); node_count]; - for candidate in &physical.candidates { - let group = physical.operator_nodes_of_candidates_for_memo_groups - [candidate.operator_node.0] - .group(); - for (input, producers) in candidate.producer_candidates_per_input.iter().enumerate() { - for producer in producers { - uses[physical.candidates[producer.0].operator_node.0].insert((group, input)); - } - } - } - let mut root_uses = vec![HashSet::new(); node_count]; - for (output, &context) in output_contexts.iter().enumerate() { - for id in physical.query_output_candidates(output) { - root_uses[physical.candidates[id.0].operator_node.0].insert(context); - } - } - let original_uses: Vec = uses - .iter() - .zip(&root_uses) - .map(|(uses, roots)| uses.len() + roots.len()) - .collect(); - expand_with_uses(physical.clone(), &original_uses, &output_contexts, deadline) -} - -/// [`expand_execution_scopes`] with the uses of each operator node -/// (`original_uses`) and each output reference's context given. -pub(crate) fn expand_with_uses( - source: PhysicalASAPMemoCandidates, - original_uses: &[usize], - output_contexts: &[usize], - deadline: Option, -) -> Result { - let past = || deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline); - let timeout = || "execution-scope expansion exceeded the deadline".to_string(); - let count = source.candidates.len(); - let query_output_candidates: Vec> = (0..source.output_reference_count()) - .map(|output| source.query_output_candidates(output).collect()) - .collect(); - if output_contexts.len() != query_output_candidates.len() { - return Err("execution-scope output contexts are incomplete".into()); - } - let mut internally_consumed = vec![false; count]; - for input in source - .candidates - .iter() - .flat_map(|candidate| &candidate.producer_candidates_per_input) - { - for producer in input { - internally_consumed[producer.0] = true; - } - } - let mut output_uses = vec![HashSet::new(); count]; - let mut output_positions = vec![0usize; count]; - for (output, root) in query_output_candidates.iter().enumerate() { - if past() { - return Err(timeout()); - } - let mut seen = vec![false; count]; - let mut pending = root.clone(); - while let Some(id) = pending.pop() { - if std::mem::replace(&mut seen[id.0], true) { - continue; - } - output_uses[id.0].insert(output_contexts[output]); - output_positions[id.0] += 1; - pending.extend( - source.candidates[id.0] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.iter().copied()), - ); - } - } - let mut candidates = Vec::new(); - let mut candidate_of_copy = Vec::new(); - let mut activation = Vec::new(); - let mut copies = vec![Vec::new(); count]; - for (id, candidate) in source.candidates.iter().enumerate() { - if past() { - return Err(timeout()); - } - let uses = *original_uses - .get(candidate.operator_node.0) - .ok_or("execution-scope source occurrence count is incomplete")?; - // Keep the original occurrence capacity, including repeated input slots. - // This is a finite catalog bound, not arbitrary unfolded-DAG enumeration. - let conflicting_readers = source.reader_constraints.iter().any(|constraint| { - matches!(constraint, - asap_types::stages::ReaderConstraint::IncompatibleOutputs { candidate, .. } if candidate.0 == id) - }); - let shared_ingestion_leaf = candidate.producer_candidates_per_input.is_empty() - && candidate.timing == ExecutionTiming::IngestionTime - && candidate.materialization == Materialization::NotMaterialized - && !conflicting_readers; - let contexts = if conflicting_readers { - output_positions[id] - } else { - output_uses[id].len() - }; - let multiplicity = if contexts <= 1 || shared_ingestion_leaf { - 1 - } else { - contexts.max(uses) - }; - let terminal_unary_reader = - !internally_consumed[id] && candidate.producer_candidates_per_input.len() == 1 && { - let node = source.physical_node(PhysicalCandidateId(id)); - matches!( - node.asap(), - Some( - asap_types::ir::ASAPOp::SummaryEstimate { .. } - | asap_types::ir::ASAPOp::FinalizeExactAccumulator { .. } - ) - ) && node.children().len() == 1 - }; - // Factored terminal readouts may need a different binding per output - // reference, including two references in one scalar query. Every active - // terminal copy must serve a root, so the reference count supplies a - // finite sufficient capacity. Copies remain optional and can be shared. - // Internal consumers retain the original occurrence/context rule. - let multiplicity = if terminal_unary_reader { - multiplicity.max(output_positions[id]) - } else { - multiplicity - }; - for _ in 0..multiplicity { - let copy = PhysicalCandidateId(candidates.len()); - if let Some(&previous) = copies[id].last() { - activation.push(ReaderConstraint::ActivationRequires { - candidate: copy, - required_candidate: previous, - }); - } - copies[id].push(copy); - candidates.push(candidate.clone()); - candidate_of_copy.push(PhysicalCandidateId(id)); - } - } - let mut producer_sets = ProducerSetInterner::default(); - let mut input_cache: HashMap = HashMap::new(); - for (id, candidate) in source.candidates.iter().enumerate() { - if past() { - return Err(timeout()); - } - let mut inputs = Vec::new(); - for input in &candidate.producer_candidates_per_input { - let compatible_producers = if let Some(producer_candidates) = input_cache.get(input) { - producer_candidates.clone() - } else { - let compatible: Vec<_> = input - .iter() - .flat_map(|producer| copies[producer.0].iter().copied()) - .collect(); - let producer_candidates = producer_sets.intern(compatible); - input_cache.insert(input.clone(), producer_candidates.clone()); - producer_candidates - }; - inputs.push(compatible_producers); - } - // Consumer copies retain distinct input slots and MILP edges; only - // their immutable compatible-producer storage is shared. - for © in &copies[id] { - candidates[copy.0].producer_candidates_per_input = inputs.clone(); - } - } - let mut reader_constraints = Vec::new(); - for constraint in &source.reader_constraints { - for © in &copies[constraint.candidate().0] { - let mut constraint = constraint.clone(); - *constraint.candidate_mut() = copy; - reader_constraints.push(constraint); - } - } - let mut expanded = PhysicalASAPMemoCandidates::new( - source.operator_nodes_of_candidates_for_memo_groups, - candidates, - source.query_roots, - reader_constraints, - ); - expanded.pruned_candidates = source.pruned_candidates; - let mut configured = PhysicalCandidatesWithCosts::new(expanded); - configured.dag.reader_constraints.extend(activation); - Ok(ExpandedCandidates { - configured, - candidate_of_copy, - }) -} - -/// [`expand_with_uses`] of `source`'s candidates (after -/// [`PhysicalCandidatesWithCosts::sync_physical_for_tests`]). Each copy -/// costs what its candidate did. -#[cfg(test)] -pub(in crate::search) fn expand_execution_scopes_for_tests( - mut source: PhysicalCandidatesWithCosts, - original_uses: &[usize], - output_contexts: &[usize], -) -> Result { - source.sync_physical_for_tests(); - let expanded = expand_with_uses(source.physical, original_uses, output_contexts, None)?; - let mut configured = expanded.configured; - for (candidate, original) in configured - .dag - .candidates - .iter_mut() - .zip(&expanded.candidate_of_copy) - { - candidate.local_cost = source.dag.candidates[original.0].local_cost; - } - Ok(configured) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::milp::model::select_candidates; - use asap_types::ir::properties::ExecutionTiming; - use asap_types::physical::Materialization; - - use crate::search::candidates::dag::ReaderConstraint; - use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, InputProducerCandidates, PhysicalCandidateWithCost, - QueryOutputCandidates, - }; - use asap_types::stages::PhysicalCandidateId; - - fn terminal_reader_inventory(separate_producers: bool) -> PhysicalCandidatesWithCosts { - use asap_logical_asap_optimization::test_support::{cms_summary, estimate_over}; - use asap_types::ir::properties::AccuracyGuarantee; - use std::rc::Rc; - let state = Rc::new(cms_summary("count(m)")); - let reader = estimate_over(state, AccuracyGuarantee::exact("scope fixture")); - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - PhysicalCandidateWithCost { - local_cost: 2.0, - producer_candidates_per_input: vec![], - }, - PhysicalCandidateWithCost { - local_cost: 3.0, - producer_candidates_per_input: vec![], - }, - PhysicalCandidateWithCost { - local_cost: 1.0, - producer_candidates_per_input: vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }], - }, - ], - query_roots: vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)] - }; - 2 - ], - ..Default::default() - }; - if separate_producers { - for output in 0..2 { - graph - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(output), - outputs: vec![output], - }); - } - } - let placeholder = crate::search::candidates::dag::placeholder_node_for_tests; - PhysicalCandidatesWithCosts::from_dag_for_tests( - graph, - [placeholder(), placeholder(), reader] - .into_iter() - .map(|node| { - ( - node, - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ) - } - - // Fix the producer catalog independently of planning budgets and progressive - // tumbling-window domains. Root-only readers need one optional binding per output; - // producers stay constant and producer connections grow exactly linearly. - // Output-to-copy assignment choices are intentionally N*N, not claimed linear. - #[test] - fn repeated_outputs_have_constant_producers_and_linear_reader_connections() { - for output_count in [2, 4, 8] { - let mut source = terminal_reader_inventory(false); - source.dag.query_roots = vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)] - }; - output_count - ]; - let expanded = - expand_execution_scopes_for_tests(source, &[1, 1, 1], &vec![0; output_count]) - .unwrap(); - let graph = &expanded.dag; - let producer_count = expanded - .physical - .candidates - .iter() - .filter(|configuration| configuration.operator_node.0 < 2) - .count(); - let terminal_count = expanded - .physical - .candidates - .iter() - .filter(|configuration| configuration.operator_node.0 == 2) - .count(); - let producer_connections: usize = graph - .candidates - .iter() - .flat_map(|candidate| &candidate.producer_candidates_per_input) - .map(|input| input.candidates.len()) - .sum(); - assert_eq!(producer_count, 2); - assert_eq!(terminal_count, output_count); - assert_eq!(graph.candidates.len(), 2 + output_count); - assert_eq!(producer_connections, 2 * output_count); - assert_eq!( - graph - .query_roots - .iter() - .map(|output| output.candidates.len()) - .sum::(), - output_count * output_count - ); - let selected = select_candidates(graph).unwrap().unwrap(); - assert_eq!(selected.query_roots.len(), output_count); - assert!(selected - .query_roots - .iter() - .all(|output| output.candidate == selected.query_roots[0].candidate)); - assert_eq!( - selected.modeled_cost, 3.0, - "additional capacity does not force duplicate work" - ); - } - } - - // Terminal-reader factoring must preserve independent bindings for two - // output references of the same scalar query, not only distinct queries. - #[test] - fn terminal_reader_copies_preserve_same_query_independent_bindings() { - let source = terminal_reader_inventory(true); - assert!(select_candidates(&source.dag).unwrap().is_none()); - let expanded = expand_execution_scopes_for_tests(source, &[1, 1, 1], &[0, 0]).unwrap(); - let selected = select_candidates(&expanded.dag) - .unwrap() - .expect("each root reference can bind a separate reader"); - assert_ne!( - selected.query_roots[0].candidate, - selected.query_roots[1].candidate - ); - let suppliers = selected - .connections - .iter() - .map(|edge| { - expanded.physical.candidates[edge.producer.0] - .operator_node - .0 - }) - .collect::>(); - assert_eq!(suppliers, std::collections::HashSet::from([0, 1])); - assert_eq!(selected.modeled_cost, 7.0); - - // Capacity is optional: without conflicting eligibility, both root - // references may still consume one reader and pay its cost once. - let shared = expand_execution_scopes_for_tests( - terminal_reader_inventory(false), - &[1, 1, 1], - &[0, 0], - ) - .unwrap(); - let selected = select_candidates(&shared.dag).unwrap().unwrap(); - assert_eq!( - selected.query_roots[0].candidate, - selected.query_roots[1].candidate - ); - assert_eq!(selected.modeled_cost, 3.0); - } - - // The terminal-readout bound does not alter capacity for a reader that - // supplies another candidate; that requires a separate dependency proof. - #[test] - fn terminal_capacity_rule_leaves_internal_readers_unchanged() { - let mut source = terminal_reader_inventory(false); - source.push_for_tests( - crate::search::candidates::dag::placeholder_node_for_tests(), - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 1.0, - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(2)].into(), - }], - ); - for output in &mut source.dag.query_roots { - output.candidates = vec![PhysicalCandidateId(3)]; - } - let expanded = expand_execution_scopes_for_tests(source, &[1; 4], &[0, 0]).unwrap(); - assert_eq!( - expanded - .physical - .candidates - .iter() - .filter(|configuration| configuration.operator_node.0 == 2) - .count(), - 1 - ); - } - - // The unknown-phase query reads both states; the two known-phase queries - // share different copies. Another independent state remains shared by all. - #[test] - fn overlapping_readers_can_separate_one_state_and_share_another() { - let id = PhysicalCandidateId; - let mut graph = DagOfPhysicalCandidatesWithCosts::default(); - for (index, cost) in [3.0, 5.0, 1.0, 1.0].into_iter().enumerate() { - graph.candidates.push(PhysicalCandidateWithCost { - local_cost: cost, - producer_candidates_per_input: if index < 2 { - Vec::new() - } else { - vec![ - InputProducerCandidates { - candidates: vec![id(0)].into(), - }, - InputProducerCandidates { - candidates: vec![id(1)].into(), - }, - ] - }, - }); - } - graph.query_roots = [2, 3, 2, 3] - .into_iter() - .map(|candidate| QueryOutputCandidates { - candidates: vec![id(candidate)], - }) - .collect(); - graph - .reader_constraints - .push(ReaderConstraint::IncompatibleOutputs { - candidate: id(0), - left: 2, - right: 3, - }); - assert!(select_candidates(&graph).unwrap().is_none()); - let original_graph = graph.clone(); - let source = PhysicalCandidatesWithCosts::from_dag_for_tests( - graph, - (0..4) - .map(|_| { - ( - crate::search::candidates::dag::placeholder_node_for_tests(), - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ); - let expanded = - expand_execution_scopes_for_tests(source, &[2, 2, 2, 2], &[0, 1, 2, 3]).unwrap(); - // Copy expansion must preserve every slot's complete ordered producer - // mapping even though repeated copies share the backing allocation. - for (id, configuration) in expanded.physical.candidates.iter().enumerate() { - let original = &original_graph.candidates[configuration.operator_node.0]; - let copied = &expanded.dag.candidates[id]; - assert_eq!( - original.producer_candidates_per_input.len(), - copied.producer_candidates_per_input.len() - ); - for (input, copied_input) in original - .producer_candidates_per_input - .iter() - .zip(&copied.producer_candidates_per_input) - { - let expected: Vec<_> = input - .candidates - .iter() - .flat_map(|producer| { - expanded.physical.candidates.iter().enumerate().filter_map( - move |(id, configuration)| { - (configuration.operator_node.0 == producer.0) - .then_some(PhysicalCandidateId(id)) - }, - ) - }) - .collect(); - assert_eq!(copied_input.candidates.as_slice(), expected.as_slice()); - } - } - let metrics = asap_types::stages::producer_storage_metrics(&expanded.physical); - assert!( - metrics.stored_producer_ids < metrics.logical_connections, - "{metrics:?}" - ); - let selected = select_candidates(&expanded.dag).unwrap().unwrap(); - assert_eq!(selected.modeled_cost, 13.0); - let uses = |source| { - selected - .selected_candidates - .iter() - .filter(|id| expanded.physical.candidates[id.0].operator_node.0 == source) - .count() - }; - assert_eq!(uses(0), 2); - assert_eq!(uses(1), 1); - assert_eq!(uses(2), 1); - assert_eq!(uses(3), 1); - for constraint in &expanded.dag.reader_constraints { - if let ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } = constraint - { - assert!( - !selected.selected_candidates.contains(candidate) - || selected.selected_candidates.contains(required_candidate) - ); - } - } - } - - // A selection over the copies decodes into a DAG over Stage 2's - // candidates that Stage 2's validation accepts: the state read by the - // two incompatible outputs runs as two nodes of one candidate. - #[test] - fn selected_copies_decode_into_nodes_of_stage2_candidates() { - let id = PhysicalCandidateId; - let input = |producer| InputProducerCandidates { - candidates: vec![id(producer)].into(), - }; - let reader = |cost| PhysicalCandidateWithCost { - local_cost: cost, - producer_candidates_per_input: vec![input(0), input(1)], - }; - let leaf = |cost| PhysicalCandidateWithCost { - local_cost: cost, - producer_candidates_per_input: Vec::new(), - }; - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![leaf(3.0), leaf(5.0), reader(1.0), reader(1.0)], - query_roots: [2, 3, 2, 3] - .into_iter() - .map(|candidate| QueryOutputCandidates { - candidates: vec![id(candidate)], - }) - .collect(), - reader_constraints: vec![ReaderConstraint::IncompatibleOutputs { - candidate: id(0), - left: 2, - right: 3, - }], - ..Default::default() - }; - let source = PhysicalCandidatesWithCosts::from_dag_for_tests( - graph, - (0..4) - .map(|_| { - ( - crate::search::candidates::dag::placeholder_node_for_tests(), - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ); - let mut expanded = - expand_with_uses(source.physical.clone(), &[2, 2, 2, 2], &[0, 1, 2, 3], None).unwrap(); - for (candidate, original) in expanded - .configured - .dag - .candidates - .iter_mut() - .zip(&expanded.candidate_of_copy) - { - candidate.local_cost = source.dag.candidates[original.0].local_cost; - } - let selected = select_candidates(&expanded.configured.dag) - .unwrap() - .unwrap(); - let dag = expanded.decode(&selected); - dag.validate(&source.physical).unwrap(); - assert_eq!( - dag.candidate_of_node - .iter() - .filter(|candidate| **candidate == id(0)) - .count(), - 2 - ); - assert_eq!(dag.node_of_output_reference.len(), 4); - // One node of candidate 0 read by both incompatible outputs is - // rejected per node. - let mut shared = dag.clone(); - let nodes_of_0: Vec = (0..shared.candidate_of_node.len()) - .filter(|&node| shared.candidate_of_node[node] == id(0)) - .collect(); - let (kept, removed) = (nodes_of_0[0], nodes_of_0[1]); - let renumber = |node: usize| match node { - node if node == removed => kept - usize::from(kept > removed), - node => node - usize::from(node > removed), - }; - shared.candidate_of_node.remove(removed); - for edge in &mut shared.edges { - edge.producer.0 = renumber(edge.producer.0); - edge.consumer.0 = renumber(edge.consumer.0); - } - for node in &mut shared.node_of_output_reference { - node.0 = renumber(node.0); - } - assert_eq!( - shared.validate(&source.physical), - Err("decoded selection violates selected-reader compatibility".to_string()) - ); - } -} diff --git a/crates/plan-selection/src/search/costing/calibration.rs b/crates/plan-selection/src/search/costing/calibration.rs deleted file mode 100644 index 38d1add31..000000000 --- a/crates/plan-selection/src/search/costing/calibration.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Calibration policy for candidate selection. Unknown calibration is never -//! fitted to a timeout-dependent subset and then treated as a fixed price model. -use crate::SelectionModels; -use asap_cost::CalibrationFit; - -#[derive(Debug, Clone, Copy)] -pub(in crate::search) enum CandidateCalibration { - Frozen(CalibrationFit), - CompleteCatalog, -} - -impl CandidateCalibration { - pub fn for_models(models: SelectionModels<'_>) -> Result { - if let Some(factor) = models.cost_inputs.frozen_calibration_factor { - return CalibrationFit::from_factor(factor) - .map(Self::Frozen) - .map_err(|error| error.to_string()); - } - if models - .cost_inputs - .measured_operation_costs - .operators - .is_empty() - { - Ok(Self::Frozen(CalibrationFit::default())) - } else { - Ok(Self::CompleteCatalog) - } - } -} diff --git a/crates/plan-selection/src/search/costing/direct_price_options_tests.rs b/crates/plan-selection/src/search/costing/direct_price_options_tests.rs deleted file mode 100644 index c8ee50398..000000000 --- a/crates/plan-selection/src/search/costing/direct_price_options_tests.rs +++ /dev/null @@ -1,780 +0,0 @@ -//! Price-interface regressions: compatible inputs must not disappear merely -//! because their statistics differ from the template's input. -use super::*; -use asap_logical_asap_optimization::test_support::lower_promql; -use asap_types::ir::properties::{AccuracyScope, AccuracyTarget}; -use asap_types::ir::{Operator, OperatorNode, ProjectItem, ScalarExpr}; - -use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, InputProducerCandidates, PhysicalCandidateWithCost, - QueryOutputCandidates, -}; -use asap_types::workload::{ - DataWorkload, Predictability, Query, QueryRequirements, QueryWorkloadEntry, RepetitionInterval, -}; - -#[test] -fn differing_producer_statistics_keep_both_price_interfaces() { - let scan = |metric| { - OperatorNode::reachable(&lower_promql(metric, AccuracyTarget::exact())) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap() - }; - let m = scan("m"); - let n = scan("n"); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: m - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: m.clone(), - })) - .unwrap(); - let mut configured = PhysicalCandidatesWithCosts::from_dag_for_tests( - DagOfPhysicalCandidatesWithCosts { - candidates: (0..3) - .map(|id| PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: if id == 2 { - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }] - } else { - vec![] - }, - }) - .collect(), - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }], - ..Default::default() - }, - vec![m, n, project.clone()] - .into_iter() - .zip((0..3).map(|_| (ExecutionTiming::QueryTime, Materialization::NotMaterialized))) - .collect(), - ); - let roots = vec![QueryRoot::Operator(project)]; - let entries = [QueryWorkloadEntry { - query: Query("m".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 100.0}}, - "sources": {"m": {"rows_held":10,"series_count":1},"n":{"rows_held":100,"series_count":1}} - })) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert_eq!(extracted.candidates[0].len(), 1); - assert_eq!(extracted.candidates[1].len(), 1); - let reader_options = &extracted.candidates[2]; - assert_eq!( - reader_options.len(), - 2, - "both legal supplier statistics must reach the joint model" - ); - for producer in [PhysicalCandidateId(0), PhysicalCandidateId(1)] { - let option = reader_options - .iter() - .find(|option| { - option - .compatible_input_tuples - .iter() - .flatten() - .any(|binding| binding.producer == producer) - }) - .expect("missing compatible supplier price option"); - assert_eq!(option.compatible_input_tuples.len(), 1); - assert_eq!(option.compatible_input_tuples[0][0].price_option, 0); - } - let low = reader_options - .iter() - .find(|option| option.compatible_input_tuples[0][0].producer == PhysicalCandidateId(0)) - .unwrap(); - let high = reader_options - .iter() - .find(|option| option.compatible_input_tuples[0][0].producer == PhysicalCandidateId(1)) - .unwrap(); - assert!( - high.profile.work_seconds > low.profile.work_seconds, - "projection work must follow selected input rows, not template rows" - ); - assert_eq!( - configured.dag.candidates[2].producer_candidates_per_input[0] - .candidates - .len(), - 2 - ); - // Force each structurally legal producer in turn. The model must price - // the selected binding rather than only returning the cheapest interface. - for producer in [PhysicalCandidateId(0), PhysicalCandidateId(1)] { - configured.dag.reader_constraints = vec![ - crate::search::candidates::dag::ReaderConstraint::ActivationRequires { - candidate: PhysicalCandidateId(2), - required_candidate: producer, - }, - ]; - configured.sync_physical_for_tests(); - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &extracted, - ) - .unwrap() - .expect("forced supplier must remain feasible"); - assert!(proposal - .selected - .connections - .iter() - .any(|edge| edge.producer == producer)); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("forced supplier rejected: {rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let modeled = proposal - .direct_modeled_cost - .expect("joint direct objective"); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "supplier {producer:?}: modeled {modeled}, actual {actual}" - ); - } - let timed = CandidatePlanningContext { - deadline: Some(std::time::Instant::now()), - ..context - }; - assert!(matches!( - extract_price_options(&configured, &timed), - Err(DirectProfileError::DeadlineExceeded(_)) - )); -} - -#[test] -fn downstream_prices_follow_the_selected_producer_interface() { - let scan = |metric| { - OperatorNode::reachable(&lower_promql(metric, AccuracyTarget::exact())) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap() - }; - let m = scan("m"); - let n = scan("n"); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: m - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: m.clone(), - })) - .unwrap(); - let mut configured = PhysicalCandidatesWithCosts::from_dag_for_tests( - DagOfPhysicalCandidatesWithCosts { - candidates: (0..3) - .map(|id| PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: if id == 2 { - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }] - } else { - vec![] - }, - }) - .collect(), - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }], - ..Default::default() - }, - vec![m, n, project.clone()] - .into_iter() - .zip((0..3).map(|_| (ExecutionTiming::QueryTime, Materialization::NotMaterialized))) - .collect(), - ); - let downstream = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: project - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: project, - })) - .unwrap(); - configured.push_for_tests( - downstream.clone(), - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 0.0, - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(2)].into(), - }], - ); - configured.dag.query_roots[0].candidates = vec![PhysicalCandidateId(3)]; - configured.sync_physical_for_tests(); - let roots = vec![QueryRoot::Operator(downstream)]; - let entries = [QueryWorkloadEntry { - query: Query("m".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 100.0}}, - "sources": {"m": {"rows_held":10,"series_count":1},"n":{"rows_held":100,"series_count":1}} - })) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert_eq!(extracted.candidates[2].len(), 2); - assert_eq!( - extracted.candidates[3].len(), - 2, - "a single supplier candidate may expose multiple downstream price interfaces" - ); - let mut referenced = Vec::new(); - for option in &extracted.candidates[3] { - assert_eq!(option.compatible_input_tuples.len(), 1); - let binding = &option.compatible_input_tuples[0][0]; - assert_eq!(binding.producer, PhysicalCandidateId(2)); - referenced.push(binding.price_option); - let supplier = &extracted.candidates[2][binding.price_option]; - assert!( - (option.profile.work_seconds - supplier.profile.work_seconds).abs() < 1e-15, - "identical projections over the same rows must receive the same work price" - ); - } - referenced.sort_unstable(); - assert_eq!(referenced, vec![0, 1]); - // Optional identity cycles must not disable otherwise legal pricing paths - // or create a zero-input phantom plan. All selected DAGs still need a scan. - let solve = - |configured: &PhysicalCandidatesWithCosts, - profile: &crate::search::milp::direct_price_options::JointPriceProfile| { - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - profile, - ) - .unwrap() - .unwrap() - }; - let baseline = solve(&configured, &extracted); - configured.dag.candidates[2].producer_candidates_per_input[0].candidates = vec![ - PhysicalCandidateId(0), - PhysicalCandidateId(1), - PhysicalCandidateId(2), - PhysicalCandidateId(3), - ] - .into(); - configured.sync_physical_for_tests(); - let cyclic = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert_eq!(cyclic.candidates[2].len(), 2); - assert_eq!(cyclic.candidates[3].len(), 2); - assert!(cyclic.candidates[2] - .iter() - .flat_map(|option| option.compatible_input_tuples.iter().flatten()) - .all(|binding| binding.producer != PhysicalCandidateId(2))); - let chosen = solve(&configured, &cyclic); - assert!( - (chosen.direct_modeled_cost.unwrap() - baseline.direct_modeled_cost.unwrap()).abs() < 1e-12 - ); - assert!(chosen - .selected - .connections - .iter() - .any(|edge| edge.producer.0 < 2)); -} - -#[test] -fn producer_worker_buckets_remain_joint_choices_and_match_evaluator() { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - use std::rc::Rc; - fn append( - node: Rc, - configured: &mut PhysicalCandidatesWithCosts, - ingestion: bool, - mode: Materialization, - ) -> PhysicalCandidateId { - let ingestion = ingestion - || (matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - && mode == Materialization::MatAtIngestion(Medium::Memory)); - let tumbling_window = - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })); - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child.clone(), configured, ingestion, mode))); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - if ingestion { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }, - if tumbling_window { - mode - } else { - Materialization::NotMaterialized - }, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - id - } - - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - let original = lower_promql("quantile_over_time(0.95, latency[5m])", target.clone()); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&[ - QueryRoot::Operator(original.clone()), - ]), - ) - .unwrap(); - let choice = generated.targets[0] - .ways_to_compute - .iter() - .position(|candidate| { - matches!( - candidate, - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::Kll) - ) - }) - .unwrap(); - let roots: Vec<_> = compose_logical_candidate(&generated, &[choice]).unwrap(); - let tumbling_window_rules = - asap_logical_asap_optimization::test_support::tumbling_window_candidates( - &roots, - &[Some(60_000)], - ); - let roots = asap_logical_asap_optimization::test_support::window_composition_pass2_candidates( - &roots, - &tumbling_window_rules, - ) - .unwrap() - .pop() - .unwrap() - .roots - .unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":100.0}}, - "sources":{ - "latency":{"series_count":1,"sample_interval":100,"rows_held":3000,"values_non_negative":true} - } - })).unwrap(); - let sized = crate::sized_as_pass1(&roots, std::slice::from_ref(&target), &data); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let QueryRoot::Operator(root) = &sized[0] else { - panic!("operator") - }; - let root_id = append( - root.clone(), - &mut configured, - false, - Materialization::MatAtIngestion(Medium::Memory), - ); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![root_id], - }); - configured.sync_physical_for_tests(); - let chain = configured - .nodes_for_tests() - .iter() - .position(|node| matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. }))) - .unwrap(); - let raw_producer = - configured.dag.candidates[chain].producer_candidates_per_input[0].candidates[0]; - configured.sync_physical_for_tests(); - assert!(matches!( - configured.node(raw_producer.0).non_asap(), - Some(NonASAPOp::Scan { .. }) - )); - // This tests the pricing kernel, not executor installation of a projection - // below a tumbling window. Both producers return exactly the same stream. The identity projection - // adds ingestion CPU without changing rows, values, or source identity. - let source = configured.node(raw_producer.0).clone(); - let projected = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: source - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: source, - })) - .unwrap(); - let projected_producer = configured.push_for_tests( - projected, - ExecutionTiming::IngestionTime, - Materialization::NotMaterialized, - 0.0, - vec![InputProducerCandidates { - candidates: vec![raw_producer].into(), - }], - ); - let originals = vec![QueryRoot::Operator(original)]; - let entries = vec![QueryWorkloadEntry { - query: Query("quantile_over_time(0.95, latency[5m])".into()), - requirements: QueryRequirements { - accuracy: target, - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - range_evaluation: None, - }]; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &crate::search::test_realizer(&originals, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let initial = extract(&configured, &context).unwrap().unwrap(); - let raw_work: f64 = [raw_producer.0, chain] - .into_iter() - .map(|id| initial.profile.nodes[id].work_seconds * 1000.0) - .sum(); - assert!(raw_work > 0.0); - let mut settings = asap_cost::CostInputs::default(); - settings.resource_speed.ms_per_cpu_op *= 850.0 / raw_work; - settings.resource_speed.ms_per_scanned_byte *= 850.0 / raw_work; - settings - .measured_operation_costs - .operators - .push(asap_cost::OperatorCost { - operator: asap_cost::OperatorKind::Project, - phase: Some(ExecutionTiming::IngestionTime), - sketch: None, - exact: None, - measure: None, - ns_per_unit: 0.0, - ns_per_invocation: 1_000_000_000.0, - memory_bytes_per_state: None, - }); - let context = CandidatePlanningContext { - models: crate::SelectionModels { - cost_inputs: &settings, - ..context.models - }, - ..context - }; - configured.dag.candidates[chain].producer_candidates_per_input[0].candidates = - vec![raw_producer, projected_producer].into(); - configured.sync_physical_for_tests(); - let old = extract(&configured, &context).unwrap().unwrap(); - assert_eq!( - old.suppliers[chain], - vec![raw_producer], - "old fixed-bucket extraction loses the second worker choice" - ); - let joint = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert_eq!(joint.candidates[chain].len(), 2); - let raw_option = joint.candidates[chain] - .iter() - .find(|option| { - option - .compatible_input_tuples - .iter() - .flatten() - .any(|binding| binding.producer == raw_producer) - }) - .unwrap(); - let projected_option = joint.candidates[chain] - .iter() - .find(|option| { - option - .compatible_input_tuples - .iter() - .flatten() - .any(|binding| binding.producer == projected_producer) - }) - .unwrap(); - assert_eq!(raw_option.profile.compaction_seconds, 0.0); - assert!(projected_option.profile.compaction_seconds > 0.0); - for supplier in [raw_producer, projected_producer] { - configured.dag.reader_constraints = vec![ - crate::search::candidates::dag::ReaderConstraint::ActivationRequires { - candidate: root_id, - required_candidate: supplier, - }, - ]; - configured.sync_physical_for_tests(); - if supplier == raw_producer { - configured.dag.reader_constraints.push( - crate::search::candidates::dag::ReaderConstraint::AllowedOutputs { - candidate: projected_producer, - outputs: vec![], - }, - ); - configured.sync_physical_for_tests(); - } - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &joint, - ) - .unwrap() - .unwrap(); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let modeled = proposal.direct_modeled_cost.unwrap(); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "{supplier:?}: direct {modeled}, evaluator {actual}" - ); - } -} - -#[test] -fn mixed_schedule_joint_prices_match_the_real_evaluator() { - let scan = |metric| { - OperatorNode::reachable(&lower_promql(metric, AccuracyTarget::exact())) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap() - }; - let m = scan("m"); - let n = scan("n"); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: m - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: m.clone(), - })) - .unwrap(); - let mut configured = PhysicalCandidatesWithCosts::from_dag_for_tests( - DagOfPhysicalCandidatesWithCosts { - candidates: (0..3) - .map(|id| PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: if id == 2 { - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }] - } else { - vec![] - }, - }) - .collect(), - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }], - ..Default::default() - }, - vec![m, n, project.clone()] - .into_iter() - .zip((0..3).map(|_| (ExecutionTiming::QueryTime, Materialization::NotMaterialized))) - .collect(), - ); - let roots = vec![QueryRoot::Operator(project); 5]; - let entries = [QueryWorkloadEntry { - query: Query("m".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 100.0}}, - "sources": {"m": {"rows_held":10,"series_count":1},"n":{"rows_held":100,"series_count":1}} - })) - .unwrap(); - use asap_types::workload::{DemandEstimate, Rate, TimestampMs}; - let mut entries = vec![entries[0].clone(); 5]; - entries[0].recurrence = - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(59_000))); - entries[1].recurrence = - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(61_000))); - entries[2].recurrence = - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(DemandEstimate { - expected_rate: Rate(0.137), - observed_at: None, - valid_for: None, - })); - entries[3].recurrence = QueryRecurrence::Repeated(RepeatedDemand::Scheduled(vec![ - TimestampMs(10), - TimestampMs(20), - ])); - entries[4].recurrence = QueryRecurrence::OneTime { - invocations: 3, - execute_at: None, - }; - configured.dag.query_roots = vec![configured.dag.query_roots[0].clone(); 5]; - configured.sync_physical_for_tests(); - // All roots use the original source; this parity check has no synthetic - // equivalence between different source names. - configured.dag.candidates[2].producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0)].into(); - configured.sync_physical_for_tests(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let profile = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert!(profile.schedules.is_some()); - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - let mut rejections = crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let modeled = proposal.direct_modeled_cost.unwrap(); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "mixed schedules: {modeled} vs {actual}" - ); -} diff --git a/crates/plan-selection/src/search/costing/direct_profile.rs b/crates/plan-selection/src/search/costing/direct_profile.rs deleted file mode 100644 index 68c6a72f1..000000000 --- a/crates/plan-selection/src/search/costing/direct_profile.rs +++ /dev/null @@ -1,2473 +0,0 @@ -//! Local cost certification shared by fixed and producer-dependent profiles. -//! -//! Price each operation from its actual producer output and state configuration. -//! The fixed-profile adapter admits one interface; producer_price_options keeps -//! compatible interfaces as joint MILP choices. Ingestion worker counts require -//! certified unary upstream work, never statistics from a template child. -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::milp::direct_cost::{DirectCostProfile, NodeCostProfile, QueryScheduleRates}; -use crate::search::refinement::planner::CandidatePlanningContext; -use asap_cost::CostModel; -use asap_cost::{NodeUsage, ProducerOutput}; -use asap_types::ir::materialized_view::RefreshWindow; -use asap_types::ir::physical_export::PhysicalASAPDAGNode; -use asap_types::ir::properties::execution_data_state::{DataPrimitive, ExecutionDataState}; -use asap_types::ir::properties::{AccuracyScope, ExecutionTiming}; -use asap_types::ir::schema::{FieldDataType, SketchParams}; -use asap_types::ir::{ASAPOp, NonASAPOp, QueryRoot}; -use asap_types::physical::{Materialization, Medium}; -use asap_types::stages::PhysicalCandidateId; -use asap_types::workload::{QueryRecurrence, RepeatedDemand}; -use std::collections::HashSet; - -#[derive(Debug, thiserror::Error)] -pub(in crate::search) enum DirectProfileError { - #[error(transparent)] - DeadlineExceeded(#[from] asap_types::stages::CandidateGenerationTimeout), - #[error("direct cost profile: {0}")] - Invalid(String), - #[error("direct cost profile unsupported: {0}")] - Unsupported(String), -} - -pub(in crate::search) struct DirectProfileExtraction { - pub profile: DirectCostProfile, - pub supported: Vec, - /// The sole input's certified producers; empty for scans/unsupported nodes. - pub suppliers: Vec>, -} - -fn supported_node(node: &asap_types::ir::OperatorNode) -> bool { - matches!( - node.non_asap(), - Some( - NonASAPOp::Scan { .. } - | NonASAPOp::TimeRange { .. } - | NonASAPOp::Filter { .. } - | NonASAPOp::Project { .. } - | NonASAPOp::Aggregate { .. } - | NonASAPOp::BinaryOp { .. } - | NonASAPOp::Join { .. } - ) - ) || matches!( - node.asap(), - Some( - ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryTumblingWindowChain { .. } - | ASAPOp::SummaryEstimate { .. } - | ASAPOp::FinalizeExactAccumulator { .. } - | ASAPOp::SummaryMerge { .. } - ) - ) -} - -/// Fixed-interface compatibility adapter. Local profiles are priced at one -/// execution; the MILP supplies schedule demand, lifetime and shared charges. -/// Joint extraction additionally carries input tuples and retention tiers. -pub(in crate::search) fn extract( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result, DirectProfileError> { - extract_scan_class(configured, context, false) -} - -/// A second restricted proposal keeps the newest-tumbling-window scan class separate from -/// ordinary full-window scans. The full candidate graph is never changed. -pub(in crate::search) fn extract_latest_tumbling_windows( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result, DirectProfileError> { - extract_scan_class(configured, context, true) -} - -struct PreparedProfile { - nodes: Vec>, - lookbacks: Vec>, - all_lookbacks: Vec>>, - delta: Option, - schedules: Option, - rate: f64, - request_rate: f64, -} - -fn prepare_profile( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - prefer_latest_tumbling_window: bool, -) -> Result, DirectProfileError> { - let settings = context.models.cost_inputs; - let CostModel::UsageOverTime(_prices) = &settings.cost_model else { - return Ok(None); - }; - let Some(first) = context.entries.first() else { - return Ok(None); - }; - if context - .query_roots - .iter() - .any(|root| !matches!(root, QueryRoot::Operator(_))) - || context.query_roots.len() != context.entries.len() - || context.query_roots.len() != configured.dag.query_roots.len() - { - return Ok(None); - } - let equal_fixed = matches!( - first.recurrence, - QueryRecurrence::Repeated( - RepeatedDemand::FixedInterval(_) | RepeatedDemand::FixedIntervalAt { .. } - ) - ) && context - .entries - .iter() - .all(|entry| entry.recurrence == first.recurrence); - let has_range = context - .entries - .iter() - .any(|entry| entry.range_evaluation.is_some()); - let rates: Vec<_> = context - .entries - .iter() - .map(|entry| { - asap_cost::execution_demand::evaluation_rate( - [(&entry.recurrence, entry.range_evaluation)], - settings.non_repeating_query_horizon_seconds, - ) - .map_err(|error| DirectProfileError::Invalid(error.to_string())) - }) - .collect::>()?; - if rates.iter().any(|rate| !rate.is_finite() || *rate < 0.0) { - return Ok(None); - } - let rate = rates - .iter() - .copied() - .filter(|rate| *rate > 0.0) - .reduce(f64::max) - .unwrap_or(1.0); - let request_rate: f64 = context - .entries - .iter() - .map(|entry| { - asap_cost::execution_demand::evaluation_rate( - [(&entry.recurrence, None)], - settings.non_repeating_query_horizon_seconds, - ) - .map_err(|error| DirectProfileError::Invalid(error.to_string())) - }) - .collect::, _>>()? - .iter() - .sum(); - let schedules = if equal_fixed && !has_range { - None - } else { - let general = crate::search::milp::direct_cost::QueryScheduleRates::new( - context.entries, - settings.non_repeating_query_horizon_seconds, - context.deadline, - ) - .map_err(|error| DirectProfileError::Unsupported(error.to_string()))?; - Some(general) - }; - let delta = match failure_budget(context)? { - Some(delta) => delta, - None => return Ok(None), - }; - let count = configured.physical.candidates.len(); - let mut nodes = Vec::with_capacity(count); - for (id, configuration) in configured.physical.candidates.iter().enumerate() { - check_deadline(context.deadline)?; - let node = Some(configured.node(id)); - nodes.push( - node.filter(|node| { - supported_node(node) - && !(configuration.timing == ExecutionTiming::IngestionTime - && configuration.materialization == Materialization::NotMaterialized - && configured - .dag - .query_roots - .iter() - .any(|root| root.candidates.contains(&PhysicalCandidateId(id)))) - && (requires_source_sizing(node) || stable_size(node, delta)) - && match configuration.timing { - ExecutionTiming::QueryTime => { - matches!( - configuration.materialization, - Materialization::NotMaterialized - | Materialization::MatAtQuery(Medium::Memory) - ) || (configuration.materialization - == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - && matches!( - node.asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) - && configuration - .may_keep_across_evaluations_per_sole_reader_query - .iter() - .any(|&may_keep| may_keep)) - } - ExecutionTiming::IngestionTime => match node.asap() { - Some(ASAPOp::SummaryTumblingWindowChain { .. }) => { - configuration.materialization - == Materialization::MatAtIngestion(Medium::Memory) - } - _ => { - matches!( - node.non_asap(), - Some( - NonASAPOp::Scan { .. } - | NonASAPOp::Filter { .. } - | NonASAPOp::Project { .. } - | NonASAPOp::TimeRange { .. } - ) - ) && configuration.materialization - == Materialization::NotMaterialized - } - }, - } - }) - .map(|node| { - let mut position = 0; - PhysicalASAPDAGNode { - id, - payload: node.operator.map_children(|_| { - let current = position; - position += 1; - current - }), - output_state: ExecutionDataState { - timing: configuration.timing, - primitive: if node.asap().and_then(ASAPOp::produced_state).is_some() { - DataPrimitive::SummaryState - } else { - DataPrimitive::Raw - }, - }, - materialization: configuration.materialization, - output_schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - retention_ms: None, - } - }), - ); - } - // Each scan keeps one explicit lookback class, not a maximum requiring an - // optionally selected witness. Every admitted consumer edge must use it. - let mut lookbacks = vec![None; count]; - let mut all_lookbacks = vec![vec![None]; count]; - for id in 0..count { - let Some(scan) = &nodes[id] else { continue }; - if scan.output_state.timing != ExecutionTiming::QueryTime - || !matches!( - scan.payload, - asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { .. }) - ) - { - continue; - } - let mut ordinary = HashSet::new(); - let mut kept = HashSet::new(); - for (consumer, candidate) in configured.dag.candidates.iter().enumerate() { - check_deadline(context.deadline)?; - let Some(node) = &nodes[consumer] else { - continue; - }; - if node.output_state.timing != ExecutionTiming::QueryTime { - continue; - } - if candidate - .producer_candidates_per_input - .iter() - .any(|input| input.candidates.contains(&PhysicalCandidateId(id))) - { - if node.materialization - == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - { - kept.insert(scan_consumer_lookback(node)); - } else { - ordinary.insert(scan_consumer_lookback(node)); - } - } - } - let raw_output = configured - .dag - .query_roots - .iter() - .any(|root| root.candidates.contains(&PhysicalCandidateId(id))); - if !raw_output { - all_lookbacks[id] = ordinary.union(&kept).copied().collect(); - all_lookbacks[id].sort(); - } - if raw_output { - lookbacks[id] = None; - } else { - let choices = if prefer_latest_tumbling_window && !kept.is_empty() { - &kept - } else if !ordinary.is_empty() { - &ordinary - } else { - &kept - }; - if choices.is_empty() { - nodes[id] = None; - } else if choices.contains(&None) - && !(prefer_latest_tumbling_window && choices.iter().any(Option::is_some)) - { - lookbacks[id] = None; - } else { - lookbacks[id] = choices.iter().copied().max().flatten(); - } - } - } - Ok(Some(PreparedProfile { - nodes, - lookbacks, - all_lookbacks, - delta, - schedules, - rate, - request_rate, - })) -} - -fn extract_scan_class( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - prefer_latest_tumbling_window: bool, -) -> Result, DirectProfileError> { - // The fixed adapter has no selected-reader retention tiers. The joint - // option extractor carries those decisions instead. - if context - .entries - .iter() - .any(|entry| entry.range_evaluation != context.entries[0].range_evaluation) - || context - .entries - .iter() - .any(|entry| entry.range_evaluation.is_some()) - && configured.physical.candidates.iter().any(|c| { - c.materialization - == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - }) - { - return Ok(None); - } - let Some(prepared) = prepare_profile(configured, context, prefer_latest_tumbling_window)? - else { - return Ok(None); - }; - let PreparedProfile { - nodes, - lookbacks, - delta, - schedules, - rate, - request_rate, - .. - } = prepared; - let mut nodes = nodes; - for (id, node) in nodes.iter_mut().enumerate() { - if configured.dag.candidates[id] - .producer_candidates_per_input - .len() - > 1 - { - *node = None; - } - } - let count = nodes.len(); - let settings = context.models.cost_inputs; - let CostModel::UsageOverTime(prices) = &settings.cost_model else { - return Ok(None); - }; - let mut cache = vec![None; count]; - let mut visited = vec![false; count]; - let mut visiting = vec![false; count]; - let mut suppliers = vec![Vec::new(); count]; - let mut sketch_counts = vec![0; count]; - let mut raw_sources = vec![None; count]; - let mut closure_work_ms = vec![WorkBounds::default(); count]; - let mut worker_counts = vec![None; count]; - let mut compaction_seconds = vec![0.0; count]; - for id in 0..count { - price( - id, - configured, - context, - &nodes, - &lookbacks, - &mut cache, - &mut visited, - &mut visiting, - &mut suppliers, - &mut sketch_counts, - &mut raw_sources, - &mut closure_work_ms, - &mut worker_counts, - &mut compaction_seconds, - delta, - None, - )?; - } - let supported: Vec<_> = cache.iter().map(Option::is_some).collect(); - - let zero = || NodeCostProfile { - per_request: false, - execution_cost: 0.0, - work_seconds: 0.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 0.0, - materialized: false, - ingestion: false, - standing_cost_per_second: 0.0, - raw_retention: None, - refresh_window: None, - }; - let profiles = cache - .iter() - .enumerate() - .map(|(id, priced)| { - let Some(priced) = priced else { - return Ok(zero()); - }; - profile_for_priced( - priced, - nodes[id].as_ref().unwrap(), - compaction_seconds[id], - context, - ) - }) - .collect::, DirectProfileError>>()?; - let request_ms = settings.measured_operation_costs.ns_per_evaluation * 1e-6; - Ok(Some(DirectProfileExtraction { - profile: DirectCostProfile { - nodes: profiles, - query_rate: rate, - schedules, - request_latency_seconds: request_ms / 1000.0, - request_cost: request_ms / settings.resource_speed.ms_per_cpu_op - * prices.cost_per_cpu_op - * request_rate, - }, - supported, - suppliers, - })) -} - -fn profile_for_priced( - priced: &NodeUsage<'_>, - node: &PhysicalASAPDAGNode, - compaction_seconds: f64, - context: &CandidatePlanningContext<'_>, -) -> Result { - let CostModel::UsageOverTime(prices) = &context.models.cost_inputs.cost_model else { - return Err(DirectProfileError::Unsupported( - "non-usage objective".into(), - )); - }; - let raw_retention = priced - .raw_scan - .map(|(source, bytes)| { - serde_json::to_string(source) - .map(|identity| (identity, bytes as f64 * prices.cost_per_disk_byte_second)) - .map_err(|error| DirectProfileError::Invalid(error.to_string())) - }) - .transpose()?; - Ok(NodeCostProfile { - per_request: node.materialization - == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory), - execution_cost: priced.cpu_ops_per_second * prices.cost_per_cpu_op - + priced.scanned_bytes_per_second * prices.cost_per_scanned_byte, - ingestion: node.output_state.timing == ExecutionTiming::IngestionTime, - standing_cost_per_second: priced.average_memory_bytes * prices.cost_per_memory_byte_second - + priced.disk_bytes as f64 * prices.cost_per_disk_byte_second, - work_seconds: priced.work_ms_per_execution / 1000.0, - compaction_seconds, - held_memory_cost_per_second: priced.held.map_or(0.0, |(bytes, _, _)| { - bytes as f64 * prices.cost_per_memory_byte_second - }), - materialized: node.materialization != Materialization::NotMaterialized, - raw_retention, - // A chain maintained at ingestion refreshes its newest tumbling window. The - // profile admits no `TimeShift` at ingestion, so that tumbling window ends at - // the evaluation time: phase 0. - refresh_window: match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - .. - }) if node.materialization == Materialization::MatAtIngestion(Medium::Memory) => { - RefreshWindow::of_window(0, i64::try_from(*tumbling_window_width_ms).unwrap_or(0)) - } - _ => None, - }, - }) -} - -struct PriceInputs { - suppliers: Vec, - upstream_work: WorkBounds, - upstream_sketches: usize, - range_span_ms: Option, -} - -#[allow(clippy::too_many_arguments)] -fn price<'a>( - id: usize, - configured: &PhysicalCandidatesWithCosts, - context: &'a CandidatePlanningContext<'_>, - nodes: &'a [Option], - lookbacks: &[Option], - cache: &mut [Option>], - visited: &mut [bool], - visiting: &mut [bool], - suppliers: &mut [Vec], - sketch_counts: &mut [usize], - raw_sources: &mut [Option], - closure_work_ms: &mut [WorkBounds], - worker_counts: &mut [Option], - compaction_seconds: &mut [f64], - delta: Option, - input_override: Option<&PriceInputs>, -) -> Result<(), DirectProfileError> { - check_deadline(context.deadline)?; - if visited[id] || visiting[id] { - return Ok(()); - } - let Some(node) = &nodes[id] else { - visited[id] = true; - return Ok(()); - }; - visiting[id] = true; - let requirements = &configured.dag.candidates[id].producer_candidates_per_input; - let options: Vec> = input_override.map_or_else( - || { - requirements.first().map_or_else( - || vec![vec![]], - |input| input.candidates.iter().map(|id| vec![*id]).collect(), - ) - }, - |inputs| vec![inputs.suppliers.clone()], - ); - 'bindings: for input_tuple in options { - let mut views = Vec::new(); - for &supplier in &input_tuple { - if visiting[supplier.0] { - continue 'bindings; - } - price( - supplier.0, - configured, - context, - nodes, - lookbacks, - cache, - visited, - visiting, - suppliers, - sketch_counts, - raw_sources, - closure_work_ms, - worker_counts, - compaction_seconds, - delta, - None, - )?; - let Some(priced) = &cache[supplier.0] else { - continue 'bindings; - }; - let producer = nodes[supplier.0].as_ref().unwrap(); - if producer.output_state.timing == ExecutionTiming::QueryTime - && matches!( - producer.payload, - asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { .. }) - ) - && lookbacks[supplier.0] != scan_consumer_lookback(node) - { - continue 'bindings; - } - - let consumer_ingests = node.output_state.timing == ExecutionTiming::IngestionTime; - let producer_ingests = producer.output_state.timing == ExecutionTiming::IngestionTime; - if (consumer_ingests && producer.materialization != Materialization::NotMaterialized) - || (consumer_ingests && !producer_ingests) - || (!consumer_ingests - && producer_ingests - && producer.materialization != Materialization::MatAtIngestion(Medium::Memory)) - { - continue 'bindings; - } - views.push(ProducerOutput { - payload: &producer.payload, - output_schema: &producer.output_schema, - output: priced.output, - sources: priced.sources.clone(), - family: priced.family, - unchanged_columns: priced.unchanged_columns.clone(), - }); - } - let supplier = input_tuple.first().copied(); - if let asap_types::ir::Operator::ASAP( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - } - | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(kind, _), - .. - }, - ) = &node.payload - { - if let SketchParams::Hll { - precision, - relative_error, - } = kind.params() - { - let Some(source) = supplier.and_then(|supplier| raw_sources[supplier.0].as_deref()) - else { - continue; - }; - let Some(bound) = context - .data - .sources - .get(source) - .and_then(|facts| facts.max_distinct_per_evaluation) - else { - continue; - }; - let Some(delta) = delta else { continue }; - if !hll_size_certified(*precision, *relative_error, delta, bound) { - continue; - } - } - } - let raw_source = match &node.payload { - asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source, .. }) => { - Some(asap_types::workload::bounds::source_name(source).to_string()) - } - asap_types::ir::Operator::NonASAP( - NonASAPOp::Filter { .. } | NonASAPOp::TimeRange { .. }, - ) if views.len() == 1 && *views[0].output_schema == node.output_schema => { - supplier.and_then(|supplier| raw_sources[supplier.0].clone()) - } - _ => None, - }; - let sketches = input_override.map_or_else( - || supplier.map_or(0, |supplier| sketch_counts[supplier.0]), - |inputs| inputs.upstream_sketches, - ) + usize::from(matches!( - &node.payload, - asap_types::ir::Operator::ASAP( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(..), - .. - } - ) - )); - // Failure-budget sizing splits delta across approximate inputs. This - // slice admits at most one sketch build along each unary output path. - if sketches > 1 && input_override.is_none() { - continue; - } - let (retained_tumbling_windows, ingestion_window) = match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_count, - tumbling_window_width_ms, - .. - }) => { - let window = - tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count)); - let span = input_override - .and_then(|inputs| inputs.range_span_ms) - .unwrap_or_else(|| { - context - .entries - .first() - .and_then(|entry| entry.range_evaluation) - .map_or(0, |range| range.span_ms) - }); - let Some(retention) = window.and_then(|window| window.checked_add(span)) else { - continue; - }; - let retained = (retention / (*tumbling_window_width_ms).max(1)) - .max(u64::from(*tumbling_window_count)); - (retained, window) - } - _ => (1, None), - }; - let Ok(priced) = asap_cost::node_usage( - node, - &views, - (1, 1.0), - asap_cost::trailing_window(lookbacks[id]), - retained_tumbling_windows, - ingestion_window, - context.data, - context.models.cost_inputs, - context.calibration.factor(), - ) else { - continue; - }; - let prior = input_override.map_or_else( - || { - supplier.map_or(WorkBounds::default(), |supplier| { - closure_work_ms[supplier.0] - }) - }, - |inputs| inputs.upstream_work, - ); - let closure_work = WorkBounds { - minimum: (prior.minimum + priced.work_ms_per_execution) - .next_down() - .max(0.0), - maximum: (prior.maximum + priced.work_ms_per_execution).next_up(), - }; - if !closure_work.maximum.is_finite() { - continue; - } - let workers = if node.output_state.timing == ExecutionTiming::IngestionTime - && node.materialization == Materialization::MatAtIngestion(Medium::Memory) - { - let Some(workers) = certified_workers(closure_work, nodes.len()) else { - continue; - }; - // Same local price does not imply the same upstream worker count. - // Restrict this producer to one proven bucket of producer bindings. - if worker_counts[id].is_some_and(|prior| prior != workers) { - continue; - } - Some(workers) - } else { - None - }; - let compaction = workers.map_or(0.0, |workers| { - state_compaction_seconds(node, &priced, workers, context) - }); - if !compaction.is_finite() { - continue; - } - // Compare the actual evaluator outputs, including every piece of the - // downstream pricing interface. This is not a template-row equivalence. - if cache[id] - .as_ref() - .is_some_and(|prior| !same_price(prior, &priced)) - { - continue; - } - if priced.cost.cost_per_second.is_none() - || (node.output_state.timing == ExecutionTiming::QueryTime - && node.materialization - != Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - && (priced.average_memory_bytes != 0.0 - || priced.disk_bytes != 0 - || priced.maintained.is_some())) - { - continue; - } - if let Some(supplier) = supplier { - suppliers[id].push(supplier); - } - sketch_counts[id] = sketch_counts[id].max(sketches); - closure_work_ms[id] = if cache[id].is_none() { - closure_work - } else { - WorkBounds { - minimum: closure_work_ms[id].minimum.min(closure_work.minimum), - maximum: closure_work_ms[id].maximum.max(closure_work.maximum), - } - }; - worker_counts[id] = workers; - compaction_seconds[id] = compaction; - if cache[id].is_none() { - cache[id] = Some(priced); - raw_sources[id] = raw_source; - } else if raw_sources[id] != raw_source { - // The same priced rows need not carry the same raw-population proof. - // Keep no proof unless every admitted binding preserves it. - raw_sources[id] = None; - } - } - visiting[id] = false; - visited[id] = true; - Ok(()) -} - -/// A conservative delta valid for every selected unary output path, including -/// rollups whose finer grouping the current evaluator uses for its row bound. -/// Unknown population bounds reject this direct proposal, not the full search. -fn failure_budget( - context: &CandidatePlanningContext<'_>, -) -> Result>, DirectProfileError> { - check_deadline(context.deadline)?; - let result = (|| { - let population = || { - let mut sources = HashSet::new(); - for root in context.query_roots { - let QueryRoot::Operator(root) = root else { - return None; - }; - for node in asap_types::ir::OperatorNode::reachable(root) { - if let Some(NonASAPOp::Scan { source, .. }) = node.non_asap() { - sources - .insert(asap_types::workload::bounds::source_name(source).to_string()); - } - } - } - let counts: Option> = sources - .iter() - .map(|source| { - context - .data - .sources - .get(source)? - .series_count - .filter(|count| *count > 0) - }) - .collect(); - let maximum_source = counts?.into_iter().max()?; - fn bound( - node: &std::rc::Rc, - maximum_source: u64, - deadline: Option, - cache: &mut std::collections::HashMap, - ) -> Option { - if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - return None; - } - let identity = std::rc::Rc::as_ptr(node) as usize; - if let Some(bound) = cache.get(&identity) { - return Some(*bound); - } - let mut children = Vec::new(); - node.operator.map_children(|child| { - children.push(bound(child, maximum_source, deadline, cache)) - }); - let children: Vec<_> = children.into_iter().collect::>()?; - let value = if children.is_empty() { - maximum_source - } else if children.len() == 1 { - children[0] - } else { - // Branching operators can concatenate rows or pair inputs. - // Both union and cross-product bounds cover supported joins, - // set operations and vector matching; sharing does not reduce - // the number of logical input positions in this calculation. - let sum = children - .iter() - .try_fold(0u64, |sum, child| sum.checked_add(*child))?; - let product = children - .iter() - .try_fold(1u64, |product, child| product.checked_mul(*child))?; - sum.max(product) - }; - cache.insert(identity, value); - Some(value) - } - let mut cache = std::collections::HashMap::new(); - context - .query_roots - .iter() - .map(|root| match root { - QueryRoot::Operator(node) => { - bound(node, maximum_source, context.deadline, &mut cache) - } - _ => None, - }) - .collect::>>()? - .into_iter() - .max() - }; - let mut delta: Option = None; - for entry in context.entries { - let target = &entry.requirements.accuracy; - if target.is_exact() { - continue; - } - let requested = target - .failure_probability - .filter(|delta| delta.is_finite() && *delta > 0.0)?; - let values = match target.accuracy_scope { - AccuracyScope::WholeResult => population()?, - scope => scope.max_values_covered(None)?, - } - .max(1); - let required = requested / values as f64; - if !required.is_finite() || required <= 0.0 { - return None; - } - delta = Some(delta.map_or(required, |prior| prior.min(required))); - } - Some(delta) - })(); - check_deadline(context.deadline)?; - Ok(result) -} - -fn requires_source_sizing(node: &asap_types::ir::OperatorNode) -> bool { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } | ASAPOp::SummaryTumblingWindowChain { family: FieldDataType::Sketch(kind, _), .. }) - if matches!(kind.params(), SketchParams::Hll { .. })) -} - -fn hll_size_certified( - precision: u8, - relative_error: Option, - delta: f64, - distinct: u64, -) -> bool { - use asap_accuracy::estimators::hll; - let error = hll::classic_relative_error(precision, relative_error); - // Unsupported populations can make min_precision return its input unchanged. - // The actual confidence certificate is essential, not merely size equality. - hll::min_precision(error, delta, distinct, precision) == precision - && hll::classic_failure_probability(precision, error, distinct) <= delta -} - -fn stable_size(node: &asap_types::ir::OperatorNode, delta: Option) -> bool { - let Some( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - } - | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(kind, _), - .. - }, - ) = node.asap() - else { - return true; - }; - if !matches!( - kind.params(), - SketchParams::Kmv { - relative_error: Some(_), - .. - } | SketchParams::Kll { .. } - | SketchParams::DDSketch { .. } - | SketchParams::Cms { .. } - | SketchParams::CountSketch { .. } - ) { - return false; - } - let Some(delta) = delta else { return false }; - let sized = asap_accuracy::estimators::with_failure_probability(kind.params(), delta); - match (rmp_serde::to_vec(kind.params()), rmp_serde::to_vec(&sized)) { - (Ok(before), Ok(after)) => before == after, - _ => false, - } -} - -fn scan_consumer_lookback(node: &PhysicalASAPDAGNode) -> Option { - match &node.payload { - asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { range, .. }) => { - Some(range.as_millis() as u64) - } - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - tumbling_window_count, - .. - }) => Some( - if node.materialization - == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - { - *tumbling_window_width_ms - } else { - tumbling_window_width_ms.saturating_mul(u64::from(*tumbling_window_count)) - }, - ), - _ => None, - } -} - -#[derive(Clone, Copy, Default)] -struct WorkBounds { - minimum: f64, - maximum: f64, -} - -// Outward path sums bound every admitted binding. Account also for the -// evaluator summing those positive values in a different traversal order. -fn certified_workers(work: WorkBounds, node_count: usize) -> Option { - let error = node_count as f64 * f64::EPSILON; - if error >= 0.5 || !work.maximum.is_finite() { - return None; - } - let lower = (work.minimum * (1.0 - error)).next_down().max(0.0); - let upper = (work.maximum / (1.0 - error)).next_up(); - let first = (lower / 1000.0).ceil().max(1.0); - let last = (upper / 1000.0).ceil().max(1.0); - (first == last && last < (1_u64 << 53) as f64).then_some(last as u64) -} - -fn state_compaction_seconds( - node: &PhysicalASAPDAGNode, - priced: &NodeUsage<'_>, - workers: u64, - context: &CandidatePlanningContext<'_>, -) -> f64 { - let settings = context.models.cost_inputs; - let measured = crate::lookup( - &settings.measured_operation_costs, - asap_cost::OperatorKind::SummaryMerge, - ExecutionTiming::QueryTime, - priced.family, - |params| crate::sketch_shape(params, crate::StateInputRows::UNKNOWN).1 as f64, - ); - let merge_ms = measured.map_or( - settings.resource_speed.ms_per_cpu_op * context.calibration.factor(), - |unit| (unit.ns_per_unit + unit.ns_per_invocation) * 1e-6, - ); - let states = priced.output.rows / asap_cost::tumbling_windows_of(node); - (workers - 1) as f64 * states as f64 * merge_ms / 1000.0 -} - -fn check_deadline(deadline: Option) -> Result<(), DirectProfileError> { - if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - Err(asap_types::stages::CandidateGenerationTimeout.into()) - } else { - Ok(()) - } -} - -fn same_price(a: &NodeUsage<'_>, b: &NodeUsage<'_>) -> bool { - let family = match (a.family, b.family) { - (crate::StateFamily::None, crate::StateFamily::None) => true, - (crate::StateFamily::Exact(a), crate::StateFamily::Exact(b)) => a == b, - (crate::StateFamily::Sketch(a), crate::StateFamily::Sketch(b)) => { - // SketchParams PartialEq intentionally omits some confidence fields. - match (rmp_serde::to_vec(a), rmp_serde::to_vec(b)) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } - } - _ => false, - }; - let bits = |p: &NodeUsage<'_>| { - [ - p.work_ms_per_execution.to_bits(), - p.cpu_ops_per_second.to_bits(), - p.scanned_bytes_per_second.to_bits(), - p.average_memory_bytes.to_bits(), - ] - }; - let held = |p: &NodeUsage<'_>| { - p.held - .map(|(bytes, rate, per_reader)| (bytes, rate.to_bits(), per_reader)) - }; - family - && a.output == b.output - && a.sources == b.sources - && a.unchanged_columns == b.unchanged_columns - && bits(a) == bits(b) - && a.peak_memory_bytes == b.peak_memory_bytes - && a.disk_bytes == b.disk_bytes - && held(a) == held(b) - && a.maintained == b.maintained - && a.raw_scan == b.raw_scan - && a.cost.cost_per_second.map(f64::to_bits) == b.cost.cost_per_second.map(f64::to_bits) -} - -#[cfg(test)] -mod tests { - #[test] - fn ingestion_worker_bound_accounts_for_summation_roundoff() { - let workers = |minimum, maximum| { - super::certified_workers(super::WorkBounds { minimum, maximum }, 100) - }; - assert_eq!(workers(999.0, 999.0), Some(1)); - assert_eq!(workers(1000.0, 1000.0), None); - assert_eq!(workers(1000.0_f64.next_down(), 1000.0), None); - assert_eq!(workers(1001.0, 1999.0), Some(2)); - assert_eq!(workers(999.0, 1001.0), None); - } - - #[test] - fn hll_admission_requires_stable_precision_and_a_supported_population() { - assert!(super::hll_size_certified(15, Some(0.05), 0.05, 1000)); - assert!(!super::hll_size_certified(12, Some(0.05), 0.05, 1000)); - // The sizing API returns the original precision when no supported - // precision proves the target. That must not admit an unproved sketch. - assert!(!super::hll_size_certified(15, Some(0.05), 0.05, 1_000_000)); - } - - use super::*; - use asap_logical_asap_optimization::test_support::lower_promql; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::{Operator, OperatorNode, ProjectItem, ScalarExpr}; - - use crate::search::candidates::dag::{ - DagOfPhysicalCandidatesWithCosts, InputProducerCandidates, PhysicalCandidateWithCost, - QueryOutputCandidates, - }; - - use asap_types::workload::{ - DataWorkload, Predictability, Query, QueryRequirements, QueryWorkloadEntry, - RepetitionInterval, - }; - - // Direct pricing must not certify a state that Stage 3 would enlarge. - #[test] - fn kmv_profiles_require_already_sized_failure_probability() { - use asap_types::ir::schema::{SketchAlgorithm, SketchKind}; - let mut node = asap_logical_asap_optimization::test_support::cms_summary("count(m)"); - let small = SketchParams::Kmv { - k: 2, - relative_error: Some(0.05), - }; - let sized = asap_accuracy::estimators::with_failure_probability(&small, 0.05); - let set = |node: &mut OperatorNode, params| { - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &mut node.operator else { - panic!("summary fixture") - }; - let FieldDataType::Sketch(_, grouping) = family else { - panic!("sketch fixture") - }; - *family = FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kmv, params), - grouping.clone(), - ); - }; - set(&mut node, small); - assert!(!stable_size(&node, Some(0.05))); - set(&mut node, sized); - assert!(stable_size(&node, Some(0.05))); - assert!(!stable_size(&node, Some(0.00001))); - assert!(!stable_size(&node, None)); - } - - // Equal-priced plain-output consumers retain both exact and sketch paths. - // The sketch-count bound controls later resizing, not the price interface. - #[test] - fn plain_reader_keeps_exact_and_kmv_producer_interfaces() { - use asap_types::ir::schema::{SketchAlgorithm, SketchKind, SketchStatistic}; - use std::rc::Rc; - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.05) - .accuracy_scope(AccuracyScope::PerValue); - let exact = OperatorNode::reachable(&lower_promql("count(m)", target.clone())) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Aggregate { .. }))) - .unwrap(); - let mut summary = asap_logical_asap_optimization::test_support::cms_summary("count(m)"); - let params = asap_accuracy::estimators::with_failure_probability( - &SketchParams::Kmv { - k: 2, - relative_error: Some(0.05), - }, - 0.05, - ); - let Operator::ASAP(ASAPOp::SummaryAgg { family, .. }) = &mut summary.operator else { - panic!("summary") - }; - let FieldDataType::Sketch(_, grouping) = family else { - panic!("sketch") - }; - *family = FieldDataType::Sketch( - SketchKind::new(SketchAlgorithm::Kmv, params), - grouping.clone(), - ); - let summary = OperatorNode::new_shared(summary.operator).unwrap(); - let estimate = Rc::new(OperatorNode::with_schema( - Operator::ASAP(ASAPOp::SummaryEstimate { - summary_input: summary, - query: SketchStatistic::Cardinality, - }), - exact.schema.clone(), - )); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: exact - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: exact.clone(), - })) - .unwrap(); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - fn append( - node: Rc, - configured: &mut PhysicalCandidatesWithCosts, - ) -> PhysicalCandidateId { - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child.clone(), configured))); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - id - } - let exact_id = append(exact, &mut configured); - let estimate_id = append(estimate, &mut configured); - let project_id = append(project.clone(), &mut configured); - configured.dag.candidates[project_id.0].producer_candidates_per_input[0].candidates = - vec![exact_id, estimate_id].into(); - configured.sync_physical_for_tests(); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![project_id], - }); - configured.sync_physical_for_tests(); - let roots = vec![QueryRoot::Operator(project)]; - let entries = vec![QueryWorkloadEntry { - query: Query("count(m)".into()), - requirements: QueryRequirements { - accuracy: target, - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ "arrival": {"ingesting": {"rows_per_second":100.0}}, "sources":{"m":{"rows_held":1000,"series_count":1}} })).unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert!(extracted.supported[exact_id.0] && extracted.supported[estimate_id.0]); - assert_eq!( - extracted.suppliers[project_id.0], - vec![exact_id, estimate_id] - ); - } - - // Certified KLL/DDS configurations keep their sizes after reconstruction; - // direct costs include working memory and agree with the complete evaluator. - #[test] - fn fixed_sketch_profiles_match_the_real_evaluator() { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - use std::rc::Rc; - fn append( - node: Rc, - configured: &mut PhysicalCandidatesWithCosts, - ) -> PhysicalCandidateId { - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child.clone(), configured))); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - id - } - for algorithm in [ - SketchAlgorithm::Kll, - SketchAlgorithm::DDSketch, - SketchAlgorithm::Hll, - ] { - for scope in [ - AccuracyScope::PerValue, - AccuracyScope::WholeResult, - AccuracyScope::GroupsWithRecordShareAtLeast(0.5), - ] { - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(scope); - let query = if algorithm == SketchAlgorithm::Hll { - "distinct_over_time(latency[5m])" - } else { - "quantile_over_time(0.95, latency[5m])" - }; - let original = lower_promql(query, target.clone()); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&[ - QueryRoot::Operator(original.clone()), - ]), - ) - .unwrap(); - let choice = generated.targets[0].ways_to_compute.iter().position(|candidate| - matches!(candidate, HowToComputeAggregateMeasure::Summary(found) if *found == algorithm)).unwrap(); - let roots: Vec<_> = compose_logical_candidate(&generated, &[choice]).unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":100.0}}, - "sources":{"latency":{"series_count":2,"rows_held":30000,"values_non_negative":true,"max_distinct_per_evaluation":1000}} - })).unwrap(); - // Sized as Pass 1 sizes it: Stage 3 prices and checks that size. - let sized = crate::sized_as_pass1(&roots, std::slice::from_ref(&target), &data); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let QueryRoot::Operator(root) = &sized[0] else { - panic!("operator") - }; - let id = append(root.clone(), &mut configured); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![id], - }); - configured.sync_physical_for_tests(); - let originals = vec![QueryRoot::Operator(original)]; - let entries = vec![QueryWorkloadEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: target, - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - range_evaluation: None, - }]; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &crate::search::test_realizer(&originals, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert!( - extracted.supported.iter().all(|supported| *supported), - "{algorithm:?} {scope:?}" - ); - let proposal = crate::search::milp::model::solve_direct_cost_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &extracted.profile, - ) - .unwrap() - .unwrap(); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{algorithm:?} {scope:?}: {rejections:?}")); - let cost = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let direct = proposal.direct_modeled_cost.unwrap(); - assert!( - (cost - direct).abs() <= 1e-9 * cost.max(1e-15), - "{algorithm:?} {scope:?}: {direct} vs {cost}" - ); - if scope == AccuracyScope::WholeResult { - let mut unknown = data.clone(); - unknown.sources.get_mut("latency").unwrap().series_count = None; - let unproved = CandidatePlanningContext { - data: &unknown, - ..context - }; - assert!( - extract(&configured, &unproved).unwrap().is_none(), - "unknown population cannot certify a whole-result sizing budget" - ); - } - let too_small_delta = match algorithm { - SketchAlgorithm::Kll => Some(1e-12), - _ => None, - }; - if let Some(delta) = too_small_delta { - assert!(configured.nodes_for_tests().iter().any(|node| matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { .. }) - ) && !stable_size( - node, - Some(delta) - ))); - } - } - } - } - - #[test] - fn ingestion_tumbling_window_profiles_match_the_real_evaluator() { - check_tumbling_window_profile(Materialization::MatAtIngestion(Medium::Memory)); - } - - #[test] - fn rebuilt_tumbling_window_profiles_match_the_real_evaluator() { - check_tumbling_window_profile(Materialization::NotMaterialized); - check_tumbling_window_profile(Materialization::MatAtQuery(Medium::Memory)); - } - - #[test] - fn kept_tumbling_window_profiles_and_scan_classes_match_the_real_evaluator() { - check_tumbling_window_profile(Materialization::MatAtQueryKeptAcrossEvaluations( - Medium::Memory, - )); - } - - fn check_tumbling_window_profile(mode: Materialization) { - check_tumbling_window_profile_with_range(mode, None); - } - - #[test] - fn identical_range_tumbling_window_profiles_match_the_real_evaluator() { - let range = Some(asap_types::workload::RangeEvaluation { - span_ms: 3_600_000, - step_ms: 60_000, - }); - for mode in [ - Materialization::NotMaterialized, - Materialization::MatAtQuery(Medium::Memory), - Materialization::MatAtIngestion(Medium::Memory), - ] { - check_tumbling_window_profile_with_range(mode, range); - } - } - - fn check_tumbling_window_profile_with_range( - mode: Materialization, - range_evaluation: Option, - ) { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - use std::rc::Rc; - fn append( - node: Rc, - configured: &mut PhysicalCandidatesWithCosts, - ingestion: bool, - mode: Materialization, - ) -> PhysicalCandidateId { - let ingestion = ingestion - || (matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - && mode == Materialization::MatAtIngestion(Medium::Memory)); - let tumbling_window = - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })); - let mut inputs = Vec::new(); - node.operator.map_children(|child| { - inputs.push(append(child.clone(), configured, ingestion, mode)) - }); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - if ingestion { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }, - if tumbling_window { - mode - } else { - Materialization::NotMaterialized - }, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - id - } - for (series_count, rows_per_second, speed_multiplier) in - [(2_u64, 100_u64, 1.0), (100, 1000, 1.0), (2, 100, 1000.0)] - .into_iter() - .chain( - (mode == Materialization::MatAtIngestion(Medium::Memory)).then_some(( - 2, - 100, - 12_345_670.0, - )), - ) - { - for width in [60_000, 30_000] { - for algorithm in [ - SketchAlgorithm::Kll, - SketchAlgorithm::DDSketch, - SketchAlgorithm::Hll, - ] { - let scope = AccuracyScope::PerValue; - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(scope); - let query = if algorithm == SketchAlgorithm::Hll { - "distinct_over_time(latency[5m])" - } else { - "quantile_over_time(0.95, latency[5m])" - }; - let original = lower_promql(query, target.clone()); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&[ - QueryRoot::Operator(original.clone()), - ]), - ) - .unwrap(); - let choice = generated.targets[0].ways_to_compute.iter().position(|candidate| - matches!(candidate, HowToComputeAggregateMeasure::Summary(found) if *found == algorithm)).unwrap(); - let roots: Vec<_> = compose_logical_candidate(&generated, &[choice]).unwrap(); - let tumbling_window_rules = - asap_logical_asap_optimization::test_support::tumbling_window_candidates( - &roots, - &[Some(width)], - ); - let roots = asap_logical_asap_optimization::test_support::window_composition_pass2_candidates(&roots, &tumbling_window_rules).unwrap().pop().unwrap().roots.unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":rows_per_second}}, - "sources":{"latency":{"series_count":series_count,"rows_held":rows_per_second * 300,"values_non_negative":true,"max_distinct_per_evaluation":1000}} - })).unwrap(); - // Sized as Pass 1 sizes it: Stage 3 prices and checks that size. - let sized = crate::sized_as_pass1(&roots, std::slice::from_ref(&target), &data); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let QueryRoot::Operator(root) = &sized[0] else { - panic!("operator") - }; - let id = append(root.clone(), &mut configured, false, mode); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![id], - }); - configured.sync_physical_for_tests(); - configured - .dag - .query_roots - .push(configured.dag.query_roots[0].clone()); - configured.sync_physical_for_tests(); - let originals = vec![ - QueryRoot::Operator(original.clone()), - QueryRoot::Operator(original), - ]; - let mut entries = vec![QueryWorkloadEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: target, - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(width as u32), - )), - range_evaluation, - }]; - entries.push(entries[0].clone()); - let mut settings = asap_cost::CostInputs::default(); - if range_evaluation.is_some() { - settings.measured_operation_costs.ns_per_evaluation = 1_000_000.0; - } - settings.resource_speed.ms_per_cpu_op *= speed_multiplier; - settings.resource_speed.ms_per_scanned_byte *= speed_multiplier; - let deployment = asap_logical_asap_optimization::DeploymentCapabilities { - query_time_results_retained: true, - ..Default::default() - }; - crate::search::candidates::configurations::fill_may_keep_across_evaluations_per_sole_reader_query( - &mut configured, - &entries, - &data, - deployment.query_time_results_retained, - ); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &asap_physical_asap_optimization::Stage2AssignmentRealizer { - query_time_results_retained: deployment.query_time_results_retained, - ..crate::search::test_realizer(&originals, &entries, &data) - }, - entries: &entries, - data: &data, - models: crate::SelectionModels { - ..crate::SelectionModels::builtin().with_cost_inputs(&settings) - }, - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert!( - extracted.supported.iter().all(|supported| *supported), - "{algorithm:?} {scope:?}" - ); - let direct_proposal = - crate::search::milp::model::solve_direct_cost_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &extracted.profile, - ); - let proposal = match direct_proposal { - Ok(Some(proposal)) => proposal, - Err(crate::search::candidates::dag::CandidateSelectionError::UnsupportedDirectCost(_)) if speed_multiplier == 1.0 => { - // Default nanosecond work can give sub-threshold - // held-memory coefficients. Preserve that fallback. - crate::search::milp::model::solve_candidate_proposal(&configured.dag, &[], None).unwrap().unwrap() - } - result => panic!("{mode:?} {algorithm:?} width={width} series={series_count} speed={speed_multiplier}: {result:?}"), - }; - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = - crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{algorithm:?} {scope:?}: {rejections:?}")); - let cost = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let direct = extracted - .profile - .selected_cost(&configured.dag, &proposal.selected); - assert!( - (cost - direct).abs() <= 1e-9 * cost.max(1e-15), - "{algorithm:?} {scope:?}: {direct} vs {cost}" - ); - let chain = configured - .nodes_for_tests() - .iter() - .position(|node| { - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - }) - .unwrap(); - let Some(ASAPOp::SummaryTumblingWindowChain { - family, - tumbling_window_count, - .. - }) = configured.node(chain).asap() - else { - unreachable!() - }; - let (_, state_bytes) = crate::summary_shape( - family, - crate::StateInputRows { - rows_per_state: Some( - (width * rows_per_second) - .div_ceil(1000) - .div_ceil(series_count), - ), - non_negative: true, - }, - ); - let CostModel::UsageOverTime(prices) = &context.models.cost_inputs.cost_model - else { - unreachable!() - }; - let expected_residency = match mode { - Materialization::MatAtIngestion(_) => { - u64::from(*tumbling_window_count) - + range_evaluation.map_or(0, |range| range.span_ms / width) - + 1 - } - Materialization::MatAtQueryKeptAcrossEvaluations(_) => { - u64::from(*tumbling_window_count) - 1 - } - _ => 0, - } * series_count - * state_bytes; - let expected_standing = - expected_residency as f64 * prices.cost_per_memory_byte_second; - assert!( - (extracted.profile.nodes[chain].standing_cost_per_second - - expected_standing) - .abs() - <= 1e-12 * expected_standing.max(1e-15) - ); - if matches!( - mode, - Materialization::MatAtIngestion(_) - | Materialization::MatAtQueryKeptAcrossEvaluations(_) - ) { - assert_eq!( - extracted.profile.nodes[chain].held_memory_cost_per_second, - 0.0 - ); - } else { - assert!(extracted.profile.nodes[chain].held_memory_cost_per_second > 0.0); - } - if mode == Materialization::MatAtIngestion(Medium::Memory) { - if speed_multiplier > 1e6 { - assert!(extracted.profile.nodes[chain].compaction_seconds > 0.0); - } - let work_ms: f64 = extracted - .profile - .nodes - .iter() - .filter(|node| node.ingestion) - .map(|node| node.work_seconds * 1000.0) - .sum(); - let mut boundary_settings = settings.clone(); - boundary_settings.resource_speed.ms_per_cpu_op *= 1000.0 / work_ms; - boundary_settings.resource_speed.ms_per_scanned_byte *= 1000.0 / work_ms; - let boundary_context = CandidatePlanningContext { - models: crate::SelectionModels { - cost_inputs: &boundary_settings, - ..context.models - }, - ..context - }; - assert!( - !extract(&configured, &boundary_context) - .unwrap() - .unwrap() - .supported[chain], - "a worker threshold cannot certify one fixed bucket" - ); - if speed_multiplier > 1e6 { - let FieldDataType::Sketch(kind, _) = family else { - unreachable!() - }; - let mut measured = settings.clone(); - measured.measured_operation_costs.operators.push( - asap_cost::OperatorCost { - operator: asap_cost::OperatorKind::SummaryMerge, - phase: Some(ExecutionTiming::QueryTime), - sketch: Some(kind.params().clone()), - exact: None, - measure: None, - ns_per_unit: 2_000_000.0, - ns_per_invocation: 500_000.0, - memory_bytes_per_state: None, - }, - ); - let measured_context = CandidatePlanningContext { - models: crate::SelectionModels { - cost_inputs: &measured, - ..context.models - }, - ..context - }; - let profile = extract(&configured, &measured_context) - .unwrap() - .unwrap() - .profile; - assert!(profile.nodes[chain].compaction_seconds > 0.0); - assert_ne!( - profile.nodes[chain].compaction_seconds, - extracted.profile.nodes[chain].compaction_seconds - ); - let measured_plan = - crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &measured_context, - &proposal.selected, - 2, - &mut Default::default(), - &mut Vec::new(), - ) - .unwrap() - .unwrap(); - let actual = measured_plan.0.costs[&measured_plan.0.selected] - .cost_per_second - .unwrap(); - let modeled = - profile.selected_cost(&configured.dag, &proposal.selected); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "measured compaction: {actual} vs {modeled}" - ); - } - } - if mode == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) { - // The same physical scan may feed either a full-window - // rebuild or a newest-tumbling-window refresh. Keep these priced - // domains distinct, and validate each selected binding. - let normal_chain = - PhysicalCandidateId(configured.physical.candidates.len()); - let chain_candidate = configured.dag.candidates[chain].clone(); - let pushed = configured.push_for_tests( - configured.node(chain).clone(), - ExecutionTiming::QueryTime, - Materialization::MatAtQuery(Medium::Memory), - chain_candidate.local_cost, - chain_candidate.producer_candidates_per_input, - ); - assert_eq!(pushed, normal_chain); - for candidate in &mut configured.dag.candidates { - for input in &mut candidate.producer_candidates_per_input { - if input.candidates.contains(&PhysicalCandidateId(chain)) { - input.candidates.push(normal_chain); - } - } - } - configured.sync_physical_for_tests(); - let normal = extract(&configured, &context).unwrap().unwrap(); - let latest = extract_latest_tumbling_windows(&configured, &context) - .unwrap() - .unwrap(); - assert!(!normal.supported[chain] && normal.supported[normal_chain.0]); - assert!(latest.supported[chain] && !latest.supported[normal_chain.0]); - let scan = configured - .nodes_for_tests() - .iter() - .position(|node| { - matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })) - }) - .unwrap(); - let ratio = 300_000.0 / width as f64; - assert!( - (normal.profile.nodes[scan].execution_cost - - ratio * latest.profile.nodes[scan].execution_cost) - .abs() - <= 1e-12 * normal.profile.nodes[scan].execution_cost - ); - let mut full_selection = proposal.selected.clone(); - for id in &mut full_selection.selected_candidates { - if id.0 == chain { - *id = normal_chain; - } - } - for edge in &mut full_selection.connections { - if edge.producer.0 == chain { - edge.producer = normal_chain; - } - if edge.consumer.0 == chain { - edge.consumer = normal_chain; - } - } - let full_evaluated = - crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &full_selection, - 1, - &mut Default::default(), - &mut Vec::new(), - ) - .unwrap() - .unwrap(); - let actual = full_evaluated.0.costs[&full_evaluated.0.selected] - .cost_per_second - .unwrap(); - let modeled = normal - .profile - .selected_cost(&configured.dag, &full_selection); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "full-window class: {actual} vs {modeled}" - ); - } - if algorithm == SketchAlgorithm::Hll { - let mut unsupported = data.clone(); - unsupported - .sources - .get_mut("latency") - .unwrap() - .max_distinct_per_evaluation = Some(1_000_000); - let outside_certificate = CandidatePlanningContext { - data: &unsupported, - ..context - }; - assert!( - !extract(&configured, &outside_certificate) - .unwrap() - .unwrap() - .supported[chain], - "unchanged HLL precision cannot certify an unsupported population" - ); - } - if scope == AccuracyScope::WholeResult { - let mut unknown = data.clone(); - unknown.sources.get_mut("latency").unwrap().series_count = None; - let unproved = CandidatePlanningContext { - data: &unknown, - ..context - }; - assert!( - extract(&configured, &unproved).unwrap().is_none(), - "unknown population cannot certify a whole-result sizing budget" - ); - } - let too_small_delta = match algorithm { - SketchAlgorithm::Kll => Some(1e-12), - _ => None, - }; - if let Some(delta) = too_small_delta { - assert!(configured.nodes_for_tests().iter().any(|node| matches!( - node.asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) && !stable_size( - node, - Some(delta) - ))); - } - } - } - } - } - - /// A KLL chain (p90 over a minute) and a DDSketch chain (p99 over five - /// minutes) kept at ingestion over one `Scan`, each in tumbling windows of its - /// query's interval. With equal tumbling-window widths, one ingestion run per tumbling window - /// computes the `Scan` for both chains, and the old Stage 3 engine's - /// modeled cost equals the real evaluator's with the `Scan` computed - /// once, for one schedule (both every 10 s) and for mixed schedules - /// (every 20 s and every 40 s, 20 s tumbling windows). With 10 s and 60 s tumbling windows the - /// chains run apart and both count the `Scan` twice (#1151). - #[test] - fn chains_of_one_refresh_window_share_their_scan_in_the_direct_profile() { - // ((p90 interval, its tumbling-window width), (p99 interval, its tumbling-window width), Scan computations) - for (kll, dd, scan_computations) in [ - ((10_000, 10_000), (10_000, 10_000), 1), - ((20_000, 20_000), (40_000, 20_000), 1), - ((10_000, 10_000), (60_000, 60_000), 2), - ] { - let (modeled, evaluated, multiplier) = two_chains_over_one_scan(kll, dd); - let case = format!("p90 (every, tumbling windows) {kll:?} ms, p99 {dd:?} ms"); - assert!( - (evaluated - modeled).abs() <= 1e-9 * evaluated.max(1e-15), - "{case}: direct {modeled}, evaluator {evaluated}" - ); - assert_eq!(multiplier, scan_computations, "{case}"); - } - } - - /// [`chains_of_one_refresh_window_share_their_scan_in_the_direct_profile`] - /// with the p90 and the p99 each `(interval in ms, tumbling-window width in ms)`: - /// the direct profile's modeled cost of the selected plan, the - /// evaluator's cost of it, and the evaluator's multiplier of the `Scan`. - fn two_chains_over_one_scan(kll: (u64, u64), dd: (u64, u64)) -> (f64, f64, u64) { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - tumbling_window_candidates, window_composition_pass2_candidates, - }; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - use std::collections::HashMap; - use std::rc::Rc; - /// `node` and every node below it as candidates of `configured`, one - /// per node (`seen`, by address), at ingestion below a tumbling-window chain. - fn append( - node: &Rc, - configured: &mut PhysicalCandidatesWithCosts, - ingestion: bool, - seen: &mut HashMap<*const OperatorNode, PhysicalCandidateId>, - ) -> PhysicalCandidateId { - if let Some(id) = seen.get(&Rc::as_ptr(node)) { - return *id; - } - let chain = matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })); - let ingestion = ingestion || chain; - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child, configured, ingestion, seen))); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - Rc::clone(node), - if ingestion { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }, - if chain { - Materialization::MatAtIngestion(Medium::Memory) - } else { - Materialization::NotMaterialized - }, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - seen.insert(Rc::as_ptr(node), id); - id - } - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - // (query, interval in ms, sketch, tumbling-window width in ms) - let queries = [ - ( - "quantile_over_time(0.9, latency[1m])", - kll.0, - SketchAlgorithm::Kll, - kll.1, - ), - ( - "quantile_over_time(0.99, latency[5m])", - dd.0, - SketchAlgorithm::DDSketch, - dd.1, - ), - ]; - let originals: Vec = queries - .iter() - .map(|(query, ..)| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&originals), - ) - .unwrap(); - // Per target, the way to compute it with its query's sketch. - let choices: Vec = generated - .targets - .iter() - .zip(&queries) - .map(|(target, (_, _, algorithm, _))| { - target - .ways_to_compute - .iter() - .position(|way| { - matches!(way, HowToComputeAggregateMeasure::Summary(found) if found == algorithm) - }) - .unwrap() - }) - .collect(); - let roots = compose_logical_candidate(&generated, &choices).unwrap(); - let grid: Vec> = queries - .iter() - .map(|(_, interval, ..)| Some(*interval)) - .collect(); - // Each build in its query's tumbling-window width. - let tumbling_windows: Vec<_> = tumbling_window_candidates(&roots, &grid) - .into_iter() - .filter(|tumbling_window| { - ( - tumbling_window.window_ms(), - tumbling_window.tumbling_window_width_ms, - ) == (60_000, kll.1) - || ( - tumbling_window.window_ms(), - tumbling_window.tumbling_window_width_ms, - ) == (300_000, dd.1) - }) - .collect(); - assert_eq!(tumbling_windows.len(), 2, "{tumbling_windows:?}"); - let roots = window_composition_pass2_candidates(&roots, &tumbling_windows) - .unwrap() - .into_iter() - .find(|candidate| { - candidate.tumbling_window_candidates.len() == 2 - && candidate.shared_chains.is_empty() - }) - .unwrap() - .roots - .unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 100.0}}, - "sources": {"latency": {"series_count": 2, "rows_held": 30_000, - "values_non_negative": true}} - })) - .unwrap(); - let sized = crate::sized_as_pass1(&roots, &[target.clone(), target.clone()], &data); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let mut seen = HashMap::new(); - for root in &sized { - let QueryRoot::Operator(root) = root else { - panic!("operator") - }; - let id = append(root, &mut configured, false, &mut seen); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![id], - }); - } - configured.sync_physical_for_tests(); - let scans: Vec = (0..configured.physical.candidates.len()) - .filter(|&id| matches!(configured.node(id).non_asap(), Some(NonASAPOp::Scan { .. }))) - .collect(); - assert_eq!(scans.len(), 1, "one shared Scan"); - let entries: Vec = queries - .iter() - .map(|(query, interval, ..)| QueryWorkloadEntry { - query: Query((*query).into()), - requirements: QueryRequirements { - accuracy: target.clone(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(*interval as u32), - )), - range_evaluation: None, - }) - .collect(); - let mut settings = asap_cost::CostInputs::default(); - settings.resource_speed.ms_per_cpu_op *= 1000.0; - settings.resource_speed.ms_per_scanned_byte *= 1000.0; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &crate::search::test_realizer(&originals, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin().with_cost_inputs(&settings), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert!(extracted.supported.iter().all(|supported| *supported)); - let proposal = crate::search::milp::model::solve_direct_cost_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &extracted.profile, - ) - .unwrap() - .unwrap(); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{rejections:?}")); - let costs = &evaluated.0.costs[&evaluated.0.selected]; - let scan_multiplier = costs - .per_node - .iter() - .find(|(_, cost)| cost.operator == asap_cost::OperatorKind::Scan) - .map(|(_, cost)| cost.multiplier) - .unwrap(); - ( - extracted - .profile - .selected_cost(&configured.dag, &proposal.selected), - costs.cost_per_second.unwrap(), - scan_multiplier, - ) - } - - // Real shared KLL/DDS plans verify schedule unions and held-memory lifetime, - // including two equal frequencies whose different phases cannot coalesce. - #[test] - fn heterogeneous_shared_sketch_profiles_match_the_real_evaluator() { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - use asap_types::workload::TimestampMs; - use std::rc::Rc; - fn append( - node: Rc, - inventory: &mut PhysicalCandidatesWithCosts, - ) -> PhysicalCandidateId { - if let Some(id) = inventory - .nodes_for_tests() - .iter() - .position(|old| Rc::ptr_eq(old, &node)) - { - return PhysicalCandidateId(id); - } - let mut children = Vec::new(); - node.operator - .map_children(|child| children.push(append(child.clone(), inventory))); - let materialization = if matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) { - Materialization::MatAtQuery(Medium::Memory) - } else { - Materialization::NotMaterialized - }; - inventory.push_for_tests( - node, - ExecutionTiming::QueryTime, - materialization, - 0.0, - children - .into_iter() - .map(|id| InputProducerCandidates { - candidates: vec![id].into(), - }) - .collect(), - ) - } - for algorithm in [SketchAlgorithm::Kll, SketchAlgorithm::DDSketch] { - for (phases, query_count) in [(false, 2), (true, 2), (false, 5), (true, 5)] { - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - let originals: Vec<_> = [0.95, 0.99, 0.5, 0.7, 0.9] - .iter() - .take(query_count as usize) - .map(|q| { - QueryRoot::Operator(lower_promql( - &format!("quantile_over_time({q}, latency[5m])"), - target.clone(), - )) - }) - .collect(); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups( - &originals, - ), - ) - .unwrap(); - let choices: Vec<_> = generated.targets.iter().map(|target| target.ways_to_compute.iter().position(|candidate| - matches!(candidate,HowToComputeAggregateMeasure::Summary(found) if *found==algorithm)).unwrap()).collect(); - let roots: Vec<_> = compose_logical_candidate(&generated, &choices).unwrap(); - // Identical sub-DAGs made one node, as the memo's groups are. - let shared: Vec<_> = - asap_logical_asap_optimization::test_support::share_common_sub_dags( - roots - .iter() - .enumerate() - .map(|(id, root)| match root { - QueryRoot::Operator(node) => (id, Rc::clone(node)), - QueryRoot::Scalar(_) => panic!("operator root"), - }) - .collect(), - ) - .into_iter() - .map(|(_, node)| QueryRoot::Operator(node)) - .collect(); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - for root in shared { - let QueryRoot::Operator(root) = root else { - panic!("operator") - }; - let id = append(root, &mut configured); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![id], - }); - configured.sync_physical_for_tests(); - } - assert_eq!( - configured - .nodes_for_tests() - .iter() - .filter(|node| matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .count(), - 1 - ); - let entries: Vec<_> = (0..query_count) - .map(|q| QueryWorkloadEntry { - query: Query(format!("quantile {q}")), - requirements: QueryRequirements { - accuracy: target.clone(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(if phases { - RepeatedDemand::FixedIntervalAt { - interval: RepetitionInterval(1000), - evaluation_phase: TimestampMs(q * 500), - } - } else { - RepeatedDemand::FixedInterval(RepetitionInterval(if q == 0 { - 500 - } else { - 1000 - })) - }), - range_evaluation: None, - }) - .collect(); - let data:DataWorkload=serde_json::from_value(serde_json::json!({"arrival":{"ingesting":{"rows_per_second":1.0}},"sources":{"latency":{"series_count":2,"rows_held":300,"values_non_negative":true}}})).unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &crate::search::test_realizer(&originals, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert!(extracted.supported.iter().all(|supported| *supported)); - if query_count == 2 { - assert_eq!( - extracted - .profile - .schedules - .as_ref() - .unwrap() - .union_rate([true, true].into_iter()), - if phases { 2.0 } else { 3.0 } - ); - } else { - assert!(extracted.profile.schedules.is_some()); - } - let proposal = crate::search::milp::model::solve_direct_cost_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &extracted.profile, - ) - .unwrap() - .unwrap(); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let (selection, _) = - crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{algorithm:?} phases={phases}: {rejections:?}")); - let actual = selection.costs[&selection.selected] - .cost_per_second - .unwrap(); - let direct = proposal.direct_modeled_cost.unwrap(); - assert!( - (actual - direct).abs() <= 1e-9 * actual.max(1e-15), - "{algorithm:?} phases={phases}: {direct} vs {actual}" - ); - } - } - } - - // A template child is not evidence that every producer has the same rows. - // Two deliberately different source alternatives exercise cost-interface - // filtering independently of the logical compatibility check upstream. - #[test] - fn different_producer_statistics_are_not_priced_as_the_template_child() { - let scan = |metric| { - OperatorNode::reachable(&lower_promql(metric, AccuracyTarget::exact())) - .into_iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap() - }; - let m = scan("m"); - let n = scan("n"); - let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: m - .schema - .fields - .iter() - .enumerate() - .map(|(index, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(index), - }) - .collect(), - qualifier: None, - child: m.clone(), - })) - .unwrap(); - let configured = PhysicalCandidatesWithCosts::from_dag_for_tests( - DagOfPhysicalCandidatesWithCosts { - candidates: (0..3) - .map(|id| PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: if id == 2 { - vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)] - .into(), - }] - } else { - vec![] - }, - }) - .collect(), - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }], - ..Default::default() - }, - vec![m, n, project.clone()] - .into_iter() - .zip((0..3).map(|_| (ExecutionTiming::QueryTime, Materialization::NotMaterialized))) - .collect(), - ); - let roots = vec![QueryRoot::Operator(project)]; - let entries = vec![QueryWorkloadEntry { - query: Query("m".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 100.0}}, - "sources": {"m": {"rows_held":10,"series_count":1},"n":{"rows_held":100,"series_count":1}} - })).unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let extracted = extract(&configured, &context).unwrap().unwrap(); - assert_eq!(extracted.supported, vec![true, true, true]); - assert_eq!(extracted.suppliers[2], vec![PhysicalCandidateId(0)]); - assert!( - extracted.profile.nodes[1].execution_cost > extracted.profile.nodes[0].execution_cost - ); - assert_eq!( - configured.dag.candidates[2].producer_candidates_per_input[0] - .candidates - .len(), - 2, - "full catalog remains unchanged" - ); - let timed = CandidatePlanningContext { - deadline: Some(std::time::Instant::now()), - ..context - }; - assert!(matches!( - extract(&configured, &timed), - Err(DirectProfileError::DeadlineExceeded(_)) - )); - } -} - -#[path = "producer_price_options.rs"] -mod producer_price_options; -pub(in crate::search) use producer_price_options::extract_price_options; - -#[cfg(test)] -#[path = "direct_price_options_tests.rs"] -mod price_options_tests; - -#[cfg(test)] -#[path = "multi_input_price_tests.rs"] -mod multi_input_price_tests; - -#[cfg(test)] -#[path = "range_price_options_tests.rs"] -mod range_price_options_tests; diff --git a/crates/plan-selection/src/search/costing/lower_bounds.rs b/crates/plan-selection/src/search/costing/lower_bounds.rs deleted file mode 100644 index cb9eada14..000000000 --- a/crates/plan-selection/src/search/costing/lower_bounds.rs +++ /dev/null @@ -1,1645 +0,0 @@ -//! Certified node-local usage bounds for fixed DDS/exact state and its query readers. -//! Consumer-dependent held memory, raw retention, request fees and extra copies -//! remain in the complete evaluator. Unsupported pricing contexts contribute zero. -use asap_types::ir::physical_export::PhysicalASAPDAGNode; -use asap_types::ir::properties::execution_data_state::{DataPrimitive, ExecutionDataState}; -use asap_types::ir::properties::ExecutionTiming; -use asap_types::ir::schema::{FieldDataType, SketchParams}; -use asap_types::ir::{ASAPOp, NonASAPOp}; -use asap_types::physical::Materialization; - -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::refinement::planner::CandidatePlanningContext; -use crate::StateFamily; -use asap_cost::CostModel; -use asap_cost::{node_usage, NodeUsage, ProducerOutput}; - -#[derive(Debug, Default, Clone, Copy)] -pub(in crate::search) struct LowerBoundMetrics { - pub priced_candidates: usize, - pub rounded_down_candidates: usize, - pub ingestion_scans: usize, - pub ingestion_tumbling_window_chains: usize, - pub query_readers: usize, - pub ingestion_transforms: usize, -} - -/// Replace placeholder costs in the actual inventory used by selection and -/// reconstruction. This is a lower bound, never a substitute for plan evaluation. -pub(in crate::search) fn apply( - configured: &mut PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result { - configured.dag.validate().map_err(|e| e.to_string())?; - let count = configured.dag.candidates.len(); - if configured.physical.candidates.len() != count { - return Err("lower-bound inputs must be indexed by candidate ID".into()); - } - let mut metrics = LowerBoundMetrics::default(); - let mut costs = vec![0.0; count]; - if matches!( - context.models.cost_inputs.cost_model, - CostModel::UsageOverTime(_) - ) { - // A selected query-time node must serve at least one query boundary. - // Removing range expansion also covers ancestors of retained state; - // taking the minimum singleton rate permits every possible reader set. - let query_rate_floor = - context - .entries - .iter() - .try_fold(f64::INFINITY, |minimum, entry| { - asap_cost::execution_demand::evaluation_rate( - [(&entry.recurrence, None)], - context - .models - .cost_inputs - .non_repeating_query_horizon_seconds, - ) - .map(|rate| minimum.min(rate)) - .map_err(|error| error.to_string()) - })?; - let query_rate_floor = if query_rate_floor.is_finite() { - query_rate_floor - } else { - 0.0 - }; - // Retained-state refresh policies can evolve independently of request - // accounting. Do not certify query work underneath any possible retained - // consumer, even when the current policy would permit this floor. - let mut retained_ancestor = vec![false; count]; - let mut pending: Vec<_> = configured - .physical - .candidates - .iter() - .enumerate() - .filter_map(|(id, config)| { - matches!( - config.materialization, - Materialization::MatAtQueryKeptAcrossEvaluations(_) - ) - .then_some(id) - }) - .collect(); - while let Some(id) = pending.pop() { - if std::mem::replace(&mut retained_ancestor[id], true) { - continue; - } - pending.extend( - configured.dag.candidates[id] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().map(|producer| producer.0)), - ); - } - let common_reader_window_ms = common_reader_windows(configured); - let nodes: Vec<_> = (0..count) - .map(|id| { - let configuration = &configured.physical.candidates[id]; - let template = configured.node(id); - let state = if configuration.timing == ExecutionTiming::QueryTime { - if retained_ancestor[id] { - return None; - } - if !matches!( - configuration.materialization, - Materialization::NotMaterialized | Materialization::MatAtQuery(_) - ) { - return None; - } - match (template.non_asap(), template.asap()) { - // A query-time scan reads at least its common - // reader window ([`common_reader_windows`]); the - // row operators and builds above it read at least - // the rows that bound certifies. - (Some(NonASAPOp::Scan { .. }), _) - if common_reader_window_ms[id].is_none() => - { - return None - } - ( - Some( - NonASAPOp::Project { .. } - | NonASAPOp::Scan { .. } - | NonASAPOp::TimeRange { .. } - | NonASAPOp::TimeShift { .. } - | NonASAPOp::Aggregate { .. }, - ), - _, - ) - | ( - _, - Some( - ASAPOp::SummaryEstimate { .. } - | ASAPOp::FinalizeExactAccumulator { .. }, - ), - ) => ExecutionDataState::QUERY_ROWS, - (_, Some(ASAPOp::SummaryMerge { .. })) => ExecutionDataState { - timing: ExecutionTiming::QueryTime, - primitive: DataPrimitive::SummaryState, - }, - ( - _, - Some( - build @ (ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryTumblingWindowChain { .. }), - ), - ) if template_price_is_a_floor(build, context) => ExecutionDataState { - timing: ExecutionTiming::QueryTime, - primitive: DataPrimitive::SummaryState, - }, - _ => return None, - } - } else { - match ( - template.non_asap(), - template.asap(), - configuration.materialization, - ) { - ( - Some( - NonASAPOp::Scan { .. } - | NonASAPOp::Project { .. } - | NonASAPOp::TimeRange { .. }, - ), - _, - Materialization::NotMaterialized, - ) => ExecutionDataState::INGESTION_ROWS, - ( - _, - Some(ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::ExactAggregate(..), - .. - }), - Materialization::MatAtIngestion(_), - ) => ExecutionDataState::INGESTION_SUMMARY, - ( - _, - Some( - chain @ ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(..), - .. - }, - ), - Materialization::MatAtIngestion(_), - ) if template_price_is_a_floor(chain, context) => { - ExecutionDataState::INGESTION_SUMMARY - } - _ => return None, - } - }; - let mut position = 0; - let payload = template.operator.map_children(|_| { - let id = position; - position += 1; - id - }); - if position - != configured.dag.candidates[id] - .producer_candidates_per_input - .len() - { - return None; - } - Some(PhysicalASAPDAGNode { - id, - payload, - output_state: state, - materialization: configuration.materialization, - output_schema: template.schema.clone(), - guarantee: template.guarantee.clone(), - retention_ms: None, - }) - }) - .collect(); - let concrete_rows = ConcreteRows::of(configured); - let concrete_prices = concrete_rows.price(context); - let certified_rows = concrete_rows.certify(configured, &concrete_prices)?; - let mut cache: Vec<_> = certified_rows - .iter() - .enumerate() - .map(|(id, &certified)| { - certified - .then(|| concrete_prices[concrete_rows.roots[id].unwrap()].clone()) - .flatten() - }) - .collect(); - let mut visiting = vec![false; count]; - let mut complete = certified_rows; - for id in 0..count { - if let Some(priced) = certified_price( - id, - configured, - &nodes, - &common_reader_window_ms, - context, - query_rate_floor, - &mut cache, - &mut visiting, - &mut complete, - )? { - let cost = priced.cost.cost_per_second.unwrap_or(0.0); - if cost.is_finite() && cost > 0.0 { - costs[id] = cost; - metrics.priced_candidates += 1; - if nodes[id].as_ref().unwrap().output_state.timing == ExecutionTiming::QueryTime - { - metrics.query_readers += 1; - } else { - match &nodes[id].as_ref().unwrap().payload { - asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { .. }) => { - metrics.ingestion_scans += 1 - } - asap_types::ir::Operator::ASAP( - ASAPOp::SummaryTumblingWindowChain { .. }, - ) => metrics.ingestion_tumbling_window_chains += 1, - _ => metrics.ingestion_transforms += 1, - } - } - } - } - } - } - // The solver's coefficient range is narrower than the full tumbling-window domain. - // Zero is still a certified lower bound for omitted tiny local charges; - // the evaluator continues to charge their complete actual cost. - metrics.rounded_down_candidates = round_down_small_bounds(&mut costs); - metrics.priced_candidates -= metrics.rounded_down_candidates; - for (candidate, cost) in configured.dag.candidates.iter_mut().zip(costs) { - candidate.local_cost = cost; - } - configured.dag.validate().map_err(|e| e.to_string())?; - Ok(metrics) -} - -/// Each candidate's common reader window: the window every candidate that -/// may read it (through time shifts) reads, as the evaluator's -/// `windows_read` counts windows (a time range's range, a tumbling-window chain's -/// tumbling windows). A query-time scan reads its readers' longest window, or every -/// row held when no reader has one; both are at least the rows of this -/// window capped by the rows held. `None` when no reader has a window, or -/// when readers' windows differ: which window the scan reads then depends on -/// the readers selected, so the scan is left uncertified like any other node -/// without a sound fixed bound. (A bound at the shortest window was sound but -/// priced a scan a short and a long window may share far below a scan for the -/// long window alone, so the search proposed the shared scan first, #994.) -fn common_reader_windows(configured: &PhysicalCandidatesWithCosts) -> Vec> { - let graph = &configured.dag; - let mut readers = vec![Vec::new(); graph.candidates.len()]; - for (reader, candidate) in graph.candidates.iter().enumerate() { - for requirement in &candidate.producer_candidates_per_input { - for producer in requirement.candidates.iter() { - readers[producer.0].push(reader); - } - } - } - (0..graph.candidates.len()) - .map(|id| { - let mut windows_ms = std::collections::BTreeSet::new(); - let mut pending = vec![id]; - let mut seen = std::collections::HashSet::new(); - while let Some(producer) = pending.pop() { - if !seen.insert(producer) { - continue; - } - for &reader in &readers[producer] { - let window = match &configured.node(reader).operator { - asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - range, .. - }) => Some(range.as_millis() as u64), - asap_types::ir::Operator::NonASAP(NonASAPOp::TimeShift { .. }) => { - pending.push(reader); - None - } - // A chain kept across evaluations reads one tumbling window; - // its scans are never certified (retained ancestors). - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - tumbling_window_count, - .. - }) => Some( - tumbling_window_width_ms - .saturating_mul(u64::from(*tumbling_window_count)), - ), - _ => None, - }; - windows_ms.extend(window); - } - } - match windows_ms.len() { - 1 => windows_ms.pop_first(), - _ => None, - } - }) - .collect() -} - -/// Whether a summary build's price at its template's parameters is at most -/// its price in any plan that selects it: always, since Stage 1 sizes every -/// summary and Stage 3 only checks, so the template is the size that runs. -fn template_price_is_a_floor(_build: &ASAPOp, _context: &CandidatePlanningContext<'_>) -> bool { - true -} - -fn round_down_small_bounds(costs: &mut [f64]) -> usize { - let largest = costs.iter().copied().fold(0.0_f64, f64::max); - let mut removed = 0; - for cost in costs { - if *cost > 0.0 - && *cost / largest < crate::search::candidates::dag::MINIMUM_RELATIVE_COEFFICIENT - { - *cost = 0.0; - removed += 1; - } - } - removed -} - -// Potential source-widening choices may cycle although every selected execution -// DAG is acyclic. Certify their row interfaces locally, rather than interpreting -// a recursive visit of a potential cycle as an unknown physical input. -struct ConcreteRows { - nodes: Vec, - children: Vec>, - roots: Vec>, -} - -impl ConcreteRows { - fn of(configured: &PhysicalCandidatesWithCosts) -> Self { - let mut result = Self { - nodes: Vec::new(), - children: Vec::new(), - roots: Vec::new(), - }; - fn append(node: &asap_types::ir::OperatorNode, result: &mut ConcreteRows) -> Option { - if !matches!( - node.non_asap(), - Some( - NonASAPOp::Scan { .. } - | NonASAPOp::Project { .. } - | NonASAPOp::TimeRange { .. } - ) - ) { - return None; - } - let mut children = Vec::new(); - let mut supported = true; - let payload = node.operator.map_children(|child| { - let index = children.len(); - match append(child, result) { - Some(child) => children.push(child), - None => supported = false, - } - index - }); - if !supported { - return None; - } - let id = result.nodes.len(); - result.nodes.push(PhysicalASAPDAGNode { - id, - payload, - output_state: ExecutionDataState::INGESTION_ROWS, - materialization: Materialization::NotMaterialized, - output_schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - retention_ms: None, - }); - result.children.push(children); - Some(id) - } - for (id, config) in configured.physical.candidates.iter().enumerate() { - let root = if config.timing == ExecutionTiming::IngestionTime - && config.materialization == Materialization::NotMaterialized - { - append(configured.node(id), &mut result) - } else { - None - }; - result.roots.push(root); - } - result - } - - fn price<'a>( - &'a self, - context: &'a CandidatePlanningContext<'a>, - ) -> Vec>> { - let mut result: Vec>> = Vec::with_capacity(self.nodes.len()); - for (id, node) in self.nodes.iter().enumerate() { - let views: Option> = self.children[id] - .iter() - .map(|&child| { - let price = result[child].as_ref()?; - Some(ProducerOutput { - payload: &self.nodes[child].payload, - output_schema: &self.nodes[child].output_schema, - output: price.output, - sources: price.sources.clone(), - family: price.family, - unchanged_columns: price.unchanged_columns.clone(), - }) - }) - .collect(); - result.push(views.and_then(|views| { - node_usage( - node, - &views, - (1, 1.0), - asap_types::ir::physical_export::ScanWindow::Unbounded, - 1, - None, - context.data, - context.models.cost_inputs, - context.calibration.factor(), - ) - .ok() - })); - } - result - } - - fn certify( - &self, - configured: &PhysicalCandidatesWithCosts, - prices: &[Option>], - ) -> Result, String> { - let interfaces: Vec<_> = self - .nodes - .iter() - .zip(prices) - .map(|(node, priced)| { - priced - .as_ref() - .map(|priced| { - // Open→closed schema refinement changes no row pricing inputs. - let mut schema = node.output_schema.clone(); - schema.closed = false; - serde_json::to_string(&( - schema, - priced.output, - &priced.sources, - &priced.unchanged_columns, - )) - .map_err(|error| error.to_string()) - }) - .transpose() - }) - .collect::>()?; - let mut valid: Vec<_> = self - .roots - .iter() - .map(|root| root.is_some_and(|root| interfaces[root].is_some())) - .collect(); - loop { - let mut changed = false; - for id in 0..valid.len() { - if !valid[id] { - continue; - } - let root = self.roots[id].unwrap(); - let inputs = &configured.dag.candidates[id].producer_candidates_per_input; - let agrees = inputs.len() == self.children[root].len() - && inputs - .iter() - .zip(&self.children[root]) - .all(|(input, &child)| { - !input.candidates.is_empty() - && input.candidates.iter().all(|supplier| { - valid[supplier.0] - && interfaces[self.roots[supplier.0].unwrap()] - == interfaces[child] - }) - }); - if !agrees { - valid[id] = false; - changed = true; - } - } - if !changed { - return Ok(valid); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -fn certified_price<'a>( - id: usize, - configured: &PhysicalCandidatesWithCosts, - nodes: &'a [Option], - common_reader_window_ms: &[Option], - context: &'a CandidatePlanningContext<'a>, - query_rate_floor: f64, - cache: &mut [Option>], - visiting: &mut [bool], - complete: &mut [bool], -) -> Result>, String> { - if complete[id] { - return Ok(cache[id].clone()); - } - if visiting[id] { - return Ok(None); - } - let Some(node) = nodes[id].as_ref() else { - complete[id] = true; - return Ok(None); - }; - visiting[id] = true; - let result: Result>, String> = (|| { - let mut views = Vec::new(); - for requirement in &configured.dag.candidates[id].producer_candidates_per_input { - let mut representative = None; - let mut signature = None; - for supplier in &requirement.candidates { - let Some(priced) = certified_price( - supplier.0, - configured, - nodes, - common_reader_window_ms, - context, - query_rate_floor, - cache, - visiting, - complete, - )? - else { - return Ok(None); - }; - let producer = nodes[supplier.0].as_ref().unwrap(); - // DDS state bytes depend on whether the selected producer - // exposes nonnegative raw samples. Equal row statistics alone - // cannot certify Scan and Project as interchangeable DDS inputs. - // Other whitelisted operators do not use this domain flag. - let raw_nonnegative = matches!( - &node.payload, - asap_types::ir::Operator::ASAP( - ASAPOp::SummaryTumblingWindowChain { family: FieldDataType::Sketch(kind, _), .. } - | ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. } - ) if matches!(kind.params(), SketchParams::DDSketch { .. }) - ) - .then(|| { - matches!( - producer.payload, - asap_types::ir::Operator::NonASAP( - NonASAPOp::Scan { .. } | NonASAPOp::TimeRange { .. } - ) - ) && priced.sources.iter().all(|source| { - asap_types::workload::bounds::declared_non_negative(context.data, source) - }) - }); - // Producer tumbling-window counts, family, schema fields, statistics and - // provenance must agree; upstream work is charged separately. - let tumbling_windows = match producer.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_count, - .. - }) => Some(tumbling_window_count), - _ => None, - }; - let family = match priced.family { - StateFamily::None => serde_json::Value::Null, - StateFamily::Sketch(params) => serde_json::json!({"sketch": params}), - StateFamily::Exact(kind) => serde_json::json!({"exact": kind}), - }; - let mut schema = producer.output_schema.clone(); - schema.closed = false; - let current = serde_json::to_string(&( - raw_nonnegative, - tumbling_windows, - family, - schema, - priced.output, - &priced.sources, - &priced.unchanged_columns, - )) - .map_err(|error| error.to_string())?; - if signature - .as_ref() - .is_some_and(|previous| previous != ¤t) - { - return Ok(None); - } - signature = Some(current); - representative = Some(ProducerOutput { - payload: &producer.payload, - output_schema: &producer.output_schema, - output: priced.output, - sources: priced.sources, - family: priced.family, - unchanged_columns: priced.unchanged_columns, - }); - } - let Some(view) = representative else { - return Ok(None); - }; - views.push(view); - } - let maintained_window = match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - tumbling_window_count, - .. - }) => Some(tumbling_window_width_ms.saturating_mul(u64::from(*tumbling_window_count))), - _ => None, - }; - let retained = match &node.payload { - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_count, - .. - }) => u64::from(*tumbling_window_count).max(1), - _ => 1, - }; - // One ingestion boundary executes once per accounted second; additional - // unmaterialized consumers can only add executions. A summary build is - // priced at its template's size, its final size - // ([`template_price_is_a_floor`]). Retention above its own tumbling-window count - // and all whole-plan charges are deliberately omitted. - Ok(node_usage( - node, - &views, - ( - 1, - if node.output_state.timing == ExecutionTiming::IngestionTime { - 1.0 - } else { - query_rate_floor - }, - ), - // Stage 3 scans the width of the union of the readers' windows, - // at least their common width, whatever their offsets. - asap_cost::trailing_window(common_reader_window_ms[id]), - retained, - maintained_window, - context.data, - context.models.cost_inputs, - context.calibration.factor(), - ) - .ok()) - })(); - visiting[id] = false; - complete[id] = true; - cache[id] = result?; - Ok(cache[id].clone()) -} - -#[cfg(test)] -mod tests { - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - // Fine tumbling windows may widen the bound range; round down only the relaxation, - // preserving a valid lower bound and the core solver's numeric contract. - #[test] - fn tiny_certificates_are_rounded_down_without_inflating_other_costs() { - let original = [0.0, 1e-12, 1e-9, 1.0]; - let mut costs = original; - assert_eq!(super::round_down_small_bounds(&mut costs), 1); - assert_eq!(costs, [0.0, 0.0, 1e-9, 1.0]); - assert!(costs - .iter() - .zip(original) - .all(|(bound, actual)| *bound <= actual)); - assert_eq!(super::round_down_small_bounds(&mut [0.0, 0.0]), 0); - } - - use super::*; - use crate::search::milp::model::{solve_candidate_proposal, ObservedAssignment}; - use crate::{PlanSelector, SelectionModels}; - use asap_cost::CalibrationFit; - use asap_logical_asap_optimization::generate_per_computation_logical_memo_candidates; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; - use asap_logical_asap_optimization::test_support::{ - tumbling_window_candidates, window_composition_pass2_candidates, - }; - use asap_physical_asap_optimization::test_support::catalog::materialize_selected_candidates; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::{OperatorNode, QueryRoot}; - use asap_types::physical::Medium; - - use crate::search::candidates::dag::{InputProducerCandidates, QueryOutputCandidates}; - - use asap_types::stages::PhysicalCandidateId; - use asap_types::workload::*; - use std::{collections::HashMap, rc::Rc}; - - fn fixture() -> ( - PhysicalCandidatesWithCosts, - Vec, - Vec, - DataWorkload, - ) { - fixture_for(false) - } - - fn fixture_for( - exact: bool, - ) -> ( - PhysicalCandidatesWithCosts, - Vec, - Vec, - DataWorkload, - ) { - fixture_for_grouping(exact, false) - } - - fn fixture_for_grouping( - exact: bool, - grouped: bool, - ) -> ( - PhysicalCandidatesWithCosts, - Vec, - Vec, - DataWorkload, - ) { - let target = if exact { - AccuracyTarget::exact() - } else { - AccuracyTarget::max_error(0.1).failure_probability(0.1) - }; - let mut roots = Vec::new(); - for q in [0.95, 0.99] { - let query = if grouped { - "sum(sum_over_time(latency[5m]))".to_string() - } else if exact { - "sum_over_time(latency[5m])".to_string() - } else { - format!("quantile_over_time({q}, latency[5m])") - }; - let root = QueryRoot::Operator(lower_promql(&query, target.clone())); - let query_roots = vec![root]; - let memo = try_populate_pass1( - generate_dag_of_logical_memo_groups(&query_roots), - &generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &query_roots, - )) - .unwrap(), - ) - .unwrap(); - let root_group = memo.query_roots[0].as_operator().unwrap().0 as usize; - let candidate = memo.candidates[root_group].iter().find(|candidate| - OperatorNode::reachable(&candidate.root_node).iter().any(|node| if exact { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(..), child, .. }) if !grouped || matches!(child.non_asap(), Some(NonASAPOp::TimeRange { .. }))) - } else { matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if matches!(kind.params(), SketchParams::DDSketch { .. })) }) - ).unwrap(); - let input = vec![QueryRoot::Operator(Rc::clone(&candidate.root_node))]; - let tumbling_windows = tumbling_window_candidates(&input, &[Some(60_000)]); - roots.push( - window_composition_pass2_candidates(&input, &tumbling_windows) - .unwrap() - .remove(0) - .roots - .unwrap() - .remove(0), - ); - } - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - fn intern( - node: &Rc, - configured: &mut PhysicalCandidatesWithCosts, - ids: &mut HashMap<*const OperatorNode, usize>, - ) -> usize { - if let Some(&id) = ids.get(&Rc::as_ptr(node)) { - return id; - } - let mut inputs = Vec::new(); - node.operator.map_children(|child| { - inputs.push(intern(child, configured, ids)); - Rc::clone(child) - }); - let id = configured.nodes_for_tests().len(); - ids.insert(Rc::as_ptr(node), id); - let tumbling_window = - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })); - let scan = matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })); - configured.push_for_tests( - Rc::clone(node), - if tumbling_window || scan { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }, - if tumbling_window { - Materialization::MatAtIngestion(Medium::Memory) - } else { - Materialization::NotMaterialized - }, - 0.0, - inputs - .into_iter() - .map(|input| InputProducerCandidates { - candidates: vec![PhysicalCandidateId(input)].into(), - }) - .collect(), - ); - id - } - let mut ids = HashMap::new(); - for root in &roots { - let QueryRoot::Operator(node) = root else { - unreachable!() - }; - let id = intern(node, &mut configured, &mut ids); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(id)], - }); - configured.sync_physical_for_tests(); - } - let chains: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, node)| { - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - .then_some(PhysicalCandidateId(id)) - }) - .collect(); - for candidate in &mut configured.dag.candidates { - for input in &mut candidate.producer_candidates_per_input { - if chains.contains(&input.candidates[0]) { - input.candidates = chains.clone().into(); - } - } - } - configured.sync_physical_for_tests(); - let entries = (0..2) - .map(|_| { - let mut entry = entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - ); - entry.predictability = Predictability::Predictable { known_at: None }; - entry - }) - .collect(); - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(2), - values_non_negative: true, - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - (configured, roots, entries, data) - } - - fn add_ingestion_transforms(configured: &mut PhysicalCandidatesWithCosts) { - use asap_types::ir::scalar::{ProjectItem, ScalarExpr}; - use asap_types::ir::TimeRangeKind; - let chains: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, node)| { - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })).then_some(id) - }) - .collect(); - let mut projects = Vec::new(); - for (index, &chain) in chains.iter().enumerate() { - let scan = - configured.dag.candidates[chain].producer_candidates_per_input[0].candidates[0]; - configured.sync_physical_for_tests(); - let input = configured.node(scan.0).clone(); - let range = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::TimeRange { - range: std::time::Duration::from_secs(300 + index as u64 * 300), - kind: TimeRangeKind::Instant, - child: input, - })) - .unwrap(); - let project = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { - cols: range - .schema - .fields - .iter() - .enumerate() - .map(|(column, field)| ProjectItem { - alias: (index != 0).then(|| field.name.clone()), - expr: ScalarExpr::Column(column), - }) - .collect(), - qualifier: None, - child: range.clone(), - })) - .unwrap(); - let mut producer = scan; - for node in [range, project] { - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - ExecutionTiming::IngestionTime, - Materialization::NotMaterialized, - 0.0, - vec![InputProducerCandidates { - candidates: vec![producer].into(), - }], - ); - producer = id; - } - projects.push(producer); - } - for chain in chains { - configured.dag.candidates[chain].producer_candidates_per_input[0].candidates = - projects.clone().into(); - configured.sync_physical_for_tests(); - let child = configured.nodes_for_tests()[projects[0].0].clone(); - let template = configured.node(chain); - configured.set_node_for_tests( - chain, - Rc::new(template.with_new_children(|_| child.clone()).unwrap()), - ); - } - } - - // Equal row schemas/statistics do not make Scan and Project equivalent - // DDS pricing inputs: nonnegative raw samples need only one bin domain. - #[test] - fn mixed_raw_and_project_inputs_never_overprice_nonnegative_dds() { - let (mut configured, roots, entries, mut data) = fixture(); - data.sources.get_mut("latency").unwrap().values_non_negative = true; - let scans: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, node)| { - matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })) - .then_some(PhysicalCandidateId(id)) - }) - .collect(); - let mut projects = Vec::new(); - for scan in &scans { - let input = configured.node(scan.0).clone(); - let project = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Project { - cols: input - .schema - .fields - .iter() - .enumerate() - .map(|(column, field)| asap_types::ir::scalar::ProjectItem { - alias: Some(field.name.clone()), - expr: asap_types::ir::scalar::ScalarExpr::Column(column), - }) - .collect(), - qualifier: None, - child: input, - })) - .unwrap(); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - project, - ExecutionTiming::IngestionTime, - Materialization::NotMaterialized, - 0.0, - vec![InputProducerCandidates { - candidates: vec![*scan].into(), - }], - ); - projects.push(id); - } - for (id, template) in configured.nodes_for_tests().iter().enumerate() { - if matches!( - template.asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) { - let input = &mut configured.dag.candidates[id].producer_candidates_per_input[0]; - // Put larger signed-domain Project pricing last to expose the - // former arbitrary representative selection. - let mut suppliers = scans.clone(); - suppliers.extend(projects.iter().copied()); - input.candidates = suppliers.into(); - } - } - configured.sync_physical_for_tests(); - let models = SelectionModels::builtin(); - let calibration = CalibrationFit::default(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration, - }; - let metrics = apply(&mut configured, &context).unwrap(); - assert!(metrics.ingestion_scans > 0); - let mut observations = Vec::new(); - let mut valid = 0; - while let Some(proposal) = - solve_candidate_proposal(&configured.dag, &observations, None).unwrap() - { - let selected = proposal.selected; - let mut modes = vec![None; configured.physical.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(configured.physical.candidates[id.0].materialization); - } - let physical = materialize_selected_candidates( - &configured.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - let mut selector = PlanSelector::new(&entries, &data, models, false).unwrap(); - selector.set_calibration(calibration); - selector.offer(&physical.plan).unwrap(); - let (result, _) = selector.finish().unwrap(); - let cost = result.costs[&result.selected].cost_per_second.unwrap(); - assert!( - selected.modeled_cost <= cost * (1.0 + 1e-9), - "bound {} > cost {cost}", - selected.modeled_cost - ); - valid += 1; - observations.push(ObservedAssignment { - selected, - exact_cost: None, - rejected_queries: Vec::new(), - }); - assert!(valid <= 128); - } - assert!( - valid > 12, - "both raw and transformed supplier bindings were checked" - ); - } - - // Every sharing/separate-state selection is bounded by the real evaluator, - // including frozen calibration and measured DDSketch operation prices. - #[test] - fn every_tiny_selection_has_positive_certified_ingestion_and_reader_bound() { - check_tiny_bounds(false, false, false); - } - - // Fixed exact accumulator state is not resized by failure-probability rules; - // every sharing/independent plan must have positive certified state pricing. - #[test] - fn exact_accumulator_tumbling_windows_and_finalizers_have_certified_bounds() { - check_tiny_bounds(true, false, false); - } - - // Potential identity-projection cycles are legal catalog choices, but only - // acyclic selected connections execute. Local row-interface certificates - // preserve positive bounds for every finite acyclic choice through them. - #[test] - fn potential_projection_cycles_do_not_poison_certified_state_bounds() { - check_tiny_bounds(false, true, false); - check_tiny_bounds(true, true, false); - } - - // Grouped ingestion state uses its own exact tumbling-window horizon, matching the - // complete evaluator's maintained_window_ms rather than an unknown window. - #[test] - fn grouped_exact_tumbling_windows_use_their_declared_maintenance_horizon() { - check_tiny_bounds(true, false, true); - } - - fn check_tiny_bounds(exact: bool, cyclic: bool, grouped: bool) { - for (measured, ranged, transformed) in [ - (false, false, false), - (true, false, false), - (false, true, false), - (true, true, false), - (false, false, true), - (true, true, true), - ] { - let (mut configured, roots, mut entries, data) = fixture_for_grouping(exact, grouped); - if cyclic && !transformed { - continue; - } - if transformed { - add_ingestion_transforms(&mut configured); - if cyclic { - let projects: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, node)| { - (configured.physical.candidates[id].timing - == ExecutionTiming::IngestionTime - && matches!(node.non_asap(), Some(NonASAPOp::Project { .. }))) - .then_some(PhysicalCandidateId(id)) - }) - .collect(); - for project in &projects { - configured.dag.candidates[project.0].producer_candidates_per_input[0] - .candidates - .extend(projects.iter().copied()); - configured.sync_physical_for_tests(); - } - } - } - if ranged { - entries[0].recurrence = QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(120_000), - )); - entries[0].range_evaluation = Some(RangeEvaluation { - span_ms: 120_000, - step_ms: 60_000, - }); - entries[1].range_evaluation = Some(RangeEvaluation { - span_ms: 240_000, - step_ms: 120_000, - }); - } - let mut settings = asap_cost::CostInputs::default(); - if measured { - settings.measured_operation_costs = serde_json::from_value(serde_json::json!({"operators": [{"operator": "SummaryTumblingWindowChain", "phase": "ingestion_time", "sketch": {"DDSketch": {"alpha": 0.1}}, "ns_per_unit": 300.0, "ns_per_invocation": 200.0, "memory_bytes_per_state": 1000.0}, {"operator": "SummaryMerge", "phase": "query_time", "sketch": {"DDSketch": {"alpha": 0.1}}, "ns_per_unit": 500.0, "ns_per_invocation": 100.0}, {"operator": "SummaryEstimate", "phase": "query_time", "sketch": {"DDSketch": {"alpha": 0.1}}, "ns_per_unit": 900.0, "ns_per_invocation": 400.0}]})).unwrap(); - } - if exact && measured { - settings.measured_operation_costs = serde_json::from_value(serde_json::json!({"operators": [ - {"operator": "SummaryTumblingWindowChain", "phase": "ingestion_time", "exact": "Sum", "ns_per_unit": 300.0, "ns_per_invocation": 200.0, "memory_bytes_per_state": 64.0}, - {"operator": "SummaryMerge", "phase": "query_time", "exact": "Sum", "ns_per_unit": 500.0, "ns_per_invocation": 100.0}, - {"operator": "FinalizeExactAccumulator", "phase": "query_time", "exact": "Sum", "ns_per_unit": 900.0, "ns_per_invocation": 400.0} - ]})).unwrap(); - } - let models = SelectionModels::builtin().with_cost_inputs(&settings); - let calibration = CalibrationFit::from_factor(2.0).unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration, - }; - let metrics = apply(&mut configured, &context).unwrap(); - assert_eq!(metrics.ingestion_tumbling_window_chains, 2); - assert!(metrics.ingestion_scans > 0); - assert!(metrics.query_readers > 0); - if exact { - let finalizers: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, template)| { - matches!( - template.asap(), - Some(ASAPOp::FinalizeExactAccumulator { .. }) - ) - .then_some(id) - }) - .collect(); - assert!(!finalizers.is_empty()); - assert!(finalizers - .iter() - .all(|&id| configured.dag.candidates[id].local_cost > 0.0)); - } - if transformed { - assert_eq!(metrics.ingestion_transforms, 4); - } - let mut observations = Vec::new(); - let mut valid = 0; - while let Some(proposal) = - solve_candidate_proposal(&configured.dag, &observations, None).unwrap() - { - let selected = proposal.selected; - let mut modes = vec![None; configured.physical.candidates.len()]; - for id in &selected.selected_candidates { - modes[id.0] = Some(configured.physical.candidates[id.0].materialization); - } - let physical = materialize_selected_candidates( - &configured.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - let mut selector = PlanSelector::new(&entries, &data, models, false).unwrap(); - selector.set_calibration(calibration); - selector.offer(&physical.plan).unwrap(); - let (result, _) = selector.finish().unwrap(); - let cost = result.costs[&result.selected].cost_per_second.unwrap(); - assert!(selected.modeled_cost > 0.0); - assert!( - selected.modeled_cost <= cost * (1.0 + 1e-9), - "bound {} > cost {cost}", - selected.modeled_cost - ); - valid += 1; - observations.push(ObservedAssignment { - selected, - exact_cost: None, - rejected_queries: Vec::new(), - }); - assert!(valid <= 128, "bounded tiny supplier choices"); - } - if cyclic { - assert!( - valid > 12, - "cycles expose additional acyclic connection choices" - ); - } else { - assert_eq!(valid, if transformed { 12 } else { 4 }); - } - } - } - // Any potential retained consumer suppresses upstream query-work bounds, - // while independent fixed-ingestion state remains safely priceable. - #[test] - fn retained_consumer_context_keeps_query_work_unpriced() { - let (mut configured, roots, entries, data) = fixture(); - for root in &configured.dag.query_roots { - for candidate in &root.candidates { - configured.physical.candidates[candidate.0].materialization = - Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory); - } - } - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let metrics = apply(&mut configured, &context).unwrap(); - assert_eq!(metrics.query_readers, 0); - assert!(metrics.ingestion_tumbling_window_chains > 0); - for (candidate, config) in configured - .dag - .candidates - .iter() - .zip(&configured.physical.candidates) - { - if config.timing == ExecutionTiming::QueryTime { - assert_eq!(candidate.local_cost, 0.0); - } - } - } - // Equal row counts do not certify different sources as interchangeable cost inputs. - #[test] - fn differing_producer_metadata_leaves_tumbling_window_bounds_zero() { - let (mut configured, roots, entries, mut data) = fixture(); - let scans: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter_map(|(id, node)| { - matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })) - .then_some(PhysicalCandidateId(id)) - }) - .collect(); - assert_eq!(scans.len(), 2); - let original = configured.node(scans[1].0).clone(); - let mut different = (*original).clone(); - let asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { source, .. }) = - &mut different.operator - else { - unreachable!() - }; - *source = asap_types::ir::operator::Source::TimeSeries { - metric: "other".into(), - }; - configured.set_node_for_tests(scans[1].0, Rc::new(different)); - data.sources - .insert("other".into(), data.sources["latency"].clone()); - let nodes = configured.nodes_for_tests(); - for (candidate, node) in configured.dag.candidates.iter_mut().zip(&nodes) { - if matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) { - candidate.producer_candidates_per_input[0].candidates = scans.clone().into(); - } - } - configured.sync_physical_for_tests(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let metrics = apply(&mut configured, &context).unwrap(); - assert_eq!(metrics.ingestion_tumbling_window_chains, 0); - assert_eq!(metrics.ingestion_scans, 2); - } - - /// Each of `queries`' Pass 1 candidates that `pick` selects, sized by - /// Pass 1 for `target` (their final size), every node at query time and - /// equal nodes shared: their configured inventory after [`apply`], the - /// bound of the selected plan, and that plan's evaluated cost. - fn query_time_bound_and_cost( - queries: &[&str], - target: AccuracyTarget, - settings: &asap_cost::CostInputs, - pick: fn(&OperatorNode) -> bool, - ) -> (PhysicalCandidatesWithCosts, f64, f64) { - let roots: Vec<_> = queries - .iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); - let entries: Vec<_> = queries - .iter() - .map(|_| { - entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 10_000, - ))), - ) - }) - .collect(); - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(2), - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let targets = vec![target.clone(); queries.len()]; - let memo = generate_per_computation_logical_memo_candidates( - generate_dag_of_logical_memo_groups(&roots), - &targets, - &asap_accuracy::IgnoreAccuracyGuarantees, - &asap_accuracy::NoAccuracyEvidence, - &data, - None, - None, - ) - .unwrap() - .memo; - let picked: Vec<_> = (0..queries.len()) - .map(|query| { - let root_group = memo.query_roots[query].as_operator().unwrap().0 as usize; - memo.candidates[root_group] - .iter() - .find(|candidate| { - OperatorNode::reachable(&candidate.root_node) - .iter() - .any(|node| pick(node)) - }) - .unwrap() - }) - .collect(); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - fn intern(node: &Rc, configured: &mut PhysicalCandidatesWithCosts) -> usize { - if let Some(id) = configured - .nodes_for_tests() - .iter() - .position(|template| **template == **node) - { - return id; - } - let mut inputs = Vec::new(); - node.operator.map_children(|child| { - inputs.push(intern(child, configured)); - Rc::clone(child) - }); - let id = configured.nodes_for_tests().len(); - configured.push_for_tests( - Rc::clone(node), - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 0.0, - inputs - .into_iter() - .map(|input| InputProducerCandidates { - candidates: vec![PhysicalCandidateId(input)].into(), - }) - .collect(), - ); - id - } - for candidate in &picked { - let root = intern(&candidate.root_node, &mut configured); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(root)], - }); - configured.sync_physical_for_tests(); - } - let roots: Vec<_> = picked - .iter() - .map(|candidate| QueryRoot::Operator(Rc::clone(&candidate.root_node))) - .collect(); - let models = SelectionModels::builtin().with_cost_inputs(settings); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - apply(&mut configured, &context).unwrap(); - let selected = solve_candidate_proposal(&configured.dag, &[], None) - .unwrap() - .unwrap() - .selected; - let modes: Vec<_> = configured - .physical - .candidates - .iter() - .map(|config| Some(config.materialization)) - .collect(); - let physical = materialize_selected_candidates( - &configured.physical, - &selected.to_selected_dag(), - &selected.materializations_by_node(&modes), - &roots, - &entries, - &data, - false, - ) - .unwrap(); - let mut selector = PlanSelector::new(&entries, &data, models, false).unwrap(); - selector.offer(&physical.plan).unwrap(); - let (result, _) = selector.finish().unwrap(); - let cost = result.costs[&result.selected].cost_per_second.unwrap(); - (configured, selected.modeled_cost, cost) - } - - fn bound_of( - configured: &PhysicalCandidatesWithCosts, - matches: impl Fn(&OperatorNode) -> bool, - ) -> f64 { - configured - .nodes_for_tests() - .iter() - .zip(&configured.dag.candidates) - .find(|(template, _)| matches(template)) - .unwrap() - .1 - .local_cost - } - - // A query-time scan, its window and the exact build over it are priced - // from the window's rows at the slowest query rate: positive bounds that - // never exceed the evaluated plan's cost, also for a shifted window, whose - // scan Stage 3 prices at the window's width and whose older raw rows it - // keeps at its own charge. - #[test] - fn query_time_scans_and_builds_have_certified_bounds() { - for query in [ - "sum_over_time(latency[5m])", - "sum_over_time(latency[5m] offset 1m)", - ] { - let (configured, bound, cost) = query_time_bound_and_cost( - &[query], - AccuracyTarget::exact(), - &asap_cost::CostInputs::default(), - |node| { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(..), child, .. }) - if matches!(child.non_asap(), Some(NonASAPOp::TimeRange { .. }))) - }, - ); - let scan = bound_of(&configured, |node| { - matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })) - }); - let range = bound_of(&configured, |node| { - matches!(node.non_asap(), Some(NonASAPOp::TimeRange { .. })) - }); - let build = bound_of(&configured, |node| { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. })) - }); - // 300 s × 1000 rows/s, every 10 s: 30000 rows read per second. - assert!( - scan > 0.0 && range > 0.0 && build > 0.0, - "{query}: {scan} {range} {build}" - ); - assert!( - bound > 0.0 && bound <= cost * (1.0 + 1e-9), - "{query}: bound {bound} against cost {cost}" - ); - } - } - - // A scan a 15 s and a 5 m window may share reads 5 minutes when the 5 m - // window reads it. A bound at the 15 s window would price it far below a - // scan for the 5 m window alone, so it gets no bound (nor do the nodes - // over it); a scan for one window keeps its bound. Both plans' bounds - // stay at or below their costs. - #[test] - fn a_scan_read_through_different_windows_has_no_bound() { - let pick: fn(&OperatorNode) -> bool = |node| { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::ExactAggregate(..), child, .. }) - if matches!(child.non_asap(), Some(NonASAPOp::TimeRange { .. }))) - }; - let is_scan = |node: &OperatorNode| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. })); - for (queries, scan_bounded) in [ - (&["sum_over_time(latency[5m])"][..], true), - ( - &["sum_over_time(latency[15s])", "sum_over_time(latency[5m])"][..], - false, - ), - ] { - let (configured, bound, cost) = query_time_bound_and_cost( - queries, - AccuracyTarget::exact(), - &asap_cost::CostInputs::default(), - pick, - ); - let scans = configured - .nodes_for_tests() - .iter() - .filter(|template| is_scan(template)) - .count(); - assert_eq!(scans, 1, "{queries:?} share one scan"); - let scan = bound_of(&configured, is_scan); - assert_eq!(scan > 0.0, scan_bounded, "{queries:?}: scan bound {scan}"); - assert_eq!( - bound > 0.0, - scan_bounded, - "{queries:?}: build bound {bound}" - ); - assert!( - bound <= cost * (1.0 + 1e-9), - "{queries:?}: bound {bound} against cost {cost}" - ); - } - } - - fn is_kll_build(node: &OperatorNode) -> bool { - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) - if matches!(kind.params(), SketchParams::Kll { .. })) - } - - // A KLL build is priced at its template's size, which Pass 1 sized for - // the target and Stage 3 never changes: at every failure probability - // the bound stays at or below the evaluated cost, and a smaller one - // gives a larger sketch. - #[test] - fn a_kll_build_bound_holds_at_every_failure_probability() { - let mut costs = Vec::new(); - for failure_probability in [0.01, 0.001, 0.0001] { - let (configured, bound, cost) = query_time_bound_and_cost( - &["quantile_over_time(0.9, latency[5m])"], - AccuracyTarget::max_error(0.01).failure_probability(failure_probability), - &asap_cost::CostInputs::default(), - is_kll_build, - ); - assert!(bound_of(&configured, is_kll_build) > 0.0); - assert!( - bound > 0.0 && bound <= cost * (1.0 + 1e-9), - "bound {bound} against cost {cost} at failure probability {failure_probability}" - ); - costs.push(cost); - } - assert!( - costs[2] > costs[0], - "a smaller failure probability sizes a larger sketch: {costs:?}" - ); - } - - // A measured table need not grow with size, but the template is the - // size the plan runs: a KLL build with measured entries, and the - // estimate over it, keep a certified bound at or below the evaluated - // cost. - #[test] - fn a_measured_kll_build_and_estimate_keep_the_bound_below_the_cost() { - let settings = asap_cost::CostInputs { - measured_operation_costs: serde_json::from_value(serde_json::json!({"operators": [ - {"operator": "SummaryAgg", "phase": "query_time", "sketch": {"Kll": {"k": 200}}, "ns_per_unit": 300.0, "ns_per_invocation": 200.0}, - {"operator": "SummaryEstimate", "phase": "query_time", "sketch": {"Kll": {"k": 8}}, "ns_per_unit": 0.0, "ns_per_invocation": 5.0e9}, - {"operator": "SummaryEstimate", "phase": "query_time", "sketch": {"Kll": {"k": 20000}}, "ns_per_unit": 0.0, "ns_per_invocation": 1.0} - ]})) - .unwrap(), - ..Default::default() - }; - for failure_probability in [0.01, 0.0001] { - let (configured, bound, cost) = query_time_bound_and_cost( - &["quantile_over_time(0.9, latency[5m])"], - AccuracyTarget::max_error(0.01).failure_probability(failure_probability), - &settings, - is_kll_build, - ); - assert!(bound_of(&configured, is_kll_build) > 0.0); - assert!( - bound > 0.0 && bound <= cost * (1.0 + 1e-9), - "{failure_probability}: {bound} above {cost}" - ); - } - } -} diff --git a/crates/plan-selection/src/search/costing/mod.rs b/crates/plan-selection/src/search/costing/mod.rs deleted file mode 100644 index 63b006eb5..000000000 --- a/crates/plan-selection/src/search/costing/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Execution demand, proposal estimates, pricing certificates, and calibration. -pub(crate) mod calibration; -pub(crate) mod lower_bounds; -pub(crate) mod proposals; - -pub(crate) mod direct_profile; diff --git a/crates/plan-selection/src/search/costing/multi_input_price_tests.rs b/crates/plan-selection/src/search/costing/multi_input_price_tests.rs deleted file mode 100644 index 3d9c11be4..000000000 --- a/crates/plan-selection/src/search/costing/multi_input_price_tests.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! End-to-end pricing uses the evaluator's binary operators and shared DAGs. -use super::*; -use asap_logical_asap_optimization::test_support::lower_promql; -use asap_types::ir::{properties::AccuracyTarget, OperatorNode}; - -use crate::search::candidates::dag::*; -use asap_types::workload::{ - DataWorkload, Predictability, Query, QueryRequirements, QueryWorkloadEntry, RepetitionInterval, -}; -use std::rc::Rc; - -fn append( - node: &Rc, - inventory: &mut PhysicalCandidatesWithCosts, -) -> PhysicalCandidateId { - // Exact fixture expressions can be interned safely; repeated operands must - // share one producer while keeping their two ordered input positions. - if let Some(id) = (0..inventory.physical.candidates.len()) - .position(|known| inventory.node(known).as_ref() == node.as_ref()) - { - return PhysicalCandidateId(id); - } - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child, inventory))); - inventory.push_for_tests( - node.clone(), - ExecutionTiming::QueryTime, - Materialization::NotMaterialized, - 0.0, - inputs - .into_iter() - .map(|producer| InputProducerCandidates { - candidates: vec![producer].into(), - }) - .collect(), - ) -} - -#[test] -fn binary_prices_match_evaluator_with_distinct_and_shared_inputs() { - for query in ["m + n", "m + m", "m or n"] { - let logical = lower_promql(query, AccuracyTarget::exact()); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let root = append(&logical, &mut configured); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![root], - }); - configured.sync_physical_for_tests(); - assert!(configured - .dag - .candidates - .iter() - .any(|candidate| candidate.producer_candidates_per_input.len() == 2)); - let roots = vec![QueryRoot::Operator(logical)]; - let entries = vec![QueryWorkloadEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(60_000), - )), - range_evaluation: None, - }]; - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":100.0}}, - "sources":{"m":{"rows_held":10,"series_count":1},"n":{"rows_held":100,"series_count":1}} - })) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let profile = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert!( - !profile.candidates[root.0].is_empty(), - "unsupported binary fixture {query}" - ); - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{query}: {rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let direct = proposal.direct_modeled_cost.unwrap(); - assert!( - (actual - direct).abs() <= 1e-9 * actual.max(1e-15), - "{query}: actual={actual}, direct={direct}" - ); - if query == "m + m" { - let binary = configured - .dag - .candidates - .iter() - .enumerate() - .find(|(_, candidate)| candidate.producer_candidates_per_input.len() == 2) - .unwrap() - .0; - let edges: Vec<_> = proposal - .selected - .connections - .iter() - .filter(|edge| edge.consumer.0 == binary) - .collect(); - assert_eq!(edges.len(), 2); - assert_eq!(edges[0].producer, edges[1].producer); - assert_ne!(edges[0].requirement, edges[1].requirement); - } - } -} - -// Two independently approximate inputs must retain their divided confidence -// budgets; pricing may not silently resize either state after selection. -#[test] -fn two_sketch_binary_prices_match_evaluator_after_budget_sizing() { - use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, - }; - use asap_types::ir::properties::{AccuracyMetric, AccuracyScope}; - use asap_types::ir::schema::SketchAlgorithm; - use asap_types::stages::HowToComputeAggregateMeasure; - let query = "quantile_over_time(0.99, m[5m]) / quantile_over_time(0.5, n[5m])"; - let target = AccuracyTarget::max_error(0.05) - .accuracy_metric(AccuracyMetric::Relative) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - let planned = AccuracyTarget::max_error(0.01) - .accuracy_metric(AccuracyMetric::Relative) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - let original = lower_promql(query, planned); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&[ - QueryRoot::Operator(original.clone()), - ]), - ) - .unwrap(); - let choices: Vec<_> = generated - .targets - .iter() - .map(|target| { - target - .ways_to_compute - .iter() - .position(|choice| { - matches!( - choice, - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::DDSketch) - ) - }) - .unwrap() - }) - .collect(); - assert_eq!(choices.len(), 2); - let roots: Vec<_> = compose_logical_candidate(&generated, &choices).unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":100.0}}, - "sources":{"m":{"rows_held":3000,"series_count":1,"values_non_negative":true}, - "n":{"rows_held":3000,"series_count":1,"values_non_negative":true}} - })) - .unwrap(); - let sized = crate::sized_as_pass1(&roots, std::slice::from_ref(&target), &data); - let QueryRoot::Operator(root) = &sized[0] else { - panic!("operator root") - }; - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let id = append(root, &mut configured); - configured.dag.query_roots.push(QueryOutputCandidates { - candidates: vec![id], - }); - configured.sync_physical_for_tests(); - let original = vec![QueryRoot::Operator(original)]; - let entries = vec![QueryWorkloadEntry { - query: Query(query.into()), - requirements: QueryRequirements { - accuracy: target, - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 60_000, - ))), - range_evaluation: None, - }]; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &original, - realizer: &crate::search::test_realizer(&original, &entries, &data), - entries: &entries, - data: &data, - models: crate::SelectionModels::builtin(), - calibration: asap_cost::CalibrationFit::default(), - }; - let profile = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - assert!( - !profile.candidates[id.0].is_empty(), - "two certified sketches must reach root" - ); - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - let mut rejections = crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("two-sketch plan rejected: {rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - assert!((actual - proposal.direct_modeled_cost.unwrap()).abs() <= 1e-9 * actual.max(1e-15)); -} diff --git a/crates/plan-selection/src/search/costing/producer_price_options.rs b/crates/plan-selection/src/search/costing/producer_price_options.rs deleted file mode 100644 index c24ed8839..000000000 --- a/crates/plan-selection/src/search/costing/producer_price_options.rs +++ /dev/null @@ -1,482 +0,0 @@ -//! Local pricing interfaces are generated from certified producer interfaces. -//! No workload plans are enumerated. A candidate's distinct local options are -//! connected jointly by the MILP; identical interfaces retain every binding. -use super::*; -use crate::search::milp::direct_price_options::{ - CandidatePriceOption, JointPriceProfile, ProducerPriceBinding, -}; - -const MAXIMUM_PRICE_OPTIONS: usize = 50_000; - -#[derive(Clone)] -struct PriceClass<'a> { - priced: NodeUsage<'a>, - lookback: Option, - sketches: usize, - raw_source: Option, - work: WorkBounds, - workers: Option, - compaction: f64, - bindings: Vec>, - ancestor_work: std::collections::BTreeMap, - sketch_ids: std::collections::BTreeSet, - sketch_sources: std::collections::BTreeMap>, - range_span_ms: Option, -} - -impl PriceClass<'_> { - fn same_interface(&self, other: &Self) -> bool { - self.ancestor_work == other.ancestor_work - && self.sketch_ids == other.sketch_ids - && self.sketch_sources == other.sketch_sources - && self.range_span_ms == other.range_span_ms - && same_price(&self.priced, &other.priced) - && self.lookback == other.lookback - && self.sketches == other.sketches - && self.raw_source == other.raw_source - && self.workers == other.workers - && self.compaction.to_bits() == other.compaction.to_bits() - && self.work.minimum.to_bits() == other.work.minimum.to_bits() - && self.work.maximum.to_bits() == other.work.maximum.to_bits() - } -} - -/// Exact scan-lookback equality remains required: max-width sharing needs an -/// explicit selected-consumer witness. Cyclic catalogs use bounded local -/// interface propagation; selected physical graphs must still be acyclic. -struct InputTuples { - choices: Vec>, - indices: Vec, - done: bool, -} -impl InputTuples { - fn new(choices: Vec>) -> Self { - Self { - indices: vec![0; choices.len()], - done: choices.iter().any(Vec::is_empty), - choices, - } - } -} -impl Iterator for InputTuples { - type Item = Vec; - fn next(&mut self) -> Option { - if self.done { - return None; - } - let tuple = self - .choices - .iter() - .zip(&self.indices) - .map(|(choices, index)| choices[*index]) - .collect(); - self.done = true; - for position in (0..self.indices.len()).rev() { - self.indices[position] += 1; - if self.indices[position] < self.choices[position].len() { - self.done = false; - break; - } - self.indices[position] = 0; - } - Some(tuple) - } -} - -pub(in crate::search) fn extract_price_options( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result, DirectProfileError> { - let Some(prepared) = prepare_profile(configured, context, false)? else { - return Ok(None); - }; - let PreparedProfile { - nodes, - mut lookbacks, - all_lookbacks, - delta, - schedules, - rate, - request_rate, - } = prepared; - let count = nodes.len(); - let mut pending = vec![0usize; count]; - let mut parents = vec![Vec::new(); count]; - for (id, node) in nodes.iter().enumerate() { - if node.is_none() { - continue; - } - for supplier in configured.dag.candidates[id] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter()) - { - if supplier.0 != id && nodes[supplier.0].is_some() { - pending[id] += 1; - parents[supplier.0].push(id); - } - } - } - let mut ready: Vec<_> = (0..count).filter(|id| pending[*id] == 0).collect(); - let mut order = Vec::new(); - while let Some(id) = ready.pop() { - check_deadline(context.deadline)?; - order.push(id); - for parent in &parents[id] { - pending[*parent] -= 1; - if pending[*parent] == 0 { - ready.push(*parent); - } - } - } - let acyclic = order.len() == count; - if !acyclic { - let mut ordered = vec![false; count]; - for id in &order { - ordered[*id] = true; - } - order.extend((0..count).filter(|id| !ordered[*id])); - } - // Reuse the existing one-node certification/pricing path. Only one already - // priced producer interface is present in this reusable scratch cache, so - // price() performs one local operation without recursive plan generation. - let mut cache = vec![None; count]; - let mut visited = vec![true; count]; - let mut visiting = vec![false; count]; - let mut suppliers = vec![Vec::new(); count]; - let mut sketch_counts = vec![0; count]; - let mut raw_sources = vec![None; count]; - let mut closure_work = vec![WorkBounds::default(); count]; - let mut worker_counts = vec![None; count]; - let mut compactions = vec![0.0; count]; - let mut classes: Vec>> = vec![Vec::new(); count]; - let mut total = 0; - let mut total_bindings = 0usize; - // Every legal unary DAG path has at most N candidates. N monotone passes - // therefore cover every supported simple path even in a cyclic catalog. - // Walk-derived bindings are harmless: the solver enforces original-node - // uniqueness and acyclicity. Class IDs remain stable as options append. - for _round in 0..if acyclic { 1 } else { count } { - let before = total; - for &id in &order { - check_deadline(context.deadline)?; - if nodes[id].is_none() { - continue; - } - let mut inputs = Vec::new(); - for input in &configured.dag.candidates[id].producer_candidates_per_input { - let mut choices = Vec::new(); - for producer in &input.candidates { - check_deadline(context.deadline)?; - if producer.0 == id { - continue; - } - for price_option in 0..classes[producer.0].len() { - choices.push(ProducerPriceBinding { - producer: *producer, - price_option, - }); - } - } - inputs.push(choices); - } - let node = nodes[id].as_ref().unwrap(); - let spans: Vec> = if matches!( - node.payload, - asap_types::ir::Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { .. }) - ) { - let mut spans: Vec<_> = context - .entries - .iter() - .map(|entry| Some(entry.range_evaluation.map_or(0, |range| range.span_ms))) - .collect(); - spans.sort(); - spans.dedup(); - spans - } else { - vec![None] - }; - for binding in InputTuples::new(inputs) { - for range_span_ms in &spans { - for &lookback in &all_lookbacks[id] { - check_deadline(context.deadline)?; - let mut selected_sources = std::collections::BTreeMap::new(); - let mut ancestor_work = std::collections::BTreeMap::new(); - let mut sketch_ids = std::collections::BTreeSet::new(); - let mut sketch_sources = std::collections::BTreeMap::new(); - let mut compatible = true; - for binding in &binding { - if selected_sources - .insert(binding.producer.0, binding.price_option) - .is_some_and(|prior| prior != binding.price_option) - { - compatible = false; - break; - } - let source = &classes[binding.producer.0][binding.price_option]; - for (ancestor, work) in &source.ancestor_work { - if ancestor_work - .insert(*ancestor, *work) - .is_some_and(|prior| prior != *work) - { - compatible = false; - } - } - sketch_ids.extend(source.sketch_ids.iter().copied()); - for (sketch, proof) in &source.sketch_sources { - if sketch_sources - .insert(*sketch, proof.clone()) - .is_some_and(|prior| prior != *proof) - { - compatible = false; - } - } - } - if !compatible || ancestor_work.contains_key(&id) { - continue; - } - let upstream_sketches = sketch_ids.len(); - if matches!( - &node.payload, - asap_types::ir::Operator::ASAP( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(..), - .. - } - ) - ) { - sketch_ids.insert(id); - sketch_sources.insert( - id, - binding.first().and_then(|input| { - classes[input.producer.0][input.price_option] - .raw_source - .clone() - }), - ); - } - if !sketch_ids.is_empty() { - let Some(delta) = delta else { continue }; - let divided = delta / sketch_ids.len() as f64; - if !sketch_ids.iter().all(|sketch| { - let template = configured.node(*sketch); - if requires_source_sizing(template) { - let Some(source) = - sketch_sources.get(sketch).and_then(|s| s.as_ref()) - else { - return false; - }; - let Some(bound) = context - .data - .sources - .get(source) - .and_then(|facts| facts.max_distinct_per_evaluation) - else { - return false; - }; - let Some( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - } - | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(kind, _), - .. - }, - ) = template.asap() - else { - return false; - }; - let SketchParams::Hll { - precision, - relative_error, - } = kind.params() - else { - return false; - }; - hll_size_certified(*precision, *relative_error, divided, bound) - } else { - stable_size(template, Some(divided)) - } - }) { - continue; - } - } - let mut upstream_work = WorkBounds::default(); - for work in ancestor_work.values() { - upstream_work.minimum = (upstream_work.minimum + f64::from_bits(*work)) - .next_down() - .max(0.0); - upstream_work.maximum = - (upstream_work.maximum + f64::from_bits(*work)).next_up(); - } - for binding in &binding { - let supplier = binding.producer.0; - let source = &classes[supplier][binding.price_option]; - cache[supplier] = Some(source.priced.clone()); - lookbacks[supplier] = source.lookback; - sketch_counts[supplier] = source.sketches; - raw_sources[supplier] = source.raw_source.clone(); - closure_work[supplier] = source.work; - worker_counts[supplier] = source.workers; - compactions[supplier] = source.compaction; - } - lookbacks[id] = lookback; - cache[id] = None; - visited[id] = false; - suppliers[id].clear(); - sketch_counts[id] = 0; - raw_sources[id] = None; - closure_work[id] = WorkBounds::default(); - worker_counts[id] = None; - compactions[id] = 0.0; - let input_context = PriceInputs { - suppliers: binding.iter().map(|binding| binding.producer).collect(), - upstream_work, - upstream_sketches, - range_span_ms: *range_span_ms, - }; - price( - id, - configured, - context, - &nodes, - &lookbacks, - &mut cache, - &mut visited, - &mut visiting, - &mut suppliers, - &mut sketch_counts, - &mut raw_sources, - &mut closure_work, - &mut worker_counts, - &mut compactions, - delta, - Some(&input_context), - )?; - let priced = cache[id].take(); - for binding in &binding { - cache[binding.producer.0] = None; - } - let Some(priced) = priced else { continue }; - if matches!( - &node.payload, - asap_types::ir::Operator::ASAP( - ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - } | ASAPOp::SummaryTumblingWindowChain { - family: FieldDataType::Sketch(..), - .. - } - ) - ) { - sketch_ids.insert(id); - } - if node.output_state.timing == ExecutionTiming::IngestionTime { - ancestor_work.insert(id, priced.work_ms_per_execution.to_bits()); - } else { - ancestor_work.clear(); - } - let candidate = PriceClass { - priced, - lookback, - sketches: sketch_counts[id], - raw_source: raw_sources[id].clone(), - // Query-time closure work never determines an ingestion - // worker count. Keeping it would distinguish every traversal - // of an identity cycle despite identical local interfaces. - work: if nodes[id].as_ref().unwrap().output_state.timing - == ExecutionTiming::IngestionTime - { - closure_work[id] - } else { - WorkBounds::default() - }, - workers: worker_counts[id], - compaction: compactions[id], - bindings: vec![binding.clone()], - ancestor_work, - sketch_ids, - sketch_sources, - range_span_ms: *range_span_ms, - }; - if let Some(existing) = classes[id] - .iter_mut() - .find(|known| known.same_interface(&candidate)) - { - for binding in candidate.bindings { - if !existing.bindings.contains(&binding) { - total_bindings += 1; - if total_bindings > 200_000 { - return Err(DirectProfileError::Unsupported("local pricing domain exceeds 200000 correlated input tuples".into())); - } - existing.bindings.push(binding); - } - } - } else { - total_bindings += candidate.bindings.len(); - if total_bindings > 200_000 { - return Err(DirectProfileError::Unsupported( - "local pricing domain exceeds 200000 correlated input tuples" - .into(), - )); - } - total += 1; - if total > MAXIMUM_PRICE_OPTIONS { - return Err(DirectProfileError::Unsupported(format!("local pricing domain exceeds {MAXIMUM_PRICE_OPTIONS} interface options"))); - } - classes[id].push(candidate); - } - } - } - } - } - if total == before { - break; - } - } - let candidates = classes - .iter() - .enumerate() - .map(|(id, classes)| { - classes - .iter() - .map(|class| { - Ok(CandidatePriceOption { - profile: profile_for_priced( - &class.priced, - nodes[id].as_ref().unwrap(), - class.compaction, - context, - )?, - compatible_input_tuples: class.bindings.clone(), - required_range_span_ms: class.range_span_ms, - }) - }) - .collect::, DirectProfileError>>() - }) - .collect::, _>>()?; - let settings = context.models.cost_inputs; - let CostModel::UsageOverTime(prices) = &settings.cost_model else { - return Ok(None); - }; - let request_ms = settings.measured_operation_costs.ns_per_evaluation * 1e-6; - Ok(Some(JointPriceProfile { - query_range_spans_ms: context - .entries - .iter() - .map(|entry| entry.range_evaluation.map_or(0, |range| range.span_ms)) - .collect(), - schedules, - query_rate: rate, - request_latency_seconds: request_ms / 1000.0, - request_cost: request_ms / settings.resource_speed.ms_per_cpu_op - * prices.cost_per_cpu_op - * request_rate, - candidates, - })) -} diff --git a/crates/plan-selection/src/search/costing/proposals.rs b/crates/plan-selection/src/search/costing/proposals.rs deleted file mode 100644 index 82659fad9..000000000 --- a/crates/plan-selection/src/search/costing/proposals.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Advisory local costs for proposal ordering when a universal bound is unknown. -//! Price original template inputs, not every possible selected connection. These -//! estimates must never become certified bounds or feasibility constraints. -use std::collections::HashMap; -use std::rc::Rc; -use std::time::Instant; - -use asap_types::ir::physical_export::PhysicalASAPDAGNode; -use asap_types::ir::properties::execution_data_state::{DataPrimitive, ExecutionDataState}; -use asap_types::ir::properties::ExecutionTiming; -use asap_types::ir::{ASAPOp, NonASAPOp, OperatorNode}; -use asap_types::physical::Materialization; - -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::refinement::planner::CandidatePlanningContext; -use asap_cost::{node_usage, NodeUsage, ProducerOutput}; - -/// Partial heuristic estimates are harmless: unknown or interrupted entries -/// remain zero, and the surrounding search still enforces its shared deadline. -pub(in crate::search) fn estimate_costs( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Vec { - let mut estimates = vec![0.0; configured.dag.candidates.len()]; - let mut templates = TemplateCatalog::default(); - let mut roots = vec![None; estimates.len()]; - for (id, configuration) in configured.physical.candidates.iter().enumerate() { - if expired(context.deadline) { - break; - } - if configuration.timing != ExecutionTiming::QueryTime - || configured.dag.candidates[id].local_cost > 0.0 - { - continue; - } - roots[id] = templates.append(configured.node(id), None, context.deadline); - } - // A singleton request rate gives a consistent initial priority. Actual - // selected-reader unions, retained lifetimes and shared resources are priced - // only by the complete evaluator after connections have been selected. - let rate = context - .entries - .iter() - .filter_map(|entry| { - asap_cost::execution_demand::evaluation_rate( - [(&entry.recurrence, entry.range_evaluation)], - context - .models - .cost_inputs - .non_repeating_query_horizon_seconds, - ) - .ok() - }) - .reduce(f64::min) - .unwrap_or(0.0); - let mut prices: Vec>> = Vec::with_capacity(templates.nodes.len()); - for (id, node) in templates.nodes.iter().enumerate() { - if expired(context.deadline) { - break; - } - let producers: Option> = templates.children[id] - .iter() - .map(|&child| { - let priced = prices[child].as_ref()?; - Some(ProducerOutput { - payload: &templates.nodes[child].payload, - output_schema: &templates.nodes[child].output_schema, - output: priced.output, - sources: priced.sources.clone(), - family: priced.family, - unchanged_columns: priced.unchanged_columns.clone(), - }) - }) - .collect(); - prices.push(producers.and_then(|producers| { - node_usage( - node, - &producers, - (1, rate), - asap_cost::trailing_window(templates.lookbacks[id]), - 1, - None, - context.data, - context.models.cost_inputs, - context.calibration.factor(), - ) - .ok() - })); - } - for (estimate, root) in estimates.iter_mut().zip(roots) { - if let Some(cost) = root.and_then(|id| prices.get(id)?.as_ref()?.cost.cost_per_second) { - if cost.is_finite() && cost >= 0.0 { - *estimate = cost; - } - } - } - estimates -} - -fn expired(deadline: Option) -> bool { - deadline.is_some_and(|deadline| Instant::now() >= deadline) -} - -#[derive(Default)] -struct TemplateCatalog { - nodes: Vec, - children: Vec>, - lookbacks: Vec>, - ids: HashMap<(*const OperatorNode, Option), usize>, -} -impl TemplateCatalog { - fn append( - &mut self, - node: &Rc, - lookback: Option, - deadline: Option, - ) -> Option { - if expired(deadline) { - return None; - } - let lookback = match node.non_asap() { - Some(NonASAPOp::TimeRange { range, .. }) => { - Some(u64::try_from(range.as_millis()).ok()?) - } - _ => lookback, - }; - let lookback = match node.asap() { - // Query-time construction reads all tumbling windows' source rows. Reuse the - // compact SummaryTumblingWindowChain formula in asap_cost::node_usage without expanding tumbling windows. - Some(ASAPOp::SummaryTumblingWindowChain { - tumbling_window_width_ms, - tumbling_window_count, - .. - }) => Some(tumbling_window_width_ms.checked_mul(u64::from(*tumbling_window_count))?), - _ => lookback, - }; - let key = (Rc::as_ptr(node), lookback); - if let Some(&id) = self.ids.get(&key) { - return Some(id); - } - let mut children = Vec::new(); - let mut complete = true; - let payload = node.operator.map_children(|child| { - let position = children.len(); - match self.append(child, lookback, deadline) { - Some(child) => children.push(child), - None => complete = false, - } - position - }); - if !complete { - return None; - } - let id = self.nodes.len(); - self.nodes.push(PhysicalASAPDAGNode { - id, - payload, - output_state: ExecutionDataState { - timing: ExecutionTiming::QueryTime, - primitive: if node.asap().and_then(ASAPOp::produced_state).is_some() { - DataPrimitive::SummaryState - } else { - DataPrimitive::Raw - }, - }, - materialization: Materialization::NotMaterialized, - output_schema: node.schema.clone(), - guarantee: node.guarantee.clone(), - retention_ms: None, - }); - self.children.push(children); - self.lookbacks.push(lookback); - self.ids.insert(key, id); - Some(id) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::configurations::enumerate_candidate_configurations; - use crate::search::candidates::inventory::build_candidate_inventory; - use crate::SelectionModels; - use asap_cost::CalibrationFit; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::QueryRoot; - use asap_types::workload::*; - - // Use the production analytical model to distinguish a whole-window exact - // quantile from KLL construction, without turning either estimate into a bound. - #[test] - fn query_time_estimates_distinguish_exact_sort_and_kll_update_cost() { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let roots = vec![QueryRoot::Operator(lower_promql( - "quantile_over_time(0.9, data[5m])", - target.clone(), - ))]; - let initial = generate_dag_of_logical_memo_groups(&roots); - let output = *initial.query_roots[0].as_operator().unwrap(); - let memo = try_populate_pass1( - initial, - &generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(), - ) - .unwrap(); - let inventory = build_candidate_inventory(&memo, &[output], |_| Ok(0.0)).unwrap(); - // One canonical scan can supply different windows. Cache the priced - // context separately while reusing a repeated identical request. - let scan = inventory - .templates - .iter() - .find(|node| matches!(node.non_asap(), Some(NonASAPOp::Scan { .. }))) - .unwrap(); - let mut contexts = TemplateCatalog::default(); - let short = contexts.append(scan, Some(60_000), None).unwrap(); - assert_eq!(short, contexts.append(scan, Some(60_000), None).unwrap()); - assert_ne!(short, contexts.append(scan, Some(300_000), None).unwrap()); - - let entries = vec![entry_with( - target, - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(10000))), - )]; - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 666.67, - }, - sources: [( - "data".into(), - SourceFacts { - series_count: Some(10000), - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let models = SelectionModels::builtin(); - let mut configured = enumerate_candidate_configurations( - &inventory, - &[0], - &entries, - &data, - &asap_logical_asap_optimization::DeploymentCapabilities::default(), - ) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - let estimates = estimate_costs(&configured, &context); - let mut exact = Vec::new(); - let mut kll = Vec::new(); - for (id, template) in configured.nodes_for_tests().iter().enumerate() { - if configured.physical.candidates[id].timing != ExecutionTiming::QueryTime { - continue; - } - match template.asap() { - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) if *kind.algorithm() == SketchAlgorithm::Kll => kll.push(estimates[id]), - _ if matches!(template.non_asap(), Some(NonASAPOp::Aggregate { .. })) => { - exact.push(estimates[id]) - } - _ => {} - } - } - assert!(!exact.is_empty() && !kll.is_empty()); - assert!(exact.iter().chain(&kll).all(|cost| *cost > 0.0)); - assert!( - kll.iter().copied().fold(f64::INFINITY, f64::min) - < exact.iter().copied().fold(f64::INFINITY, f64::min) - ); - assert!(configured - .dag - .candidates - .iter() - .all(|candidate| candidate.local_cost == 0.0)); - // A query-time tumbling-window chain reads its full window, not an unbounded - // source. Price it compactly; a missing horizon previously made every - // tumbling-window estimate zero and wrongly favored it over priced whole builds. - let tumbling_window_id = configured - .nodes_for_tests() - .iter() - .enumerate() - .find_map(|(id, node)| { - (configured.physical.candidates[id].timing == ExecutionTiming::QueryTime - && matches!(node.asap(), Some(ASAPOp::SummaryAgg { .. }))) - .then_some(id) - }) - .unwrap(); - let build = configured.node(tumbling_window_id); - let Some(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - grouping, - filter, - }) = build.asap() - else { - unreachable!() - }; - let Some(NonASAPOp::TimeRange { child: source, .. }) = child.non_asap() else { - unreachable!() - }; - let tumbling_window = OperatorNode::new_shared(asap_types::ir::Operator::ASAP( - ASAPOp::SummaryTumblingWindowChain { - child: Rc::clone(source), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.clone(), - tumbling_window_width_ms: 1000, - tumbling_window_count: 300, - }, - )) - .unwrap(); - configured.set_node_for_tests(tumbling_window_id, tumbling_window); - let tumbling_window_estimates = estimate_costs(&configured, &context); - assert!( - tumbling_window_estimates[tumbling_window_id] > 0.0, - "query-time tumbling-window build must not look free" - ); - assert_eq!( - configured.dag.candidates[tumbling_window_id].local_cost, - 0.0 - ); - - // Deadline cancellation merely omits advisory priorities; no inventory - // or feasibility fact is changed by incomplete heuristic work. - let expired = CandidatePlanningContext { - deadline: Some(Instant::now()), - ..context - }; - assert!(estimate_costs(&configured, &expired) - .iter() - .all(|cost| *cost == 0.0)); - } -} diff --git a/crates/plan-selection/src/search/costing/range_price_options_tests.rs b/crates/plan-selection/src/search/costing/range_price_options_tests.rs deleted file mode 100644 index e21a6041c..000000000 --- a/crates/plan-selection/src/search/costing/range_price_options_tests.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Whole-evaluator parity for selected consumer range-retention tiers. -use super::*; -use asap_logical_asap_optimization::test_support::lower_promql; -use asap_logical_asap_optimization::test_support::{ - compose_logical_candidate, generate_per_computation_candidates, -}; -use asap_types::ir::properties::{AccuracyScope, AccuracyTarget}; -use asap_types::ir::schema::SketchAlgorithm; -use asap_types::ir::OperatorNode; -use asap_types::stages::HowToComputeAggregateMeasure; - -use crate::search::candidates::dag::{InputProducerCandidates, QueryOutputCandidates}; -use asap_types::workload::{ - DataWorkload, Predictability, Query, QueryRequirements, QueryWorkloadEntry, RangeEvaluation, - RepetitionInterval, -}; -use std::rc::Rc; - -#[test] -fn shared_tumbling_window_range_tiers_match_evaluator_for_ingestion_and_kept_refresh() { - fn append( - node: Rc, - configured: &mut PhysicalCandidatesWithCosts, - ingestion: bool, - mode: Materialization, - ) -> PhysicalCandidateId { - let ingestion = ingestion - || (matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - && mode == Materialization::MatAtIngestion(Medium::Memory)); - let tumbling_window = - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })); - let mut inputs = Vec::new(); - node.operator - .map_children(|child| inputs.push(append(child.clone(), configured, ingestion, mode))); - let id = PhysicalCandidateId(configured.physical.candidates.len()); - configured.push_for_tests( - node, - if ingestion { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }, - if tumbling_window { - mode - } else { - Materialization::NotMaterialized - }, - 0.0, - inputs - .into_iter() - .map(|supplier| InputProducerCandidates { - candidates: vec![supplier].into(), - }) - .collect(), - ); - id - } - - for mode in [ - Materialization::MatAtIngestion(Medium::Memory), - Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory), - ] { - let target = AccuracyTarget::max_error(0.05) - .failure_probability(0.01) - .accuracy_scope(AccuracyScope::PerValue); - let original = lower_promql("quantile_over_time(0.95, latency[5m])", target.clone()); - let generated = generate_per_computation_candidates( - &asap_logical_asap_optimization::generate_dag_of_logical_memo_groups(&[ - QueryRoot::Operator(original.clone()), - ]), - ) - .unwrap(); - let choice = generated.targets[0] - .ways_to_compute - .iter() - .position(|candidate| { - matches!( - candidate, - HowToComputeAggregateMeasure::Summary(SketchAlgorithm::Kll) - ) - }) - .unwrap(); - let roots: Vec<_> = compose_logical_candidate(&generated, &[choice]).unwrap(); - let rules = asap_logical_asap_optimization::test_support::tumbling_window_candidates( - &roots, - &[Some(10_000)], - ); - let roots = - asap_logical_asap_optimization::test_support::window_composition_pass2_candidates( - &roots, &rules, - ) - .unwrap() - .pop() - .unwrap() - .roots - .unwrap(); - let data: DataWorkload = serde_json::from_value(serde_json::json!({ - "arrival":{"ingesting":{"rows_per_second":100.0}}, - "sources":{"latency":{"series_count":2,"rows_held":30000,"values_non_negative":true}} - })) - .unwrap(); - let sized = crate::sized_as_pass1(&roots, std::slice::from_ref(&target), &data); - let mut configured = PhysicalCandidatesWithCosts::empty_for_tests(); - let QueryRoot::Operator(root) = &sized[0] else { - panic!("operator") - }; - let root_id = append(root.clone(), &mut configured, false, mode); - configured.dag.query_roots = vec![ - QueryOutputCandidates { - candidates: vec![root_id] - }; - 2 - ]; - configured.sync_physical_for_tests(); - let originals = vec![ - QueryRoot::Operator(original.clone()), - QueryRoot::Operator(original), - ]; - let entries: Vec<_> = [(60_000, 120_000, 10_000), (90_000, 600_000, 30_000)] - .into_iter() - .map(|(interval, span_ms, step_ms)| QueryWorkloadEntry { - query: Query("quantile_over_time(0.95, latency[5m])".into()), - requirements: QueryRequirements { - accuracy: target.clone(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval( - if mode == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory) - { - 10_000 - } else { - interval - }, - ), - )), - range_evaluation: Some(RangeEvaluation { span_ms, step_ms }), - }) - .collect(); - let mut settings = asap_cost::CostInputs::default(); - settings.resource_speed.ms_per_cpu_op *= 1000.0; - settings.resource_speed.ms_per_scanned_byte *= 1000.0; - settings.measured_operation_costs.ns_per_evaluation = 1_000_000.0; - let deployment = asap_logical_asap_optimization::DeploymentCapabilities { - query_time_results_retained: true, - ..Default::default() - }; - crate::search::candidates::configurations::fill_may_keep_across_evaluations_per_sole_reader_query( - &mut configured, - &entries, - &data, - deployment.query_time_results_retained, - ); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &originals, - realizer: &asap_physical_asap_optimization::Stage2AssignmentRealizer { - query_time_results_retained: deployment.query_time_results_retained, - ..crate::search::test_realizer(&originals, &entries, &data) - }, - entries: &entries, - data: &data, - models: crate::SelectionModels { - cost_inputs: &settings, - ..crate::SelectionModels::builtin() - }, - calibration: asap_cost::CalibrationFit::default(), - }; - let profile = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - let chain = (0..configured.physical.candidates.len()) - .position(|id| { - matches!( - configured.node(id).asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) - }) - .unwrap(); - let tiers: Vec<_> = profile.candidates[chain] - .iter() - .map(|option| option.required_range_span_ms) - .collect(); - assert!( - tiers.contains(&Some(120_000)) && tiers.contains(&Some(600_000)), - "{mode:?}: {tiers:?}" - ); - let general = profile.schedules.as_ref().unwrap(); - let kept = mode == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory); - assert!( - (general.union_rate([true, true].into_iter()) - if kept { 2.9 } else { 0.45 }).abs() - < 1e-12 - ); - assert!( - (general.request_union_rate([true, true].into_iter()) - - if kept { 0.1 } else { 1.0 / 60.0 + 1.0 / 90.0 }) - .abs() - < 1e-12 - ); - assert!(profile.candidates[chain] - .iter() - .all(|option| option.profile.per_request - == (mode == Materialization::MatAtQueryKeptAcrossEvaluations(Medium::Memory)))); - let proposal = - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - // Keep option indices/bindings unchanged, but deny every option a long - // enough retention witness. Both outputs must then be infeasible. - let mut insufficient = profile.clone(); - for option in &mut insufficient.candidates[chain] { - option.required_range_span_ms = Some(120_000); - } - assert!( - crate::search::milp::direct_price_options::solve_joint_price_proposal_with_metrics( - &configured.dag, - &[], - None, - &mut Default::default(), - &insufficient - ) - .unwrap() - .is_none() - ); - let mut rejections = - crate::search::refinement::planner::CandidateRejectionSummary::default(); - let evaluated = crate::search::refinement::planner::evaluate_configured_candidate( - &configured, - &context, - &proposal.selected, - 0, - &mut rejections, - &mut Vec::new(), - ) - .unwrap() - .unwrap_or_else(|| panic!("{mode:?}: {rejections:?}")); - let actual = evaluated.0.costs[&evaluated.0.selected] - .cost_per_second - .unwrap(); - let modeled = proposal.direct_modeled_cost.unwrap(); - assert!( - (actual - modeled).abs() <= 1e-9 * actual.max(1e-15), - "{mode:?}: direct {modeled}, evaluator {actual}" - ); - } -} diff --git a/crates/plan-selection/src/search/milp/direct_cost.rs b/crates/plan-selection/src/search/milp/direct_cost.rs deleted file mode 100644 index 9ff9253a2..000000000 --- a/crates/plan-selection/src/search/milp/direct_cost.rs +++ /dev/null @@ -1,1542 +0,0 @@ -//! Exact objective encoding for certified query-time and ingestion profiles. -//! Equal schedules have a compact fast path; mixed recurrence/range demand -//! uses exact evaluator atoms. Retained state carries separate standing charges. -//! Profiles are supplied by physical costing; this module never infers prices -//! from a logical template or treats accuracy as a monetary penalty. -use crate::search::candidates::graph::{QueryRootAssignment, SelectedCandidateGraph}; -use asap_types::ir::materialized_view::RefreshWindow; -use good_lp::{constraint, variable, Constraint, Expression, ProblemVariables, Variable}; -use std::collections::{BTreeMap, BTreeSet}; - -#[path = "general_schedule.rs"] -mod general_schedule; -#[path = "general_schedule_rates.rs"] -mod general_schedule_rates; -#[cfg(test)] -#[path = "schedule_selection_tests.rs"] -mod schedule_selection_tests; -pub(crate) use general_schedule_rates::QueryScheduleRates; - -use crate::search::candidates::dag::*; -use crate::search::candidates::graph::ProducerConsumerConnection; - -/// Smallest/largest monetary coefficient ratio after lifetime normalization. -/// The minimum is scaled to one; no positive cost is rounded away. -pub(super) const MINIMUM_DIRECT_RELATIVE_COEFFICIENT: f64 = 1e-12; - -// Held-time terms weight lifetimes by dimensionless rates. Their small entries -// need a stronger guard than monetary objective coefficients, since absolute -// LP feasibility tolerances can erase a rare reader's otherwise large cost. -const MINIMUM_QUERY_RATE_WEIGHT: f64 = 1e-6; -// Lifetime gates use unit-scale bounds. Smaller positive terms can disappear -// within LP feasibility tolerances and then be amplified by a memory price. -const MINIMUM_NORMALIZED_LATENCY_TERM: f64 = 1e-6; - -#[derive(Debug, Clone, Default)] -pub(crate) struct NodeCostProfile { - pub per_request: bool, - pub execution_cost: f64, - pub ingestion: bool, - pub standing_cost_per_second: f64, - pub work_seconds: f64, - /// Certified fixed-worker merge delay; the query pays the maximum reached delay. - pub compaction_seconds: f64, - pub held_memory_cost_per_second: f64, - pub materialized: bool, - pub raw_retention: Option<(String, f64)>, - /// For a node maintained at ingestion, the refresh window of its - /// ingestion runs: one run computes an unmaterialized node below the - /// nodes of one refresh window once for all of them. - pub refresh_window: Option, -} - -impl NodeCostProfile { - fn query_work_seconds(&self) -> f64 { - if self.ingestion { - 0.0 - } else { - self.work_seconds - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct DirectCostProfile { - pub nodes: Vec, - pub schedules: Option, - pub query_rate: f64, - pub request_latency_seconds: f64, - /// Total request fee per second across all queries. - pub request_cost: f64, -} - -impl DirectCostProfile { - pub(crate) fn validate( - &self, - graph: &DagOfPhysicalCandidatesWithCosts, - ) -> Result<(), CandidateSelectionError> { - let valid = |v: f64| v.is_finite() && v >= 0.0; - if self.nodes.len() != graph.candidates.len() - || !valid(self.query_rate) - || self.query_rate == 0.0 - || !valid(self.request_latency_seconds) - || !valid(self.request_cost) - || self.nodes.iter().any(|n| { - !valid(n.execution_cost) - || (n.per_request && (!n.materialized || n.ingestion)) - || !valid(n.standing_cost_per_second) - || (n.ingestion && n.held_memory_cost_per_second != 0.0) - || !valid(n.compaction_seconds) - || (n.compaction_seconds > 0.0 && !(n.ingestion && n.materialized)) - || !valid(n.work_seconds) - || !valid(n.held_memory_cost_per_second) - || n.raw_retention - .as_ref() - .is_some_and(|(_, cost)| !valid(*cost)) - }) - { - return Err(CandidateSelectionError::InvalidGraph( - "invalid direct-cost profile".into(), - )); - } - for (consumer, candidate) in graph.candidates.iter().enumerate() { - for producer in candidate - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter()) - { - let source = &self.nodes[producer.0]; - // Query latency stops at the first maintained ingestion state. - // This certified slice admits only raw upstream closures, not - // nested maintained states whose deeper compaction is hidden. - if (self.nodes[consumer].ingestion && (!source.ingestion || source.materialized)) - || (!self.nodes[consumer].ingestion && source.ingestion && !source.materialized) - { - return Err(CandidateSelectionError::InvalidGraph( - "direct-cost edge crosses an unsupported ingestion boundary".into(), - )); - } - } - } - if graph - .query_roots - .iter() - .flat_map(|root| &root.candidates) - .any(|id| self.nodes[id.0].ingestion && !self.nodes[id.0].materialized) - { - return Err(CandidateSelectionError::InvalidGraph( - "query output cannot directly read unmaterialized ingestion work".into(), - )); - } - if let Some(schedule) = &self.schedules { - schedule.validate(graph.query_roots.len())?; - } - if let Some(schedule) = &self.schedules { - let total_rate: f64 = schedule.per_query_rates().iter().sum(); - if schedule - .per_query_rates() - .iter() - .any(|rate| *rate > 0.0 && rate / total_rate < MINIMUM_QUERY_RATE_WEIGHT) - { - return Err(CandidateSelectionError::UnsupportedDirectCost( - "query-rate weights are below the supported held-time numerical range".into(), - )); - } - } - let maximum_latency = self.maximum_latency(graph); - if self.coefficients(graph).any(|cost| !cost.is_finite()) - || !maximum_latency.is_finite() - || !(maximum_latency * graph.query_roots.len() as f64).is_finite() - { - return Err(CandidateSelectionError::InvalidGraph( - "direct-cost latency bound overflow".into(), - )); - } - if self - .nodes - .iter() - .any(|node| node.held_memory_cost_per_second > 0.0) - { - let mut compaction_levels: Vec<_> = self - .nodes - .iter() - .map(|node| node.compaction_seconds) - .filter(|delay| *delay > 0.0) - .collect(); - compaction_levels.sort_by(f64::total_cmp); - compaction_levels.dedup(); - let mut previous = 0.0; - let increments = compaction_levels.into_iter().map(|delay| { - let increment = delay - previous; - previous = delay; - increment - }); - let minimum = std::iter::once(self.request_latency_seconds) - .chain(self.nodes.iter().map(NodeCostProfile::query_work_seconds)) - .chain(increments) - .filter(|term| *term > 0.0) - .reduce(f64::min); - let minimum_rate_weight = self.schedules.as_ref().map_or(1.0, |schedule| { - let total: f64 = schedule.per_query_rates().iter().sum(); - schedule - .per_query_rates() - .iter() - .filter(|rate| **rate > 0.0) - .map(|rate| rate / total) - .fold(1.0, f64::min) - }); - // Per-consumer held-time terms multiply these coefficients; - // checking each factor separately would miss their small product. - if minimum.is_some_and(|term| { - term / maximum_latency * minimum_rate_weight < MINIMUM_NORMALIZED_LATENCY_TERM - }) { - return Err(CandidateSelectionError::UnsupportedDirectCost( - format!("normalized latency terms are below the supported held-memory numerical range: minimum={minimum:?}, maximum={maximum_latency:e}, rate_weight={minimum_rate_weight:e}, request={:e}", self.request_latency_seconds), - )); - } - } - Ok(()) - } - - /// Recompute the complete selected objective independently of LP auxiliaries. - /// A time-limited incumbent may leave nonminimal epigraph variables. - pub(crate) fn selected_cost( - &self, - graph: &DagOfPhysicalCandidatesWithCosts, - selected: &SelectedCandidateGraph, - ) -> f64 { - let mut inputs = vec![Vec::new(); self.nodes.len()]; - for edge in &selected.connections { - inputs[edge.consumer.0].push(edge.producer.0); - } - let mut reached = vec![vec![false; self.nodes.len()]; graph.query_roots.len()]; - for root in &selected.query_roots { - let mut pending = vec![root.candidate.0]; - while let Some(id) = pending.pop() { - if !std::mem::replace(&mut reached[root.query_root][id], true) { - pending.extend(&inputs[id]); - } - } - } - let latency: Vec = reached - .iter() - .map(|row| { - self.request_latency_seconds - + row - .iter() - .enumerate() - .filter(|(_, yes)| **yes) - .map(|(id, _)| self.nodes[id].query_work_seconds()) - .sum::() - + row - .iter() - .enumerate() - .filter(|(_, yes)| **yes) - .map(|(id, _)| self.nodes[id].compaction_seconds) - .fold(0.0, f64::max) - }) - .collect(); - let rates = self.schedules.as_ref().map_or_else( - || vec![self.query_rate; graph.query_roots.len()], - |schedule| schedule.per_query_rates().to_vec(), - ); - let union_rate = |id: usize| { - if self.nodes[id].ingestion { - return 1.0; - } - let Some(schedule) = &self.schedules else { - return self.query_rate; - }; - if self.nodes[id].per_request { - schedule.request_union_rate(reached.iter().map(|row| row[id])) - } else { - schedule.union_rate(reached.iter().map(|row| row[id])) - } - }; - // The longest latency of the queries reaching each node. - let longest = |id: usize| { - reached - .iter() - .zip(&latency) - .filter(|(row, _)| row[id]) - .map(|(_, latency)| *latency) - .fold(0.0, f64::max) - }; - // Per unmaterialized node, its executions per second and its held - // time per second: each consumer (a query, or a materialized node) - // computes its own copy at its rate and holds it for its query's - // latency, or for the materialized node's longest reader's; the - // nodes maintained at ingestion of one refresh window share one - // ingestion run, which computes it once for all of them. - let mut execution_rates = vec![0.0; self.nodes.len()]; - let mut held_seconds = vec![0.0; self.nodes.len()]; - let boundaries = selected - .query_roots - .iter() - .map(|root| { - let query = root.query_root; - (vec![root.candidate.0], rates[query], latency[query], None) - }) - .chain( - selected - .selected_candidates - .iter() - .filter(|id| self.nodes[id.0].materialized) - .map(|id| { - ( - inputs[id.0].clone(), - union_rate(id.0), - longest(id.0), - self.nodes[id.0].refresh_window, - ) - }), - ); - // The (node, refresh window) pairs whose ingestion run is counted. - let mut runs = BTreeSet::new(); - for (mut pending, rate, lifetime, refresh_window) in boundaries { - let mut seen = vec![false; self.nodes.len()]; - while let Some(id) = pending.pop() { - if self.nodes[id].materialized || std::mem::replace(&mut seen[id], true) { - continue; - } - if refresh_window.is_none_or(|window| runs.insert((id, window))) { - execution_rates[id] += rate; - held_seconds[id] += rate * lifetime; - } - pending.extend(&inputs[id]); - } - } - let mut total = self.request_cost; - let mut raw = BTreeMap::<&str, f64>::new(); - for id in &selected.selected_candidates { - let node = &self.nodes[id.0]; - let (execution_rate, held_seconds) = if node.materialized { - let rate = union_rate(id.0); - (rate, rate * longest(id.0)) - } else { - (execution_rates[id.0], held_seconds[id.0]) - }; - total += node.standing_cost_per_second - + execution_rate * node.execution_cost - + node.held_memory_cost_per_second * held_seconds; - if let Some((source, cost)) = &node.raw_retention { - let value = raw.entry(source).or_default(); - *value = value.max(*cost); - } - } - total + raw.values().sum::() - } - - /// Sum all required input roles, but take the maximum compatible producer - /// for each role. This bounds every selected DAG (shared work may be counted - /// twice) without summing mutually exclusive catalog choices. Potential - /// cycles conservatively use the old all-node sum, never a cyclic DP value. - fn maximum_latency(&self, graph: &DagOfPhysicalCandidatesWithCosts) -> f64 { - let count = self.nodes.len(); - let mut pending = vec![0usize; count]; - let mut parents = vec![Vec::new(); count]; - for (id, candidate) in graph.candidates.iter().enumerate() { - if self.nodes[id].ingestion { - continue; - } - for supplier in candidate - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter()) - { - pending[id] += 1; - parents[supplier.0].push(id); - } - } - let mut ready: Vec<_> = (0..count).filter(|id| pending[*id] == 0).collect(); - let mut bounds = vec![0.0_f64; count]; - let mut processed = 0; - while let Some(id) = ready.pop() { - processed += 1; - if !self.nodes[id].ingestion { - bounds[id] = self.nodes[id].query_work_seconds() - + graph.candidates[id] - .producer_candidates_per_input - .iter() - .map(|input| { - input - .candidates - .iter() - .map(|supplier| bounds[supplier.0]) - .fold(0.0, f64::max) - }) - .sum::(); - } - for parent in &parents[id] { - pending[*parent] -= 1; - if pending[*parent] == 0 { - ready.push(*parent); - } - } - } - let work = if processed == count { - graph - .query_roots - .iter() - .flat_map(|root| &root.candidates) - .map(|id| bounds[id.0]) - .fold(0.0, f64::max) - } else { - self.nodes - .iter() - .map(NodeCostProfile::query_work_seconds) - .sum() - }; - self.request_latency_seconds - + work - + self - .nodes - .iter() - .map(|node| node.compaction_seconds) - .fold(0.0, f64::max) - } - - pub(crate) fn coefficients<'a>( - &'a self, - graph: &DagOfPhysicalCandidatesWithCosts, - ) -> impl Iterator + 'a { - // Constant request fees cannot influence selection and need not limit - // the coefficient range. Lifetimes use a dimensionless [0,1] scale. - let latency_scale = self.maximum_latency(graph).max(f64::MIN_POSITIVE); - let rates = self.schedules.as_ref().map_or_else( - || vec![self.query_rate], - |schedule| schedule.coefficient_rates(), - ); - self.nodes.iter().flat_map(move |node| { - let mut coefficients = Vec::with_capacity(rates.len() * 2 + 1); - let node_rates = if node.ingestion { - &[1.0][..] - } else { - &rates[..] - }; - for rate in node_rates { - coefficients.extend([ - node.execution_cost * rate, - node.held_memory_cost_per_second * rate * latency_scale, - ]); - } - coefficients.push(node.standing_cost_per_second); - coefficients.push(node.raw_retention.as_ref().map_or(0.0, |(_, cost)| *cost)); - coefficients - }) - } -} - -fn gated( - variables: &mut ProblemVariables, - constraints: &mut Vec, - value: Expression, - enabled: Variable, - maximum: f64, -) -> Variable { - let product = variables.add(variable().min(0).max(maximum)); - constraints.push(constraint!(product <= value.clone())); - constraints.push(constraint!(product <= maximum * enabled)); - constraints.push(constraint!(product >= value - maximum * (1.0 - enabled))); - product -} - -struct BoundaryReach { - boundary: usize, - reached: Vec>, -} - -#[allow(clippy::too_many_arguments)] -fn boundary_flows( - graph: &DagOfPhysicalCandidatesWithCosts, - profile: &DirectCostProfile, - variables: &mut ProblemVariables, - constraints: &mut Vec, - active: &[Variable], - edges: &[(ProducerConsumerConnection, Variable)], - roots: &[(QueryRootAssignment, Variable)], - check_deadline: &impl Fn() -> Result<(), CandidateSelectionError>, -) -> Result, CandidateSelectionError> { - let count = graph.candidates.len(); - let queries = graph.query_roots.len(); - let mut flows = Vec::new(); - // Each root query and each selected materialized computation is one - // independent execution boundary. Materialized nodes stop propagation. - for boundary in 0..queries + count { - check_deadline()?; - if boundary >= queries && !profile.nodes[boundary - queries].materialized { - continue; - } - let mut possible = vec![false; count]; - let mut pending = if boundary < queries { - graph.query_roots[boundary].candidates.clone() - } else { - graph.candidates[boundary - queries] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().copied()) - .collect() - }; - while let Some(id) = pending.pop() { - check_deadline()?; - if profile.nodes[id.0].materialized || std::mem::replace(&mut possible[id.0], true) { - continue; - } - pending.extend( - graph.candidates[id.0] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().copied()), - ); - } - let used: Vec<_> = possible - .iter() - .map(|possible| possible.then(|| variables.add(variable().min(0).max(1)))) - .collect(); - let mut support = vec![Expression::from(0.0); count]; - if boundary < queries { - for (root, selected) in roots.iter().filter(|(root, _)| root.query_root == boundary) { - if let Some(reached) = used[root.candidate.0] { - constraints.push(constraint!(*selected <= reached)); - support[root.candidate.0] += *selected; - } - } - } - for (edge, connected) in edges { - check_deadline()?; - let Some(producer) = used[edge.producer.0] else { - continue; - }; - if boundary >= queries && edge.consumer.0 == boundary - queries { - constraints.push(constraint!(*connected <= producer)); - support[edge.producer.0] += *connected; - } else if let Some(consumer) = used[edge.consumer.0] { - let traversed = variables.add(variable().min(0).max(1)); - constraints.push(constraint!(traversed <= consumer)); - constraints.push(constraint!(traversed <= *connected)); - constraints.push(constraint!(traversed >= consumer + *connected - 1)); - constraints.push(constraint!(traversed <= producer)); - support[edge.producer.0] += traversed; - } - } - for (id, reached) in used.iter().enumerate() { - if let Some(reached) = reached { - constraints.push(constraint!(*reached <= support[id].clone())); - constraints.push(constraint!(*reached <= active[id])); - } - } - flows.push(BoundaryReach { - boundary, - reached: used, - }); - } - Ok(flows) -} - -/// Encode max compaction exactly, including when no memory cost prices latency. -/// Descending threshold ORs are implied integral by binary query reach. Their -/// telescoping weighted sum equals the largest reached delay, with O(N) rows. -fn query_latency_expressions( - graph: &DagOfPhysicalCandidatesWithCosts, - profile: &DirectCostProfile, - variables: &mut ProblemVariables, - constraints: &mut Vec, - reach: &[Vec>], - check_deadline: &impl Fn() -> Result<(), CandidateSelectionError>, -) -> Result, CandidateSelectionError> { - let scale = profile.maximum_latency(graph).max(f64::MIN_POSITIVE); - let mut result = Vec::with_capacity(reach.len()); - for row in reach { - check_deadline()?; - let mut latency = Expression::from(profile.request_latency_seconds / scale); - let mut delays = Vec::new(); - for (id, reached) in row.iter().enumerate() { - if let Some(reached) = reached { - latency += profile.nodes[id].query_work_seconds() / scale * *reached; - let delay = profile.nodes[id].compaction_seconds; - if delay > 0.0 { - delays.push((delay, *reached)); - } - } - } - delays.sort_by(|left, right| right.0.total_cmp(&left.0)); - let mut previous = None; - let mut index = 0; - while index < delays.len() { - check_deadline()?; - let delay = delays[index].0; - let threshold_reached = variables.add(variable().min(0.0).max(1.0)); - let mut support = Expression::from(0.0); - if let Some(previous) = previous { - constraints.push(constraint!(threshold_reached >= previous)); - support += previous; - } - while index < delays.len() && delays[index].0 == delay { - let reached = delays[index].1; - constraints.push(constraint!(threshold_reached >= reached)); - support += reached; - index += 1; - } - constraints.push(constraint!(threshold_reached <= support)); - let next_delay = delays.get(index).map_or(0.0, |entry| entry.0); - latency += (delay - next_delay) / scale * threshold_reached; - previous = Some(threshold_reached); - } - result.push(latency); - } - Ok(result) -} - -/// Exact on integer activation/connection decisions. Reach variables are -/// implied integral by the selected acyclic graph, as in the main model. -#[allow(clippy::too_many_arguments)] -pub(super) fn encode( - graph: &DagOfPhysicalCandidatesWithCosts, - profile: &DirectCostProfile, - variables: &mut ProblemVariables, - constraints: &mut Vec, - active: &[Variable], - edges: &[(ProducerConsumerConnection, Variable)], - roots: &[(QueryRootAssignment, Variable)], - reach: &[Vec>], - divisor: f64, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, -) -> Result { - if let Some(schedule) = &profile.schedules { - return general_schedule::encode( - graph, - profile, - schedule, - variables, - constraints, - active, - edges, - roots, - reach, - divisor, - check_deadline, - ); - } - let count = graph.candidates.len(); - let queries = graph.query_roots.len(); - let latency_scale = profile.maximum_latency(graph).max(f64::MIN_POSITIVE); - let maximum_latency = 1.0; - let latency = query_latency_expressions( - graph, - profile, - variables, - constraints, - reach, - &check_deadline, - )?; - let flows = boundary_flows( - graph, - profile, - variables, - constraints, - active, - edges, - roots, - &check_deadline, - )?; - // Each node's executions: one per boundary reaching it, except that - // the boundaries maintained at ingestion of one refresh window share one - // ingestion run, executed when any of them reaches the node. - let mut boundary_count = vec![Expression::from(0.0); count]; - let mut runs: BTreeMap<(usize, RefreshWindow), Vec> = BTreeMap::new(); - for flow in &flows { - let refresh_window = flow - .boundary - .checked_sub(queries) - .and_then(|consumer| profile.nodes[consumer].refresh_window); - for (id, reached) in flow.reached.iter().enumerate() { - match (reached, refresh_window) { - (Some(reached), Some(window)) => { - runs.entry((id, window)).or_default().push(*reached) - } - (Some(reached), None) => boundary_count[id] += *reached, - (None, _) => {} - } - } - } - for ((id, _), reached) in runs { - let run = variables.add(variable().min(0).max(1)); - for reached in reached { - constraints.push(constraint!(run >= reached)); - } - boundary_count[id] += run; - } - // Each materialized node's longest reader's latency, built on first use. - let mut longest_of: BTreeMap = BTreeMap::new(); - let mut longest = - |id: usize, variables: &mut ProblemVariables, constraints: &mut Vec| { - *longest_of.entry(id).or_insert_with(|| { - let longest = variables.add(variable().min(0).max(maximum_latency)); - for (query, row) in reach.iter().enumerate() { - if let Some(reached) = row[id] { - constraints.push(constraint!( - longest >= latency[query].clone() - maximum_latency * (1.0 - reached) - )); - } - } - longest - }) - }; - let mut objective = Expression::from(profile.request_cost / divisor); - let mut retained: BTreeMap<&str, Vec<(usize, f64)>> = BTreeMap::new(); - for (id, node) in profile.nodes.iter().enumerate() { - check_deadline()?; - let executions = if node.materialized { - Expression::from(active[id]) - } else { - boundary_count[id].clone() - }; - let execution_rate = if node.ingestion { - 1.0 - } else { - profile.query_rate - }; - objective += node.standing_cost_per_second / divisor * active[id]; - objective += node.execution_cost * execution_rate / divisor * executions.clone(); - if let Some((source, cost)) = &node.raw_retention { - retained.entry(source).or_default().push((id, *cost)); - } - if node.held_memory_cost_per_second == 0.0 { - continue; - } - // Every consumer runs at the one query rate: a materialized node - // holds its result for its longest reader's latency; an - // unmaterialized node is computed once per consumer (a query, or a - // materialized node), each copy held for that query's latency or - // that node's longest reader's. - let held_latency = if node.materialized { - Expression::from(longest(id, variables, constraints)) - } else { - let mut held = Expression::from(0.0); - for flow in &flows { - check_deadline()?; - let Some(reached) = flow.reached[id] else { - continue; - }; - let lifetime = if flow.boundary < queries { - latency[flow.boundary].clone() - } else { - longest(flow.boundary - queries, variables, constraints).into() - }; - held += gated(variables, constraints, lifetime, reached, maximum_latency); - } - held - }; - objective += node.held_memory_cost_per_second * profile.query_rate * latency_scale - / divisor - * held_latency; - } - for entries in retained.into_values() { - let cost = variables.add(variable().min(0)); - for (id, bytes_cost) in entries { - constraints.push(constraint!(cost >= bytes_cost / divisor * active[id])); - } - objective += cost; - } - Ok(objective) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::milp::model::{ - solve_direct_cost_proposal_with_metrics, CandidateSolveMetrics, - }; - use asap_types::stages::*; - - fn node(input: Option) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: input - .into_iter() - .map(|id| InputProducerCandidates { - candidates: vec![PhysicalCandidateId(id)].into(), - }) - .collect(), - } - } - fn root(id: usize) -> QueryOutputCandidates { - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(id)], - } - } - fn solve(graph: &DagOfPhysicalCandidatesWithCosts, profile: &DirectCostProfile) -> f64 { - solve_direct_cost_proposal_with_metrics( - graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - profile, - ) - .unwrap() - .unwrap() - .model_lower_bound - .unwrap() - } - - // Shared build demand is one; unmaterialized demand is two. Raw data is - // retained once at the maximum horizon, while fees remain per workload. - #[test] - fn complete_cost_counts_shared_demand_held_memory_retention_and_fees() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0)), node(Some(0))], - query_roots: vec![root(1), root(2)], - ..Default::default() - }; - let mut profile = DirectCostProfile { - schedules: None, - query_rate: 2.0, - request_latency_seconds: 0.5, - request_cost: 7.0, - nodes: vec![ - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - raw_retention: Some(("source".into(), 4.0)), - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 3.0, - compaction_seconds: 0.0, - raw_retention: Some(("source".into(), 6.0)), - ..Default::default() - }, - ], - }; - assert!((solve(&graph, &profile) - 48.0).abs() < 1e-7); - profile.nodes[0].materialized = false; - assert!((solve(&graph, &profile) - 73.0).abs() < 1e-7); - } - - // Two queries behind one materialized computation: its unmaterialized - // input is computed once, for that computation, and held, as its result - // is, for the longer reader's latency (7.5 s, not the 7 s mean). - // Charging it once per query would double memory. - #[test] - fn upstream_lifetime_is_the_materialized_consumers_longest_reader() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0)), node(Some(1)), node(Some(1))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - let profile = DirectCostProfile { - schedules: None, - query_rate: 2.0, - request_latency_seconds: 0.5, - request_cost: 7.0, - nodes: vec![ - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - ..Default::default() - }, - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 4.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 3.0, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 4.0, - compaction_seconds: 0.0, - ..Default::default() - }, - ], - }; - assert!((solve(&graph, &profile) - 118.0).abs() < 1e-7); - } - // Independently enumerate both readers' choices. Sharing changes execution - // counts and held lifetime; the least-node graph alone cannot price these. - #[test] - fn direct_selection_matches_four_assignment_cost_oracle() { - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(Some(0)), node(Some(0))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - for reader in [2, 3] { - graph.candidates[reader].producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - } - for materialized_build_cost in [1.0, 10.0, 100.0] { - let profile = DirectCostProfile { - schedules: None, - query_rate: 2.0, - request_latency_seconds: 0.5, - request_cost: 7.0, - nodes: vec![ - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: materialized_build_cost, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - work_seconds: 0.5, - compaction_seconds: 0.0, - held_memory_cost_per_second: 1.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 3.0, - compaction_seconds: 0.0, - ..Default::default() - }, - ], - }; - let mut minimum = f64::INFINITY; - for assignment in [[0, 0], [0, 1], [1, 0], [1, 1]] { - let durations = [ - 2.5 + [1.0, 0.5][assignment[0]], - 3.5 + [1.0, 0.5][assignment[1]], - ]; - let mut cost = 7.0 + 4.0; - for producer in [0, 1] { - let times: Vec = assignment - .iter() - .enumerate() - .filter(|(_, supplier)| **supplier == producer) - .map(|(q, _)| durations[q]) - .collect(); - if times.is_empty() { - continue; - } - cost += if producer == 0 { - 2.0 * (materialized_build_cost - + 3.0 * times.iter().copied().fold(0.0, f64::max)) - } else { - 2.0 * (2.0 * times.len() as f64 + times.iter().sum::()) - }; - } - minimum = minimum.min(cost); - } - assert!((solve(&graph, &profile) - minimum).abs() < 1e-7); - } - } - // Small reader fees remain meaningful alongside a 2.2-billion-times larger - // possible raw-buffer cost; currency normalization must not drop the fee. - #[test] - fn direct_currency_range_preserves_tiny_meaningful_costs() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None)], - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)], - }], - ..Default::default() - }; - let profile = DirectCostProfile { - schedules: None, - nodes: vec![ - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 7.1e-13, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 7.1e-13 * 2.2e9, - ..Default::default() - }, - ], - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 1.2e-15, - }; - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert_eq!( - proposal.selected.selected_candidates, - vec![PhysicalCandidateId(0)] - ); - let expected = 7.1e-13 + 1.2e-15; - assert!((proposal.direct_modeled_cost.unwrap() / expected - 1.0).abs() < 1e-9); - assert!((proposal.model_lower_bound.unwrap() / expected - 1.0).abs() < 1e-9); - } - fn ingestion_profile( - two_states: bool, - query_rate: f64, - ) -> (DagOfPhysicalCandidatesWithCosts, DirectCostProfile) { - let mut candidates = vec![node(None), node(Some(0))]; - let mut nodes = vec![ - NodeCostProfile { - ingestion: true, - execution_cost: 2.0, - work_seconds: 0.2, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: true, - materialized: true, - execution_cost: 3.0, - standing_cost_per_second: 7.0, - work_seconds: 0.4, - compaction_seconds: 0.0, - ..Default::default() - }, - ]; - if two_states { - candidates.push(node(Some(0))); - nodes.push(nodes[1].clone()); - } - let first_reader = candidates.len(); - candidates.extend([node(Some(1)), node(Some(if two_states { 2 } else { 1 }))]); - nodes.extend((0..2).map(|_| NodeCostProfile { - execution_cost: 1.0, - work_seconds: 0.5, - compaction_seconds: 0.0, - held_memory_cost_per_second: 2.0, - ..Default::default() - })); - ( - DagOfPhysicalCandidatesWithCosts { - candidates, - query_roots: vec![root(first_reader), root(first_reader + 1)], - ..Default::default() - }, - DirectCostProfile { - nodes, - schedules: None, - query_rate, - request_latency_seconds: 0.1, - request_cost: 1.0, - }, - ) - } - - // Update work and standing state are charged once regardless of query - // frequency; ingestion work contributes no query-held evaluation latency. - #[test] - fn maintained_state_cost_is_independent_of_reader_frequency() { - for rate in [1.0, 5.0] { - let (graph, profile) = ingestion_profile(false, rate); - let expected = 13.0 + 4.4 * rate; - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-6); - } - } - - // Two maintained states independently update a shared unmaterialized scan. - // Both equal and heterogeneous query schedules leave that ingestion rate2. - #[test] - fn two_ingestion_boundaries_recompute_their_shared_input() { - let (graph, mut profile) = ingestion_profile(true, 1.0); - assert!((solve(&graph, &profile) - 29.4).abs() < 1e-6); - profile.schedules = Some(schedule_selection_tests::rate_schedule(&[1.0, 2.0])); - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - 31.6).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - 31.6).abs() < 1e-6); - } - - // Enumerate shared or separate state choices without charging a shared - // producer twice; standing memory can change which choice wins. - #[test] - fn ingestion_selection_matches_shared_state_cost_oracle() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - node(None), - node(Some(0)), - node(Some(0)), - node(Some(0)), - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(1), PhysicalCandidateId(2)].into(), - }], - }, - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(1), PhysicalCandidateId(3)].into(), - }], - }, - ], - query_roots: vec![root(4), root(5)], - ..Default::default() - }; - for shared_standing in [1.0, 20.0] { - let profile = DirectCostProfile { - query_rate: 5.0, - schedules: None, - request_latency_seconds: 0.0, - request_cost: 0.0, - nodes: vec![ - NodeCostProfile { - ingestion: true, - execution_cost: 2.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: true, - materialized: true, - execution_cost: 3.0, - standing_cost_per_second: shared_standing, - ..Default::default() - }, - NodeCostProfile { - ingestion: true, - materialized: true, - execution_cost: 3.0, - standing_cost_per_second: 7.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: true, - materialized: true, - execution_cost: 3.0, - standing_cost_per_second: 7.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - ..Default::default() - }, - ], - }; - let expected = [[1, 1], [1, 3], [2, 1], [2, 3]] - .into_iter() - .map(|assignment| { - let states: std::collections::HashSet<_> = assignment.into_iter().collect(); - 10.0 + states - .iter() - .map(|state| 2.0 + 3.0 + if *state == 1 { shared_standing } else { 7.0 }) - .sum::() - }) - .fold(f64::INFINITY, f64::min); - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-6); - } - } - // Query-kept state refreshes once at its readers' union rate. Residency is - // charged once, while its unmaterialized input, computed once per - // refresh, is held for the longest reader's latency, as the state is. - // This differs from charging the kept state per reader. - #[test] - fn retained_query_state_prices_residency_refresh_and_input_lifetime() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0)), node(Some(1)), node(Some(1))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - let mut profile = DirectCostProfile { - query_rate: 2.0, - schedules: None, - request_latency_seconds: 0.1, - request_cost: 0.0, - nodes: vec![ - NodeCostProfile { - execution_cost: 2.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - materialized: true, - standing_cost_per_second: 7.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - work_seconds: 0.5, - compaction_seconds: 0.0, - held_memory_cost_per_second: 2.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - work_seconds: 1.5, - compaction_seconds: 0.0, - held_memory_cost_per_second: 2.0, - ..Default::default() - }, - ], - }; - // Latencies are 3.6 and 4.6 seconds. Equal-rate readers share one - // refresh; distinct fixed intervals add under the evaluator's policy. - for (rates, union, expected) in [(vec![2.0, 2.0], 2.0, 77.4), (vec![1.0, 2.0], 3.0, 86.0)] { - if rates[0] != rates[1] { - profile.schedules = Some(schedule_selection_tests::rate_schedule(&rates)); - } - let weighted_latency = rates[0] * 3.6 + rates[1] * 4.6; - let rate_sum: f64 = rates.iter().sum(); - let independent = 7.0 + union * (3.0 + 3.0 * 4.6) + rate_sum + 2.0 * weighted_latency; - assert!((independent - expected).abs() < 1e-10); - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-7 * expected); - } - } - // Max is fixed by reach even without held-memory pricing: minimizing or - // maximizing the latency expression gives the same value for every mask. - #[test] - fn compaction_max_is_exact_without_a_held_memory_objective() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None); 3], - query_roots: vec![root(0), root(1), root(2)], - ..Default::default() - }; - use good_lp::{highs, Solution, SolverModel}; - let profile = DirectCostProfile { - nodes: [2.0, 5.0, 5.0] - .into_iter() - .map(|delay| NodeCostProfile { - ingestion: true, - materialized: true, - compaction_seconds: delay, - ..Default::default() - }) - .collect(), - query_rate: 1.0, - schedules: None, - request_latency_seconds: 1.0, - request_cost: 0.0, - }; - for mask in 0..8 { - let expected = 1.0 - + [2.0_f64, 5.0, 5.0] - .iter() - .enumerate() - .filter(|(id, _)| mask & (1 << id) != 0) - .map(|(_, delay)| *delay) - .fold(0.0, f64::max); - for sign in [-1.0, 1.0] { - let mut variables = ProblemVariables::new(); - let mut constraints = Vec::new(); - let row = (0..3) - .map(|id| { - let reached = variables.add(variable().binary()); - constraints.push(constraint!(reached == f64::from(mask & (1 << id) != 0))); - Some(reached) - }) - .collect(); - let latency = query_latency_expressions( - &graph, - &profile, - &mut variables, - &mut constraints, - &[row], - &|| Ok(()), - ) - .unwrap() - .remove(0); - let solution = variables - .minimise(sign * latency.clone()) - .using(highs) - .with_all(constraints) - .solve() - .unwrap(); - assert!( - (solution.eval(&latency) * profile.maximum_latency(&graph) - expected).abs() - < 1e-9 - ); - } - } - } - - // Exhaustive reader bindings distinguish max compaction from summing - // delays, and preserve once-only standing/update costs for shared states. - #[test] - fn compaction_selection_matches_exhaustive_reader_bindings() { - let mut reader = node(Some(0)); - reader.producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - reader - .producer_candidates_per_input - .push(node(Some(2)).producer_candidates_per_input.remove(0)); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(None), reader.clone(), reader], - query_roots: vec![root(3), root(4)], - ..Default::default() - }; - let mut profile = DirectCostProfile { - nodes: [(2.0, 4.0), (5.0, 1.0), (3.0, 2.0)] - .into_iter() - .map(|(delay, cost)| NodeCostProfile { - ingestion: true, - materialized: true, - execution_cost: cost, - compaction_seconds: delay, - ..Default::default() - }) - .chain((0..2).map(|_| NodeCostProfile { - work_seconds: 1.0, - held_memory_cost_per_second: 2.0, - ..Default::default() - })) - .collect(), - query_rate: 1.0, - schedules: None, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - for rates in [[1.0, 1.0], [1.0, 2.0]] { - if rates[0] != rates[1] { - profile.schedules = Some(schedule_selection_tests::rate_schedule(&rates)); - } - let expected = (0..4) - .map(|mask| { - let chosen = [mask & 1, (mask >> 1) & 1]; - let update = 2.0 - + if chosen.contains(&0) { 4.0 } else { 0.0 } - + if chosen.contains(&1) { 1.0 } else { 0.0 }; - update - + (0..2) - .map(|q| { - rates[q] * 2.0 * (1.0 + if chosen[q] == 0 { 3.0 } else { 5.0 }) - }) - .sum::() - }) - .fold(f64::INFINITY, f64::min); - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-7); - } - } - - // Hidden compaction below an already maintained state is not query-visible; - // reject that unsupported profile instead of charging all ancestor maxima. - #[test] - fn nested_maintained_ingestion_states_are_not_admitted() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0))], - query_roots: vec![root(1)], - ..Default::default() - }; - let profile = DirectCostProfile { - nodes: (0..2) - .map(|_| NodeCostProfile { - ingestion: true, - materialized: true, - compaction_seconds: 1.0, - ..Default::default() - }) - .collect(), - query_rate: 1.0, - schedules: None, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - assert!(matches!( - profile.validate(&graph), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - // An inactive huge-delay candidate must not erase a selected tiny delay: - // its held-memory charge remains significant in actual currency units. - #[test] - fn extreme_unselected_latency_does_not_erase_selected_memory_cost() { - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(Some(0))], - query_roots: vec![root(2)], - ..Default::default() - }; - graph.candidates[2].producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - for tiny_compaction in [false, true] { - let profile = DirectCostProfile { - nodes: vec![ - NodeCostProfile { - ingestion: true, - materialized: true, - compaction_seconds: if tiny_compaction { 1e-6 } else { 0.0 }, - ..Default::default() - }, - NodeCostProfile { - ingestion: true, - materialized: true, - compaction_seconds: 1e9, - ..Default::default() - }, - NodeCostProfile { - work_seconds: if tiny_compaction { 0.0 } else { 1e-6 }, - held_memory_cost_per_second: 1e6, - ..Default::default() - }, - ], - query_rate: 1.0, - schedules: None, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - assert!(matches!( - solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile - ), - Err(CandidateSelectionError::UnsupportedDirectCost(_)) - )); - // Ordinary microsecond-scale candidates remain admitted and priced; - // only the extreme dynamic range requires evaluator-based fallback. - let mut supported = profile.clone(); - supported.nodes[1].compaction_seconds = 2e-6; - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &supported, - ) - .unwrap() - .unwrap(); - assert!((proposal.direct_modeled_cost.unwrap() - 1.0).abs() < 1e-8); - assert!((proposal.model_lower_bound.unwrap() - 1.0).abs() < 1e-8); - } - } - // Producer choices use max, required positions use sum, and potential - // cycles conservatively fall back instead of inventing a smaller bound. - #[test] - fn latency_bound_respects_choices_required_inputs_and_potential_cycles() { - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(Some(0))], - query_roots: vec![root(2)], - ..Default::default() - }; - graph.candidates[2].producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - let profile = DirectCostProfile { - nodes: [1.0, 3.0, 2.0] - .into_iter() - .map(|work_seconds| NodeCostProfile { - work_seconds, - ..Default::default() - }) - .collect(), - query_rate: 1.0, - schedules: None, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - assert_eq!(profile.maximum_latency(&graph), 5.0); - graph.candidates[2] - .producer_candidates_per_input - .push(node(Some(0)).producer_candidates_per_input.remove(0)); - assert_eq!(profile.maximum_latency(&graph), 6.0); - graph.candidates[0].producer_candidates_per_input = - node(Some(2)).producer_candidates_per_input; - assert_eq!(profile.maximum_latency(&graph), 6.0); - } -} diff --git a/crates/plan-selection/src/search/milp/direct_price_options.rs b/crates/plan-selection/src/search/milp/direct_price_options.rs deleted file mode 100644 index 5fd71a717..000000000 --- a/crates/plan-selection/src/search/milp/direct_price_options.rs +++ /dev/null @@ -1,698 +0,0 @@ -//! Joint selection of producer-dependent local pricing interfaces. -//! Pricing options describe one physical candidate, not independent executions. -use super::direct_cost::{NodeCostProfile, QueryScheduleRates}; -use asap_types::stages::PhysicalCandidateId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ProducerPriceBinding { - pub producer: PhysicalCandidateId, - pub price_option: usize, -} - -#[derive(Debug, Clone)] -pub(crate) struct CandidatePriceOption { - pub profile: NodeCostProfile, - pub compatible_input_tuples: Vec>, - pub required_range_span_ms: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct JointPriceProfile { - pub schedules: Option, - pub query_rate: f64, - pub request_latency_seconds: f64, - pub request_cost: f64, - pub candidates: Vec>, - pub query_range_spans_ms: Vec, -} - -use super::direct_cost::DirectCostProfile; -use super::model::{CandidateProposal, CandidateSolveMetrics, ObservedAssignment}; -use crate::search::candidates::dag::ReaderConstraint; -use crate::search::candidates::dag::*; -use crate::search::candidates::graph::ProducerConsumerConnection; -use crate::search::candidates::graph::QueryRootAssignment; -use good_lp::{constraint, variable, Constraint, Expression, ProblemVariables, Variable}; - -/// Internal option nodes share an original physical identity. At most one may -/// activate, so two readers cannot obtain incompatible views of one producer. -pub(super) struct PriceOptionProjection<'a> { - graph: &'a DagOfPhysicalCandidatesWithCosts, - origins: Vec, - options: Vec>, - observations: &'a [ObservedAssignment], - input_tuples: Vec<(PhysicalCandidateId, Vec>)>, - range_contexts: Vec<(PhysicalCandidateId, u64)>, - query_spans: &'a [u64], -} - -impl PriceOptionProjection<'_> { - fn connection(&self, edge: ProducerConsumerConnection) -> ProducerConsumerConnection { - ProducerConsumerConnection { - producer: self.origins[edge.producer.0], - consumer: self.origins[edge.consumer.0], - requirement: edge.requirement, - } - } - - fn root(&self, root: QueryRootAssignment) -> QueryRootAssignment { - QueryRootAssignment { - candidate: self.origins[root.candidate.0], - ..root - } - } - - pub(super) fn constrain_activation( - &self, - active: &[Variable], - constraints: &mut Vec, - ) { - let sum = |id: PhysicalCandidateId| -> Expression { - self.options[id.0] - .iter() - .map(|option| active[option.0]) - .sum() - }; - for (id, options) in self.options.iter().enumerate() { - if options.len() > 1 { - constraints.push(constraint!(sum(PhysicalCandidateId(id)) <= 1.0)); - } - } - for rule in &self.graph.reader_constraints { - if let ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } = rule - { - constraints.push(constraint!(sum(*candidate) <= sum(*required_candidate))); - } - } - } - - pub(super) fn constrain_input_tuples( - &self, - variables: &mut ProblemVariables, - active: &[Variable], - edges: &[(ProducerConsumerConnection, Variable)], - constraints: &mut Vec, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, - ) -> Result<(), CandidateSelectionError> { - let mut by_consumer = std::collections::BTreeMap::<_, Vec<_>>::new(); - for (edge, selected) in edges { - check_deadline()?; - by_consumer - .entry(edge.consumer) - .or_default() - .push((edge, selected)); - } - for (consumer, tuples) in &self.input_tuples { - check_deadline()?; - let mut selectors = Vec::new(); - let mut support = std::collections::BTreeMap::<_, Expression>::new(); - for tuple in tuples { - check_deadline()?; - let selector = variables.add(variable().binary()); - selectors.push(selector); - for (position, producer) in tuple.iter().enumerate() { - *support - .entry((position, *producer)) - .or_insert_with(|| Expression::from(0.0)) += selector; - } - } - constraints.push(constraint!( - selectors.iter().copied().sum::() == active[consumer.0] - )); - for (edge, selected) in by_consumer.get(consumer).into_iter().flatten() { - check_deadline()?; - let matches = support - .get(&(edge.requirement, edge.producer)) - .cloned() - .unwrap_or_else(|| Expression::from(0.0)); - constraints.push(constraint!(**selected == matches)); - } - } - Ok(()) - } - - pub(super) fn constrain_reader_contexts( - &self, - active: &[Variable], - reach: &[Vec>], - constraints: &mut Vec, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, - ) -> Result<(), CandidateSelectionError> { - for (candidate, span) in &self.range_contexts { - check_deadline()?; - let mut witnesses = Expression::from(0.0); - for (query, row) in reach.iter().enumerate() { - check_deadline()?; - if let Some(reached) = row[candidate.0] { - if self.query_spans[query] > *span { - constraints.push(constraint!(reached == 0)); - } - if self.query_spans[query] == *span { - witnesses += reached; - } - } - } - constraints.push(constraint!(active[candidate.0] <= witnesses)); - } - Ok(()) - } - - /// A rejected physical DAG remains rejected across all of its price-option - /// encodings. Cuts refer to sums over the original activation/edge identity. - pub(super) fn constrain_observations( - &self, - active: &[Variable], - edges: &[(ProducerConsumerConnection, Variable)], - roots: &[(QueryRootAssignment, Variable)], - constraints: &mut Vec, - ) { - for observation in self.observations { - for witness in &observation.rejected_queries { - let mut changed = Expression::from( - (witness.connections.len() + witness.query_roots.len()) as f64, - ); - for (edge, variable) in edges { - if witness.connections.contains(&self.connection(*edge)) { - changed -= *variable; - } - } - for (root, variable) in roots { - if witness.query_roots.contains(&self.root(*root)) { - changed -= *variable; - } - } - constraints.push(constraint!(changed >= 1.0)); - } - if observation.exact_cost.is_some() { - continue; - } - let selected = &observation.selected; - let mut mismatch = Expression::from( - (selected.selected_candidates.len() - + selected.connections.len() - + selected.query_roots.len()) as f64, - ); - for (id, variable) in active.iter().enumerate() { - mismatch += if selected.selected_candidates.contains(&self.origins[id]) { - -1.0 - } else { - 1.0 - } * *variable; - } - for (edge, variable) in edges { - mismatch += if selected.connections.contains(&self.connection(*edge)) { - -1.0 - } else { - 1.0 - } * *variable; - } - for (root, variable) in roots { - mismatch += if selected.query_roots.contains(&self.root(*root)) { - -1.0 - } else { - 1.0 - } * *variable; - } - constraints.push(constraint!(mismatch >= 1.0)); - } - } -} - -pub(crate) fn solve_joint_price_proposal_with_metrics( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], - remaining_seconds: Option, - metrics: &mut CandidateSolveMetrics, - profile: &JointPriceProfile, -) -> Result, CandidateSelectionError> { - let started = std::time::Instant::now(); - graph.validate()?; - super::model::validate_observations(graph, observations)?; - let invalid = |message: &str| CandidateSelectionError::InvalidGraph(message.into()); - if profile.candidates.len() != graph.candidates.len() { - return Err(invalid( - "joint price options do not match candidate inventory", - )); - } - let mut projection = PriceOptionProjection { - graph, - origins: Vec::new(), - options: Vec::new(), - observations, - input_tuples: Vec::new(), - range_contexts: Vec::new(), - query_spans: &profile.query_range_spans_ms, - }; - let mut expanded = DagOfPhysicalCandidatesWithCosts::default(); - let mut nodes = Vec::new(); - for (id, options) in profile.candidates.iter().enumerate() { - let mut ids = Vec::new(); - for option in options { - ids.push(PhysicalCandidateId(nodes.len())); - projection.origins.push(PhysicalCandidateId(id)); - nodes.push(option.profile.clone()); - expanded.candidates.push(PhysicalCandidateWithCost { - producer_candidates_per_input: Vec::new(), - ..graph.candidates[id].clone() - }); - } - projection.options.push(ids); - } - for (id, options) in profile.candidates.iter().enumerate() { - if remaining_seconds.is_some_and(|s| started.elapsed().as_secs_f64() >= s) { - return Err(CandidateSelectionError::Solver( - "time budget exhausted expanding price options".into(), - )); - } - let required = &graph.candidates[id].producer_candidates_per_input; - for (index, option) in options.iter().enumerate() { - let consumer = projection.options[id][index]; - if let Some(span) = option.required_range_span_ms { - if profile.query_range_spans_ms.len() != graph.query_roots.len() { - return Err(invalid("range contexts do not match query roots")); - } - projection.range_contexts.push((consumer, span)); - } - let mut tuples = Vec::new(); - for tuple in &option.compatible_input_tuples { - if tuple.len() != required.len() { - return Err(invalid("price input tuple arity mismatch")); - } - let mut mapped = Vec::new(); - for (position, binding) in tuple.iter().enumerate() { - if !required[position].candidates.contains(&binding.producer) { - return Err(invalid("price tuple references incompatible supplier")); - } - mapped.push( - *projection - .options - .get(binding.producer.0) - .and_then(|options| options.get(binding.price_option)) - .ok_or_else(|| { - invalid("price tuple references missing supplier option") - })?, - ); - } - if !tuples.contains(&mapped) { - tuples.push(mapped); - } - } - for position in 0..required.len() { - let mut suppliers = Vec::new(); - for tuple in &tuples { - if !suppliers.contains(&tuple[position]) { - suppliers.push(tuple[position]); - } - } - expanded.candidates[consumer.0] - .producer_candidates_per_input - .push(InputProducerCandidates { - candidates: suppliers.into(), - }); - } - // Unary choices and single tuples are already exact under the - // existing one-producer-per-input constraints; no extra binaries. - let cartesian_size = expanded.candidates[consumer.0] - .producer_candidates_per_input - .iter() - .try_fold(1usize, |size, input| { - size.checked_mul(input.candidates.len()) - }); - if required.len() > 1 && tuples.len() > 1 && cartesian_size != Some(tuples.len()) { - projection.input_tuples.push((consumer, tuples)); - } - } - } - for root in &graph.query_roots { - expanded.query_roots.push(QueryOutputCandidates { - candidates: root - .candidates - .iter() - .flat_map(|id| projection.options[id.0].iter().copied()) - .collect(), - }); - } - if expanded - .query_roots - .iter() - .any(|root| root.candidates.is_empty()) - { - return Ok(None); - } - for budget in &graph.resource_budgets { - expanded.resource_budgets.push(ResourceBudget { - name: budget.name.clone(), - capacity: budget.capacity, - candidate_usage: projection - .origins - .iter() - .map(|id| budget.candidate_usage[id.0]) - .collect(), - }); - } - for rule in &graph.reader_constraints { - let (candidate, mapped): (_, Box ReaderConstraint>) = - match rule { - ReaderConstraint::ActivationRequires { .. } => continue, - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => ( - *candidate, - Box::new(|candidate| ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs: query_outputs.clone(), - minimum: *minimum, - }), - ), - ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => ( - *candidate, - Box::new( - |candidate| ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs: query_outputs.clone(), - }, - ), - ), - ReaderConstraint::AllowedOutputs { candidate, outputs } => ( - *candidate, - Box::new(|candidate| ReaderConstraint::AllowedOutputs { - candidate, - outputs: outputs.clone(), - }), - ), - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => ( - *candidate, - Box::new(|candidate| ReaderConstraint::IncompatibleOutputs { - candidate, - left: *left, - right: *right, - }), - ), - }; - expanded - .reader_constraints - .extend(projection.options[candidate.0].iter().map(|id| mapped(*id))); - } - let fixed = DirectCostProfile { - nodes, - schedules: profile.schedules.clone(), - query_rate: profile.query_rate, - request_latency_seconds: profile.request_latency_seconds, - request_cost: profile.request_cost, - }; - metrics.model_construction_seconds += started.elapsed().as_secs_f64(); - let remaining = remaining_seconds.map(|s| (s - started.elapsed().as_secs_f64()).max(0.0)); - metrics.joint_price_solve_calls += 1; - let proposal = super::model::solve_with_objective( - &expanded, - &[], - remaining, - metrics, - Some(&fixed), - Some(&projection), - )?; - proposal - .map(|mut proposal| { - let selected = &mut proposal.selected; - selected.selected_candidates = selected - .selected_candidates - .iter() - .map(|id| projection.origins[id.0]) - .collect(); - selected.selected_candidates.sort(); - if selected - .selected_candidates - .windows(2) - .any(|pair| pair[0] == pair[1]) - { - return Err(invalid( - "multiple price options selected for one physical candidate", - )); - } - selected.connections = selected - .connections - .iter() - .map(|edge| projection.connection(*edge)) - .collect(); - selected.query_roots = selected - .query_roots - .iter() - .map(|root| projection.root(*root)) - .collect(); - selected.modeled_cost = selected - .selected_candidates - .iter() - .map(|id| graph.candidates[id.0].local_cost) - .sum(); - validate_selected_graph(graph, selected) - .map_err(CandidateSelectionError::InvalidGraph)?; - Ok(proposal) - }) - .transpose() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::dag::ReaderConstraint; - - fn candidate(inputs: &[usize]) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: if inputs.is_empty() { - vec![] - } else { - vec![InputProducerCandidates { - candidates: inputs - .iter() - .map(|id| PhysicalCandidateId(*id)) - .collect::>() - .into(), - }] - }, - } - } - fn option( - cost: f64, - materialized: bool, - producer: Option<(usize, usize)>, - ) -> CandidatePriceOption { - CandidatePriceOption { - profile: NodeCostProfile { - execution_cost: cost, - materialized, - ..Default::default() - }, - required_range_span_ms: None, - compatible_input_tuples: producer - .into_iter() - .map(|(id, price_option)| { - vec![ProducerPriceBinding { - producer: PhysicalCandidateId(id), - price_option, - }] - }) - .collect(), - } - } - fn fixture() -> (DagOfPhysicalCandidatesWithCosts, JointPriceProfile) { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - candidate(&[]), - candidate(&[]), - candidate(&[0, 1]), - candidate(&[2]), - candidate(&[2]), - ], - query_roots: vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(3)], - }, - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(4)], - }, - ], - ..Default::default() - }; - let profile = JointPriceProfile { - query_range_spans_ms: vec![], - schedules: None, - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - candidates: vec![ - vec![option(1.0, false, None)], - vec![option(5.0, false, None)], - vec![ - option(0.0, true, Some((0, 0))), - option(0.0, true, Some((1, 0))), - ], - vec![ - option(1.0, false, Some((2, 0))), - option(100.0, false, Some((2, 1))), - ], - vec![ - option(100.0, false, Some((2, 0))), - option(1.0, false, Some((2, 1))), - ], - ], - }; - (graph, profile) - } - - // Readers of the same shared state must agree on its upstream price interface. - #[test] - fn shared_producer_has_one_joint_price_configuration() { - let (graph, profile) = fixture(); - let proposal = solve_joint_price_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert_eq!(proposal.direct_modeled_cost, Some(102.0)); - assert!(proposal - .selected - .selected_candidates - .contains(&PhysicalCandidateId(0))); - assert!(!proposal - .selected - .selected_candidates - .contains(&PhysicalCandidateId(1))); - assert_eq!( - proposal - .selected - .selected_candidates - .iter() - .filter(|id| id.0 == 2) - .count(), - 1 - ); - } - - // Rejecting an original connection assignment excludes all internal encodings. - #[test] - fn projected_rejection_selects_the_other_producer() { - let (graph, profile) = fixture(); - let first = solve_joint_price_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - let observations = vec![ObservedAssignment { - selected: first.selected, - exact_cost: None, - rejected_queries: vec![], - }]; - let second = solve_joint_price_proposal_with_metrics( - &graph, - &observations, - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert_eq!(second.direct_modeled_cost, Some(106.0)); - assert!(second - .selected - .selected_candidates - .contains(&PhysicalCandidateId(1))); - } - // Resource and reader rules still refer to the physical candidate identity. - #[test] - fn original_capacity_and_reader_constraints_survive_option_expansion() { - let (mut graph, profile) = fixture(); - graph.resource_budgets.push(ResourceBudget { - name: "exclude first source".into(), - capacity: 0.0, - candidate_usage: vec![1.0, 0.0, 0.0, 0.0, 0.0], - }); - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(2), - query_outputs: vec![vec![0], vec![1]], - minimum: 2, - }); - let proposal = solve_joint_price_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert_eq!(proposal.direct_modeled_cost, Some(106.0)); - } - // Tuple correlation forbids mixing the cheap first input from one tuple - // with the cheap second input of another tuple. - #[test] - fn correlated_inputs_cannot_form_an_unpriced_cartesian_combination() { - let mut consumer = candidate(&[0, 1]); - consumer - .producer_candidates_per_input - .push(consumer.producer_candidates_per_input[0].clone()); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![candidate(&[]), candidate(&[]), consumer], - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }], - ..Default::default() - }; - let binding = |id| ProducerPriceBinding { - producer: PhysicalCandidateId(id), - price_option: 0, - }; - let mut reader = option(1.0, false, None); - reader.compatible_input_tuples = - vec![vec![binding(0), binding(1)], vec![binding(1), binding(0)]]; - let profile = JointPriceProfile { - candidates: vec![ - vec![option(0.0, true, None)], - vec![option(10.0, true, None)], - vec![reader], - ], - schedules: None, - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - query_range_spans_ms: vec![0], - }; - let solved = solve_joint_price_proposal_with_metrics( - &graph, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((solved.direct_modeled_cost.unwrap() - 11.0).abs() < 1e-9); - assert_eq!(solved.selected.connections.len(), 2); - assert_ne!( - solved.selected.connections[0].producer, - solved.selected.connections[1].producer - ); - } -} diff --git a/crates/plan-selection/src/search/milp/general_schedule.rs b/crates/plan-selection/src/search/milp/general_schedule.rs deleted file mode 100644 index 5906a5c49..000000000 --- a/crates/plan-selection/src/search/milp/general_schedule.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Exact mixed-schedule demand and per-consumer lifetimes without subset tables. -use super::*; - -fn both( - variables: &mut ProblemVariables, - constraints: &mut Vec, - left: Variable, - right: Variable, -) -> Variable { - let value = variables.add(variable().min(0).max(1)); - constraints.push(constraint!(value <= left)); - constraints.push(constraint!(value <= right)); - constraints.push(constraint!(value >= left + right - 1)); - value -} - -/// Whose lifetime a demand's held memory lasts: query `.0`'s latency, or -/// node `.0`'s longest reader's latency. -#[derive(Debug, Clone, Copy)] -enum Holder { - Query(usize), - Node(usize), -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn encode( - graph: &DagOfPhysicalCandidatesWithCosts, - profile: &DirectCostProfile, - schedule: &QueryScheduleRates, - variables: &mut ProblemVariables, - constraints: &mut Vec, - active: &[Variable], - edges: &[(ProducerConsumerConnection, Variable)], - roots: &[(QueryRootAssignment, Variable)], - reach: &[Vec>], - divisor: f64, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, -) -> Result { - let count = graph.candidates.len(); - let queries = graph.query_roots.len(); - let latency_scale = profile.maximum_latency(graph).max(f64::MIN_POSITIVE); - let latency = query_latency_expressions( - graph, - profile, - variables, - constraints, - reach, - &check_deadline, - )?; - let mut materialized = vec![Vec::new(); count]; - for (id, node) in profile.nodes.iter().enumerate() { - check_deadline()?; - if node.materialized { - materialized[id] = if node.ingestion { - vec![(active[id], 1.0)] - } else { - schedule.encode_terms( - id, - reach, - variables, - constraints, - &check_deadline, - node.per_request, - )? - }; - } - } - // Per node, its demands `(used, rate, holder)`: a materialized node's own - // executions, and an unmaterialized node's per consumer, each held for - // its holder's lifetime; the consumers maintained at ingestion of one - // refresh window share one ingestion run, one demand used when any of - // them uses the node. - let mut demands: Vec> = materialized - .iter() - .enumerate() - .map(|(id, terms)| { - terms - .iter() - .map(|&(used, rate)| (used, rate, Holder::Node(id))) - .collect() - }) - .collect(); - // Per (node, refresh window), the uses of the node by that window's - // consumers, and one of them, whose lifetime holds the run's result. - let mut runs: BTreeMap<(usize, RefreshWindow), (Vec, usize)> = BTreeMap::new(); - for flow in boundary_flows( - graph, - profile, - variables, - constraints, - active, - edges, - roots, - &check_deadline, - )? { - for (id, reached) in flow.reached.into_iter().enumerate() { - check_deadline()?; - let Some(reached) = reached else { continue }; - if flow.boundary < queries { - if schedule.per_query_rates()[flow.boundary] > 0.0 { - demands[id].push(( - reached, - schedule.per_query_rates()[flow.boundary], - Holder::Query(flow.boundary), - )); - } - } else { - let consumer = flow.boundary - queries; - for (used, rate) in &materialized[consumer] { - let use_of_node = both(variables, constraints, reached, *used); - match profile.nodes[consumer].refresh_window { - Some(window) => runs - .entry((id, window)) - .or_insert_with(|| (Vec::new(), consumer)) - .0 - .push(use_of_node), - None => demands[id].push((use_of_node, *rate, Holder::Node(consumer))), - } - } - } - } - } - // One ingestion run per (node, refresh window), at the ingestion rate: - // used when any consumer of the window uses the node (minimization keeps - // it at the largest use). - for ((id, _), (uses, consumer)) in runs { - let run = variables.add(variable().min(0).max(1)); - for use_of_node in uses { - constraints.push(constraint!(run >= use_of_node)); - } - demands[id].push((run, 1.0, Holder::Node(consumer))); - } - let mut objective = Expression::from(profile.request_cost / divisor); - let mut retained = BTreeMap::<&str, Vec<(usize, f64)>>::new(); - // Each materialized node's longest reader's latency, built on first use. - let mut longest_of: BTreeMap = BTreeMap::new(); - for (id, node) in profile.nodes.iter().enumerate() { - check_deadline()?; - objective += node.standing_cost_per_second / divisor * active[id]; - for (used, rate, _) in &demands[id] { - objective += node.execution_cost * *rate / divisor * *used; - } - if let Some((source, cost)) = &node.raw_retention { - retained.entry(source).or_default().push((id, *cost)); - } - if node.held_memory_cost_per_second == 0.0 || demands[id].is_empty() { - continue; - } - // Held for each demand's holder's lifetime: a query's latency, or a - // materialized node's longest reader's. - for &(used, rate, holder) in &demands[id] { - let lifetime = match holder { - Holder::Query(query) => latency[query].clone(), - Holder::Node(holder) => { - let longest = *longest_of.entry(holder).or_insert_with(|| { - let longest = variables.add(variable().min(0).max(1)); - for (q, row) in reach.iter().enumerate() { - if let Some(reached) = row[holder] { - constraints.push(constraint!( - longest >= latency[q].clone() - (1.0 - reached) - )); - } - } - longest - }); - Expression::from(longest) - } - }; - objective += node.held_memory_cost_per_second * latency_scale * rate / divisor - * gated(variables, constraints, lifetime, used, 1.0); - } - } - for costs in retained.values() { - check_deadline()?; - let maximum = costs - .iter() - .map(|(_, cost)| *cost / divisor) - .fold(0.0, f64::max); - let cost = variables.add(variable().min(0).max(maximum)); - for (id, charge) in costs { - constraints.push(constraint!(cost >= *charge / divisor * active[*id])); - } - objective += cost; - } - Ok(objective) -} - -#[cfg(test)] -#[path = "general_schedule_tests.rs"] -mod tests; diff --git a/crates/plan-selection/src/search/milp/general_schedule_rates.rs b/crates/plan-selection/src/search/milp/general_schedule_rates.rs deleted file mode 100644 index 037e7b100..000000000 --- a/crates/plan-selection/src/search/milp/general_schedule_rates.rs +++ /dev/null @@ -1,771 +0,0 @@ -//! Exact finite demand atoms for the evaluator's recurrence accounting policy. -//! Different fixed intervals add; an unknown phase joins the first selected -//! known phase, estimated rates add, and nonrepeating demand takes a maximum. -use crate::search::candidates::dag::CandidateSelectionError; -use asap_cost::execution_demand::evaluation_rate; -use asap_types::workload::{QueryRecurrence, QueryWorkloadEntry, RangeEvaluation, RepeatedDemand}; -use good_lp::{constraint, variable, Constraint, ProblemVariables, Variable}; -use std::collections::BTreeMap; -use std::time::Instant; - -const MAXIMUM_ENUMERATED_OFFSETS: usize = 100_000; -const MAXIMUM_OFFSET_VISITS: usize = 500_000; - -#[derive(Debug, Clone)] -struct Atom { - readers: Vec, - count: u64, -} -#[derive(Debug, Clone)] -struct FixedPhase { - readers: Vec, - atoms: Vec, -} -#[derive(Debug, Clone)] -struct FixedInterval { - rate: f64, - unknown: Vec, - unknown_atoms: Vec, - // Ascending phase order is semantically significant. - known: Vec, -} -#[derive(Debug, Clone, Default)] -struct DemandAtoms { - fixed: Vec, - estimated: Vec<(usize, f64)>, - batch: Vec<(u64, Vec)>, - horizon: f64, -} -#[derive(Debug, Clone)] -pub(crate) struct QueryScheduleRates { - queries: Vec<(QueryRecurrence, Option)>, - rates: Vec, - evaluations: DemandAtoms, - requests: DemandAtoms, -} -fn unsupported(reason: &str) -> CandidateSelectionError { - CandidateSelectionError::UnsupportedDirectCost(reason.into()) -} -fn check(deadline: Option) -> Result<(), CandidateSelectionError> { - if deadline.is_some_and(|deadline| Instant::now() >= deadline) { - Err(CandidateSelectionError::Solver( - "time budget exhausted constructing schedule atoms".into(), - )) - } else { - Ok(()) - } -} - -// With a common step, k-ranges are nested and can be counted without expanding -// even a billion individual timestamps. Mixed steps retain exact coverage sets. -fn coverage( - readers: &[(usize, u64, u64)], - deadline: Option, -) -> Result, CandidateSelectionError> { - if readers.is_empty() { - return Ok(Vec::new()); - } - if readers.iter().all(|(_, step, _)| *step == readers[0].1) { - let mut endpoints: Vec<_> = readers - .iter() - .map(|(_, step, span)| span / step + 1) - .collect(); - endpoints.sort_unstable(); - endpoints.dedup(); - let mut previous = 0; - let mut atoms = Vec::new(); - for end in endpoints { - check(deadline)?; - let active = readers - .iter() - .filter(|(_, step, span)| span / step + 1 >= end) - .map(|(id, _, _)| *id) - .collect(); - atoms.push(Atom { - readers: active, - count: end - previous, - }); - previous = end; - } - return Ok(atoms); - } - // A few distinct arithmetic progressions can be counted exactly without - // enumerating their offsets. Duplicate geometries share all their readers. - let mut geometries = BTreeMap::<(u64, u64), Vec>::new(); - for &(id, step, span) in readers { - check(deadline)?; - geometries.entry((step, span)).or_default().push(id); - } - if geometries.len() <= 12 { - return arithmetic_coverage(&geometries.into_iter().collect::>(), deadline); - } - let mut offsets: BTreeMap> = BTreeMap::new(); - let mut visits = 0usize; - for &(id, step, span) in readers { - let count = span / step + 1; - if count > MAXIMUM_OFFSET_VISITS as u64 - || visits.saturating_add(count as usize) > MAXIMUM_OFFSET_VISITS - { - return Err(unsupported( - "mixed-step range demand exceeds the exact offset construction budget", - )); - } - visits += count as usize; - for k in 0..count { - check(deadline)?; - offsets.entry(k * step).or_default().push(id); - if offsets.len() > MAXIMUM_ENUMERATED_OFFSETS { - return Err(unsupported( - "mixed-step range demand exceeds the exact offset domain", - )); - } - } - } - let mut classes = BTreeMap::, u64>::new(); - for (_, mut readers) in offsets { - check(deadline)?; - readers.sort_unstable(); - readers.dedup(); - *classes.entry(readers).or_default() += 1; - } - Ok(classes - .into_iter() - .map(|(readers, count)| Atom { readers, count }) - .collect()) -} - -fn arithmetic_coverage( - geometries: &[((u64, u64), Vec)], - deadline: Option, -) -> Result, CandidateSelectionError> { - let size = 1usize << geometries.len(); - let mut counts = vec![0i128; size]; - for (mask, count) in counts.iter_mut().enumerate().skip(1) { - check(deadline)?; - let intersection: Vec<_> = geometries - .iter() - .enumerate() - .filter(|(bit, _)| mask & (1 << bit) != 0) - .map(|(_, (geometry, _))| *geometry) - .collect(); - *count = asap_cost::execution_demand::offset_intersection_count(&intersection); - } - // Superset Möbius inversion changes intersection counts into counts of - // offsets covered by exactly each geometry subset. - for bit in 0..geometries.len() { - for mask in 1..size { - check(deadline)?; - if mask & (1 << bit) == 0 { - counts[mask] -= counts[mask | (1 << bit)]; - } - } - } - let mut atoms = Vec::new(); - for (mask, count) in counts.into_iter().enumerate().skip(1) { - check(deadline)?; - if count == 0 { - continue; - } - let count = u64::try_from(count) - .map_err(|_| unsupported("arithmetic range coverage count overflow"))?; - let mut readers: Vec<_> = geometries - .iter() - .enumerate() - .filter(|(bit, _)| mask & (1 << bit) != 0) - .flat_map(|(_, (_, readers))| readers.iter().copied()) - .collect(); - readers.sort_unstable(); - readers.dedup(); - atoms.push(Atom { readers, count }); - } - Ok(atoms) -} - -impl DemandAtoms { - fn new( - queries: &[(QueryRecurrence, Option)], - horizon: f64, - per_request: bool, - deadline: Option, - ) -> Result { - let mut fixed = BTreeMap::, Vec<(usize, u64, u64)>>>::new(); - let mut estimated = Vec::new(); - let mut batch = BTreeMap::>::new(); - for (id, (recurrence, range)) in queries.iter().enumerate() { - check(deadline)?; - let range = if per_request { None } else { *range }; - let steps = range.map_or(1, |range| range.evaluations_per_request()); - if steps > (1u64 << 53) { - return Err(unsupported( - "range evaluation count exceeds exact floating-point integer precision", - )); - } - match recurrence { - QueryRecurrence::Repeated( - RepeatedDemand::FixedInterval(interval) - | RepeatedDemand::FixedIntervalAt { interval, .. }, - ) => { - let phase = match recurrence { - QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { - evaluation_phase, - .. - }) => Some(evaluation_phase.0 % u64::from(interval.0)), - _ => None, - }; - let (step, span) = range - .filter(|_| steps > 1) - .map_or((u64::from(interval.0), 0), |r| (r.step_ms, r.span_ms)); - fixed - .entry(interval.0) - .or_default() - .entry(phase) - .or_default() - .push((id, step, span)); - } - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(estimate)) => { - estimated.push((id, estimate.expected_rate.0 * steps as f64)) - } - _ => { - let requests = match recurrence { - QueryRecurrence::Repeated(RepeatedDemand::Scheduled(times)) => { - times.len() as u64 - } - QueryRecurrence::OneTime { invocations, .. } => *invocations, - _ => 1, - }; - let count = requests - .checked_mul(steps) - .ok_or_else(|| unsupported("batch invocation count overflow"))?; - if count > (1u64 << 53) { - return Err(unsupported( - "batch invocation count exceeds exact floating-point integer precision", - )); - } - if count > 0 { - batch.entry(count).or_default().push(id); - } - } - } - } - let mut intervals = Vec::new(); - for (interval, mut phases) in fixed { - let unknown = phases.remove(&None).unwrap_or_default(); - let unknown_atoms = coverage(&unknown, deadline)?; - let mut known = Vec::new(); - for (_, readers) in phases { - let members = readers.iter().map(|(id, _, _)| *id).collect(); - let mut together = readers; - together.extend_from_slice(&unknown); - known.push(FixedPhase { - readers: members, - atoms: coverage(&together, deadline)?, - }); - } - intervals.push(FixedInterval { - rate: 1000.0 / f64::from(interval), - unknown: unknown.iter().map(|(id, _, _)| *id).collect(), - unknown_atoms, - known, - }); - } - Ok(Self { - fixed: intervals, - estimated, - batch: batch.into_iter().collect(), - horizon, - }) - } -} - -impl QueryScheduleRates { - pub(crate) fn new( - entries: &[QueryWorkloadEntry], - horizon: f64, - deadline: Option, - ) -> Result { - check(deadline)?; - if entries.is_empty() || !horizon.is_finite() || horizon <= 0.0 { - return Err(CandidateSelectionError::InvalidGraph( - "invalid general schedule horizon or empty workload".into(), - )); - } - let queries: Vec<_> = entries - .iter() - .map(|entry| (entry.recurrence.clone(), entry.range_evaluation)) - .collect(); - for (recurrence, range) in &queries { - check(deadline)?; - if range.is_some_and(|r| r.step_ms == 0 || r.span_ms == u64::MAX) { - return Err(unsupported("invalid or overflowing range schedule")); - } - if matches!(recurrence,QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval) | RepeatedDemand::FixedIntervalAt{interval,..}) if interval.0==0) - { - return Err(CandidateSelectionError::InvalidGraph( - "zero fixed interval".into(), - )); - } - } - // Build checked integer products before calling the evaluator oracle. - let evaluations = DemandAtoms::new(&queries, horizon, false, deadline)?; - let requests = DemandAtoms::new(&queries, horizon, true, deadline)?; - let rates = queries - .iter() - .map(|(r, range)| { - evaluation_rate([(r, *range)], horizon) - .map_err(|e| CandidateSelectionError::InvalidGraph(e.to_string())) - }) - .collect::, _>>()?; - if rates.iter().any(|rate| !rate.is_finite() || *rate < 0.0) { - return Err(unsupported("nonfinite general schedule rate")); - } - Ok(Self { - queries, - rates, - evaluations, - requests, - }) - } - pub(crate) fn validate(&self, queries: usize) -> Result<(), CandidateSelectionError> { - if queries != self.queries.len() { - return Err(CandidateSelectionError::InvalidGraph( - "inconsistent general schedule rates".into(), - )); - } - Ok(()) - } - pub(crate) fn coefficient_rates(&self) -> Vec { - let mut result = Vec::new(); - for atoms in [&self.evaluations, &self.requests] { - for interval in &atoms.fixed { - result.extend( - interval - .known - .iter() - .flat_map(|phase| &phase.atoms) - .chain(&interval.unknown_atoms) - .map(|atom| interval.rate * atom.count as f64), - ); - } - result.extend(atoms.estimated.iter().map(|(_, rate)| *rate)); - let mut previous = 0; - for (count, _) in &atoms.batch { - result.push((*count - previous) as f64 / atoms.horizon); - previous = *count; - } - } - result.retain(|rate| *rate > 0.0); - result - } - pub(crate) fn per_query_rates(&self) -> &[f64] { - &self.rates - } - pub(crate) fn union_rate(&self, selected: impl Iterator) -> f64 { - self.rate(selected, false) - } - pub(crate) fn request_union_rate(&self, selected: impl Iterator) -> f64 { - self.rate(selected, true) - } - fn rate(&self, selected: impl Iterator, per_request: bool) -> f64 { - let selected: Vec<_> = selected.collect(); - let atoms = if per_request { - &self.requests - } else { - &self.evaluations - }; - let any = |readers: &[usize]| { - readers - .iter() - .any(|q| selected.get(*q).copied().unwrap_or(false)) - }; - let mut total = 0.0; - for interval in &atoms.fixed { - let first = interval.known.iter().position(|phase| any(&phase.readers)); - for (index, phase) in interval.known.iter().enumerate() { - for atom in &phase.atoms { - if atom.readers.iter().any(|q| { - selected.get(*q).copied().unwrap_or(false) - && (!interval.unknown.contains(q) || first == Some(index)) - }) { - total += interval.rate * atom.count as f64; - } - } - } - if first.is_none() { - for atom in &interval.unknown_atoms { - if any(&atom.readers) { - total += interval.rate * atom.count as f64; - } - } - } - } - total += atoms - .estimated - .iter() - .filter(|(q, _)| selected.get(*q).copied().unwrap_or(false)) - .map(|(_, rate)| *rate) - .sum::(); - total += atoms - .batch - .iter() - .filter(|(_, readers)| any(readers)) - .map(|(count, _)| *count) - .max() - .unwrap_or(0) as f64 - / atoms.horizon; - total - } - pub(crate) fn encode_terms( - &self, - node: usize, - reach: &[Vec>], - variables: &mut ProblemVariables, - constraints: &mut Vec, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, - per_request: bool, - ) -> Result, CandidateSelectionError> { - let atoms = if per_request { - &self.requests - } else { - &self.evaluations - }; - let mut terms = Vec::new(); - let readers = |ids: &[usize]| { - ids.iter() - .filter_map(|q| reach[*q][node]) - .collect::>() - }; - for interval in &atoms.fixed { - check_deadline()?; - let mut prior = None; - for phase in &interval.known { - let present = or(variables, constraints, &readers(&phase.readers)); - let first = present.map(|present| without(variables, constraints, present, prior)); - for atom in &phase.atoms { - check_deadline()?; - let (unknown, known): (Vec<&usize>, Vec<&usize>) = atom - .readers - .iter() - .partition(|q| interval.unknown.contains(q)); - let known: Vec<_> = known.into_iter().filter_map(|q| reach[*q][node]).collect(); - let unknown: Vec<_> = unknown - .into_iter() - .filter_map(|q| reach[*q][node]) - .collect(); - let mut occupied = or(variables, constraints, &known) - .into_iter() - .collect::>(); - if let (Some(first), Some(unknown)) = - (first, or(variables, constraints, &unknown)) - { - occupied.push(and(variables, constraints, first, unknown)); - } - if let Some(value) = or(variables, constraints, &occupied) { - terms.push((value, interval.rate * atom.count as f64)); - } - } - prior = or( - variables, - constraints, - &prior.into_iter().chain(present).collect::>(), - ); - } - for atom in &interval.unknown_atoms { - check_deadline()?; - if let Some(unknown) = or(variables, constraints, &readers(&atom.readers)) { - terms.push(( - without(variables, constraints, unknown, prior), - interval.rate * atom.count as f64, - )); - } - } - } - for &(q, rate) in &atoms.estimated { - check_deadline()?; - if rate > 0.0 { - if let Some(reach) = reach[q][node] { - terms.push((reach, rate)); - } - } - } - let mut previous = 0; - for (count, _) in &atoms.batch { - check_deadline()?; - let members: Vec<_> = atoms - .batch - .iter() - .filter(|(other, _)| other >= count) - .flat_map(|(_, ids)| readers(ids)) - .collect(); - if let Some(value) = or(variables, constraints, &members) { - terms.push((value, (*count - previous) as f64 / atoms.horizon)); - } - previous = *count; - } - Ok(terms) - } -} -fn or( - variables: &mut ProblemVariables, - constraints: &mut Vec, - inputs: &[Variable], -) -> Option { - if inputs.is_empty() { - return None; - } - if inputs.len() == 1 { - return Some(inputs[0]); - } - let value = variables.add(variable().min(0).max(1)); - for input in inputs { - constraints.push(constraint!(value >= *input)); - } - constraints.push(constraint!( - value <= inputs.iter().copied().sum::() - )); - Some(value) -} -fn and( - variables: &mut ProblemVariables, - constraints: &mut Vec, - left: Variable, - right: Variable, -) -> Variable { - let value = variables.add(variable().min(0).max(1)); - constraints.push(constraint!(value <= left)); - constraints.push(constraint!(value <= right)); - constraints.push(constraint!(value >= left + right - 1)); - value -} -fn without( - variables: &mut ProblemVariables, - constraints: &mut Vec, - value: Variable, - excluded: Option, -) -> Variable { - let Some(excluded) = excluded else { - return value; - }; - let result = variables.add(variable().min(0).max(1)); - constraints.push(constraint!(result <= value)); - constraints.push(constraint!(result <= 1.0 - excluded)); - constraints.push(constraint!(result >= value - excluded)); - result -} - -#[cfg(test)] -mod tests { - use super::*; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::workload::{ - DemandEstimate, Predictability, Query, QueryRequirements, Rate, RepetitionInterval, - TimestampMs, - }; - use good_lp::{highs, Expression, Solution, SolverModel}; - - fn entry(recurrence: QueryRecurrence, range: Option<(u64, u64)>) -> QueryWorkloadEntry { - QueryWorkloadEntry { - query: Query("sum(m)".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence, - range_evaluation: range.map(|(span_ms, step_ms)| RangeEvaluation { span_ms, step_ms }), - } - } - fn fixed(interval: u32, phase: Option, range: Option<(u64, u64)>) -> QueryWorkloadEntry { - entry( - QueryRecurrence::Repeated(match phase { - None => RepeatedDemand::FixedInterval(RepetitionInterval(interval)), - Some(phase) => RepeatedDemand::FixedIntervalAt { - interval: RepetitionInterval(interval), - evaluation_phase: TimestampMs(phase), - }, - }), - range, - ) - } - fn compare_all_subsets(entries: &[QueryWorkloadEntry]) { - let schedule = QueryScheduleRates::new(entries, 60.0, None).unwrap(); - for per_request in [false, true] { - for mask in 0..(1usize << entries.len()) { - let mut variables = ProblemVariables::new(); - let mut constraints = Vec::new(); - let reach: Vec<_> = (0..entries.len()) - .map(|q| { - let v = variables.add(variable().binary()); - constraints.push(constraint!(v == f64::from(mask & (1 << q) != 0))); - vec![Some(v)] - }) - .collect(); - let terms = schedule - .encode_terms( - 0, - &reach, - &mut variables, - &mut constraints, - || Ok(()), - per_request, - ) - .unwrap(); - let expression: Expression = terms.iter().map(|(v, rate)| *rate * *v).sum(); - // Maximise to prove that an auxiliary rate cannot be inflated; - // all OR/first-phase/max relations are exact equalities in use. - let solution = variables - .minimise(-expression.clone()) - .using(highs) - .with_all(constraints) - .solve() - .unwrap(); - let expected = evaluation_rate( - entries - .iter() - .enumerate() - .filter(|(q, _)| mask & (1 << q) != 0) - .map(|(_, entry)| { - ( - &entry.recurrence, - if per_request { - None - } else { - entry.range_evaluation - }, - ) - }), - 60.0, - ) - .unwrap(); - let actual = solution.eval(&expression); - let decoded = schedule.rate( - (0..entries.len()).map(|q| mask & (1 << q) != 0), - per_request, - ); - assert!( - (decoded - expected).abs() <= 1e-10 * expected.max(1.0), - "decoded {decoded} != oracle {expected}" - ); - assert!( - (actual - expected).abs() <= 1e-10 * expected.max(1.0), - "mask={mask}, requests={per_request}: {actual} != {expected}" - ); - } - } - } - #[test] - fn fixed_ranges_join_unknown_to_first_selected_known_phase() { - compare_all_subsets(&[ - fixed(1000, None, Some((6, 2))), - fixed(1000, Some(0), Some((6, 3))), - fixed(1000, Some(500), Some((6, 2))), - fixed(2000, Some(0), Some((6, 2))), - ]); - } - #[test] - fn mixed_estimates_scheduled_batches_and_unknown_match_evaluator() { - let estimate = |rate| { - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(DemandEstimate { - expected_rate: Rate(rate), - observed_at: None, - valid_for: None, - })) - }; - compare_all_subsets(&[ - fixed(997, None, Some((10, 3))), - entry(estimate(0.137), Some((8, 2))), - entry(estimate(0.193), None), - entry( - QueryRecurrence::Repeated(RepeatedDemand::Scheduled(vec![ - TimestampMs(10), - TimestampMs(20), - ])), - Some((6, 3)), - ), - entry( - QueryRecurrence::OneTime { - invocations: 3, - execute_at: None, - }, - Some((8, 2)), - ), - entry(QueryRecurrence::Unknown, Some((10, 1))), - ]); - } - #[test] - fn same_step_large_ranges_are_counted_without_timestamp_enumeration() { - let entries = [ - fixed(1000, None, Some((1_000_000_000, 1))), - fixed(1000, None, Some((500_000_000, 1))), - ]; - let schedule = QueryScheduleRates::new(&entries, 60.0, None).unwrap(); - assert_eq!(schedule.evaluations.fixed[0].unknown_atoms.len(), 2); - assert_eq!( - schedule.union_rate([true, true].into_iter()), - 1_000_000_001.0 - ); - assert_eq!(schedule.request_union_rate([true, true].into_iter()), 1.0); - } - #[test] - fn large_coprime_ranges_use_exact_arithmetic_coverage() { - let entries = [ - fixed(1000, None, Some((1_000_000_000, 2))), - fixed(1000, None, Some((1_000_000_000, 3))), - ]; - let schedule = QueryScheduleRates::new(&entries, 60.0, None).unwrap(); - assert_eq!(schedule.evaluations.fixed[0].unknown_atoms.len(), 3); - assert_eq!(schedule.union_rate([true, true].into_iter()), 666_666_668.0); - assert_eq!( - schedule.union_rate([true, false].into_iter()), - 500_000_001.0 - ); - assert_eq!( - schedule.union_rate([false, true].into_iter()), - 333_333_334.0 - ); - compare_all_subsets(&[ - fixed(1000, None, Some((60, 2))), - fixed(1000, None, Some((60, 3))), - ]); - } - #[test] - fn arithmetic_atoms_match_offset_coverage_with_duplicates_and_lcm_overflow() { - let readers = [(0, 2, 60), (1, 3, 60), (2, 2, 60), (3, 5, 37), (4, 7, 0)]; - let atoms = coverage(&readers, None).unwrap(); - let actual: BTreeMap<_, _> = atoms - .into_iter() - .map(|atom| (atom.readers, atom.count)) - .collect(); - let mut expected = BTreeMap::, u64>::new(); - for offset in 0..=60 { - let members: Vec<_> = readers - .iter() - .filter(|(_, step, span)| offset <= *span && offset % step == 0) - .map(|(id, _, _)| *id) - .collect(); - if !members.is_empty() { - *expected.entry(members).or_default() += 1; - } - } - assert_eq!(actual, expected); - let large = 1u64 << 63; - let atoms = coverage(&[(0, large - 1, large), (1, large - 3, large)], None).unwrap(); - assert_eq!(atoms.iter().map(|atom| atom.count).sum::(), 3); - assert!(atoms - .iter() - .any(|atom| atom.readers == vec![0, 1] && atom.count == 1)); - } - #[test] - fn excessive_mixed_offset_domains_and_deadlines_are_explicit() { - let entries: Vec<_> = (1..=13) - .map(|step| fixed(1000, None, Some((10_000_000, step)))) - .collect(); - assert!(matches!( - QueryScheduleRates::new(&entries, 60.0, None), - Err(CandidateSelectionError::UnsupportedDirectCost(_)) - )); - assert!(matches!( - QueryScheduleRates::new(&entries, 60.0, Some(Instant::now())), - Err(CandidateSelectionError::Solver(_)) - )); - } -} diff --git a/crates/plan-selection/src/search/milp/general_schedule_tests.rs b/crates/plan-selection/src/search/milp/general_schedule_tests.rs deleted file mode 100644 index dfa052acf..000000000 --- a/crates/plan-selection/src/search/milp/general_schedule_tests.rs +++ /dev/null @@ -1,242 +0,0 @@ -use super::*; -use crate::search::milp::model::{solve_direct_cost_proposal_with_metrics, CandidateSolveMetrics}; -use asap_types::ir::properties::AccuracyTarget; -use asap_types::stages::*; -use asap_types::workload::{ - DemandEstimate, Predictability, Query, QueryRecurrence, QueryRequirements, QueryWorkloadEntry, - RangeEvaluation, Rate, RepeatedDemand, RepetitionInterval, -}; - -fn entry(recurrence: QueryRecurrence, evaluations: u64) -> QueryWorkloadEntry { - QueryWorkloadEntry { - query: Query("sum(m)".into()), - requirements: QueryRequirements { - accuracy: AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence, - range_evaluation: (evaluations > 1).then_some(RangeEvaluation { - span_ms: (evaluations - 1) * 1000, - step_ms: 1000, - }), - } -} -fn fixed(interval: u32, evaluations: u64) -> QueryWorkloadEntry { - entry( - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(interval))), - evaluations, - ) -} -fn node(input: Option) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: input - .into_iter() - .map(|input| InputProducerCandidates { - candidates: vec![PhysicalCandidateId(input)].into(), - }) - .collect(), - } -} -fn check_cost( - entries: &[QueryWorkloadEntry], - expected_rates: &[f64], - expected_union: f64, - per_request: bool, -) { - let general = QueryScheduleRates::new(entries, 60.0, None).unwrap(); - for (actual, expected) in general.per_query_rates().iter().zip(expected_rates) { - assert!((actual - expected).abs() < 1e-12); - } - let schedule = general; - let mut candidates = vec![node(None), node(Some(0))]; - candidates.extend((0..entries.len()).map(|_| node(Some(1)))); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates, - query_roots: (0..entries.len()) - .map(|q| QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(q + 2)], - }) - .collect(), - ..Default::default() - }; - let mut nodes = vec![ - NodeCostProfile { - execution_cost: 2.0, - work_seconds: 1.0, - held_memory_cost_per_second: 3.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 4.0, - work_seconds: 2.0, - held_memory_cost_per_second: if per_request { 0.0 } else { 2.0 }, - standing_cost_per_second: if per_request { 13.0 } else { 0.0 }, - materialized: true, - per_request, - ..Default::default() - }, - ]; - nodes.extend((0..entries.len()).map(|q| NodeCostProfile { - execution_cost: 1.0, - work_seconds: q as f64 + 1.0, - ..Default::default() - })); - let sum: f64 = expected_rates.iter().sum(); - // Node 0 is computed once per execution of node 1, its one consumer, and - // held, as node 1's result is, for the longest reader's latency. - let longest = entries.len() as f64 + 3.0; - let expected = expected_union - * (6.0 + 3.0 * longest + if per_request { 0.0 } else { 2.0 * longest }) - + sum - + if per_request { 13.0 } else { 0.0 }; - let profile = DirectCostProfile { - nodes, - schedules: Some(schedule), - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - let mut metrics = CandidateSolveMetrics::default(); - let proposal = - solve_direct_cost_proposal_with_metrics(&graph, &[], None, &mut metrics, &profile) - .unwrap() - .unwrap(); - let direct = proposal.direct_modeled_cost.unwrap(); - assert!( - (direct - expected).abs() < 1e-8 * expected.max(1.0), - "direct {direct}, independent expected {expected}" - ); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-6 * expected.max(1.0)); - assert!( - metrics.peak_variables < 5000, - "a rate lattice or reader subset product is unnecessary" - ); -} - -#[test] -fn noncommensurate_fixed_intervals_price_held_memory_without_integer_lattice() { - let intervals = [59_000, 61_000, 67_000, 71_000]; - let entries: Vec<_> = intervals - .iter() - .map(|interval| fixed(*interval, 1)) - .collect(); - let rates: Vec<_> = intervals - .iter() - .map(|interval| 1000.0 / f64::from(*interval)) - .collect(); - check_cost(&entries, &rates, rates.iter().sum(), false); -} - -#[test] -fn mixed_schedules_price_materialized_union_and_unmaterialized_input() { - let estimate = QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(DemandEstimate { - expected_rate: Rate(0.137), - observed_at: None, - valid_for: None, - })); - let entries = vec![ - fixed(1000, 2), - fixed(2000, 3), - entry(estimate, 4), - entry( - QueryRecurrence::OneTime { - invocations: 3, - execute_at: None, - }, - 5, - ), - entry(QueryRecurrence::Unknown, 6), - ]; - let rates = [2.0, 1.5, 0.548, 0.25, 0.1]; - // Fixed intervals and estimates add; the two batch demands use max(15,6)/60. - check_cost(&entries, &rates, 2.0 + 1.5 + 0.548 + 0.25, false); - // Retained refreshes use request rates; readers still use evaluation - // rates including each query's different range length. - check_cost(&entries, &rates, 1.0 + 0.5 + 0.137 + 3.0 / 60.0, true); -} - -#[test] -fn zero_rate_readers_preserve_materialized_and_unmaterialized_lifetimes() { - // With all rates zero the execution cost is zero. Whatever the readers' - // rates, a shared query-time materialization and its unmaterialized - // input, computed once per execution of it, use its longest reached - // latency. - for rates in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.0]] { - let entries: Vec<_> = rates - .iter() - .map(|rate| { - entry( - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(DemandEstimate { - expected_rate: Rate(*rate), - observed_at: None, - valid_for: None, - })), - 1, - ) - }) - .collect(); - let general = QueryScheduleRates::new(&entries, 60.0, None).unwrap(); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0)), node(Some(1)), node(Some(1))], - query_roots: vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }, - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(3)], - }, - ], - ..Default::default() - }; - let profile = DirectCostProfile { - nodes: vec![ - NodeCostProfile { - execution_cost: 2.0, - work_seconds: 1.0, - held_memory_cost_per_second: 3.0, - ..Default::default() - }, - NodeCostProfile { - materialized: true, - execution_cost: 4.0, - work_seconds: 2.0, - held_memory_cost_per_second: 2.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - work_seconds: 1.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 1.0, - work_seconds: 2.0, - ..Default::default() - }, - ], - schedules: Some(general), - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - let sum: f64 = rates.iter().sum(); - let expected = sum * (2.0 + 4.0 + 3.0 * 5.0 + 2.0 * 5.0) + sum; - let proposal = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut Default::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!( - (proposal.direct_modeled_cost.unwrap() - expected).abs() < 1e-8, - "rates {rates:?}: expected {expected}, got {:?}", - proposal.direct_modeled_cost - ); - assert!((proposal.model_lower_bound.unwrap() - expected).abs() < 1e-6); - } -} diff --git a/crates/plan-selection/src/search/milp/mod.rs b/crates/plan-selection/src/search/milp/mod.rs deleted file mode 100644 index d50e12d21..000000000 --- a/crates/plan-selection/src/search/milp/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! MILP encoding and complete feasible initialization. -pub(crate) mod model; - -pub(crate) mod direct_cost; -pub(crate) mod direct_price_options; diff --git a/crates/plan-selection/src/search/milp/model.rs b/crates/plan-selection/src/search/milp/model.rs deleted file mode 100644 index c75cf72f5..000000000 --- a/crates/plan-selection/src/search/milp/model.rs +++ /dev/null @@ -1,1911 +0,0 @@ -//! MILP encoding and solution decoding for the finite candidate graph. -//! -//! Local fixed-context cost bounds and evaluated assignment costs constrain the -//! objective. Workload demand, physical realization, and evaluator refinement -//! are supplied by the surrounding planning stages. - -#[cfg(test)] -use std::collections::HashSet; - -#[path = "warm_start.rs"] -mod warm_start; - -fn initialized( - definition: good_lp::VariableDefinition, - value: Option, -) -> good_lp::VariableDefinition { - match value { - Some(value) => definition.initial(value), - None => definition, - } -} - -use crate::search::candidates::dag::ReaderConstraint; -use crate::search::candidates::dag::*; -use crate::search::candidates::graph::ProducerConsumerConnection; -use crate::search::candidates::graph::RejectedQuerySubgraph; -pub(in crate::search) use crate::search::candidates::graph::{ - QueryRootAssignment, SelectedCandidateGraph, -}; -use asap_types::stages::*; -use good_lp::solvers::highs::highs; -use good_lp::solvers::{ResolutionError, SolutionStatus}; -use good_lp::{ - constraint, variable, Expression, ProblemVariables, Solution, SolverModel, Variable, -}; - -/// Only outputs and dependency paths inspected by reader constraints need flow -/// variables. Activation symmetry is independent of readers. A corridor starts -/// at a constrained producer and follows possible consumers back toward roots. -fn required_reader_reach( - graph: &DagOfPhysicalCandidatesWithCosts, - all_outputs: bool, - check_deadline: impl Fn() -> Result<(), CandidateSelectionError>, -) -> Result>, CandidateSelectionError> { - let count = graph.candidates.len(); - let mut potential = vec![vec![false; count]; graph.query_roots.len()]; - let mut consumers = vec![Vec::new(); count]; - for (consumer, candidate) in graph.candidates.iter().enumerate() { - check_deadline()?; - for input in &candidate.producer_candidates_per_input { - for producer in &input.candidates { - consumers[producer.0].push(consumer); - } - } - } - for (output, root) in graph.query_roots.iter().enumerate() { - let mut pending = root.candidates.clone(); - while let Some(candidate) = pending.pop() { - check_deadline()?; - if !std::mem::replace(&mut potential[output][candidate.0], true) { - pending.extend( - graph.candidates[candidate.0] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().copied()), - ); - } - } - } - if all_outputs { - return Ok(potential); - } - let mut needed = vec![vec![false; count]; graph.query_roots.len()]; - let mut pending = Vec::new(); - for constraint in &graph.reader_constraints { - check_deadline()?; - match constraint { - ReaderConstraint::ActivationRequires { .. } => {} - ReaderConstraint::HighestSharedQueryMaterialization { candidate, .. } => { - for (output, reachable) in potential.iter().enumerate() { - if reachable[candidate.0] { - pending.push((output, candidate.0)); - } - } - } - ReaderConstraint::AllowedOutputs { candidate, outputs } => { - for (output, reachable) in potential.iter().enumerate() { - if reachable[candidate.0] && !outputs.contains(&output) { - pending.push((output, candidate.0)); - } - } - } - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => { - if potential[*left][candidate.0] && potential[*right][candidate.0] { - pending.extend([(*left, candidate.0), (*right, candidate.0)]); - } - } - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => { - // Every active candidate has a supported path to a root. Thus - // one reader is already guaranteed without a separate flow. - if *minimum > 1 && *minimum <= query_outputs.len() { - for (output, reachable) in potential.iter().enumerate() { - if reachable[candidate.0] { - pending.push((output, candidate.0)); - } - } - } - } - } - } - while let Some((output, candidate)) = pending.pop() { - check_deadline()?; - if !std::mem::replace(&mut needed[output][candidate], true) { - pending.extend( - consumers[candidate] - .iter() - .filter(|&&consumer| potential[output][consumer]) - .map(|&consumer| (output, consumer)), - ); - } - } - Ok(needed) -} - -/// Return the cheapest covering DAG, or `None` when no such graph exists. -/// Numerical optimality is subject to HiGHS tolerances. No production timeout or -/// incumbent fallback is implied by this fixed-context core. -#[cfg(test)] -pub(crate) fn select_candidates( - graph: &DagOfPhysicalCandidatesWithCosts, -) -> Result, CandidateSelectionError> { - let proposal = solve_candidate_proposal(graph, &[], None)?; - proposal - .map(|proposal| { - if proposal.model_lower_bound.is_none() { - return Err(CandidateSelectionError::Solver( - "covering graph is feasible but optimality is unproven".into(), - )); - } - Ok(proposal.selected) - }) - .transpose() -} - -/// One validated observation over every candidate, connection and output decision. -/// `None` excludes an infeasible assignment; a cost supplies its exact objective. -#[derive(Debug, Clone)] -pub(crate) struct ObservedAssignment { - pub selected: SelectedCandidateGraph, - pub exact_cost: Option, - pub rejected_queries: Vec, -} - -#[derive(Debug)] -pub(crate) struct CandidateProposal { - pub selected: SelectedCandidateGraph, - /// Complete modeled cost of this selected DAG, only for a certified direct profile. - pub direct_modeled_cost: Option, - /// Certified optimal relaxation objective, distinct from the local sum. - /// A feasible gap/time-limited incumbent objective is NOT a lower bound. - pub model_lower_bound: Option, -} - -/// Accumulated across refinement rounds, including infeasible and stopped solves. -#[derive(Debug, Default)] -pub(crate) struct CandidateSolveMetrics { - pub model_construction_seconds: f64, - pub solver_seconds: f64, - pub solve_calls: usize, - pub direct_cost_solve_calls: usize, - /// Direct solves jointly selecting producer-dependent pricing options. - pub joint_price_solve_calls: usize, - pub direct_cost_validated_plans: usize, - pub direct_cost_best_validated_cost: Option, - pub direct_cost_fallback_reasons: Vec, - /// Largest model submitted across refinement rounds, before solver presolve. - pub peak_variables: usize, - pub peak_constraints: usize, -} - -/// Refine exact costs at complete assignments and certified query failures at -/// their complete upstream dependency closures. -/// Feasible stopped solves may return proposals without a certified bound. -#[cfg(test)] -pub(crate) fn solve_candidate_proposal( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], - remaining_seconds: Option, -) -> Result, CandidateSelectionError> { - solve_candidate_proposal_with_metrics( - graph, - observations, - remaining_seconds, - &mut CandidateSolveMetrics::default(), - ) -} - -pub(crate) fn solve_candidate_proposal_with_metrics( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], - remaining_seconds: Option, - metrics: &mut CandidateSolveMetrics, -) -> Result, CandidateSelectionError> { - solve_with_objective(graph, observations, remaining_seconds, metrics, None, None) -} - -pub(crate) fn solve_direct_cost_proposal_with_metrics( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], - remaining_seconds: Option, - metrics: &mut CandidateSolveMetrics, - profile: &super::direct_cost::DirectCostProfile, -) -> Result, CandidateSelectionError> { - solve_with_objective( - graph, - observations, - remaining_seconds, - metrics, - Some(profile), - None, - ) -} - -pub(super) fn validate_observations( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], -) -> Result<(), CandidateSelectionError> { - for observation in observations { - validate_selected_graph(graph, &observation.selected) - .map_err(CandidateSelectionError::InvalidGraph)?; - for witness in &observation.rejected_queries { - witness - .validate(&observation.selected) - .map_err(CandidateSelectionError::InvalidGraph)?; - } - if observation.exact_cost.is_some() && !observation.rejected_queries.is_empty() { - return Err(CandidateSelectionError::InvalidGraph( - "feasible observation cannot reject a query".into(), - )); - } - if observation - .exact_cost - .is_some_and(|cost| !cost.is_finite() || cost < observation.selected.modeled_cost) - { - return Err(CandidateSelectionError::InvalidGraph( - "observed cost must be finite and at least its certified local bound".into(), - )); - } - } - Ok(()) -} - -pub(super) fn solve_with_objective( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &[ObservedAssignment], - remaining_seconds: Option, - metrics: &mut CandidateSolveMetrics, - direct_profile: Option<&super::direct_cost::DirectCostProfile>, - projection: Option<&super::direct_price_options::PriceOptionProjection<'_>>, -) -> Result, CandidateSelectionError> { - let started = std::time::Instant::now(); - graph.validate()?; - if let Some(profile) = direct_profile { - profile.validate(graph)?; - } - if remaining_seconds.is_some_and(|seconds| !seconds.is_finite() || seconds <= 0.0) { - return Err(CandidateSelectionError::Solver( - "no positive solver time remains".into(), - )); - } - let check_deadline = || { - if remaining_seconds.is_some_and(|seconds| started.elapsed().as_secs_f64() >= seconds) { - Err(CandidateSelectionError::Solver( - "time budget exhausted constructing model".into(), - )) - } else { - Ok(()) - } - }; - validate_observations(graph, observations)?; - if graph.query_roots.is_empty() { - if observations - .iter() - .any(|observation| observation.exact_cost.is_none()) - { - return Ok(None); - } - return Ok(Some(CandidateProposal { - direct_modeled_cost: direct_profile.map(|profile| profile.request_cost), - selected: SelectedCandidateGraph { - selected_candidates: Vec::new(), - connections: Vec::new(), - query_roots: Vec::new(), - modeled_cost: 0.0, - }, - model_lower_bound: Some( - observations - .iter() - .filter_map(|observation| observation.exact_cost) - .fold( - direct_profile.map_or(0.0, |profile| profile.request_cost), - f64::max, - ), - ), - })); - } - if graph - .query_roots - .iter() - .any(|root| root.candidates.is_empty()) - { - return Ok(None); - } - - // Direct objective auxiliaries have no complete feasible start yet. - let warm_start = direct_profile - .is_none() - .then(|| warm_start::cheapest_start(graph, observations)) - .flatten(); - let count = graph.candidates.len(); - let mut variables = ProblemVariables::new(); - let candidate_selected: Vec = (0..count) - .map(|index| { - variables.add(initialized( - variable().binary(), - warm_start - .as_ref() - .map(|start| f64::from(start.active.contains(&PhysicalCandidateId(index)))), - )) - }) - .collect(); - let topological_rank: Vec = (0..count) - .map(|index| { - variables.add(initialized( - variable().min(0).max((count - 1) as f64), - warm_start.as_ref().map(|start| start.ranks[index] as f64), - )) - }) - .collect(); - let mut connections = Vec::new(); - let mut root_assignments = Vec::new(); - let mut constraints = Vec::new(); - let mut support = vec![Expression::from(0.0); count]; - if let Some(projection) = projection { - projection.constrain_activation(&candidate_selected, &mut constraints); - } - - for (consumer, candidate) in graph.candidates.iter().enumerate() { - check_deadline()?; - for (requirement, input) in candidate.producer_candidates_per_input.iter().enumerate() { - let mut producers_selected = Expression::from(0.0); - for &producer in &input.candidates { - check_deadline()?; - let connection_selected = variables.add(initialized( - variable().binary(), - warm_start.as_ref().map(|start| { - f64::from(start.selected.connections.contains( - &ProducerConsumerConnection { - producer, - consumer: PhysicalCandidateId(consumer), - requirement, - }, - )) - }), - )); - producers_selected += connection_selected; - support[producer.0] += connection_selected; - constraints.push(constraint!( - connection_selected <= candidate_selected[producer.0] - )); - constraints.push(constraint!( - topological_rank[producer.0] + 1.0 - <= topological_rank[consumer] + count as f64 * (1.0 - connection_selected) - )); - connections.push(( - ProducerConsumerConnection { - producer, - consumer: PhysicalCandidateId(consumer), - requirement, - }, - connection_selected, - )); - } - constraints.push(constraint!( - producers_selected == candidate_selected[consumer] - )); - } - } - for (query_root, root) in graph.query_roots.iter().enumerate() { - let mut root_selected = Expression::from(0.0); - for &candidate in &root.candidates { - let assignment_selected = variables.add(initialized( - variable().binary(), - warm_start.as_ref().map(|start| { - f64::from(start.selected.query_roots.contains(&QueryRootAssignment { - query_root, - candidate, - })) - }), - )); - root_selected += assignment_selected; - support[candidate.0] += assignment_selected; - constraints.push(constraint!( - assignment_selected <= candidate_selected[candidate.0] - )); - root_assignments.push(( - QueryRootAssignment { - query_root, - candidate, - }, - assignment_selected, - )); - } - constraints.push(constraint!(root_selected == 1)); - } - let mut objective_reach = Vec::new(); - if !graph.reader_constraints.is_empty() || direct_profile.is_some() { - // Reachability is implied integral: roots and selected edges are binary, - // and selected edges form a DAG. Induction from query roots makes each - // AND/OR below exactly zero or one, so these auxiliaries need no integer - // branching. Unselected edges force their conjunction to zero. - // Allocate only potentially reachable output/candidate pairs. Selected - // edges determine actual reach; incompatible potential readers do not - // rule out a candidate used by a compatible subset. - let mut reach = Vec::with_capacity(graph.query_roots.len()); - for (output, potential) in - required_reader_reach(graph, direct_profile.is_some(), check_deadline)? - .into_iter() - .enumerate() - { - reach.push( - potential - .into_iter() - .enumerate() - .map(|(candidate, possible)| { - possible.then(|| { - variables.add(initialized( - variable().min(0).max(1), - warm_start.as_ref().map(|start| { - f64::from( - start.reached[output] - .contains(&PhysicalCandidateId(candidate)), - ) - }), - )) - }) - }) - .collect::>(), - ); - } - for (output, reachable) in reach.iter().enumerate() { - check_deadline()?; - let mut demanded = vec![Expression::from(0.0); count]; - for (root, selected) in &root_assignments { - if root.query_root == output { - if let Some(target) = reachable[root.candidate.0] { - constraints.push(constraint!(*selected <= target)); - demanded[root.candidate.0] += *selected; - } - } - } - for (edge, connected) in &connections { - check_deadline()?; - if let (Some(consumer_reached), Some(producer_reached)) = - (reachable[edge.consumer.0], reachable[edge.producer.0]) - { - let used = variables.add(initialized( - variable().min(0).max(1), - warm_start.as_ref().map(|start| { - f64::from( - start.reached[output].contains(&edge.consumer) - && start.selected.connections.contains(edge), - ) - }), - )); - constraints.push(constraint!(used <= consumer_reached)); - constraints.push(constraint!(used <= *connected)); - constraints.push(constraint!(used >= consumer_reached + *connected - 1)); - constraints.push(constraint!(used <= producer_reached)); - demanded[edge.producer.0] += used; - } - } - for (candidate, variable) in reachable.iter().enumerate() { - if let Some(reached) = variable { - constraints.push(constraint!(*reached <= demanded[candidate].clone())); - constraints.push(constraint!(*reached <= candidate_selected[candidate])); - } - } - } - if direct_profile.is_some() { - objective_reach = reach.clone(); - } - for requirement in &graph.reader_constraints { - match requirement { - ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } => { - constraints.push(constraint!( - candidate_selected[candidate.0] <= candidate_selected[required_candidate.0] - )); - } - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => { - if *minimum <= 1 { - continue; - } - if *minimum > query_outputs.len() { - constraints.push(constraint!(candidate_selected[candidate.0] == 0)); - continue; - } - let mut readers = Expression::from(0.0); - for outputs in query_outputs { - let reached: Vec<_> = outputs - .iter() - .filter_map(|&output| reach[output][candidate.0]) - .collect(); - if reached.is_empty() { - continue; - } - let query_reached = variables.add(initialized( - variable().min(0).max(1), - warm_start.as_ref().map(|start| { - f64::from( - outputs - .iter() - .any(|output| start.reached[*output].contains(candidate)), - ) - }), - )); - let mut any_output = Expression::from(0.0); - for output_reached in reached { - constraints.push(constraint!(query_reached >= output_reached)); - any_output += output_reached; - } - constraints.push(constraint!(query_reached <= any_output)); - readers += query_reached; - } - constraints.push(constraint!( - readers >= *minimum as f64 * candidate_selected[candidate.0] - )); - } - ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => { - let mut witnesses: Expression = root_assignments - .iter() - .filter(|(root, _)| root.candidate == *candidate) - .map(|(_, value)| *value) - .sum(); - for (edge, connected) in &connections { - if edge.producer != *candidate { - continue; - } - let witness = variables.add(initialized( - variable().binary(), - warm_start.as_ref().map(|start| { - f64::from( - start.selected.connections.contains(edge) - && query_outputs - .iter() - .filter(|outputs| { - outputs.iter().any(|output| { - start.reached[*output].contains(&edge.consumer) - }) - }) - .count() - <= 1, - ) - }), - )); - constraints.push(constraint!(witness <= *connected)); - let mut readers = Expression::from(0.0); - for outputs in query_outputs { - let reached: Vec<_> = outputs - .iter() - .filter_map(|output| reach[*output][edge.consumer.0]) - .collect(); - if reached.is_empty() { - continue; - } - let query = variables.add(initialized( - variable().min(0).max(1), - warm_start.as_ref().map(|start| { - f64::from(outputs.iter().any(|output| { - start.reached[*output].contains(&edge.consumer) - })) - }), - )); - for value in &reached { - constraints.push(constraint!(query >= *value)); - } - constraints.push(constraint!( - query <= reached.into_iter().sum::() - )); - readers += query; - } - constraints.push(constraint!( - readers <= 1.0 + query_outputs.len() as f64 * (1.0 - witness) - )); - witnesses += witness; - } - constraints.push(constraint!(candidate_selected[candidate.0] <= witnesses)); - } - ReaderConstraint::AllowedOutputs { candidate, outputs } => { - for (output, reachable) in reach.iter().enumerate() { - if !outputs.contains(&output) { - if let Some(reached) = reachable[candidate.0] { - constraints.push(constraint!(reached == 0)); - } - } - } - } - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => { - if let (Some(left), Some(right)) = - (reach[*left][candidate.0], reach[*right][candidate.0]) - { - constraints.push(constraint!(left + right <= 1)); - } - } - } - } - } - for candidate in 0..count { - constraints.push(constraint!( - candidate_selected[candidate] <= support[candidate].clone() - )); - } - for budget in &graph.resource_budgets { - // Normalize each resource independently of currency and other resources. - let scale = budget - .candidate_usage - .iter() - .copied() - .fold(budget.capacity, f64::max) - .max(f64::MIN_POSITIVE); - let usage: Expression = budget - .candidate_usage - .iter() - .zip(&candidate_selected) - .map(|(usage, selected)| (*usage / scale) * *selected) - .sum(); - constraints.push(constraint!(usage <= budget.capacity / scale)); - } - // Validation rejects positive coefficients outside the supported relative - // range; uniform currency-unit changes do not alter the selected graph. - let maximum_cost = graph - .candidates - .iter() - .map(|c| c.local_cost) - .fold(0.0, f64::max); - let maximum_cost = direct_profile - .into_iter() - .flat_map(|profile| profile.coefficients(graph)) - .fold(maximum_cost, f64::max); - let maximum_cost = observations - .iter() - .filter_map(|observation| observation.exact_cost) - .fold(maximum_cost, f64::max); - // Keep the smallest meaningful monetary coefficient at unit scale. - // Scaling by the largest (possibly unused, very expensive) candidate can - // push every feasible objective below the solver's absolute tolerance and - // turn an incumbent into a spurious optimum despite zero MIP gap settings. - let divisor = graph - .candidates - .iter() - .map(|candidate| candidate.local_cost) - .chain( - observations - .iter() - .filter_map(|observation| observation.exact_cost), - ) - .chain( - direct_profile - .into_iter() - .flat_map(|profile| profile.coefficients(graph)), - ) - .filter(|cost| *cost > 0.0) - .reduce(f64::min) - .unwrap_or(1.0); - // Direct held-memory profiles can span tiny reader buffers and large raw - // windows. Their lifetimes are dimensionless and the smallest currency - // coefficient is scaled to one; permit up to 1e12 here without rounding - // away fees. The fixed-node graph keeps its original stricter range. - let relative_coefficient_limit = if direct_profile.is_some() { - super::direct_cost::MINIMUM_DIRECT_RELATIVE_COEFFICIENT - } else { - MINIMUM_RELATIVE_COEFFICIENT - }; - if graph - .candidates - .iter() - .map(|candidate| candidate.local_cost) - .chain( - observations - .iter() - .filter_map(|observation| observation.exact_cost), - ) - .chain( - direct_profile - .into_iter() - .flat_map(|profile| profile.coefficients(graph)), - ) - .any(|cost| cost > 0.0 && cost / maximum_cost < relative_coefficient_limit) - { - let local_minimum = graph - .candidates - .iter() - .map(|candidate| candidate.local_cost) - .filter(|cost| *cost > 0.0) - .reduce(f64::min); - let direct_minimum = direct_profile - .into_iter() - .flat_map(|profile| profile.coefficients(graph)) - .filter(|cost| *cost > 0.0) - .reduce(f64::min); - let reason = format!("observed costs and objective coefficients exceed supported numerical range: min={divisor:e}, max={maximum_cost:e}, local_min={local_minimum:?}, direct_min={direct_minimum:?}"); - return Err(if direct_profile.is_some() { - CandidateSelectionError::UnsupportedDirectCost(reason) - } else { - CandidateSelectionError::InvalidGraph(reason) - }); - } - let local_bound: Expression = graph - .candidates - .iter() - .enumerate() - .map(|(index, candidate)| (candidate.local_cost / divisor) * candidate_selected[index]) - .sum(); - let total_cost = variables.add(initialized( - variable().min(0.0), - warm_start.as_ref().map(|start| start.cost / divisor), - )); - constraints.push(constraint!(total_cost >= local_bound)); - let objective = if let Some(profile) = direct_profile { - super::direct_cost::encode( - graph, - profile, - &mut variables, - &mut constraints, - &candidate_selected, - &connections, - &root_assignments, - &objective_reach, - divisor, - check_deadline, - )? - } else { - Expression::from(total_cost) - }; - // A certified direct expression needs no epigraph row. Keeping its large - // currency sum in the objective avoids postsolve cancellation in that row. - for observation in observations { - check_deadline()?; - for witness in &observation.rejected_queries { - let mut changed = Expression::from(0.0); - for (edge, variable) in &connections { - if witness.connections.contains(edge) { - changed += 1.0 - *variable; - } - } - for (root, variable) in &root_assignments { - if witness.query_roots.contains(root) { - changed += 1.0 - *variable; - } - } - constraints.push(constraint!(changed >= 1.0)); - } - let mut mismatch = Expression::from(0.0); - for (id, &variable) in candidate_selected.iter().enumerate() { - if observation - .selected - .selected_candidates - .contains(&PhysicalCandidateId(id)) - { - mismatch += 1.0 - variable; - } else { - mismatch += variable; - } - } - for (connection, variable) in &connections { - if observation.selected.connections.contains(connection) { - mismatch += 1.0 - *variable; - } else { - mismatch += *variable; - } - } - for (assignment, variable) in &root_assignments { - if observation.selected.query_roots.contains(assignment) { - mismatch += 1.0 - *variable; - } else { - mismatch += *variable; - } - } - match observation.exact_cost { - Some(_) if direct_profile.is_some() => {} // Already priced exactly. - Some(cost) => constraints.push(constraint!( - total_cost >= (cost / divisor) * (1.0 - mismatch) - )), - None => constraints.push(constraint!(mismatch >= 1.0)), - } - } - if let Some(projection) = projection { - projection.constrain_input_tuples( - &mut variables, - &candidate_selected, - &connections, - &mut constraints, - check_deadline, - )?; - projection.constrain_reader_contexts( - &candidate_selected, - &objective_reach, - &mut constraints, - check_deadline, - )?; - projection.constrain_observations( - &candidate_selected, - &connections, - &root_assignments, - &mut constraints, - ); - } - if warm_start.is_some() && variables.initial_solution_len() != variables.len() { - return Err(CandidateSelectionError::Solver( - "incomplete candidate warm start".into(), - )); - } - #[cfg(test)] - if warm_start.is_some() { - let values: std::collections::HashMap<_, _> = variables - .iter_variables_with_def() - .map(|(variable, definition)| (variable, definition.get_initial().unwrap())) - .collect(); - for constraint in &constraints { - let value = constraint.expression().eval_with(&values); - assert!( - if constraint.is_equality() { - value.abs() <= 1e-7 - } else { - value <= 1e-7 - }, - "infeasible warm-start constraint: {constraint:?} = {value}" - ); - } - } - metrics.peak_variables = metrics.peak_variables.max(variables.len()); - metrics.peak_constraints = metrics.peak_constraints.max(constraints.len()); - let mut model = variables - .minimise(objective.clone()) - .using(highs) - .set_option("mip_rel_gap", 0.0) - .set_option("mip_abs_gap", 0.0) - .with_all(constraints); - metrics.model_construction_seconds += started.elapsed().as_secs_f64(); - if let Some(seconds) = remaining_seconds { - let remaining = seconds - started.elapsed().as_secs_f64(); - if remaining <= 0.0 { - return Err(CandidateSelectionError::Solver( - "time budget exhausted constructing model".into(), - )); - } - model = model.set_option("time_limit", remaining); - } - let solve_started = std::time::Instant::now(); - metrics.solve_calls += 1; - metrics.direct_cost_solve_calls += usize::from(direct_profile.is_some()); - let result = model.solve(); - metrics.solver_seconds += solve_started.elapsed().as_secs_f64(); - let solution = match result { - Ok(solution) => solution, - Err(ResolutionError::Infeasible) => return Ok(None), - Err(error) => return Err(CandidateSelectionError::Solver(error.to_string())), - }; - // A stopped solve may still have a useful integral incumbent. Never round - // fractional or nonfinite decision values into a different feasible graph. - for variable in candidate_selected - .iter() - .chain(connections.iter().map(|(_, variable)| variable)) - .chain(root_assignments.iter().map(|(_, variable)| variable)) - { - validate_binary_value(solution.value(*variable))?; - } - let selected_candidates: Vec<_> = candidate_selected - .iter() - .enumerate() - .filter(|(_, variable)| solution.value(**variable) > 0.5) - .map(|(index, _)| PhysicalCandidateId(index)) - .collect(); - let modeled_cost = selected_candidates - .iter() - .map(|id| graph.candidates[id.0].local_cost) - .sum(); - let selected = SelectedCandidateGraph { - selected_candidates, - connections: connections - .into_iter() - .filter(|(_, selected)| solution.value(*selected) > 0.5) - .map(|(connection, _)| connection) - .collect(), - query_roots: root_assignments - .into_iter() - .filter(|(_, selected)| solution.value(*selected) > 0.5) - .map(|(assignment, _)| assignment) - .collect(), - modeled_cost, - }; - let direct_modeled_cost = direct_profile.map(|profile| profile.selected_cost(graph, &selected)); - let solved_objective = solution.eval(&objective) * divisor; - if matches!(solution.status(), SolutionStatus::Optimal) { - if let Some(decoded) = direct_modeled_cost { - // Decoding checks the selected graph independently of LP lifetime - // auxiliaries. A lost small term must not become a false certificate. - let tolerance = 1e-7 * decoded.abs().max(solved_objective.abs()); - if !decoded.is_finite() - || !solved_objective.is_finite() - || (decoded - solved_objective).abs() > tolerance - { - return Err(CandidateSelectionError::UnsupportedDirectCost(format!( - "direct solver objective disagrees with decoded cost: solver={solved_objective:e}, decoded={decoded:e}" - ))); - } - } - } - decoded_proposal(graph, selected, solution.status(), solved_objective).map(|mut proposal| { - proposal.direct_modeled_cost = direct_modeled_cost; - Some(proposal) - }) -} - -fn validate_binary_value(value: f64) -> Result<(), CandidateSelectionError> { - if !value.is_finite() || (value.abs() > 1e-6 && (value - 1.0).abs() > 1e-6) { - return Err(CandidateSelectionError::Solver( - "nonintegral candidate decision in solver incumbent".into(), - )); - } - Ok(()) -} - -fn decoded_proposal( - graph: &DagOfPhysicalCandidatesWithCosts, - selected: SelectedCandidateGraph, - status: SolutionStatus, - objective: f64, -) -> Result { - validate_selected_graph(graph, &selected).map_err(CandidateSelectionError::Solver)?; - if !objective.is_finite() || objective < 0.0 { - return Err(CandidateSelectionError::Solver( - "invalid solver incumbent objective".into(), - )); - } - Ok(CandidateProposal { - direct_modeled_cost: None, - selected, - model_lower_bound: matches!(status, SolutionStatus::Optimal).then_some(objective), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // A feasible stopped incumbent is usable, but its objective is not a dual bound. - #[test] - fn stopped_proposal_keeps_valid_graph_without_certifying_objective() { - let graph = dependency_graph(vec![candidate(2.0, &[])], &[&[0]]); - let selected = select_candidates(&graph).unwrap().unwrap(); - for status in [SolutionStatus::GapLimit, SolutionStatus::TimeLimit] { - let proposal = decoded_proposal(&graph, selected.clone(), status, 50.0).unwrap(); - assert_eq!(proposal.selected, selected); - assert_eq!(proposal.model_lower_bound, None); - } - assert_eq!( - decoded_proposal(&graph, selected.clone(), SolutionStatus::Optimal, 2.0) - .unwrap() - .model_lower_bound, - Some(2.0) - ); - let mut invalid = selected; - invalid.query_roots.clear(); - assert!(decoded_proposal(&graph, invalid, SolutionStatus::GapLimit, 50.0).is_err()); - for value in [f64::NAN, f64::INFINITY, -1.0, 0.4, 0.999] { - assert!(validate_binary_value(value).is_err()); - } - for value in [0.0, 1.0, 1.0 - 1e-8] { - assert!(validate_binary_value(value).is_ok()); - } - } - - // A shared producer owns resident state once, regardless of its reader count. - #[test] - fn resource_limits_price_shared_state_once_and_change_the_selected_plan() { - let mut graph = dependency_graph( - vec![ - candidate(8.0, &[]), - candidate(1.0, &[&[0]]), - candidate(1.0, &[&[0]]), - candidate(12.0, &[]), - candidate(12.0, &[]), - ], - &[&[1, 3], &[2, 4]], - ); - graph.resource_budgets.push(ResourceBudget { - name: "resident memory".into(), - capacity: 6.0, - candidate_usage: vec![6.0, 0.0, 0.0, 1.0, 1.0], - }); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 10.0); - let shared = compare_with_oracle(&graph).unwrap(); - graph.resource_budgets[0].capacity = 5.0; - assert!(validate_selected_graph(&graph, &shared).is_err()); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 24.0); - graph.resource_budgets[0].capacity = 1.0; - assert!(compare_with_oracle(&graph).is_none()); - graph.resource_budgets[0].candidate_usage.pop(); - assert!(matches!( - select_candidates(&graph), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - - // Unreachable large states must not silently erase a live resource constraint. - #[test] - fn unsupported_resource_dynamic_range_is_explicitly_rejected() { - let mut graph = dependency_graph( - vec![ - candidate(2.0, &[]), - candidate(1.0, &[]), - candidate(1.0, &[]), - ], - &[&[0, 1]], - ); - graph.resource_budgets.push(ResourceBudget { - name: "memory".into(), - capacity: 1.0, - candidate_usage: vec![1.0, 2.0, 1e20], - }); - assert!(matches!( - select_candidates(&graph), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - - // Activation ordering does not inspect readers and must not allocate - // per-output reachability flows through the entire candidate graph. - #[test] - fn activation_prefix_needs_no_reader_flow_variables() { - let mut graph = dependency_graph( - vec![candidate(1.0, &[]), candidate(1.0, &[])], - &[&[0], &[1]], - ); - let mut plain = CandidateSolveMetrics::default(); - solve_candidate_proposal_with_metrics(&graph, &[], None, &mut plain) - .unwrap() - .unwrap(); - graph - .reader_constraints - .push(ReaderConstraint::ActivationRequires { - candidate: PhysicalCandidateId(1), - required_candidate: PhysicalCandidateId(0), - }); - let mut ordered = CandidateSolveMetrics::default(); - solve_candidate_proposal_with_metrics(&graph, &[], None, &mut ordered) - .unwrap() - .unwrap(); - assert_eq!(ordered.peak_variables, plain.peak_variables); - assert_eq!(ordered.peak_constraints, plain.peak_constraints + 1); - compare_with_oracle(&graph).unwrap(); - } - - // An incompatible potential reader must choose another state, not disable it for everyone. - #[test] - fn reader_legality_depends_on_selected_transitive_connections() { - let mut graph = dependency_graph( - vec![ - candidate(2.0, &[]), - candidate(5.0, &[]), - candidate(1.0, &[&[0, 1]]), - candidate(1.0, &[&[0, 1]]), - candidate(0.0, &[&[2]]), - ], - &[&[4], &[3]], - ); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 4.0); - graph - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(0), - outputs: vec![0], - }); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 7.0); - graph.reader_constraints.clear(); - graph - .reader_constraints - .push(ReaderConstraint::IncompatibleOutputs { - candidate: PhysicalCandidateId(0), - left: 0, - right: 1, - }); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 7.0); - graph - .reader_constraints - .push(ReaderConstraint::IncompatibleOutputs { - candidate: PhysicalCandidateId(1), - left: 0, - right: 1, - }); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 9.0); - } - - // Scalar output references count as one query; separate queries can enable sharing. - #[test] - fn minimum_readers_count_queries_not_output_references() { - let mut graph = dependency_graph( - vec![candidate(1.0, &[]), candidate(5.0, &[])], - &[&[0, 1], &[0, 1]], - ); - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0, 1], vec![]], - minimum: 2, - }); - let one_query = compare_with_oracle(&graph).unwrap(); - assert_eq!(one_query.selected_candidates, vec![PhysicalCandidateId(1)]); - // Decoding must reject the same selected cheap state under the one-query mapping. - graph.reader_constraints.clear(); - let invalid_shared = select_candidates(&graph).unwrap().unwrap(); - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0, 1]], - minimum: 2, - }); - assert!(validate_selected_graph(&graph, &invalid_shared).is_err()); - graph.reader_constraints[0] = ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0], vec![1], vec![]], - minimum: 2, - }; - assert_eq!( - compare_with_oracle(&graph).unwrap().selected_candidates, - vec![PhysicalCandidateId(0)] - ); - } - - // Missing, repeated, and invalid references cannot define a query partition. - #[test] - fn minimum_reader_buckets_must_partition_outputs() { - for query_outputs in [ - vec![vec![0]], - vec![vec![0, 0]], - vec![vec![0, 2]], - vec![vec![0], vec![1, 1]], - ] { - let mut graph = dependency_graph(vec![candidate(1.0, &[])], &[&[0], &[0]]); - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs, - minimum: 2, - }); - assert!(matches!( - select_candidates(&graph), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - } - - #[test] - fn certified_query_cut_ignores_unrelated_queries_but_allows_repaired_input() { - let graph = dependency_graph( - vec![ - candidate(1.0, &[]), - candidate(4.0, &[]), - candidate(1.0, &[&[0, 1]]), - candidate(1.0, &[]), - candidate(2.0, &[]), - ], - &[&[2], &[3, 4]], - ); - let selected = select_candidates(&graph).unwrap().unwrap(); - let witness = RejectedQuerySubgraph::from_query_outputs(&selected, &[0]).unwrap(); - assert_eq!(witness.connections.len(), 1); - let observations = [ObservedAssignment { - selected, - exact_cost: None, - rejected_queries: vec![witness], - }]; - let repaired = solve_candidate_proposal(&graph, &observations, None) - .unwrap() - .unwrap(); - assert!(repaired - .selected - .connections - .iter() - .any(|edge| edge.producer == PhysicalCandidateId(1))); - // Even forcing another unrelated output cannot make the bad input legal. - let mut changed = graph.clone(); - changed.query_roots[1].candidates = vec![PhysicalCandidateId(4)]; - // Keep observations structurally valid by changing that unrelated binding. - let mut observation = observations[0].clone(); - observation - .selected - .selected_candidates - .retain(|id| *id != PhysicalCandidateId(3)); - observation - .selected - .selected_candidates - .push(PhysicalCandidateId(4)); - observation.selected.query_roots[1].candidate = PhysicalCandidateId(4); - observation.selected.modeled_cost += 1.0; - let repaired = solve_candidate_proposal(&changed, &[observation], None) - .unwrap() - .unwrap(); - assert!(repaired - .selected - .connections - .iter() - .any(|edge| edge.producer == PhysicalCandidateId(1))); - } - - #[test] - fn certified_query_cut_rejects_incomplete_or_foreign_closure() { - let graph = dependency_graph(vec![candidate(1.0, &[]), candidate(1.0, &[&[0]])], &[&[1]]); - let selected = select_candidates(&graph).unwrap().unwrap(); - let mut witness = RejectedQuerySubgraph::from_query_outputs(&selected, &[0]).unwrap(); - witness.connections.clear(); - let observation = ObservedAssignment { - selected, - exact_cost: None, - rejected_queries: vec![witness], - }; - assert!(matches!( - solve_candidate_proposal(&graph, &[observation], None), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - - #[test] - fn query_cut_contains_every_requested_scalar_output_closure() { - let graph = dependency_graph( - vec![ - candidate(1.0, &[]), - candidate(1.0, &[&[0]]), - candidate(1.0, &[]), - ], - &[&[1], &[2]], - ); - let selected = select_candidates(&graph).unwrap().unwrap(); - let witness = RejectedQuerySubgraph::from_query_outputs(&selected, &[0, 1]).unwrap(); - assert_eq!(witness.query_roots.len(), 2); - assert_eq!(witness.connections.len(), 1); - witness.validate(&selected).unwrap(); - } - - /// An unused ingestion configuration must not scale away improvements in - /// cheap query-time sharing. The unobserved materialized reader still has - /// zero certified cost and must be explored before declaring the known - /// unmaterialized plan optimal, independently of currency units. - #[test] - fn expensive_unused_candidate_cannot_hide_cheap_sharing_improvement() { - for currency_scale in [1e-6, 1.0, 1e6] { - let graph = dependency_graph( - vec![ - candidate(0.0, &[]), - candidate(0.0, &[&[0]]), - candidate(0.0, &[&[0]]), - candidate(16.800072 * currency_scale, &[]), - ], - &[&[1, 2, 3], &[1, 2, 3]], - ); - let observed = SelectedCandidateGraph { - selected_candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)], - connections: vec![ - crate::search::candidates::graph::ProducerConsumerConnection { - consumer: PhysicalCandidateId(1), - requirement: 0, - producer: PhysicalCandidateId(0), - }, - ], - query_roots: (0..2) - .map( - |query_root| crate::search::candidates::graph::QueryRootAssignment { - query_root, - candidate: PhysicalCandidateId(1), - }, - ) - .collect(), - modeled_cost: 0.0, - }; - let proposal = solve_candidate_proposal( - &graph, - &[ObservedAssignment { - selected: observed, - exact_cost: Some(8.533333870933332e-6 * currency_scale), - rejected_queries: vec![], - }], - None, - ) - .unwrap() - .unwrap(); - assert_eq!(proposal.model_lower_bound, Some(0.0)); - assert!(proposal - .selected - .selected_candidates - .contains(&PhysicalCandidateId(2))); - } - } - - // The start includes ranks and all per-output AND/OR auxiliaries, including - // two references belonging to one query. It does not change certified cost. - #[test] - fn complete_warm_start_preserves_oracle_optimum_and_reader_constraints() { - let mut graph = dependency_graph( - vec![ - candidate(3.0, &[]), - candidate(1.0, &[&[0]]), - candidate(1.0, &[&[0]]), - ], - &[&[1], &[1], &[2]], - ); - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0, 1], vec![2]], - minimum: 2, - }); - let selected = compare_with_oracle(&graph).unwrap(); - let observations = vec![ObservedAssignment { - exact_cost: Some(selected.modeled_cost), - selected: selected.clone(), - rejected_queries: vec![], - }]; - let start = warm_start::cheapest_start(&graph, &observations).unwrap(); - assert_eq!(start.ranks, vec![0, 1, 1]); - assert_eq!( - start.reached[0], - HashSet::from([PhysicalCandidateId(0), PhysicalCandidateId(1)]) - ); - let proposal = solve_candidate_proposal(&graph, &observations, None) - .unwrap() - .unwrap(); - assert_eq!(proposal.model_lower_bound, Some(selected.modeled_cost)); - assert_eq!(proposal.selected.modeled_cost, selected.modeled_cost); - } - - // A certified query rejection invalidates a seed even if another output - // differs; conflicting full-assignment observations also cannot seed HiGHS. - #[test] - fn warm_start_filters_rejections_and_uses_strongest_matching_cost() { - let graph = dependency_graph(vec![candidate(1.0, &[]), candidate(2.0, &[])], &[&[0, 1]]); - let selected = compare_with_oracle(&graph).unwrap(); - let mut observations = vec![ - ObservedAssignment { - selected: selected.clone(), - exact_cost: Some(3.0), - rejected_queries: vec![], - }, - ObservedAssignment { - selected: selected.clone(), - exact_cost: Some(5.0), - rejected_queries: vec![], - }, - ]; - assert_eq!( - warm_start::cheapest_start(&graph, &observations) - .unwrap() - .cost, - 5.0 - ); - let proposal = solve_candidate_proposal(&graph, &observations, None) - .unwrap() - .unwrap(); - assert_eq!( - proposal.selected.selected_candidates, - vec![PhysicalCandidateId(1)] - ); - assert_eq!(proposal.model_lower_bound, Some(2.0)); - observations.push(ObservedAssignment { - selected: selected.clone(), - exact_cost: None, - rejected_queries: vec![ - RejectedQuerySubgraph::from_query_outputs(&selected, &[0]).unwrap() - ], - }); - assert!(warm_start::cheapest_start(&graph, &observations).is_none()); - assert_eq!( - solve_candidate_proposal(&graph, &observations, None) - .unwrap() - .unwrap() - .selected - .selected_candidates, - vec![PhysicalCandidateId(1)] - ); - } - - // A query-scoped cut excludes a warm start even when the rejected complete - // assignment used a different implementation for another query. - #[test] - fn warm_start_checks_partial_rejection_closures() { - let graph = dependency_graph( - vec![ - candidate(1.0, &[]), - candidate(1.0, &[]), - candidate(2.0, &[]), - ], - &[&[0], &[1, 2]], - ); - let selected = compare_with_oracle(&graph).unwrap(); - let mut rejected = selected.clone(); - rejected.selected_candidates = vec![PhysicalCandidateId(0), PhysicalCandidateId(2)]; - rejected.query_roots[1].candidate = PhysicalCandidateId(2); - rejected.modeled_cost = 3.0; - validate_selected_graph(&graph, &rejected).unwrap(); - let witness = RejectedQuerySubgraph::from_query_outputs(&rejected, &[0]).unwrap(); - let observations = vec![ - ObservedAssignment { - selected, - exact_cost: Some(2.0), - rejected_queries: vec![], - }, - ObservedAssignment { - selected: rejected, - exact_cost: None, - rejected_queries: vec![witness], - }, - ]; - assert!(warm_start::cheapest_start(&graph, &observations).is_none()); - assert!(solve_candidate_proposal(&graph, &observations, None) - .unwrap() - .is_none()); - } - - // Stage 2 permits non-summary materialization only at the highest shared - // node: a shared parent blocks it, while distinct private parents allow it. - #[test] - fn highest_shared_materialization_matches_selected_connections() { - let mut graph = dependency_graph( - vec![ - candidate(1.0, &[]), - candidate(5.0, &[]), - candidate(1.0, &[&[0, 1]]), - candidate(1.0, &[&[0, 1]]), - ], - &[&[2], &[2]], - ); - graph - .reader_constraints - .push(ReaderConstraint::HighestSharedQueryMaterialization { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0], vec![1]], - }); - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 6.0); - graph.query_roots[1].candidates = vec![PhysicalCandidateId(3)]; - assert_eq!(compare_with_oracle(&graph).unwrap().modeled_cost, 3.0); - graph.query_roots[0].candidates = vec![PhysicalCandidateId(0)]; - let selected = compare_with_oracle(&graph).unwrap(); - assert_eq!(selected.modeled_cost, 2.0); - let observation = ObservedAssignment { - selected, - exact_cost: Some(2.0), - rejected_queries: vec![], - }; - assert!(solve_candidate_proposal(&graph, &[observation], None) - .unwrap() - .is_some()); - } - - fn candidate(cost: f64, inputs: &[&[usize]]) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: cost, - producer_candidates_per_input: inputs - .iter() - .map(|sources| InputProducerCandidates { - candidates: sources.iter().copied().map(PhysicalCandidateId).collect(), - }) - .collect(), - } - } - - fn dependency_graph( - candidates: Vec, - roots: &[&[usize]], - ) -> DagOfPhysicalCandidatesWithCosts { - DagOfPhysicalCandidatesWithCosts { - candidates, - resource_budgets: Vec::new(), - reader_constraints: Vec::new(), - query_roots: roots - .iter() - .map(|choices| QueryOutputCandidates { - candidates: choices.iter().copied().map(PhysicalCandidateId).collect(), - }) - .collect(), - } - } - - /// Independent small-input oracle: enumerate roots and edges, then traverse - /// their reachable graph. It uses DFS cycle detection, not rank constraints. - fn exhaustive_cost(graph: &DagOfPhysicalCandidatesWithCosts) -> Option { - let edges: Vec<_> = graph - .candidates - .iter() - .enumerate() - .flat_map(|(consumer, c)| { - c.producer_candidates_per_input.iter().enumerate().flat_map( - move |(requirement, input)| { - input - .candidates - .iter() - .map(move |producer| (consumer, requirement, producer.0)) - }, - ) - }) - .collect(); - let roots: Vec<_> = graph - .query_roots - .iter() - .enumerate() - .flat_map(|(q, root)| root.candidates.iter().map(move |c| (q, c.0))) - .collect(); - let bits = edges.len() + roots.len(); - assert!( - bits <= 20, - "oracle is deliberately restricted to tiny graphs" - ); - let mut best: Option = None; - for assignment in 0..(1usize << bits) { - let chosen = |bit: usize| assignment & (1usize << bit) != 0; - let selected_roots: Vec<_> = roots - .iter() - .enumerate() - .filter(|(i, _)| chosen(edges.len() + i)) - .map(|(_, root)| *root) - .collect(); - if (0..graph.query_roots.len()) - .any(|q| selected_roots.iter().filter(|(root, _)| *root == q).count() != 1) - { - continue; - } - let mut dependencies = vec![Vec::new(); graph.candidates.len()]; - for (index, &(consumer, requirement, producer)) in edges.iter().enumerate() { - if chosen(index) { - dependencies[consumer].push((requirement, producer)); - } - } - fn visit( - candidate: usize, - graph: &DagOfPhysicalCandidatesWithCosts, - dependencies: &[Vec<(usize, usize)>], - marks: &mut [u8], - ) -> bool { - if marks[candidate] == 1 { - return false; - } - if marks[candidate] == 2 { - return true; - } - marks[candidate] = 1; - for requirement in 0..graph.candidates[candidate] - .producer_candidates_per_input - .len() - { - let producers: Vec<_> = dependencies[candidate] - .iter() - .filter(|(r, _)| *r == requirement) - .collect(); - if producers.len() != 1 || !visit(producers[0].1, graph, dependencies, marks) { - return false; - } - } - marks[candidate] = 2; - true - } - let mut marks = vec![0; graph.candidates.len()]; - if !selected_roots - .iter() - .all(|(_, root)| visit(*root, graph, &dependencies, &mut marks)) - { - continue; - } - if dependencies - .iter() - .enumerate() - .any(|(c, edges)| marks[c] == 0 && !edges.is_empty()) - { - continue; - } - if graph.resource_budgets.iter().any(|budget| { - budget - .candidate_usage - .iter() - .zip(&marks) - .filter(|(_, mark)| **mark == 2) - .map(|(usage, _)| usage) - .sum::() - > budget.capacity - }) { - continue; - } - let reaches = |output: usize, target: PhysicalCandidateId| { - let mut individual = vec![0; graph.candidates.len()]; - for (_, root) in selected_roots.iter().filter(|(q, _)| *q == output) { - if !visit(*root, graph, &dependencies, &mut individual) { - return false; - } - } - individual[target.0] == 2 - }; - if graph - .reader_constraints - .iter() - .any(|requirement| match requirement { - ReaderConstraint::ActivationRequires { - candidate, - required_candidate, - } => marks[candidate.0] == 2 && marks[required_candidate.0] != 2, - ReaderConstraint::HighestSharedQueryMaterialization { - candidate, - query_outputs, - } => { - marks[candidate.0] == 2 - && !selected_roots.iter().any(|(_, root)| *root == candidate.0) - && !dependencies - .iter() - .enumerate() - .any(|(consumer, producers)| { - marks[consumer] == 2 - && producers - .iter() - .any(|(_, producer)| *producer == candidate.0) - && query_outputs - .iter() - .filter(|outputs| { - outputs.iter().any(|q| { - reaches(*q, PhysicalCandidateId(consumer)) - }) - }) - .count() - <= 1 - }) - } - ReaderConstraint::MinimumQueryReaders { - candidate, - query_outputs, - minimum, - } => { - marks[candidate.0] == 2 - && query_outputs - .iter() - .filter(|outputs| { - outputs.iter().any(|&output| reaches(output, *candidate)) - }) - .count() - < *minimum - } - ReaderConstraint::AllowedOutputs { candidate, outputs } => { - (0..graph.query_roots.len()) - .any(|output| !outputs.contains(&output) && reaches(output, *candidate)) - } - ReaderConstraint::IncompatibleOutputs { - candidate, - left, - right, - } => reaches(*left, *candidate) && reaches(*right, *candidate), - }) - { - continue; - } - let cost = graph - .candidates - .iter() - .zip(&marks) - .filter(|(_, mark)| **mark == 2) - .map(|(c, _)| c.local_cost) - .sum::(); - best = Some(best.map_or(cost, |previous| previous.min(cost))); - } - best - } - - fn compare_with_oracle( - graph: &DagOfPhysicalCandidatesWithCosts, - ) -> Option { - let expected = exhaustive_cost(graph); - let actual = select_candidates(graph).unwrap(); - match (&actual, expected) { - (Some(plan), Some(expected)) => { - assert!((plan.modeled_cost - expected).abs() <= 1e-8 * expected.abs().max(1.0)) - } - (None, None) => {} - _ => panic!("MILP {actual:?}, exhaustive {expected:?}"), - } - actual - } - - /// One activated build serves two query roots without paying per connection. - #[test] - fn shared_build_cost_is_owned_once() { - let graph = dependency_graph( - vec![ - candidate(8.0, &[]), - candidate(1.0, &[&[0]]), - candidate(1.0, &[&[0]]), - candidate(9.0, &[]), - candidate(9.0, &[]), - ], - &[&[1, 3], &[2, 4]], - ); - let selected = compare_with_oracle(&graph).unwrap(); - assert_eq!(selected.modeled_cost, 10.0); - assert_eq!(selected.connections.len(), 2); - assert!(selected - .connections - .iter() - .all(|c| c.producer == PhysicalCandidateId(0))); - } - - /// Distinct consumer contracts can require two physical choices of one group. - #[test] - fn one_group_can_have_multiple_selected_candidates() { - let graph = dependency_graph( - vec![ - candidate(3.0, &[]), - candidate(5.0, &[]), - candidate(1.0, &[&[0]]), - candidate(1.0, &[&[1]]), - ], - &[&[2], &[3]], - ); - let selected = compare_with_oracle(&graph).unwrap(); - assert_eq!(selected.modeled_cost, 10.0); - // Candidates 0 and 1 are both the first group's. - assert_eq!( - selected - .selected_candidates - .iter() - .filter(|c| c.0 < 2) - .count(), - 2 - ); - } - - /// A join needs both requirements, even when one producer supplies both. - #[test] - fn every_requirement_has_a_producer() { - let graph = dependency_graph( - vec![candidate(2.0, &[]), candidate(1.0, &[&[0], &[0]])], - &[&[1]], - ); - let selected = compare_with_oracle(&graph).unwrap(); - assert_eq!(selected.modeled_cost, 3.0); - assert_eq!( - selected - .connections - .iter() - .map(|c| c.requirement) - .collect::>(), - vec![0, 1] - ); - let impossible = dependency_graph(vec![candidate(1.0, &[&[]])], &[&[0]]); - assert!(compare_with_oracle(&impossible).is_none()); - } - - /// Root-reachable cycles are infeasible; unused zero-cost cycles cannot activate. - #[test] - fn cycles_and_unused_nodes_are_excluded() { - let graph = dependency_graph( - vec![ - candidate(0.0, &[]), - candidate(0.0, &[&[2]]), - candidate(0.0, &[&[1]]), - candidate(0.0, &[]), - ], - &[&[0]], - ); - let selected = compare_with_oracle(&graph).unwrap(); - assert_eq!(selected.selected_candidates, vec![PhysicalCandidateId(0)]); - let cyclic = dependency_graph(graph.candidates, &[&[1]]); - assert!(compare_with_oracle(&cyclic).is_none()); - let self_cycle = dependency_graph(vec![candidate(0.0, &[&[0]])], &[&[0]]); - assert!(compare_with_oracle(&self_cycle).is_none()); - } - - /// Dense compatibility choices agree with exhaustive graph selection across costs. - #[test] - fn competing_producers_match_exhaustive_enumeration() { - for build_cost in [0.0, 1.0, 4.0, 12.0] { - for local_cost in [0.5, 3.0, 8.0] { - let graph = dependency_graph( - vec![ - candidate(build_cost, &[]), - candidate(2.0, &[]), - candidate(0.25, &[&[0, 1]]), - candidate(local_cost, &[]), - candidate(0.25, &[&[0, 1]]), - candidate(local_cost, &[]), - ], - &[&[2, 3], &[4, 5]], - ); - compare_with_oracle(&graph); - } - } - } - - /// Currency-unit scaling must not erase the cheaper candidate's advantage. - #[test] - fn small_uniform_cost_units_preserve_selection() { - let graph = dependency_graph( - vec![candidate(3e-15, &[]), candidate(1e-15, &[])], - &[&[0, 1]], - ); - let selected = select_candidates(&graph).unwrap().unwrap(); - assert_eq!(selected.selected_candidates, vec![PhysicalCandidateId(1)]); - assert_eq!(selected.modeled_cost, 1e-15); - } - - /// An unrelated large candidate must not erase relevant root-choice costs. - #[test] - fn unsupported_cost_dynamic_range_is_rejected() { - let graph = dependency_graph( - vec![ - candidate(1e300, &[]), - candidate(1e-300, &[]), - candidate(2e-300, &[]), - ], - &[&[1, 2]], - ); - assert!(matches!( - select_candidates(&graph), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - - /// Decoder validation catches missing requirements and unused selected nodes. - #[test] - fn decoded_graph_is_checked_independently_of_solver_values() { - let graph = dependency_graph( - vec![ - candidate(2.0, &[]), - candidate(1.0, &[&[0]]), - candidate(0.0, &[]), - ], - &[&[1]], - ); - let selected = select_candidates(&graph).unwrap().unwrap(); - assert!(validate_selected_graph(&graph, &selected).is_ok()); - let mut missing_input = selected.clone(); - missing_input.connections.clear(); - assert!(validate_selected_graph(&graph, &missing_input).is_err()); - let mut unused = selected.clone(); - unused.selected_candidates.push(PhysicalCandidateId(2)); - assert!(validate_selected_graph(&graph, &unused).is_err()); - let mut bad_root = selected; - bad_root.query_roots[0].query_root = 9; - assert!(validate_selected_graph(&graph, &bad_root).is_err()); - } - - /// Invalid indices and numeric costs fail before entering the native solver. - #[test] - fn malformed_graphs_return_errors() { - for cost in [f64::NAN, f64::INFINITY, -1.0] { - assert!(matches!( - select_candidates(&dependency_graph(vec![candidate(cost, &[])], &[&[0]])), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - for invalid in [ - dependency_graph(vec![candidate(1.0, &[&[1]])], &[&[0]]), - dependency_graph(vec![candidate(1.0, &[])], &[&[1]]), - dependency_graph(vec![candidate(1.0, &[&[0, 0]])], &[&[0]]), - dependency_graph(vec![candidate(1.0, &[])], &[&[0, 0]]), - ] { - assert!(matches!( - select_candidates(&invalid), - Err(CandidateSelectionError::InvalidGraph(_)) - )); - } - assert_eq!( - select_candidates(&DagOfPhysicalCandidatesWithCosts::default()) - .unwrap() - .unwrap() - .modeled_cost, - 0.0 - ); - assert!(select_candidates(&dependency_graph(Vec::new(), &[&[]])) - .unwrap() - .is_none()); - } -} diff --git a/crates/plan-selection/src/search/milp/schedule_selection_tests.rs b/crates/plan-selection/src/search/milp/schedule_selection_tests.rs deleted file mode 100644 index 00d54c502..000000000 --- a/crates/plan-selection/src/search/milp/schedule_selection_tests.rs +++ /dev/null @@ -1,360 +0,0 @@ -use super::*; -use crate::search::milp::model::{solve_direct_cost_proposal_with_metrics, CandidateSolveMetrics}; -use asap_types::stages::*; -use asap_types::workload::{QueryRecurrence, RepeatedDemand, RepetitionInterval}; - -fn union(queries: &[QueryRecurrence], mask: usize) -> f64 { - asap_cost::execution_demand::evaluation_rate( - queries - .iter() - .enumerate() - .filter(|(q, _)| mask & (1 << q) != 0) - .map(|(_, query)| (query, None)), - 3600.0, - ) - .unwrap() -} -fn schedules(queries: &[QueryRecurrence]) -> QueryScheduleRates { - use asap_types::workload::{Predictability, Query, QueryRequirements, QueryWorkloadEntry}; - let entries: Vec<_> = queries - .iter() - .map(|recurrence| QueryWorkloadEntry { - query: Query("m".into()), - requirements: QueryRequirements { - accuracy: asap_types::ir::properties::AccuracyTarget::exact(), - max_latency_ms: None, - }, - predictability: Predictability::Predictable { known_at: None }, - recurrence: recurrence.clone(), - range_evaluation: None, - }) - .collect(); - QueryScheduleRates::new(&entries, 3600.0, None).unwrap() -} -pub(super) fn rate_schedule(rates: &[f64]) -> QueryScheduleRates { - schedules( - &rates - .iter() - .map(|rate| { - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - (1000.0 / rate) as u32, - ))) - }) - .collect::>(), - ) -} -fn node(input: Option) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: input - .into_iter() - .map(|id| InputProducerCandidates { - candidates: vec![PhysicalCandidateId(id)].into(), - }) - .collect(), - } -} -fn root(id: usize) -> QueryOutputCandidates { - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(id)], - } -} - -// Boundary demand is the evaluator's union rate; upstream held memory is -// computed once per boundary execution, at that union rate, and held for the -// boundary's longest reader (7.5 s), not the rate-weighted mean (6.83 s) nor -// at the sum of root rates. -#[test] -fn heterogeneous_intervals_price_boundary_rates_and_longest_reader_lifetimes() { - let queries = [500, 1000] - .map(|ms| QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(ms)))); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(Some(0)), node(Some(1)), node(Some(1))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - let profile = DirectCostProfile { - schedules: Some(schedules(&queries)), - query_rate: 1.0, - request_latency_seconds: 0.5, - request_cost: 7.0, - nodes: vec![ - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - ..Default::default() - }, - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 4.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 3.0, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 4.0, - compaction_seconds: 0.0, - ..Default::default() - }, - ], - }; - assert_eq!( - profile - .schedules - .as_ref() - .unwrap() - .union_rate([true, true].into_iter()), - 3.0 - ); - let result = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((result.direct_modeled_cost.unwrap() - 173.5).abs() < 1e-8); - assert!((result.model_lower_bound.unwrap() - 173.5).abs() < 1e-8); -} - -// Same-interval readers can share one evaluation only at compatible phases. -// Compare with the evaluator's policy rather than assuming equal rates merge. -#[test] -fn aligned_and_offset_phases_use_evaluator_union_policy() { - use asap_types::workload::TimestampMs; - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None)], - query_roots: vec![root(0), root(0)], - ..Default::default() - }; - for (second_phase, expected) in [(0, 7.0), (500, 14.0)] { - let queries = [0, second_phase].map(|phase| { - QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { - interval: RepetitionInterval(1000), - evaluation_phase: TimestampMs(phase), - }) - }); - let profile = DirectCostProfile { - schedules: Some(schedules(&queries)), - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - nodes: vec![NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 3.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 2.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }], - }; - let result = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((result.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - // HiGHS epigraph feasibility is tolerance-based; the decoded selected - // cost above is recomputed independently and remains exact here. - assert!( - (result.model_lower_bound.unwrap() - expected).abs() <= 1e-7 * expected, - "bound={:?}, expected={expected}", - result.model_lower_bound - ); - } -} - -// Every reader can independently choose either materialized producer. -// Compare the joint optimum with four independently priced assignments. -#[test] -fn heterogeneous_selection_matches_exhaustive_reader_assignments() { - let queries = [500, 1000] - .map(|ms| QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(ms)))); - let schedule = schedules(&queries); - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(Some(0)), node(Some(0))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - for id in [2, 3] { - graph.candidates[id].producer_candidates_per_input[0].candidates = - vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(); - } - let profile = DirectCostProfile { - schedules: Some(schedule.clone()), - query_rate: 1.0, - request_latency_seconds: 0.5, - request_cost: 7.0, - nodes: vec![ - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 10.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - held_memory_cost_per_second: 3.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - per_request: false, - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - work_seconds: 0.5, - compaction_seconds: 0.0, - held_memory_cost_per_second: 1.0, - materialized: true, - raw_retention: None, - refresh_window: None, - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 2.0, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - work_seconds: 3.0, - compaction_seconds: 0.0, - ..Default::default() - }, - ], - }; - let expected = [[0, 0], [0, 1], [1, 0], [1, 1]] - .into_iter() - .map(|assignment| { - let latency = [ - 2.5 + [1.0, 0.5][assignment[0]], - 3.5 + [1.0, 0.5][assignment[1]], - ]; - let mut cost = 7.0 + 3.0; - for supplier in [0, 1] { - let mask = assignment - .iter() - .enumerate() - .filter(|(_, chosen)| **chosen == supplier) - .fold(0, |mask, (q, _)| mask | (1 << q)); - let longest = latency - .iter() - .enumerate() - .filter(|(q, _)| mask & (1 << q) != 0) - .map(|(_, time)| *time) - .fold(0.0, f64::max); - cost += union(&queries, mask) - * ([10.0, 2.0][supplier] + [3.0, 1.0][supplier] * longest); - } - cost - }) - .fold(f64::INFINITY, f64::min); - let result = solve_direct_cost_proposal_with_metrics( - &graph, - &[], - None, - &mut CandidateSolveMetrics::default(), - &profile, - ) - .unwrap() - .unwrap(); - assert!((result.direct_modeled_cost.unwrap() - expected).abs() < 1e-8); - // HiGHS epigraph feasibility is tolerance-based; the decoded selected - // cost above is recomputed independently and remains exact here. - assert!( - (result.model_lower_bound.unwrap() - expected).abs() <= 1e-7 * expected, - "bound={:?}, expected={expected}", - result.model_lower_bound - ); -} -// A rare slow reader's weighted held lifetime can dominate cost despite a -// tiny rate. Reject that numerically unsupported mean before optimization, -// rather than erase its contribution in a relaxed equality. -#[test] -fn extreme_reader_rates_are_explicitly_unsupported() { - let queries = [1, u32::MAX] - .map(|ms| QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(ms)))); - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![node(None), node(None), node(Some(0)), node(Some(0))], - query_roots: vec![root(2), root(3)], - ..Default::default() - }; - let profile = DirectCostProfile { - schedules: Some(schedules(&queries)), - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - nodes: vec![ - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - held_memory_cost_per_second: 1e6, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - held_memory_cost_per_second: 1e6, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 1.0, - compaction_seconds: 0.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - work_seconds: 1e9, - compaction_seconds: 0.0, - ..Default::default() - }, - ], - }; - let rates = &profile.schedules.as_ref().unwrap().per_query_rates(); - assert!( - rates[1] * 1e9 * 1e6 > 1e8, - "the rare reader has meaningful memory cost" - ); - assert!(matches!( - profile.validate(&graph), - Err(CandidateSelectionError::UnsupportedDirectCost(_)) - )); -} diff --git a/crates/plan-selection/src/search/milp/warm_start.rs b/crates/plan-selection/src/search/milp/warm_start.rs deleted file mode 100644 index 8085b0a8b..000000000 --- a/crates/plan-selection/src/search/milp/warm_start.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Complete feasible starts from already validated candidate assignments. -use super::{ - DagOfPhysicalCandidatesWithCosts, ObservedAssignment, PhysicalCandidateId, - SelectedCandidateGraph, -}; -use std::collections::HashSet; - -pub(in crate::search) struct WarmStart<'a> { - pub selected: &'a SelectedCandidateGraph, - pub active: HashSet, - pub ranks: Vec, - pub reached: Vec>, - pub cost: f64, -} - -#[derive(PartialEq, Eq)] -struct AssignmentKey { - candidates: Vec, - connections: Vec<(usize, usize, usize)>, - outputs: Vec<(usize, usize)>, -} - -impl AssignmentKey { - fn new(selected: &SelectedCandidateGraph) -> Self { - let mut candidates = selected.selected_candidates.clone(); - let mut connections: Vec<_> = selected - .connections - .iter() - .map(|edge| (edge.consumer.0, edge.requirement, edge.producer.0)) - .collect(); - let mut outputs: Vec<_> = selected - .query_roots - .iter() - .map(|root| (root.query_root, root.candidate.0)) - .collect(); - candidates.sort_unstable(); - connections.sort_unstable(); - outputs.sort_unstable(); - Self { - candidates, - connections, - outputs, - } - } -} - -/// Inputs have passed structural validation. A later certified rejection may -/// exclude a formerly feasible observation; never submit that assignment as a -/// start. Cost refinements of the same assignment all apply simultaneously. -pub(in crate::search) fn cheapest_start<'a>( - graph: &DagOfPhysicalCandidatesWithCosts, - observations: &'a [ObservedAssignment], -) -> Option> { - // Canonical decision keys avoid repeated quadratic edge-set comparisons - // as the refinement history grows. - let keys: Vec<_> = observations - .iter() - .map(|observation| AssignmentKey::new(&observation.selected)) - .collect(); - let (observation, cost) = observations - .iter() - .enumerate() - .filter_map(|(index, observation)| { - observation.exact_cost?; - let selected = &observation.selected; - if observations.iter().enumerate().any(|(other_index, other)| { - (other.exact_cost.is_none() && keys[index] == keys[other_index]) - || other.rejected_queries.iter().any(|witness| { - witness - .connections - .iter() - .all(|edge| selected.connections.contains(edge)) - && witness - .query_roots - .iter() - .all(|root| selected.query_roots.contains(root)) - }) - }) { - return None; - } - let local: f64 = selected - .selected_candidates - .iter() - .map(|id| graph.candidates[id.0].local_cost) - .sum(); - let cost = observations - .iter() - .enumerate() - .filter(|(other_index, _)| keys[index] == keys[*other_index]) - .filter_map(|(_, other)| other.exact_cost) - .fold(local, f64::max); - Some((observation, cost)) - }) - .min_by(|(_, left), (_, right)| left.total_cmp(right))?; - let selected = &observation.selected; - let active: HashSet<_> = selected.selected_candidates.iter().copied().collect(); - let mut producers = vec![Vec::new(); graph.candidates.len()]; - let mut consumers = vec![Vec::new(); graph.candidates.len()]; - for edge in &selected.connections { - producers[edge.consumer.0].push(edge.producer); - consumers[edge.producer.0].push(edge.consumer); - } - let mut outstanding: Vec<_> = producers.iter().map(Vec::len).collect(); - let mut ready: Vec<_> = active - .iter() - .copied() - .filter(|id| outstanding[id.0] == 0) - .collect(); - let mut ranks = vec![0; graph.candidates.len()]; - while let Some(producer) = ready.pop() { - for &consumer in &consumers[producer.0] { - ranks[consumer.0] = ranks[consumer.0].max(ranks[producer.0] + 1); - outstanding[consumer.0] -= 1; - if outstanding[consumer.0] == 0 { - ready.push(consumer); - } - } - } - let mut reached = vec![HashSet::new(); graph.query_roots.len()]; - for root in &selected.query_roots { - let mut pending = vec![root.candidate]; - while let Some(candidate) = pending.pop() { - if reached[root.query_root].insert(candidate) { - pending.extend(producers[candidate.0].iter().copied()); - } - } - } - Some(WarmStart { - selected, - active, - ranks, - reached, - cost, - }) -} diff --git a/crates/plan-selection/src/search/mod.rs b/crates/plan-selection/src/search/mod.rs deleted file mode 100644 index efa443b74..000000000 --- a/crates/plan-selection/src/search/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Today's Stage 3 engine behind [`select_plan`](crate::select_plan): the -//! candidate graph with costs, cost bounds, the MILP and the evaluator-backed -//! refinement loop. #1095 replaces it. - -pub(crate) mod candidates; -pub(crate) mod costing; -pub(crate) mod milp; -pub(crate) mod refinement; -pub(crate) mod select; - -/// Stage 2's realizer over `roots`, `entries` and `data`, for tests that -/// build their own catalogs. -#[cfg(test)] -pub(crate) fn test_realizer<'a>( - roots: &'a [asap_types::ir::QueryRoot], - entries: &'a [asap_types::workload::QueryWorkloadEntry], - data: &'a asap_types::workload::DataWorkload, -) -> asap_physical_asap_optimization::Stage2AssignmentRealizer<'a> { - asap_physical_asap_optimization::Stage2AssignmentRealizer { - original_roots: roots, - entries, - data, - query_time_results_retained: false, - } -} diff --git a/crates/plan-selection/src/search/refinement/materialization.rs b/crates/plan-selection/src/search/refinement/materialization.rs deleted file mode 100644 index 07b21a96b..000000000 --- a/crates/plan-selection/src/search/refinement/materialization.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Restricted proposals over the same finite catalog, preserving computations -//! and producer bindings while changing one query-time materialization choice. -use crate::search::candidates::dag::DagOfPhysicalCandidatesWithCosts; -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::candidates::dag::ReaderConstraint; -use crate::search::candidates::graph::SelectedCandidateGraph; -use asap_types::physical::Materialization; -use asap_types::stages::PhysicalCandidateId; - -pub(super) fn counterparts( - configured: &PhysicalCandidatesWithCosts, - deadline: Option, -) -> Vec<(PhysicalCandidateId, PhysicalCandidateId)> { - let expired = || deadline.is_some_and(|limit| std::time::Instant::now() >= limit); - let mut materialized = std::collections::HashMap::new(); - for (id, configuration) in configured.physical.candidates.iter().enumerate() { - if expired() { - break; - } - if matches!( - configuration.materialization, - Materialization::MatAtQuery(_) - ) { - materialized - .entry((configuration.operator_node, configuration.timing)) - .or_insert(PhysicalCandidateId(id)); - } - } - let mut changes = Vec::new(); - for (from, current) in configured.physical.candidates.iter().enumerate() { - if expired() { - break; - } - if current.materialization == Materialization::NotMaterialized { - if let Some(&to) = materialized.get(&(current.operator_node, current.timing)) { - changes.push((PhysicalCandidateId(from), to)); - } - } - } - changes -} - -pub(super) fn restrict( - graph: &DagOfPhysicalCandidatesWithCosts, - selected: &SelectedCandidateGraph, - from: PhysicalCandidateId, - to: PhysicalCandidateId, - deadline: std::time::Instant, -) -> Option { - if std::time::Instant::now() >= deadline - || !selected.selected_candidates.contains(&from) - || selected.selected_candidates.contains(&to) - { - return None; - } - let replace = |id| if id == from { to } else { id }; - let mut active = vec![false; graph.candidates.len()]; - for &id in &selected.selected_candidates { - active[replace(id).0] = true; - } - let bindings: std::collections::HashMap<_, _> = selected - .connections - .iter() - .map(|edge| { - ( - (replace(edge.consumer), edge.requirement), - replace(edge.producer), - ) - }) - .collect(); - if std::time::Instant::now() >= deadline { - return None; - } - let mut restricted = graph.clone(); - for (id, candidate) in restricted.candidates.iter_mut().enumerate() { - if std::time::Instant::now() >= deadline { - return None; - } - if !active[id] { - candidate.producer_candidates_per_input.clear(); - restricted - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(id), - outputs: Vec::new(), - }); - continue; - } - for (position, requirement) in candidate - .producer_candidates_per_input - .iter_mut() - .enumerate() - { - let producer = *bindings.get(&(PhysicalCandidateId(id), position))?; - if !requirement.candidates.contains(&producer) { - return None; - } - requirement.candidates = vec![producer].into(); - } - } - for root in &selected.query_roots { - let candidate = replace(root.candidate); - let suppliers = &mut restricted.query_roots[root.query_root].candidates; - if !suppliers.contains(&candidate) { - return None; - } - *suppliers = vec![candidate]; - } - Some(restricted) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::dag::{ - InputProducerCandidates, PhysicalCandidateWithCost, QueryOutputCandidates, - }; - use crate::search::refinement::search::{ - search_candidates_with_restricted_feedback, CandidateEvaluation, CandidateSearchLimits, - CandidateSearchStatus, - }; - - use std::time::Duration; - - fn graph(readers: usize) -> DagOfPhysicalCandidatesWithCosts { - let mut graph = DagOfPhysicalCandidatesWithCosts::default(); - for _ in 0..2 { - graph.candidates.push(PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: vec![], - }); - } - for query in 0..readers { - graph.candidates.push(PhysicalCandidateWithCost { - local_cost: 0.0, - producer_candidates_per_input: vec![InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }], - }); - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(query + 2)], - }); - } - graph - .reader_constraints - .push(ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(1), - query_outputs: (0..readers).map(|query| vec![query]).collect(), - minimum: 2, - }); - graph - } - - fn run( - readers: usize, - budget: usize, - cancel_neighbor: bool, - ) -> (super::super::search::CandidateSearchResult, Vec) { - let graph = graph(readers); - let weights: Vec<_> = (0..graph.candidates.len()) - .map(|id| if id == 1 { 20.0 } else { 1.0 }) - .collect(); - let mut visited = Vec::new(); - let result = search_candidates_with_restricted_feedback( - &graph, - CandidateSearchLimits { - max_refinements: budget, - wall_time: Duration::from_secs(5), - }, - &weights, - &[], - &[(PhysicalCandidateId(0), PhysicalCandidateId(1))], - |selected| { - let materialized = selected - .selected_candidates - .contains(&PhysicalCandidateId(1)); - visited.push(usize::from(materialized)); - if materialized && cancel_neighbor { - return Ok(CandidateEvaluation { - budget_exhausted: true, - ..Default::default() - }); - } - // Materializing the producer avoids repeated upstream work; - // local certified costs deliberately cannot express that saving. - Ok(Some(if materialized { 10.0 } else { 30.0 }).into()) - }, - ) - .unwrap(); - (result, visited) - } - - #[test] - fn materialization_proposal_improves_cost_without_global_bound() { - let (result, visited) = run(2, 2, false); - assert_eq!(visited, vec![0, 1]); - assert_eq!(result.incumbent.unwrap().actual_cost, 10.0); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.refinements, 2); - assert_eq!(result.solve_metrics.solve_calls, 2); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - } - - #[test] - fn materialization_proposal_preserves_reader_legality_and_budget() { - let (single, visited) = run(1, 2, false); - assert!(visited.iter().all(|&id| id == 0)); - assert_eq!(single.incumbent.unwrap().actual_cost, 30.0); - let (limited, visited) = run(2, 1, false); - assert_eq!(visited, vec![0]); - assert_eq!(limited.refinements, 1); - assert_eq!(limited.solve_metrics.solve_calls, 1); - } - - #[test] - fn canceled_materialization_validation_keeps_validated_incumbent() { - let (result, visited) = run(2, 2, true); - assert_eq!(visited, vec![0, 1]); - assert_eq!(result.incumbent.unwrap().actual_cost, 30.0); - assert_eq!(result.status, CandidateSearchStatus::TimeLimit); - assert_eq!(result.certified_query_cuts, 0); - } -} diff --git a/crates/plan-selection/src/search/refinement/mod.rs b/crates/plan-selection/src/search/refinement/mod.rs deleted file mode 100644 index b978b23b3..000000000 --- a/crates/plan-selection/src/search/refinement/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Evaluator-backed refinement and optimization policies. -mod materialization; -pub(crate) mod objectives; -pub(crate) mod planner; -#[cfg(test)] -mod pruning; -pub(crate) mod search; diff --git a/crates/plan-selection/src/search/refinement/objectives.rs b/crates/plan-selection/src/search/refinement/objectives.rs deleted file mode 100644 index 14beaebd5..000000000 --- a/crates/plan-selection/src/search/refinement/objectives.rs +++ /dev/null @@ -1,518 +0,0 @@ -//! Exact evaluator ranking over a bounded traversal of the candidate graph. -//! Traversal priorities are not an objective scalarization: PlanSelector owns -//! cluster lexicographic priorities, Pareto dominance, constraints and tie policy. -use std::collections::BTreeMap; -use std::time::Instant; - -use asap_types::physical::PhysicalWorkloadPlan; - -use crate::search::candidates::dag::CandidateSelectionError; -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::candidates::graph::SelectedCandidateGraph; -use crate::search::costing::calibration::CandidateCalibration; -use crate::search::milp::model::{ - solve_candidate_proposal_with_metrics, CandidateSolveMetrics, ObservedAssignment, -}; -use crate::search::refinement::planner::{ - accuracy_baseline_weights, reconstruct_configured_candidate, CandidatePlanningContext, - CandidateRejectionSummary, -}; -use crate::search::refinement::search::{CandidateSearchLimits, CandidateSearchStatus}; -use crate::{ErrorSelectingPlan, PlanSelector, WholePlanSelection}; -use asap_cost::CalibrationFit; - -// `calibration` and `calibration_scope` are read by the engine's tests; #1095 replaces the engine. -#[cfg_attr(not(test), allow(dead_code))] -pub(in crate::search) struct ObjectivePlanningResult { - pub status: CandidateSearchStatus, - pub solve_metrics: CandidateSolveMetrics, - pub validation_seconds: f64, - pub calibration: CalibrationFit, - pub calibration_scope: &'static str, - pub selected_graph: Option, - pub best: Option<(WholePlanSelection, PhysicalWorkloadPlan)>, - /// When no plan is valid, the first rejection of each cause. - pub evaluation_rejections: Vec, -} - -/// Enumerate connection assignments without replacing a lexicographic -/// objective by a weighted sum. Exhaustion proves optimality for this -/// catalog; a budget stop returns the incumbent over evaluated candidates -/// only. Complete-catalog calibration must finish before any calibrated -/// plan is returned. -pub(in crate::search) fn optimize_objective_candidates( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - limits: CandidateSearchLimits, - calibration: CandidateCalibration, -) -> Result { - let calibration = match context.models.cost_inputs.frozen_calibration_factor { - Some(factor) => CandidateCalibration::Frozen( - CalibrationFit::from_factor(factor).map_err(|error| error.to_string())?, - ), - None => calibration, - }; - let started = Instant::now(); - let context = &CandidatePlanningContext { - deadline: crate::search::refinement::planner::earliest_deadline( - context.deadline, - started.checked_add(limits.wall_time), - ), - ..*context - }; - let original = &configured.dag; - original.validate().map_err(|error| error.to_string())?; - if configured.physical.candidates.len() != original.candidates.len() - || context.query_roots.len() != context.entries.len() - { - return Err("objective search configuration and query dimensions disagree".into()); - } - let mut selector = PlanSelector::new(context.entries, context.data, context.models, false) - .map_err(|error| error.to_string())?; - let mut fit = match calibration { - CandidateCalibration::Frozen(fit) => { - if !fit.factor().is_finite() || fit.factor() <= 0.0 { - return Err( - "objective search requires a positive finite frozen calibration".into(), - ); - } - selector.set_calibration(fit); - fit - } - CandidateCalibration::CompleteCatalog => CalibrationFit::default(), - }; - let mut traversal = original.clone(); - // Prefer an accuracy-safe first proposal when the contract requires one. - // Ignored guarantees must not delay a legal cheaper implementation. - if !context.models.accuracy.may_accept_regardless_of_guarantee() { - for (candidate, weight) in traversal - .candidates - .iter_mut() - .zip(accuracy_baseline_weights(configured, context)?) - { - candidate.local_cost = weight; - } - } - let mut observations = Vec::new(); - let mut physical_catalog = Vec::new(); - let mut selected_by_id = BTreeMap::new(); - let mut metrics = CandidateSolveMetrics::default(); - let mut rejections = CandidateRejectionSummary::default(); - let mut validation_seconds = 0.0; - let mut refinements = 0; - let mut catalog_exhausted = false; - let mut status = loop { - let remaining = limits.wall_time.saturating_sub(started.elapsed()); - if remaining.is_zero() { - break CandidateSearchStatus::TimeLimit; - } - if refinements >= limits.max_refinements { - break CandidateSearchStatus::RefinementLimit; - } - let proposal = match solve_candidate_proposal_with_metrics( - &traversal, - &observations, - Some(remaining.as_secs_f64()), - &mut metrics, - ) { - Ok(Some(proposal)) => proposal, - Ok(None) => { - catalog_exhausted = true; - break CandidateSearchStatus::Optimal; - } - Err(CandidateSelectionError::Solver(_)) if started.elapsed() >= limits.wall_time => { - break CandidateSearchStatus::TimeLimit - } - Err(error) => break CandidateSearchStatus::SolverStopped(error.to_string()), - }; - let mut selected = proposal.selected.clone(); - selected.modeled_cost = selected - .selected_candidates - .iter() - .map(|id| original.candidates[id.0].local_cost) - .sum(); - refinements += 1; - let validation_started = Instant::now(); - let mut rejected_queries = Vec::new(); - let reconstructed = reconstruct_configured_candidate( - configured, - context, - &selected, - refinements, - &mut rejections, - &mut rejected_queries, - ); - let evaluation = match reconstructed { - Ok(Some(physical)) => { - selected_by_id.insert(physical.id.clone(), selected); - match calibration { - CandidateCalibration::CompleteCatalog => { - match selector.calibration_fit(&physical) { - Ok(candidate_fit) => { - fit.add(candidate_fit); - physical_catalog.push(physical); - Ok(()) - } - Err(error) => Err(error), - } - } - CandidateCalibration::Frozen(_) => { - selector.offer_before(&physical, context.deadline) - } - } - } - Ok(None) => Ok(()), - Err(error) => Err(ErrorSelectingPlan::Solver(error)), - }; - validation_seconds += validation_started.elapsed().as_secs_f64(); - if let Err(error) = evaluation { - break match error { - ErrorSelectingPlan::EvaluationDeadlineExceeded => CandidateSearchStatus::TimeLimit, - error => CandidateSearchStatus::EvaluatorStopped(error.to_string()), - }; - } - // The seed preference is not an enumeration order for the entire - // catalog: that would exhaust exact variants before visiting summaries. - // Costs only order proposals here; no-good cuts retain every other plan - // and PlanSelector still owns the configured objective/Pareto ranking. - if refinements == 1 { - for (candidate, original) in traversal.candidates.iter_mut().zip(&original.candidates) { - candidate.local_cost = original.local_cost; - } - } - // These are traversal skip decisions, not claims that a validated plan - // is infeasible. The selector retains its incumbent/Pareto archive. - let mut observed = proposal.selected; - observed.modeled_cost = observed - .selected_candidates - .iter() - .map(|id| traversal.candidates[id.0].local_cost) - .sum(); - observations.push(ObservedAssignment { - selected: observed, - exact_cost: None, - rejected_queries, - }); - }; - let mut evaluation_complete = catalog_exhausted; - let calibration_scope = match calibration { - CandidateCalibration::CompleteCatalog => { - if !catalog_exhausted { - return Err(format!("complete-catalog calibration is incomplete after {refinements} assignments ({status:?}); no partially calibrated plan is returned; supply frozen_calibration_factor to use bounded planning")); - } - if !fit.factor().is_finite() || fit.factor() <= 0.0 { - return Err( - "complete-catalog calibration produced a nonfinite or nonpositive factor" - .into(), - ); - } - let validation_started = Instant::now(); - selector.set_calibration(fit); - for (index, physical) in physical_catalog.iter().enumerate() { - if started.elapsed() >= limits.wall_time { - status = CandidateSearchStatus::TimeLimit; - evaluation_complete = false; - break; - } - match selector.offer_before(physical, context.deadline) { - Ok(()) => {} - Err(ErrorSelectingPlan::EvaluationDeadlineExceeded) => { - status = CandidateSearchStatus::TimeLimit; - evaluation_complete = false; - break; - } - Err(error) => return Err(error.to_string()), - } - // Evaluators are cooperative; retain the validated incumbent - // from an overrun, but never begin another unbudgeted offer. - if started.elapsed() >= limits.wall_time { - status = CandidateSearchStatus::TimeLimit; - evaluation_complete = index + 1 == physical_catalog.len(); - break; - } - } - validation_seconds += validation_started.elapsed().as_secs_f64(); - "complete_catalog" - } - CandidateCalibration::Frozen(_) if selector.needs_calibration() => "provided_frozen", - CandidateCalibration::Frozen(_) - if context - .models - .cost_inputs - .frozen_calibration_factor - .is_some() => - { - "provided_frozen" - } - CandidateCalibration::Frozen(_) => "not_required", - }; - let mut evaluation_rejections = Vec::new(); - let best = match selector.finish() { - Ok(best) => { - for rejection in best - .0 - .rejected - .iter() - .filter(|rejection| rejection.cause.failed_a_check()) - { - let category = serde_json::to_value(rejection.cause) - .ok() - .and_then(|value| value.as_str().map(str::to_owned)) - .unwrap_or_else(|| format!("{:?}", rejection.cause)); - rejections.record(category, &rejection.reason); - } - Some(best) - } - Err(ErrorSelectingPlan::NoValidCandidate(rejected)) => { - evaluation_rejections = rejected.clone(); - for rejection in rejected { - let category = serde_json::to_value(rejection.cause) - .ok() - .and_then(|value| value.as_str().map(str::to_owned)) - .unwrap_or_else(|| format!("{:?}", rejection.cause)); - rejections.record(category, &rejection.reason); - } - if catalog_exhausted && evaluation_complete && status == CandidateSearchStatus::Optimal - { - status = CandidateSearchStatus::Infeasible; - } - None - } - Err(error) => return Err(error.to_string()), - }; - let selected_graph = best - .as_ref() - .map(|(selection, _)| { - selected_by_id - .remove(&selection.selected) - .ok_or("selected physical plan is missing its connection assignment") - }) - .transpose()?; - Ok(ObjectivePlanningResult { - status, - solve_metrics: metrics, - validation_seconds, - calibration: fit, - calibration_scope, - selected_graph, - best, - evaluation_rejections, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::configurations::enumerate_candidate_configurations; - use crate::search::candidates::inventory::build_candidate_inventory; - use crate::search::milp::model::solve_candidate_proposal; - use crate::SelectionModels; - use asap_cost::CostInputs; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; - use asap_types::ir::{properties::AccuracyTarget, QueryRoot}; - use asap_types::workload::{ - DataArrival, DataWorkload, Predictability, QueryRecurrence, QueryWorkloadEntry, - RepeatedDemand, RepetitionInterval, SourceFacts, - }; - use std::time::Duration; - - fn fixture() -> ( - PhysicalCandidatesWithCosts, - Vec, - Vec, - DataWorkload, - ) { - let root = QueryRoot::Operator(lower_promql( - "sum_over_time(latency[1m])", - AccuracyTarget::exact(), - )); - let roots = vec![root]; - let query_roots = vec![roots[0].clone()]; - let initial = generate_dag_of_logical_memo_groups(&query_roots); - let output = *initial.query_roots[0].as_operator().unwrap(); - let generated = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&query_roots)) - .unwrap(); - let memo = try_populate_pass1(initial, &generated).unwrap(); - let inventory = build_candidate_inventory(&memo, &[output], |_| Ok(0.0)).unwrap(); - let mut entry = entry_with( - AccuracyTarget::exact(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))), - ); - entry.predictability = Predictability::Predictable { known_at: None }; - let entries = vec![entry]; - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 100.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(1), - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let configured = enumerate_candidate_configurations( - &inventory, - &[0], - &entries, - &data, - &asap_logical_asap_optimization::DeploymentCapabilities::default(), - ) - .unwrap(); - (configured, roots, entries, data) - } - - fn limits(n: usize) -> CandidateSearchLimits { - CandidateSearchLimits { - max_refinements: n, - wall_time: Duration::from_secs(30), - } - } - - // Independently enumerate using zero costs, then use the pre-existing - // batch selector as the ranking and calibration reference. - fn reference_catalog( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - ) -> Vec { - let graph = &configured.dag; - let mut observations = Vec::new(); - let mut catalog = Vec::new(); - for index in 1..=256 { - let Some(proposal) = solve_candidate_proposal(graph, &observations, None).unwrap() - else { - return catalog; - }; - if let Some(physical) = reconstruct_configured_candidate( - configured, - context, - &proposal.selected, - index, - &mut CandidateRejectionSummary::default(), - &mut Vec::new(), - ) - .unwrap() - { - catalog.push(physical); - } - observations.push(ObservedAssignment { - selected: proposal.selected, - exact_cost: None, - rejected_queries: Vec::new(), - }); - } - panic!("reference fixture must exhaust its small catalog"); - } - - fn measured_settings() -> CostInputs { - serde_json::from_value(serde_json::json!({"measured_operation_costs":{"operators":[{"operator":"Scan", "ns_per_unit":100.0}]}})).unwrap() - } - - // Complete-catalog fitting agrees with select_plan; a prefix is not a fit. - #[test] - fn measured_calibration_requires_complete_catalog_or_frozen_factor() { - let (configured, roots, entries, data) = fixture(); - let settings = measured_settings(); - let models = SelectionModels { - cost_inputs: &settings, - ..SelectionModels::builtin() - }; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - assert!(matches!( - CandidateCalibration::for_models(models).unwrap(), - CandidateCalibration::CompleteCatalog - )); - let error = optimize_objective_candidates( - &configured, - &context, - limits(1), - CandidateCalibration::CompleteCatalog, - ) - .err() - .unwrap(); - assert!(error.contains("calibration is incomplete")); - let catalog = reference_catalog(&configured, &context); - let expected = crate::select_among_plans(&catalog, &entries, &data, models).unwrap(); - let result = optimize_objective_candidates( - &configured, - &context, - limits(256), - CandidateCalibration::CompleteCatalog, - ) - .unwrap(); - assert_eq!(result.calibration_scope, "complete_catalog"); - assert!(result.calibration.factor() > 1.0); - let (selection, _) = result.best.unwrap(); - let actual_cost = selection.costs[&selection.selected] - .cost_per_second - .unwrap(); - let expected_cost = expected.costs[&expected.selected].cost_per_second.unwrap(); - assert!((actual_cost - expected_cost).abs() <= expected_cost * 1e-9); - } - - // An explicit fit is stable across bounded discovery and selector calls. - #[test] - fn frozen_factor_is_validated_and_overrides_local_fit() { - for invalid in [0.0, -1.0, f64::INFINITY, f64::NAN] { - assert!(CalibrationFit::from_factor(invalid).is_err()); - } - let (configured, roots, entries, data) = fixture(); - let settings = CostInputs { - frozen_calibration_factor: Some(3.0), - ..measured_settings() - }; - let models = SelectionModels { - cost_inputs: &settings, - ..SelectionModels::builtin() - }; - let mut selector = PlanSelector::new(&entries, &data, models, false).unwrap(); - assert!(!selector.needs_calibration()); - selector.set_calibration(CalibrationFit::from_factor(99.0).unwrap()); - assert_eq!(selector.formula_scale, 3.0); - let policy = CandidateCalibration::for_models(models).unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::from_factor(3.0).unwrap(), - }; - let mismatched = CandidatePlanningContext { - deadline: None, - calibration: CalibrationFit::default(), - ..context - }; - let error = crate::search::refinement::planner::optimize_configured_candidates( - &configured, - &mismatched, - limits(1), - ) - .err() - .unwrap(); - assert!(error.contains("same frozen calibration factor")); - let result = - optimize_objective_candidates(&configured, &context, limits(1), policy).unwrap(); - assert_eq!(result.calibration.factor(), 3.0); - assert_eq!(result.calibration_scope, "provided_frozen"); - assert!(result.best.is_some()); - } -} diff --git a/crates/plan-selection/src/search/refinement/planner.rs b/crates/plan-selection/src/search/refinement/planner.rs deleted file mode 100644 index 12d14ec95..000000000 --- a/crates/plan-selection/src/search/refinement/planner.rs +++ /dev/null @@ -1,2376 +0,0 @@ -//! Evaluator-backed refinement over configured candidate connections. -//! -//! Every proposal passes Stage 2 and PlanSelector; only a validated price -//! becomes an exact refinement cut. -use asap_types::ir::QueryRoot; -use asap_types::physical::PhysicalWorkloadPlan; -use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; -use std::collections::BTreeMap; - -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::candidates::graph::RejectedQuerySubgraph; -use crate::search::candidates::graph::SelectedCandidateGraph; -use crate::search::refinement::search::{ - search_candidates_with_direct_feedback, CandidateEvaluation, CandidateSearchLimits, - CandidateSearchResult, DirectPriceModel, -}; -use crate::{ErrorSelectingPlan, PlanSelector, SelectionModels, WholePlanSelection}; -use asap_cost::{CalibrationFit, CostModel}; -use asap_types::stages::{AssignmentRealizer, PhysicalCandidateError}; - -#[derive(Clone, Copy)] -pub(in crate::search) struct CandidatePlanningContext<'a> { - pub deadline: Option, - pub refine_materialization: bool, - /// The workload's query roots as Stage 3 sees them: each output - /// reference is an operator tree of its group read from Stage 2's - /// candidates ([`query_roots_of`](crate::search::candidates::dag::query_roots_of)). - pub query_roots: &'a [QueryRoot], - /// Stage 2's realizer, over the whole workload. - pub realizer: &'a dyn AssignmentRealizer, - pub entries: &'a [QueryWorkloadEntry], - pub data: &'a DataWorkload, - pub models: SelectionModels<'a>, - /// One externally established fit, held constant across every proposal. - pub calibration: CalibrationFit, -} - -pub(in crate::search) fn earliest_deadline( - outer: Option, - inner: Option, -) -> Option { - match (outer, inner) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - } -} - -/// Bounded evidence explaining rejected assignments, not complete plan history. -#[derive(Debug, Default)] -pub(in crate::search) struct CandidateRejectionSummary { - pub counts: BTreeMap, - pub examples: Vec, -} - -impl CandidateRejectionSummary { - pub(in crate::search) fn record(&mut self, category: String, reason: &str) { - *self.counts.entry(category.clone()).or_default() += 1; - let example = format!( - "{category}: {}", - reason.chars().take(512).collect::() - ); - if self.examples.len() < 8 && !self.examples.contains(&example) { - self.examples.push(example); - } - } -} - -// Read by the engine's tests; #1095 replaces the engine. -#[cfg_attr(not(test), allow(dead_code))] -pub(in crate::search) struct CandidatePlanningResult { - pub search: CandidateSearchResult, - pub rejections: CandidateRejectionSummary, - /// Same convention as PlanSelector::finish: WholePlanSelection.plan is the validated - /// execution plan; PhysicalWorkloadPlan is the offered Stage 2 candidate. - pub best: Option<(WholePlanSelection, PhysicalWorkloadPlan)>, -} - -/// Usage and provisioned-price objectives use the complete evaluator cost, -/// including held memory, raw retention and request overhead where applicable. -/// Cluster objectives use candidate_objectives and the original selector -/// archive; their Pareto/lexicographic policy is not a scalar price. -pub(in crate::search) fn optimize_configured_candidates( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - limits: CandidateSearchLimits, -) -> Result { - if !matches!( - context.models.cost_inputs.cost_model, - CostModel::UsageOverTime(_) | CostModel::ProvisionedCapacity(_) - ) { - return Err("candidate price search does not support ClusterCapacity objectives".into()); - } - if configured.physical.candidates.len() != configured.dag.candidates.len() { - return Err("physical configurations must be indexed by candidate ID".into()); - } - if context.query_roots.len() != context.entries.len() { - return Err("query roots and workload entries differ in length".into()); - } - // Reject invalid settings/deployment even if the search budget is zero. - PlanSelector::new(context.entries, context.data, context.models, false) - .map_err(|e| e.to_string())?; - if context - .models - .cost_inputs - .frozen_calibration_factor - .is_some_and(|factor| factor != context.calibration.factor()) - { - return Err( - "candidate cost bounds and evaluator must use the same frozen calibration factor" - .into(), - ); - } - if !context.calibration.factor().is_finite() || context.calibration.factor() <= 0.0 { - return Err("candidate search requires a finite positive frozen calibration".into()); - } - let local_deadline = std::time::Instant::now().checked_add(limits.wall_time); - let context = &CandidatePlanningContext { - deadline: earliest_deadline(context.deadline, local_deadline), - ..*context - }; - let mut best: Option<( - f64, - SelectedCandidateGraph, - WholePlanSelection, - PhysicalWorkloadPlan, - )> = None; - let mut evaluation_number = 0; - let mut rejections = CandidateRejectionSummary::default(); - let weighting_started = std::time::Instant::now(); - let (weights, exclusions) = accuracy_baseline(configured, context)?; - let changes = if context.refine_materialization { - super::materialization::counterparts(configured, context.deadline) - } else { - Vec::new() - }; - let mut joint_fallback_reasons = Vec::new(); - let joint_context = CandidatePlanningContext { - deadline: earliest_deadline( - context.deadline, - std::time::Instant::now() - .checked_add((limits.wall_time / 6).min(std::time::Duration::from_secs(2))), - ), - ..*context - }; - let joint = joint_cost_seed( - configured, - &joint_context, - &exclusions, - &mut joint_fallback_reasons, - )?; - let mut direct = Vec::new(); - // Use a fixed-interface profile as a bounded fallback when the - // joint interface catalog cannot be certified within the planning budget. - // Both adapters share the same construction deadline, preserving time for - // the feasibility search even when direct pricing is unsupported. - if joint.is_none() { - if let Some(seed) = direct_cost_seed(configured, &joint_context, &exclusions, false)? { - direct.push(seed); - } - if let Some(seed) = direct_cost_seed(configured, &joint_context, &exclusions, true)? { - direct.push(seed); - } - } - // A maintained-state seed avoids the large raw-scan latency range without - // weakening the direct model's numerical guards. It only proposes plans; - // the unrestricted graph and evaluator still determine bounds and validity. - let maintained = joint - .as_ref() - .filter(|(_, profile)| profile.schedules.is_some()) - .and_then(|(_, profile)| maintained_state_profile(configured, profile, context.deadline)); - // If even this smaller profile has unrepresentable monetary coefficients, - // its supported paths can still provide a structural feasibility seed. - let maintained_graph = maintained - .as_ref() - .map(|profile| { - use crate::search::candidates::dag::ReaderConstraint; - use asap_types::stages::PhysicalCandidateId; - let mut graph = configured.dag.clone(); - for (id, candidate) in graph.candidates.iter_mut().enumerate() { - candidate.local_cost = weights[id]; - if profile.candidates[id].is_empty() { - candidate.producer_candidates_per_input.clear(); - graph - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(id), - outputs: Vec::new(), - }); - } else { - for input in &mut candidate.producer_candidates_per_input { - input - .candidates - .retain(|producer| !profile.candidates[producer.0].is_empty()); - } - } - } - for root in &mut graph.query_roots { - root.candidates - .retain(|id| !profile.candidates[id.0].is_empty()); - } - graph - }) - .filter(|graph| { - graph - .query_roots - .iter() - .all(|root| !root.candidates.is_empty()) - }); - let mut direct_refs: Vec<_> = direct - .iter() - .map(|(graph, profile)| (graph, DirectPriceModel::Fixed(profile))) - .collect(); - if let (Some((graph, _)), Some(profile)) = (&joint, &maintained) { - direct_refs.push((graph, DirectPriceModel::Joint(profile))); - } - if let Some(graph) = &maintained_graph { - direct_refs.push((graph, DirectPriceModel::Structural)); - } - if let Some((graph, profile)) = &joint { - direct_refs.push((graph, DirectPriceModel::Joint(profile))); - } - let mut evaluate = |selected: &SelectedCandidateGraph| -> Result { - evaluation_number += 1; - let mut rejected_queries = Vec::new(); - let evaluated = evaluate_configured_candidate_before( - configured, - context, - selected, - evaluation_number, - &mut rejections, - &mut rejected_queries, - ); - let Some((selection, physical)) = (match evaluated { - Ok(result) => result, - Err(ErrorSelectingPlan::EvaluationDeadlineExceeded) => { - return Ok(CandidateEvaluation { - budget_exhausted: true, - ..Default::default() - }); - } - Err( - error @ ErrorSelectingPlan::Cost { - error: crate::AnalyticalCostError::Overflow, - .. - }, - ) => { - let reason = error.to_string(); - rejections.record("cost_not_representable".into(), &reason); - return Ok(CandidateEvaluation { - unpriced_reason: Some(reason), - ..Default::default() - }); - } - Err(error) => return Err(error.to_string()), - }) else { - return Ok(CandidateEvaluation { - actual_cost: None, - rejected_queries, - budget_exhausted: false, - unpriced_reason: None, - }); - }; - let cost = selection - .costs - .get(&selection.selected) - .and_then(|cost| cost.cost_per_second) - .ok_or("validated candidate has no scalar price")?; - if best.as_ref().is_none_or(|(previous, ..)| cost < *previous) { - best = Some((cost, selected.clone(), selection, physical)); - } - Ok(Some(cost).into()) - }; - let mut search = search_candidates_with_direct_feedback( - &configured.dag, - CandidateSearchLimits { - wall_time: limits.wall_time.saturating_sub(weighting_started.elapsed()), - ..limits - }, - &weights, - &exclusions, - &changes, - &direct_refs, - &mut evaluate, - )?; - search - .solve_metrics - .direct_cost_fallback_reasons - .extend(joint_fallback_reasons); - match (&search.incumbent, &best) { - (Some(incumbent), Some((cost, selected, ..))) - if incumbent.actual_cost == *cost && incumbent.selected == *selected => {} - (None, None) => {} - _ => return Err("search incumbent and validated physical plan disagree".into()), - } - Ok(CandidatePlanningResult { - search, - rejections, - best: best.map(|(_, _, selection, physical)| (selection, physical)), - }) -} - -/// Every supported local price interface participates in one joint solve. -/// Keep paths whose scans run at ingestion time and whose query readouts are -/// not materialized. This avoids tiny held-memory charges on scalar readouts -/// spanning a much smaller scale than ingestion costs. Prune unsupported price -/// tuples to a fixed point before compacting option indices. -fn maintained_state_profile( - configured: &PhysicalCandidatesWithCosts, - profile: &crate::search::milp::direct_price_options::JointPriceProfile, - deadline: Option, -) -> Option { - let mut supported: Vec> = profile - .candidates - .iter() - .enumerate() - .map(|(id, options)| { - let scan = matches!( - configured.node(id).non_asap(), - Some(asap_types::ir::NonASAPOp::Scan { .. }) - ); - options - .iter() - .map(|option| { - (!scan || option.profile.ingestion) - && (option.profile.ingestion || !option.profile.materialized) - }) - .collect() - }) - .collect(); - loop { - let mut changed = false; - for (id, options) in profile.candidates.iter().enumerate() { - if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - return None; - } - for (index, option) in options.iter().enumerate() { - if supported[id][index] - && !option.compatible_input_tuples.iter().any(|tuple| { - tuple - .iter() - .all(|binding| supported[binding.producer.0][binding.price_option]) - }) - { - supported[id][index] = false; - changed = true; - } - } - } - if !changed { - break; - } - } - let remap: Vec>> = supported - .iter() - .map(|options| { - let mut next = 0; - options - .iter() - .map(|keep| { - if *keep { - let index = next; - next += 1; - Some(index) - } else { - None - } - }) - .collect() - }) - .collect(); - let mut result = profile.clone(); - for (id, options) in result.candidates.iter_mut().enumerate() { - let mut index = 0; - options.retain_mut(|option| { - let keep = supported[id][index]; - index += 1; - if keep { - option.compatible_input_tuples.retain(|tuple| { - tuple - .iter() - .all(|binding| supported[binding.producer.0][binding.price_option]) - }); - for tuple in &mut option.compatible_input_tuples { - for binding in tuple { - binding.price_option = - remap[binding.producer.0][binding.price_option].unwrap(); - } - } - } - keep - }); - } - Some(result) -} - -fn joint_cost_seed( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - exclusions: &[asap_types::stages::PhysicalCandidateId], - fallback_reasons: &mut Vec, -) -> Result< - Option<( - crate::search::candidates::dag::DagOfPhysicalCandidatesWithCosts, - crate::search::milp::direct_price_options::JointPriceProfile, - )>, - String, -> { - use crate::search::costing::direct_profile::{extract_price_options, DirectProfileError}; - let mut profile = match extract_price_options(configured, context) { - Ok(Some(profile)) => profile, - Ok(None) => return Ok(None), - Err(DirectProfileError::DeadlineExceeded(_)) => { - fallback_reasons - .push("joint pricing: interface generation exhausted its reserved budget".into()); - return Ok(None); - } - Err(DirectProfileError::Unsupported(reason)) => { - fallback_reasons.push(format!("joint pricing: {reason}")); - return Ok(None); - } - Err(error) => return Err(error.to_string()), - }; - // As with the fixed-price seed, avoid permutations of interchangeable - // execution copies in this bounded proposal. Every producer price option - // remains available on the first copy; the full search retains all copies. - for constraint in &configured.dag.reader_constraints { - if let crate::search::candidates::dag::ReaderConstraint::ActivationRequires { - candidate, - .. - } = constraint - { - profile.candidates[candidate.0].clear(); - } - } - for excluded in exclusions { - profile.candidates[excluded.0].clear(); - } - // Remove bindings to excluded options without renumbering other options. - let sizes: Vec<_> = profile.candidates.iter().map(Vec::len).collect(); - for options in &mut profile.candidates { - for option in options { - option.compatible_input_tuples.retain(|tuple| { - tuple - .iter() - .all(|binding| binding.price_option < sizes[binding.producer.0]) - }); - } - } - Ok(Some((configured.dag.clone(), profile))) -} - -/// Restrict only the direct-price proposal. Unsupported configurations and -/// producer interfaces remain available in the full evaluator-refined search. -fn direct_cost_seed( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - seed_exclusions: &[asap_types::stages::PhysicalCandidateId], - latest_tumbling_windows: bool, -) -> Result< - Option<( - crate::search::candidates::dag::DagOfPhysicalCandidatesWithCosts, - crate::search::milp::direct_cost::DirectCostProfile, - )>, - String, -> { - use crate::search::candidates::dag::ReaderConstraint; - use crate::search::costing::direct_profile::{ - extract, extract_latest_tumbling_windows, DirectProfileError, - }; - use asap_types::stages::PhysicalCandidateId; - let extracted = if latest_tumbling_windows { - extract_latest_tumbling_windows(configured, context) - } else { - extract(configured, context) - }; - let mut extracted = match extracted { - Ok(Some(extracted)) => extracted, - Ok(None) - | Err(DirectProfileError::DeadlineExceeded(_)) - | Err(DirectProfileError::Unsupported(_)) => return Ok(None), - Err(error) => return Err(error.to_string()), - }; - if context - .deadline - .is_some_and(|deadline| std::time::Instant::now() >= deadline) - { - return Ok(None); - } - let mut graph = configured.dag.clone(); - let mut supported = extracted.supported; - // One interchangeable execution-scope copy is sufficient for this seed. - // Keep all copies in the full graph, where independent input bindings may - // require them; avoiding their permutations bounds the direct-price solve. - for constraint in &graph.reader_constraints { - if let ReaderConstraint::ActivationRequires { candidate, .. } = constraint { - supported[candidate.0] = false; - } - } - for excluded in seed_exclusions { - supported[excluded.0] = false; - } - for (id, candidate) in graph.candidates.iter_mut().enumerate() { - if context - .deadline - .is_some_and(|deadline| std::time::Instant::now() >= deadline) - { - return Ok(None); - } - if !supported[id] { - extracted.profile.nodes[id] = Default::default(); - candidate.local_cost = 0.0; - candidate.producer_candidates_per_input.clear(); - graph - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(id), - outputs: Vec::new(), - }); - } else { - for input in &mut candidate.producer_candidates_per_input { - input.candidates.retain(|supplier| { - supported[supplier.0] && extracted.suppliers[id].contains(supplier) - }); - } - } - } - for root in &mut graph.query_roots { - root.candidates.retain(|candidate| supported[candidate.0]); - if root.candidates.is_empty() { - return Ok(None); - } - } - Ok(Some((graph, extracted.profile))) -} - -/// Price work and state for proposal ordering without an exactness surcharge. -/// Recomposition and validation still decide the selected graph's accuracy. -pub(in crate::search) fn accuracy_baseline_weights( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result, String> { - accuracy_baseline(configured, context).map(|(weights, _)| weights) -} - -/// Conservative per-state DDS budgets for the feasibility seed. Counting -/// quantile operands ([`quantile_operands`]) keeps ratio operands on -/// composed-budget capacity tiers even when their sign contracts are still -/// symbolic. This is a seed-only restriction, -/// not a proof of query accuracy; the complete catalog and final validator keep -/// mixed exact/approximate plans and all otherwise legal capacities. -fn seed_dds_budgets( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - output_queries: &[usize], -) -> Vec { - use asap_accuracy::per_input_target; - use asap_types::ir::properties::AccuracyMetric; - let graph = &configured.dag; - let mut budgets = vec![f64::INFINITY; graph.candidates.len()]; - if context.models.accuracy.may_accept_regardless_of_guarantee() { - return budgets; - } - let query_budgets: Vec<_> = context - .query_roots - .iter() - .zip(context.entries) - .map(|(root, entry)| { - let count = quantile_operands(root); - (count > 1) - .then(|| { - per_input_target( - &entry.requirements.accuracy, - AccuracyMetric::Relative, - count, - ) - }) - .flatten() - .map(|target| target.max_error) - }) - .collect(); - let mut pending = Vec::new(); - for (root, &query) in graph.query_roots.iter().zip(output_queries) { - if let Some(budget) = query_budgets[query] { - pending.extend(root.candidates.iter().copied().map(|id| (id, budget))); - } - } - while let Some((id, budget)) = pending.pop() { - if context - .deadline - .is_some_and(|limit| std::time::Instant::now() >= limit) - { - break; - } - if budget < budgets[id.0] { - budgets[id.0] = budget; - pending.extend( - graph.candidates[id.0] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().copied()) - .map(|producer| (producer, budget)), - ); - } - } - budgets -} - -/// The quantile measures `root`'s value reads as operands: each quantile -/// aggregate counts as often as the most operands that read it from one -/// path (`q / q` reads `q` twice), however the nodes are shared. A diamond -/// of different parents over one aggregate (an average's sum and count of -/// the same values) reads it once. The count must not depend on node -/// sharing: Pass 1 builds each query's root over the memo groups' nodes, so -/// equal operands are one node there and two in a frontend's tree. -fn quantile_operands(root: &QueryRoot) -> usize { - use asap_types::ir::{operator::AggIntent, NonASAPOp, OperatorNode}; - use std::collections::{HashMap, HashSet}; - use std::rc::Rc; - let operators = match root { - QueryRoot::Operator(node) => vec![node], - QueryRoot::Scalar(expression) => expression.operator_refs(), - }; - // Parents before children: reverse post-order. - fn post_order<'a>( - node: &'a Rc, - seen: &mut HashSet<*const OperatorNode>, - order: &mut Vec<&'a Rc>, - ) { - if seen.insert(Rc::as_ptr(node)) { - for child in node.children() { - post_order(child, seen, order); - } - order.push(node); - } - } - let mut seen = HashSet::new(); - let mut order = Vec::new(); - for node in &operators { - post_order(node, &mut seen, &mut order); - } - // Each node's operand count: the most times one path reads it. - let mut reads: HashMap<*const OperatorNode, usize> = HashMap::new(); - for node in &operators { - let count = reads.entry(Rc::as_ptr(node)).or_insert(0); - *count = count.saturating_add(1); - } - let mut quantiles = 0usize; - for node in order.into_iter().rev() { - let count = reads.get(&Rc::as_ptr(node)).copied().unwrap_or(1); - if let Some(NonASAPOp::Aggregate { measures, .. }) = node.non_asap() { - let own = measures - .iter() - .filter(|measure| matches!(measure, AggIntent::Quantile { .. })) - .count(); - quantiles = quantiles.saturating_add(own.saturating_mul(count)); - } - let mut times: HashMap<*const OperatorNode, usize> = HashMap::new(); - for child in node.children() { - *times.entry(Rc::as_ptr(child)).or_insert(0) += 1; - } - for (child, times) in times { - let through = count.saturating_mul(times); - let entry = reads.entry(child).or_insert(0); - *entry = (*entry).max(through); - } - } - quantiles -} - -fn accuracy_baseline( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, -) -> Result<(Vec, Vec), String> { - use asap_types::ir::properties::AccuracyMetric; - use asap_types::ir::schema::FieldDataType; - use asap_types::ir::ASAPOp; - let graph = &configured.dag; - graph.validate().map_err(|error| error.to_string())?; - if configured.physical.candidates.len() != graph.candidates.len() { - return Err("baseline templates must be indexed by candidate ID".into()); - } - let mut output_queries = Vec::new(); - for (query, root) in context.query_roots.iter().enumerate() { - match root { - QueryRoot::Operator(_) => output_queries.push(query), - QueryRoot::Scalar(expression) => { - expression.map_operator_refs(&mut |node| { - output_queries.push(query); - std::rc::Rc::clone(node) - }); - } - } - } - if output_queries.len() != graph.query_roots.len() { - return Err("baseline output references do not match original query roots".into()); - } - let confidence_required = context - .entries - .iter() - .any(|entry| entry.requirements.accuracy.failure_probability.is_some()); - let dds_budgets = seed_dds_budgets(configured, context, &output_queries); - let mut rank_requested = vec![false; graph.candidates.len()]; - let mut pending: Vec<_> = graph - .query_roots - .iter() - .zip(output_queries) - .filter(|(_, query)| { - context.entries[*query] - .requirements - .accuracy - .accuracy_metric - == Some(AccuracyMetric::Rank) - }) - .flat_map(|(root, _)| root.candidates.iter().copied()) - .collect(); - while let Some(candidate) = pending.pop() { - if !rank_requested[candidate.0] { - rank_requested[candidate.0] = true; - pending.extend( - graph.candidates[candidate.0] - .producer_candidates_per_input - .iter() - .flat_map(|input| input.candidates.iter().copied()), - ); - } - } - // Scan-widening projections preserve a logical source schema, but cannot - // appear below the executor's raw TimeRange boundary. Keep original raw - // source bindings for the feasibility seed; the full graph still contains - // the useful projected candidates for other consumers. - let raw_source_groups: std::collections::HashSet<_> = (0..configured.physical.candidates.len()) - .map(asap_types::stages::PhysicalCandidateId) - .filter(|&id| { - configured - .physical - .operator_nodes_of_candidates_for_memo_groups - [configured.physical.candidates[id.0].operator_node.0] - .is_implementation_root - && raw_scan_with_shifts(configured.physical.physical_node(id)) - }) - .map(|id| configured.physical.memo_group_of(id)) - .collect(); - // Bound the numerical range; these tiers are intentionally heuristic. - let preference = (graph.candidates.len() as f64 + 1.0).min(1000.0); - // A missing certificate means unknown cost, not free computation. Advisory - // query-time work and retained-state estimates guide proposals only; the - // candidate graph retains its original certified lower-bound coefficients. - let estimated_costs = crate::search::costing::proposals::estimate_costs(configured, context); - let proposal_costs: Vec<_> = graph - .candidates - .iter() - .enumerate() - .map(|(id, candidate)| { - candidate.local_cost.max(estimated_costs[id]).max( - retained_tumbling_window_priority_cost( - configured.node(id), - &configured.physical.candidates[id], - context, - ), - ) - }) - .collect(); - // Feasibility seeds compare retained states; cost-only seeds compare query - // work. Large unrelated tumbling window residency must not make CPU savings smaller - // than the structural node-count tie-break when accuracy is ignored. - let mut positive_costs: Vec<_> = proposal_costs - .iter() - .enumerate() - .filter_map(|(id, &cost)| { - (cost > 0.0 - && if context.models.accuracy.may_accept_regardless_of_guarantee() { - configured.physical.candidates[id].timing - == asap_types::ir::properties::ExecutionTiming::QueryTime - } else { - matches!( - configured.node(id).asap(), - Some(ASAPOp::SummaryTumblingWindowChain { .. }) - ) - }) - .then_some(cost) - }) - .collect(); - if positive_costs.is_empty() { - positive_costs.extend(proposal_costs.iter().copied().filter(|cost| *cost > 0.0)); - } - positive_costs.sort_by(f64::total_cmp); - let reference_cost = positive_costs - .get(positive_costs.len().saturating_sub(1) / 2) - .copied() - .unwrap_or(0.0); - let priorities = (0..configured.physical.candidates.len()) - .map(|id| { - let node = configured.node(id); - // Accuracy determines admissibility through composition and validation. - // It must not add a price surcharge to an approximate implementation. - let cost_priority = if reference_cost > 0.0 { - let relative = proposal_costs[id] / reference_cost; - // Preserve ordering beyond the normal linear range, without - // letting a very fine unused tumbling window destroy numerical scaling. - preference - * if relative <= 1000.0 { - relative - } else { - // Subtract logs instead of taking the log of a ratio - // that can overflow for otherwise finite estimates. - 1000.0 - + (proposal_costs[id].ln() - reference_cost.ln() - 1000.0_f64.ln()) - .max(0.0) - } - } else { - 0.0 - }; - // A strict feasibility seed must not spend its limited attempts on - // estimators whose error still needs unavailable workload facts. - // This is seed-only: another producer may repair the contract, so - // the complete graph retains these candidates and validates them. - let unresolved_estimator_error = - matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })) - && { - let root = QueryRoot::Operator(std::rc::Rc::clone(node)); - crate::bound_root_guarantee( - &root, - asap_types::workload::bounds::series_read(&root, context.data), - context.data, - &context.models, - ).is_none_or(|guarantee| { - guarantee.spec.error.evaluate().is_none() - || (confidence_required - && !guarantee.is_exact() - && guarantee.spec.failure_probability.evaluate().is_none()) - }) - }; - let contract_risk = !context.models.accuracy.may_accept_regardless_of_guarantee() - && (unresolved_estimator_error || node.guarantee.as_ref().is_some_and(|guarantee| { - !guarantee.is_exact() - && guarantee.spec.accuracy_metric == AccuracyMetric::Rank - && !rank_requested[id] - }) || (node.guarantee.is_none() - && matches!(node.asap(), Some(ASAPOp::SummaryEstimate { .. })))); - // Highest-shared-node eligibility depends on chosen consumers. - // Start with explicit build/tumbling window materialization; optional shared - // non-summary materializations remain in the complete search. - let contextual_materialization = - !context.models.accuracy.may_accept_regardless_of_guarantee() - && configured.physical.candidates[id].materialization - != asap_types::physical::Materialization::NotMaterialized - && !matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { .. } | ASAPOp::SummaryTumblingWindowChain { .. }) - ); - let projected_raw_source = raw_source_groups - .contains(&configured.physical.memo_group_of(asap_types::stages::PhysicalCandidateId(id))) - && matches!( - node.non_asap(), - Some(asap_types::ir::NonASAPOp::Project { .. }) - ); - // Unpriced tumbling window pipelines are not free. Prefer candidates whose - // local query work can be priced for the initial proposal; the - // complete search retains every legal tumbling window and its certified bound. - let unknown_work_priority = if context.models.accuracy.may_accept_regardless_of_guarantee() - && configured.physical.candidates[id].timing - == asap_types::ir::properties::ExecutionTiming::QueryTime - && matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - && proposal_costs[id] == 0.0 - { - preference * preference - } else { - 0.0 - }; - let insufficient_composed_budget = node.asap() - .and_then(ASAPOp::produced_state) - .is_some_and(|family| matches!(family, - FieldDataType::Sketch(kind, _) - if matches!(kind.params(), asap_types::ir::schema::SketchParams::DDSketch { alpha } - if *alpha > dds_budgets[id]))); - Ok(( - 1.0 + cost_priority + unknown_work_priority, - contract_risk || contextual_materialization || projected_raw_source || insufficient_composed_budget, - )) - }) - .collect::, String>>()?; - let exclusions = priorities - .iter() - .enumerate() - .filter_map(|(id, (_, excluded))| { - excluded.then_some(asap_types::stages::PhysicalCandidateId(id)) - }) - .collect(); - Ok(( - priorities.into_iter().map(|(weight, _)| weight).collect(), - exclusions, - )) -} - -fn raw_scan_with_shifts(node: &asap_types::ir::OperatorNode) -> bool { - match node.non_asap() { - Some(asap_types::ir::NonASAPOp::Scan { .. }) => true, - Some(asap_types::ir::NonASAPOp::TimeShift { child, .. }) => raw_scan_with_shifts(child), - // Projection after an already formed range is a different, legal case. - _ => false, - } -} - -// A heuristic memory charge, never a lower bound or an evaluator replacement. -// Group/cardinality and producer choices remain authoritative after selection. -fn retained_tumbling_window_priority_cost( - node: &std::rc::Rc, - configuration: &asap_types::stages::PhysicalCandidate, - context: &CandidatePlanningContext<'_>, -) -> f64 { - use asap_types::ir::{ASAPOp, NonASAPOp}; - use asap_types::physical::{Materialization, Medium}; - let Materialization::MatAtIngestion(medium) = configuration.materialization else { - return 0.0; - }; - let Some(ASAPOp::SummaryTumblingWindowChain { - family, - tumbling_window_width_ms, - tumbling_window_count, - reduction, - child, - .. - }) = node.asap() - else { - return 0.0; - }; - let price = match (&context.models.cost_inputs.cost_model, medium) { - (CostModel::UsageOverTime(prices), Medium::Memory) => prices.cost_per_memory_byte_second, - (CostModel::UsageOverTime(prices), Medium::Disk) => prices.cost_per_disk_byte_second, - _ => return 0.0, - }; - let reachable = asap_types::ir::OperatorNode::reachable(node); - let sources: std::collections::BTreeSet<_> = reachable - .iter() - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Scan { source, .. }) => { - Some(asap_types::workload::bounds::source_name(source)) - } - _ => None, - }) - .collect(); - let groups = match reduction { - asap_types::ir::operator::Reduction::Reduce(keys) - if keys.keys().is_empty() && !keys.is_without() => - { - 1 - } - _ => asap_types::workload::bounds::declared_series(&sources, context.data) - .unwrap_or(1) - .max(1), - }; - let rows_per_state = asap_types::workload::bounds::source_rows_per_ms(&sources, context.data) - .map(|rate| (rate * *tumbling_window_width_ms as f64 / groups as f64).ceil() as u64); - let non_negative = matches!( - child.non_asap(), - Some(NonASAPOp::Scan { .. } | NonASAPOp::TimeRange { .. }) - ) && sources - .iter() - .all(|source| asap_types::workload::bounds::declared_non_negative(context.data, source)); - let (_, bytes) = crate::summary_shape( - family, - crate::StateInputRows { - rows_per_state, - non_negative, - }, - ); - let cost = price * bytes as f64 * groups as f64 * (f64::from(*tumbling_window_count) + 1.0); - if cost.is_finite() { - cost - } else { - 0.0 - } -} - -pub(in crate::search) enum MaterializationOutcome { - Implemented(PhysicalWorkloadPlan), - RejectedAssignment, -} - -pub(in crate::search) fn materialize_configured_candidate( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - selected: &SelectedCandidateGraph, - evaluation_number: usize, - rejections: &mut CandidateRejectionSummary, -) -> Result, String> { - match materialize_configured_candidate_with_diagnostics( - configured, - context, - selected, - evaluation_number, - rejections, - )? { - MaterializationOutcome::Implemented(physical) => Ok(Some(physical)), - MaterializationOutcome::RejectedAssignment => Ok(None), - } -} - -pub(in crate::search) fn materialize_configured_candidate_with_diagnostics( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - selected: &SelectedCandidateGraph, - evaluation_number: usize, - rejections: &mut CandidateRejectionSummary, -) -> Result { - // Only a typed infeasible assignment justifies a no-good cut. Missing - // identity, incompatible contracts and unsupported rewrites stop search. - configured - .validate_selected(selected) - .map_err(|error| PhysicalCandidateError::InvalidOrUnsupported(error).to_string())?; - let mut realized = match context - .realizer - .realize(&configured.physical, &selected.to_selected_dag()) - { - Ok(realized) => realized, - Err(PhysicalCandidateError::RejectedAssignment(reason)) => { - rejections.record("materialization_assignment".into(), &reason); - return Ok(MaterializationOutcome::RejectedAssignment); - } - Err(error) => return Err(error.to_string()), - }; - // Stage 2 leaves IDs to its caller. A nonempty, stable proposal label keeps - // selected-plan references and viewer cost lookups consistent. - realized.plan.id = format!("joint-plan-{evaluation_number}"); - realized.plan.from_logical = format!("joint-logical-{evaluation_number}"); - Ok(MaterializationOutcome::Implemented(realized.plan)) -} - -pub(in crate::search) fn reconstruct_configured_candidate( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - selected: &SelectedCandidateGraph, - evaluation_number: usize, - rejections: &mut CandidateRejectionSummary, - rejected_queries: &mut Vec, -) -> Result, String> { - let Some(physical) = materialize_configured_candidate( - configured, - context, - selected, - evaluation_number, - rejections, - )? - else { - return Ok(None); - }; - // Reuse the accuracy evaluator's size-independent proof. Other consumers - // can change sizes, but cannot repair this certified failure of the same - // complete query dependency subgraph. - let targets = crate::accuracy_targets(context.entries); - let mut output_index = 0; - for (query, root) in physical.roots.iter().enumerate() { - let mut outputs = Vec::new(); - match &context.query_roots[query] { - QueryRoot::Operator(_) => { - outputs.push(output_index); - output_index += 1; - } - QueryRoot::Scalar(expression) => { - expression.map_operator_refs(&mut |node| { - outputs.push(output_index); - output_index += 1; - std::rc::Rc::clone(node) - }); - } - } - if let Err(failure) = - crate::guarantee_check(query, root, &targets[query], context.data, &context.models) - { - if failure.size_independent && !outputs.is_empty() { - rejected_queries.push(RejectedQuerySubgraph::from_query_outputs( - selected, &outputs, - )?); - let category = serde_json::to_value(failure.cause) - .ok() - .and_then(|value| value.as_str().map(str::to_owned)) - .unwrap_or_else(|| format!("{:?}", failure.cause)); - rejections.record(category, &failure.reason); - } - } - } - if !rejected_queries.is_empty() { - return Ok(None); - } - Ok(Some(physical)) -} - -#[cfg(test)] -pub(in crate::search) fn evaluate_configured_candidate( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - selected: &SelectedCandidateGraph, - evaluation_number: usize, - rejections: &mut CandidateRejectionSummary, - rejected_queries: &mut Vec, -) -> Result, String> { - evaluate_configured_candidate_before( - configured, - context, - selected, - evaluation_number, - rejections, - rejected_queries, - ) - .map_err(|error| error.to_string()) -} - -pub(in crate::search) fn evaluate_configured_candidate_before( - configured: &PhysicalCandidatesWithCosts, - context: &CandidatePlanningContext<'_>, - selected: &SelectedCandidateGraph, - evaluation_number: usize, - rejections: &mut CandidateRejectionSummary, - rejected_queries: &mut Vec, -) -> Result, ErrorSelectingPlan> { - let Some(physical) = reconstruct_configured_candidate( - configured, - context, - selected, - evaluation_number, - rejections, - rejected_queries, - ) - .map_err(ErrorSelectingPlan::Solver)? - else { - return Ok(None); - }; - let mut selector = PlanSelector::new(context.entries, context.data, context.models, false)?; - selector.set_calibration(context.calibration); - selector.offer_before(&physical, context.deadline)?; - match selector.finish() { - Ok(result) => Ok(Some(result)), - Err(ErrorSelectingPlan::NoValidCandidate(rejected)) => { - for rejection in rejected { - let category = serde_json::to_value(rejection.cause) - .ok() - .and_then(|value| value.as_str().map(str::to_owned)) - .unwrap_or_else(|| format!("{:?}", rejection.cause)); - rejections.record(category, &rejection.reason); - } - Ok(None) - } - Err(error) => Err(error), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::configurations::enumerate_candidate_configurations; - use crate::search::candidates::inventory::build_candidate_inventory; - use crate::search::refinement::search::CandidateSearchStatus; - use asap_logical_asap_optimization::generate_dag_of_logical_memo_groups; - use asap_logical_asap_optimization::test_support::generate_per_computation_candidates; - use asap_logical_asap_optimization::test_support::sharing_rules_and_shared_state_groups; - use asap_logical_asap_optimization::test_support::try_populate_pass1; - use asap_logical_asap_optimization::test_support::{entry_with, lower_promql}; - use asap_logical_asap_optimization::TumblingWindowWidthDomain; - use asap_types::ir::properties::AccuracyTarget; - use asap_types::ir::schema::{FieldDataType, SketchAlgorithm}; - use asap_types::ir::{ASAPOp, OperatorNode}; - use asap_types::physical::Materialization; - use asap_types::stages::OriginOfCandidateForMemoGroup; - use asap_types::workload::{ - DataArrival, Predictability, QueryRecurrence, RepeatedDemand, RepetitionInterval, - SourceFacts, - }; - use std::time::Duration; - - fn fixture() -> ( - PhysicalCandidatesWithCosts, - Vec, - Vec, - DataWorkload, - ) { - let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let roots: Vec<_> = [0.95, 0.99] - .into_iter() - .map(|q| { - QueryRoot::Operator(lower_promql( - &format!("quantile_over_time({q}, latency[5m])"), - target.clone(), - )) - }) - .collect(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs: Vec<_> = (0..2) - .map(|q| *initial.query_roots[q].as_operator().unwrap()) - .collect(); - let generated = generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &roots.clone(), - )) - .unwrap(); - let mut memo = sharing_rules_and_shared_state_groups( - try_populate_pass1(initial, &generated).unwrap(), - TumblingWindowWidthDomain::Complete, - ) - .unwrap(); - // Keep the real generated shared KLL readers to bound this evaluator - // integration test; materialization configurations still remain choices. - for candidates in &mut memo.candidates { - let shared: Vec<_> = candidates.iter().filter(|candidate| candidate.origin == OriginOfCandidateForMemoGroup::SharedBuild && OperatorNode::reachable(&candidate.root_node).iter().any(|node| - matches!(node.asap(), Some(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. }) if *kind.algorithm() == SketchAlgorithm::Kll))) - .cloned().collect(); - if !shared.is_empty() { - *candidates = shared; - } - } - let source = build_candidate_inventory(&memo, &outputs, |_| Ok(0.0)).unwrap(); - let entries: Vec<_> = (0..2) - .map(|_| { - let mut entry = entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 300_000, - ))), - ); - entry.predictability = Predictability::Predictable { known_at: None }; - entry - }) - .collect(); - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: [( - "latency".into(), - SourceFacts { - series_count: Some(1), - ..Default::default() - }, - )] - .into_iter() - .collect(), - }; - let mut configured = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &asap_logical_asap_optimization::DeploymentCapabilities::default(), - ) - .unwrap(); - // Keep this refinement fixture's finite search space focused on summary - // modes. Non-summary shared materialization has separate physical tests. - for (id, configuration) in configured.physical.candidates.iter().enumerate() { - if matches!( - configuration.materialization, - Materialization::MatAtQuery(_) - ) && !matches!( - configured.node(id).asap(), - Some(ASAPOp::SummaryAgg { .. } | ASAPOp::SummaryTumblingWindowChain { .. }) - ) { - configured.dag.reader_constraints.push( - crate::search::candidates::dag::ReaderConstraint::AllowedOutputs { - candidate: asap_types::stages::PhysicalCandidateId(id), - outputs: Vec::new(), - }, - ); - } - } - (configured, roots, entries, data) - } - - /// Restricting a seed removes all query-time scan paths and repairs every - /// producer option reference without changing the original profile. - #[test] - fn maintained_seed_prunes_and_remaps_producer_options() { - use crate::search::costing::direct_profile::extract_price_options; - let (configured, roots, entries, data) = fixture(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let profile = extract_price_options(&configured, &context) - .unwrap() - .unwrap(); - let original_count: usize = profile.candidates.iter().map(Vec::len).sum(); - let restricted = maintained_state_profile(&configured, &profile, None).unwrap(); - assert!(restricted - .candidates - .iter() - .any(|options| !options.is_empty())); - assert!(restricted.candidates.iter().map(Vec::len).sum::() < original_count); - for (id, options) in restricted.candidates.iter().enumerate() { - let scan = matches!( - configured.node(id).non_asap(), - Some(asap_types::ir::NonASAPOp::Scan { .. }) - ); - for option in options { - assert!(!scan || option.profile.ingestion); - assert!(option.profile.ingestion || !option.profile.materialized); - assert!(!option.compatible_input_tuples.is_empty()); - for binding in option.compatible_input_tuples.iter().flatten() { - assert!(binding.price_option < restricted.candidates[binding.producer.0].len()); - } - } - } - assert_eq!( - profile.candidates.iter().map(Vec::len).sum::(), - original_count - ); - assert!( - maintained_state_profile(&configured, &profile, Some(std::time::Instant::now())) - .is_none() - ); - } - - // An optional direct-price proposal must not abort planning when its - // schedule construction budget expires; the ordinary search remains usable. - #[test] - fn unsupported_schedule_profiles_leave_both_seed_fallbacks_available() { - let (configured, roots, mut entries, data) = fixture(); - entries[1].recurrence = QueryRecurrence::Unknown; - let context = CandidatePlanningContext { - deadline: Some(std::time::Instant::now()), - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let mut reasons = Vec::new(); - assert!(joint_cost_seed(&configured, &context, &[], &mut reasons) - .unwrap() - .is_none()); - assert!(!reasons.is_empty()); - for latest_tumbling_windows in [false, true] { - assert!( - direct_cost_seed(&configured, &context, &[], latest_tumbling_windows) - .unwrap() - .is_none() - ); - } - // Malformed demand is a real input error, not an unsupported model. - let mut invalid_entries = entries.clone(); - invalid_entries[1].recurrence = - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(0))); - let invalid_context = CandidatePlanningContext { - deadline: None, - entries: &invalid_entries, - ..context - }; - assert!(joint_cost_seed(&configured, &invalid_context, &[], &mut reasons).is_err()); - for latest_tumbling_windows in [false, true] { - assert!( - direct_cost_seed(&configured, &invalid_context, &[], latest_tumbling_windows) - .is_err() - ); - } - } - - // One logical group can serve a repeated reader through tumbling windows and an - // unpredictable reader locally; only selected tumbling window consumers need eligibility. - #[test] - fn nongridded_reader_does_not_veto_another_readers_ingestion_tumbling_windows() { - use crate::search::milp::model::select_candidates; - use asap_types::ir::properties::ExecutionTiming; - let (_, original, mut entries, mut data) = fixture(); - for facts in data.sources.values_mut() { - facts.values_non_negative = true; - } - let roots = vec![original[0].clone(), original[0].clone()]; - let initial = generate_dag_of_logical_memo_groups(&roots); - let output = *initial.query_roots[0].as_operator().unwrap(); - let generated = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&roots)) - .unwrap(); - let mut memo = try_populate_pass1(initial, &generated).unwrap(); - entries[0].recurrence = - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval(60_000))); - entries[1].recurrence = QueryRecurrence::Unknown; - entries[1].predictability = Predictability::Unknown; - // This test isolates selected-consumer eligibility, not width search. - asap_logical_asap_optimization::test_support::add_local_window_composition_in_domain( - &mut memo, - &entries, - asap_logical_asap_optimization::TumblingWindowWidthDomain::SeedGrids, - ); - let source = build_candidate_inventory(&memo, &[output, output], |_| Ok(0.0)).unwrap(); - let models = SelectionModels::builtin(); - let mut configured = enumerate_candidate_configurations( - &source, - &[0, 1], - &entries, - &data, - &asap_logical_asap_optimization::DeploymentCapabilities::default(), - ) - .unwrap(); - let has_tumbling_windows: Vec<_> = configured - .nodes_for_tests() - .iter() - .map(|node| { - OperatorNode::reachable(node).iter().any(|node| { - matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - }) - }) - .collect(); - configured.dag.query_roots[0] - .candidates - .retain(|id| has_tumbling_windows[id.0]); - configured.dag.query_roots[1] - .candidates - .retain(|id| !has_tumbling_windows[id.0]); - assert!(!configured.dag.query_roots[0].candidates.is_empty()); - let allowed: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .map(|(id, node)| { - !matches!(node.asap(), Some(ASAPOp::SummaryTumblingWindowChain { .. })) - || configured.physical.candidates[id].timing == ExecutionTiming::IngestionTime - }) - .collect(); - for candidate in &mut configured.dag.candidates { - for input in &mut candidate.producer_candidates_per_input { - input.candidates.retain(|id| allowed[id.0]); - } - } - let selected = select_candidates(&configured.dag) - .unwrap() - .expect("repeated reader still has an ingestion tumbling window plan"); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - let evaluated = evaluate_configured_candidate( - &configured, - &context, - &selected, - 1, - &mut CandidateRejectionSummary::default(), - &mut Vec::new(), - ) - .unwrap(); - assert!(evaluated.is_some()); - configured.dag.query_roots[1].candidates = configured.dag.query_roots[0].candidates.clone(); - assert!( - select_candidates(&configured.dag).unwrap().is_none(), - "unpredictable reader cannot bind to ingestion-only tumbling windows" - ); - } - - #[test] - fn ratio_seed_uses_composed_dds_capacities_without_removing_full_candidates() { - let target = AccuracyTarget::max_error(0.05); - let root = QueryRoot::Operator(lower_promql( - "quantile_over_time(0.9, latency[1h]) / quantile_over_time(0.5, latency[1h])", - target.clone(), - )); - let query_roots = vec![root.clone()]; - let initial = generate_dag_of_logical_memo_groups(&query_roots); - let output = *initial.query_roots[0].as_operator().unwrap(); - let generated = - generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&query_roots)) - .unwrap(); - let memo = sharing_rules_and_shared_state_groups( - try_populate_pass1(initial, &generated).unwrap(), - TumblingWindowWidthDomain::Complete, - ) - .unwrap(); - let source = build_candidate_inventory(&memo, &[output], |_| Ok(0.0)).unwrap(); - let (_, _, _, mut data) = fixture(); - for facts in data.sources.values_mut() { - facts.values_non_negative = true; - } - let roots = [root]; - let entries = [entry_with(target, QueryRecurrence::Unknown)]; - let models = SelectionModels::builtin(); - let configured = enumerate_candidate_configurations( - &source, - &[0], - &entries, - &data, - &asap_logical_asap_optimization::DeploymentCapabilities::default(), - ) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - let original_count = configured.dag.candidates.len(); - let (_, excluded) = accuracy_baseline(&configured, &context).unwrap(); - let mut coarse = 0; - let mut composed = 0; - for (id, node) in configured.nodes_for_tests().iter().enumerate() { - let Some(FieldDataType::Sketch(kind, _)) = node.asap().and_then(ASAPOp::produced_state) - else { - continue; - }; - let asap_types::ir::schema::SketchParams::DDSketch { alpha } = kind.params() else { - continue; - }; - if *alpha >= 0.05 { - coarse += 1; - assert!(excluded.contains(&asap_types::stages::PhysicalCandidateId(id))); - } else if !excluded.contains(&asap_types::stages::PhysicalCandidateId(id)) { - composed += 1; - } - } - assert!( - coarse > 0 && composed > 0, - "both whole and composed capacities remain in the catalog" - ); - assert_eq!(configured.dag.candidates.len(), original_count); - let settings = asap_cost::CostInputs { - ..Default::default() - }; - let ignored = CandidatePlanningContext { - models: models - .with_cost_inputs(&settings) - .with_accuracy(&asap_accuracy::IgnoreAccuracyGuarantees), - ..context - }; - assert!(seed_dds_budgets(&configured, &ignored, &[0]) - .iter() - .all(|budget| budget.is_infinite())); - } - - // Rank-only quantiles cannot feed value arithmetic, and approximate values - // cannot establish an exact top-k ranking; contract-aware priorities find a - // validated seed without changing the feasible candidate catalog. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn accuracy_baseline_validates_composed_value_and_topk_workload() { - let target = AccuracyTarget::max_error(0.05).failure_probability(0.3); - let roots: Vec<_> = [ - "avg(quantile_over_time(0.95, latency[5m]))", - "topk by (job) (3, sum_over_time(requests[5m]))", - ] - .into_iter() - .map(|query| QueryRoot::Operator(lower_promql(query, target.clone()))) - .collect(); - let initial = generate_dag_of_logical_memo_groups(&roots); - let outputs: Vec<_> = (0..roots.len()) - .map(|query| *initial.query_roots[query].as_operator().unwrap()) - .collect(); - let generated = generate_per_computation_candidates(&generate_dag_of_logical_memo_groups( - &roots.clone(), - )) - .unwrap(); - let mut memo = try_populate_pass1(initial, &generated).unwrap(); - let entries: Vec<_> = (0..roots.len()) - .map(|_| { - let mut entry = entry_with( - target.clone(), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(RepetitionInterval( - 1000, - ))), - ); - entry.predictability = Predictability::Predictable { known_at: None }; - entry - }) - .collect(); - asap_logical_asap_optimization::test_support::add_local_window_composition( - &mut memo, &entries, - ); - let memo = sharing_rules_and_shared_state_groups(memo, TumblingWindowWidthDomain::Complete) - .unwrap(); - let source = build_candidate_inventory(&memo, &outputs, |_| Ok(0.0)).unwrap(); - let deployment = asap_logical_asap_optimization::DeploymentCapabilities { - raw_data_retained: false, - ..Default::default() - }; - let data = DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1000.0, - }, - sources: ["latency", "requests"] - .into_iter() - .map(|name| { - ( - name.into(), - SourceFacts { - series_count: Some(10), - values_non_negative: true, - ..Default::default() - }, - ) - }) - .collect(), - }; - let models = SelectionModels { - ..SelectionModels::builtin() - }; - let configured = - enumerate_candidate_configurations(&source, &[0, 1], &entries, &data, &deployment) - .unwrap(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - let limits = CandidateSearchLimits { - max_refinements: 1, - wall_time: Duration::from_secs(20), - }; - let mut rejected = CandidateRejectionSummary::default(); - let mut rejected_queries = Vec::new(); - let mut bad_assignment = None; - let plain = crate::search::refinement::search::search_candidates_with_baseline( - &configured.dag, - limits, - |selected| { - bad_assignment = Some(selected.clone()); - Ok(evaluate_configured_candidate( - &configured, - &context, - selected, - 1, - &mut rejected, - &mut rejected_queries, - )? - .map(|(selection, _)| { - selection.costs[&selection.selected] - .cost_per_second - .unwrap() - })) - }, - ) - .unwrap(); - assert!( - plain.incumbent.is_none(), - "fixture must expose node-count baseline's missing contract" - ); - assert!(rejected.counts.contains_key("no_accuracy_guarantee")); - assert!( - !rejected_queries.is_empty(), - "composed value/top-k failures must supply certified cuts" - ); - // A deployment may acquire evidence at another shared summary size. - // Reuse the original predicate's conservative guard, not a new model. - struct PotentialEvidence; - impl asap_accuracy::AccuracyEvidenceProvider for PotentialEvidence {} - let evidence = PotentialEvidence; - let evidence_context = CandidatePlanningContext { - deadline: None, - models: SelectionModels { - evidence: &evidence, - ..models - }, - ..context - }; - let mut guarded_cuts = Vec::new(); - let _ = evaluate_configured_candidate( - &configured, - &evidence_context, - bad_assignment.as_ref().unwrap(), - 2, - &mut CandidateRejectionSummary::default(), - &mut guarded_cuts, - ) - .unwrap(); - assert!( - guarded_cuts.is_empty(), - "custom evidence cannot justify size-independent cuts" - ); - - let result = optimize_configured_candidates(&configured, &context, limits).unwrap(); - assert!( - result.best.is_some(), - "{:?}: {:?}", - result.search.status, - result.rejections - ); - assert_eq!(result.search.lower_bound, 0.0); - assert_ne!(result.search.status, CandidateSearchStatus::Optimal); - } - - // A projected raw-source producer would put Project below TimeRange at - // installation. Projection above a range remains a legal seed candidate. - #[test] - fn feasibility_seed_distinguishes_raw_source_and_range_projections() { - use crate::search::candidates::dag::InputProducerCandidates; - use asap_types::ir::{NonASAPOp, Operator, ProjectItem, ScalarExpr}; - use asap_types::stages::PhysicalCandidateId; - let (mut configured, roots, entries, data) = fixture(); - let find = |is_range: bool| { - (0..configured.physical.candidates.len()) - .map(PhysicalCandidateId) - .filter(|&id| { - configured - .physical - .operator_nodes_of_candidates_for_memo_groups - [configured.physical.candidates[id.0].operator_node.0] - .is_implementation_root - }) - .find(|id| { - let node = configured.node(id.0); - configured.physical.candidates[id.0].materialization - == Materialization::NotMaterialized - && matches!( - (is_range, node.non_asap()), - (false, Some(NonASAPOp::Scan { .. })) - | (true, Some(NonASAPOp::TimeRange { .. })) - ) - }) - .expect("fixture exposes source and range groups") - }; - let source = find(false); - let range = find(true); - let mut projected = Vec::new(); - for supplier in [source, range] { - let child = configured.node(supplier.0).clone(); - let template = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project { - cols: child - .schema - .fields - .iter() - .enumerate() - .map(|(column, field)| ProjectItem { - alias: Some(field.name.clone()), - expr: ScalarExpr::Column(column), - }) - .collect(), - qualifier: None, - child, - })) - .unwrap(); - let id = configured.push_root_like_for_tests( - template, - supplier, - vec![InputProducerCandidates { - candidates: vec![supplier].into(), - }], - ); - projected.push(id); - } - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let (_, excluded) = accuracy_baseline(&configured, &context).unwrap(); - assert!(excluded.contains(&projected[0])); - assert!(!excluded.contains(&projected[1])); - assert!(!excluded.contains(&source)); - let ignored = asap_cost::CostInputs { - ..Default::default() - }; - let (_, ignored_excluded) = accuracy_baseline( - &configured, - &CandidatePlanningContext { - models: SelectionModels::builtin() - .with_cost_inputs(&ignored) - .with_accuracy(&asap_accuracy::IgnoreAccuracyGuarantees), - ..context - }, - ) - .unwrap(); - // Runtime raw-window shape restrictions do not depend on accuracy. - assert!(ignored_excluded.contains(&projected[0])); - assert!(!ignored_excluded.contains(&projected[1])); - assert!(!ignored_excluded.contains(&source)); - assert_eq!( - configured - .physical - .implementation_roots(configured.physical.memo_group_of(source)) - .last(), - Some(&projected[0]), - "seed policy does not remove the projected candidate from the inventory" - ); - } - - // Accuracy policy changes feasibility, not the price of a summary candidate; - // costs and final evaluation still determine which implementation is kept. - #[test] - fn accuracy_policy_does_not_penalize_summary_candidates() { - let (configured, roots, entries, data) = fixture(); - let settings = asap_cost::CostInputs { - ..Default::default() - }; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin() - .with_cost_inputs(&settings) - .with_accuracy(&asap_accuracy::IgnoreAccuracyGuarantees), - calibration: CalibrationFit::default(), - }; - let id = configured - .nodes_for_tests() - .iter() - .position(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(..), - .. - }) - ) - }) - .expect("fixture includes a summary build"); - let (ignored, excluded) = accuracy_baseline(&configured, &context).unwrap(); - assert!( - excluded.is_empty(), - "ignored-policy cost seeds retain all materialization choices" - ); - let enforced = accuracy_baseline_weights( - &configured, - &CandidatePlanningContext { - models: SelectionModels::builtin(), - ..context - }, - ) - .unwrap(); - assert_eq!( - ignored[id], enforced[id], - "enforcing accuracy must not add an exactness penalty" - ); - } - - // A shared state can cost more than two smaller states; proposal priorities - // must account for certified work without changing the solver's cost model. - #[test] - fn accuracy_baseline_accounts_for_state_cost() { - let (mut configured, roots, entries, data) = fixture(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let original = accuracy_baseline_weights(&configured, &context).unwrap(); - configured.dag.candidates[0].local_cost = 100.0; - let priced = accuracy_baseline_weights(&configured, &context).unwrap(); - assert!(priced[0] > original[0] + 2.0); - assert_eq!(priced[1..], original[1..]); - assert_eq!(configured.dag.candidates[0].local_cost, 100.0); - } - - // An irrelevant extremely fine tumbling window must not erase the cost distinction - // between ordinary state configurations in the feasible-plan heuristic. - #[test] - fn baseline_cost_scale_is_not_diluted_by_an_outlier() { - let (mut configured, roots, entries, data) = fixture(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let original = accuracy_baseline_weights(&configured, &context).unwrap(); - for (candidate, cost) in configured.dag.candidates.iter_mut().zip([100.0, 1.0, 1e8]) { - candidate.local_cost = cost; - } - let weighted = accuracy_baseline_weights(&configured, &context).unwrap(); - assert!(weighted[0] > original[0] + 2.0); - } - - // Uncertified memory is not free: a 1200-tumbling-window shared state must not beat - // two much smaller same-family states solely by saving one activation. - #[test] - fn baseline_prices_uncertified_tumbling_window_storage_without_changing_bounds() { - use asap_types::ir::{NonASAPOp, Operator}; - use asap_types::physical::Medium; - let (mut configured, roots, entries, data) = fixture(); - let exact = vec![QueryRoot::Operator(lower_promql( - "sum_over_time(latency[20m])", - AccuracyTarget::exact(), - ))]; - let memo = try_populate_pass1( - generate_dag_of_logical_memo_groups(&exact), - &generate_per_computation_candidates(&generate_dag_of_logical_memo_groups(&exact)) - .unwrap(), - ) - .unwrap(); - let build = memo - .candidates - .iter() - .flatten() - .flat_map(|candidate| OperatorNode::reachable(&candidate.root_node)) - .find(|node| { - matches!( - node.asap(), - Some(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(..), - .. - }) - ) - }) - .unwrap(); - let Some(ASAPOp::SummaryAgg { - child, - family, - input, - reduction, - grouping, - filter, - }) = build.asap() - else { - unreachable!() - }; - let child = match child.non_asap() { - Some(NonASAPOp::TimeRange { child, .. }) => child, - _ => child, - }; - for (id, (tumbling_window_width_ms, tumbling_window_count)) in - [(1000, 1200), (1000, 60), (30000, 40)] - .into_iter() - .enumerate() - { - configured.set_node_for_tests( - id, - OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryTumblingWindowChain { - child: child.clone(), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.clone(), - tumbling_window_width_ms, - tumbling_window_count, - })) - .unwrap(), - ); - configured.physical.candidates[id].materialization = - Materialization::MatAtIngestion(Medium::Memory); - configured.physical.candidates[id].timing = - asap_types::ir::properties::ExecutionTiming::IngestionTime; - } - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let weights = accuracy_baseline_weights(&configured, &context).unwrap(); - assert!(weights[0] > weights[1] + weights[2]); - assert!(configured - .dag - .candidates - .iter() - .all(|candidate| candidate.local_cost == 0.0)); - } - - // Explicit rank requirements remain valid baseline priorities, rather than - // being treated as value-error requests or eliminating rank candidates. - #[test] - fn accuracy_baseline_respects_explicit_rank_targets() { - use asap_types::ir::properties::AccuracyMetric; - let (configured, roots, mut entries, data) = fixture(); - let weights = |entries: &[QueryWorkloadEntry]| { - accuracy_baseline( - &configured, - &CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, entries, &data), - entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }, - ) - .unwrap() - }; - let (value_weights, value_exclusions) = weights(&entries); - for entry in &mut entries { - entry.requirements.accuracy.accuracy_metric = Some(AccuracyMetric::Rank); - } - let (rank_weights, rank_exclusions) = weights(&entries); - let rank_nodes: Vec<_> = configured - .nodes_for_tests() - .iter() - .enumerate() - .filter(|(id, _)| { - configured - .dag - .query_roots - .iter() - .any(|root| root.candidates.iter().any(|candidate| candidate.0 == *id)) - }) - .filter_map(|(id, node)| { - node.guarantee - .as_ref() - .filter(|guarantee| { - !guarantee.is_exact() - && guarantee.spec.accuracy_metric == AccuracyMetric::Rank - }) - .map(|_| id) - }) - .collect(); - assert!(!rank_nodes.is_empty()); - assert!(rank_nodes - .iter() - .all(|id| value_exclusions.iter().any(|excluded| excluded.0 == *id))); - assert!(rank_nodes - .iter() - .all(|id| rank_exclusions.iter().all(|excluded| excluded.0 != *id))); - - assert!(rank_nodes - .iter() - .all(|&id| rank_weights[id] == value_weights[id])); - assert!(configured - .dag - .candidates - .iter() - .all(|candidate| candidate.local_cost == 0.0)); - } - - // Both scalar price objectives use the full evaluator, with one frozen calibration. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn real_kll_search_cost_matches_independent_plan_selector_evaluation() { - let (configured, roots, entries, data) = fixture(); - for provisioned in [false, true] { - let mut settings = asap_cost::CostInputs::default(); - if provisioned { - let CostModel::UsageOverTime(prices) = settings.cost_model.clone() else { - unreachable!() - }; - settings.cost_model = CostModel::ProvisionedCapacity(prices); - } - let models = SelectionModels { - cost_inputs: &settings, - ..SelectionModels::builtin() - }; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models, - calibration: CalibrationFit::default(), - }; - let result = optimize_configured_candidates( - &configured, - &context, - CandidateSearchLimits { - max_refinements: 3, - wall_time: Duration::from_secs(20), - }, - ) - .unwrap(); - let (selection, physical) = result - .best - .as_ref() - .expect("search should retain a validated KLL plan"); - assert!( - !selection.selected.is_empty(), - "selected plan needs a viewer-visible ID" - ); - assert_eq!(selection.selected, physical.id); - let mut independent = PlanSelector::new(&entries, &data, models, false).unwrap(); - independent.set_calibration(context.calibration); - independent.offer(physical).unwrap(); - let (reference, _) = independent.finish().unwrap(); - assert_eq!( - selection.costs[&selection.selected].cost_per_second, - reference.costs[&reference.selected].cost_per_second - ); - assert_eq!( - result.search.incumbent.as_ref().unwrap().actual_cost, - reference.costs[&reference.selected] - .cost_per_second - .unwrap() - ); - assert!(matches!( - result.search.status, - CandidateSearchStatus::RefinementLimit | CandidateSearchStatus::Optimal - )); - } - } - - // A one-evaluation budget returns the validated full plan and an unfinished status. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn bounded_search_keeps_its_validated_plan() { - let (configured, roots, entries, data) = fixture(); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let result = optimize_configured_candidates( - &configured, - &context, - CandidateSearchLimits { - max_refinements: 1, - wall_time: Duration::from_secs(20), - }, - ) - .unwrap(); - assert!(result.best.is_some(), "{:?}", result.search.status); - assert_eq!(result.search.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.search.refinements, 1); - assert!(result.search.relative_gap.unwrap() > 0.0); - } - - // Workload eligibility can reject one offered configuration; exclude only - // that complete assignment and continue to a validated legal configuration. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn rejected_materialization_does_not_stop_search_for_legal_plan() { - let (configured, roots, mut entries, data) = fixture(); - // The catalog was generated for predictable readers. This deliberately - // exercises Stage 2's authoritative recheck on its ingestion proposal. - entries[1].predictability = Predictability::AdHoc; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - // Select an ingestion-only summary assignment explicitly. Solver - // traversal order can change with warm starts and must not determine - // whether this test exercises authoritative eligibility rejection. - let mut ingestion_graph = configured.dag.clone(); - for (id, configuration) in configured.physical.candidates.iter().enumerate() { - if matches!( - configured.node(id).asap(), - Some(ASAPOp::SummaryAgg { .. } | ASAPOp::SummaryTumblingWindowChain { .. }) - ) && !matches!( - configuration.materialization, - Materialization::MatAtIngestion(_) - ) { - ingestion_graph.reader_constraints.push( - crate::search::candidates::dag::ReaderConstraint::AllowedOutputs { - candidate: asap_types::stages::PhysicalCandidateId(id), - outputs: vec![], - }, - ); - } - } - let rejected = - crate::search::milp::model::solve_candidate_proposal(&ingestion_graph, &[], None) - .unwrap() - .unwrap() - .selected; - let mut rejections = CandidateRejectionSummary::default(); - assert!(evaluate_configured_candidate( - &configured, - &context, - &rejected, - 0, - &mut rejections, - &mut Vec::new() - ) - .unwrap() - .is_none()); - assert_eq!( - rejections.counts.get("materialization_assignment"), - Some(&1) - ); - assert!(!rejections.examples.is_empty()); - let result = optimize_configured_candidates( - &configured, - &context, - CandidateSearchLimits { - max_refinements: 20, - wall_time: Duration::from_secs(20), - }, - ) - .unwrap(); - // The bounded search must recover a legal plan; proving optimality is - // tested separately and depends on the generated catalog's size. - assert!(matches!( - result.search.status, - CandidateSearchStatus::Optimal | CandidateSearchStatus::RefinementLimit - )); - let (_, physical) = result.best.unwrap(); - assert!(physical.materialization.values().all(|mode| !matches!( - mode, - asap_types::physical::Materialization::MatAtIngestion(_) - ))); - } - - // A broken output contract is an adapter error, never evidence of infeasibility. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn unsupported_output_contract_stops_without_an_infeasibility_cut() { - let (configured, mut roots, entries, data) = fixture(); - let QueryRoot::Operator(node) = &mut roots[0] else { - unreachable!() - }; - std::rc::Rc::make_mut(node).schema.fields[0] - .name - .push_str("_changed"); - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels::builtin(), - calibration: CalibrationFit::default(), - }; - let result = optimize_configured_candidates( - &configured, - &context, - CandidateSearchLimits { - max_refinements: 20, - wall_time: Duration::from_secs(20), - }, - ) - .unwrap(); - assert!(matches!( - result.search.status, - CandidateSearchStatus::EvaluatorStopped(_) - )); - assert_eq!(result.search.refinements, 1); - assert!(result.best.is_none()); - } - - // A cluster objective must not silently become scalar price optimization. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn scalar_price_search_does_not_scalarize_cluster_objectives() { - let (configured, roots, entries, data) = fixture(); - let settings = asap_cost::CostInputs { - cost_model: CostModel::ClusterCapacity(asap_cost::ClusterLimits { - memory_bytes: u64::MAX, - disk_bytes: u64::MAX, - cpu_cores: None, - objective: asap_cost::ClusterObjective::LowestLatency, - }), - ..Default::default() - }; - let context = CandidatePlanningContext { - deadline: None, - refine_materialization: false, - query_roots: &roots, - realizer: &crate::search::test_realizer(&roots, &entries, &data), - entries: &entries, - data: &data, - models: SelectionModels { - cost_inputs: &settings, - ..SelectionModels::builtin() - }, - calibration: CalibrationFit::default(), - }; - assert!(optimize_configured_candidates( - &configured, - &context, - CandidateSearchLimits { - max_refinements: 1, - wall_time: Duration::from_secs(1) - } - ) - .err() - .unwrap() - .contains("ClusterCapacity")); - } - - /// `q / q + q'` reads three quantiles, also when the two `q` are one - /// shared node, as in Pass 1's roots over the memo groups. Counted by - /// node, the shared form read two, so the feasibility seed of the - /// ratio-of-quantiles workload admitted sketches sized for half the - /// budget, whose outputs have no guarantee, and missed the exact plan. - #[test] - fn quantile_operands_count_reads_not_shared_nodes() { - let frontend = vec![QueryRoot::Operator(lower_promql( - "avg by (job) (quantile_over_time(0.5, data[5m]) \ - / quantile_over_time(0.5, data[5m]) + quantile_over_time(0.9, data[5m]))", - AccuracyTarget::max_error(0.01).failure_probability(0.001), - ))]; - let over_groups = generate_dag_of_logical_memo_groups(&frontend).roots_over_groups(); - // The two `quantile 0.5` are one node over the groups. - let QueryRoot::Operator(root) = &over_groups[0] else { - unreachable!() - }; - let mut seen = std::collections::HashSet::new(); - let distinct_quantile_nodes = OperatorNode::reachable(root) - .into_iter() - .filter(|node| seen.insert(std::rc::Rc::as_ptr(node))) - .filter(|node| { - matches!( - node.non_asap(), - Some(asap_types::ir::NonASAPOp::Aggregate { measures, .. }) - if measures.iter().any(|m| matches!(m, asap_types::ir::operator::AggIntent::Quantile { .. })) - ) - }) - .count(); - assert_eq!(distinct_quantile_nodes, 2); - assert_eq!(quantile_operands(&frontend[0]), 3); - assert_eq!(quantile_operands(&over_groups[0]), 3); - } - - /// Two different parents over one quantile (Stage 2 implements an - /// average as a sum and a count of the same values) read it once; one - /// parent reading it as both operands reads it twice. - #[test] - fn quantile_operands_count_a_diamond_once() { - use asap_types::ir::operator::operator_properties::BinaryOpKind; - use asap_types::ir::scalar::ArithmeticOpKind; - use asap_types::ir::{BinaryOperator, NonASAPOp, Operator}; - let average = lower_promql( - "avg by (job) (quantile_over_time(0.5, data[5m]))", - AccuracyTarget::max_error(0.01).failure_probability(0.001), - ); - let divide = |lhs: std::rc::Rc, rhs: std::rc::Rc| { - QueryRoot::Operator( - OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Div), - vector_match: None, - }, - return_bool: false, - lhs, - rhs, - })) - .unwrap(), - ) - }; - assert_eq!(quantile_operands(&QueryRoot::Operator(average.clone())), 1); - // Two parents (copies of the average) over the same quantile node. - let diamond = divide( - std::rc::Rc::new((*average).clone()), - std::rc::Rc::new((*average).clone()), - ); - assert_eq!(quantile_operands(&diamond), 1); - assert_eq!(quantile_operands(÷(average.clone(), average)), 2); - } -} diff --git a/crates/plan-selection/src/search/refinement/pruning.rs b/crates/plan-selection/src/search/refinement/pruning.rs deleted file mode 100644 index 5426f08d0..000000000 --- a/crates/plan-selection/src/search/refinement/pruning.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Unit tests of Stage 2's structural pruning -//! (`asap_physical_asap_optimization::test_support::catalog`) that select with the solver. -use crate::search::candidates::dag::DagOfPhysicalCandidatesWithCosts; -use crate::search::candidates::dag::PhysicalCandidatesWithCosts; -use crate::search::candidates::dag::ReaderConstraint; -use asap_physical_asap_optimization::test_support::catalog::usable_candidates; -use asap_types::stages::PhysicalCandidateId; - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::milp::model::select_candidates; - use asap_types::ir::properties::ExecutionTiming; - use asap_types::physical::Materialization; - - use crate::search::candidates::dag::{ - InputProducerCandidates, PhysicalCandidateWithCost, QueryOutputCandidates, ResourceBudget, - }; - - fn candidate(cost: f64, inputs: &[&[usize]]) -> PhysicalCandidateWithCost { - PhysicalCandidateWithCost { - local_cost: cost, - producer_candidates_per_input: inputs - .iter() - .map(|ids| InputProducerCandidates { - candidates: ids.iter().map(|id| PhysicalCandidateId(*id)).collect(), - }) - .collect(), - } - } - fn configured(graph: DagOfPhysicalCandidatesWithCosts) -> PhysicalCandidatesWithCosts { - let count = graph.candidates.len(); - PhysicalCandidatesWithCosts::from_dag_for_tests( - graph, - (0..count) - .map(|_| { - ( - crate::search::candidates::dag::placeholder_node_for_tests(), - (ExecutionTiming::QueryTime, Materialization::NotMaterialized), - ) - }) - .collect(), - ) - } - fn usable(graph: &DagOfPhysicalCandidatesWithCosts) -> Vec { - usable_candidates(&configured(graph.clone()).physical) - } - - // Removing an unrelated candidate remaps IDs once per shared producer - // list; the subsequent solve still sees independent consumer requirements. - #[test] - fn pruning_retains_shared_producer_storage_after_remapping() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - candidate(9.0, &[]), - candidate(1.0, &[]), - candidate(1.0, &[&[1]]), - candidate(1.0, &[&[1]]), - ], - query_roots: vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2)], - }, - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(3)], - }, - ], - ..Default::default() - }; - let original = graph.clone(); - let mut inventory = configured(graph); - assert_eq!(inventory.prune_for_tests(), 1); - let graph = &inventory.dag; - assert_eq!( - graph.candidates[1].producer_candidates_per_input[0] - .candidates - .as_slice(), - &[PhysicalCandidateId(0)] - ); - assert_eq!( - asap_types::stages::producer_storage_metrics(&inventory.physical), - asap_types::stages::ProducerStorageMetrics { - logical_connections: 2, - unique_producer_sets: 1, - stored_producer_ids: 1 - } - ); - assert_eq!( - select_candidates(graph).unwrap().unwrap().modeled_cost, - select_candidates(&original).unwrap().unwrap().modeled_cost - ); - } - - // Dead dependency cycles and unused nodes disappear; a cycle with a finite - // escape remains available. Cost and resource accounting survive remapping. - #[test] - fn pruning_preserves_optimum_and_resource_indices() { - let graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - candidate(9.0, &[&[1]]), - candidate(9.0, &[&[0]]), - candidate(2.0, &[]), - candidate(1.0, &[&[2, 4]]), - candidate(1.0, &[&[3]]), - candidate(1.0, &[]), - ], - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(3)], - }], - resource_budgets: vec![ResourceBudget { - name: "state".into(), - capacity: 5.0, - candidate_usage: vec![9.0, 9.0, 2.0, 3.0, 1.0, 9.0], - }], - ..Default::default() - }; - let before = select_candidates(&graph).unwrap().unwrap(); - let mut inventory = configured(graph); - assert_eq!(inventory.prune_for_tests(), 3); - assert_eq!( - inventory.dag.resource_budgets[0].candidate_usage, - vec![2.0, 3.0, 1.0] - ); - assert_eq!(inventory.physical.candidates[0].operator_node.0, 2); - let after = select_candidates(&inventory.dag).unwrap().unwrap(); - assert_eq!(before.modeled_cost, after.modeled_cost); - assert_eq!(after.modeled_cost, 3.0); - } - - // Multiple output positions of one query do not satisfy two-reader sharing. - #[test] - fn required_reader_pruning_propagates_without_losing_fallback() { - let mut graph = DagOfPhysicalCandidatesWithCosts { - candidates: vec![ - candidate(1.0, &[]), - candidate(2.0, &[]), - candidate(1.0, &[&[0]]), - ], - query_roots: vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(1), PhysicalCandidateId(2)] - }; - 2 - ], - reader_constraints: vec![ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0, 1]], - minimum: 2, - }], - ..Default::default() - }; - assert_eq!(usable(&graph), vec![false, true, false]); - graph.reader_constraints = vec![ReaderConstraint::MinimumQueryReaders { - candidate: PhysicalCandidateId(0), - query_outputs: vec![vec![0], vec![1]], - minimum: 2, - }]; - assert_eq!(usable(&graph), vec![true, true, true]); - graph - .reader_constraints - .push(ReaderConstraint::AllowedOutputs { - candidate: PhysicalCandidateId(0), - outputs: vec![0], - }); - assert_eq!(usable(&graph), vec![false, true, false]); - } - - // Missing inputs preserve an uncovered root as infeasible, not an empty workload. - #[test] - fn impossible_root_is_not_silently_removed() { - let mut inventory = configured(DagOfPhysicalCandidatesWithCosts { - candidates: vec![candidate(1.0, &[&[]])], - query_roots: vec![QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0)], - }], - ..Default::default() - }); - inventory.prune_for_tests(); - assert_eq!(inventory.dag.query_roots.len(), 1); - assert!(select_candidates(&inventory.dag).unwrap().is_none()); - } -} diff --git a/crates/plan-selection/src/search/refinement/search.rs b/crates/plan-selection/src/search/refinement/search.rs deleted file mode 100644 index b255b44a4..000000000 --- a/crates/plan-selection/src/search/refinement/search.rs +++ /dev/null @@ -1,1415 +0,0 @@ -//! Bounded evaluator refinement over candidate connections, without enumerating -//! complete plans up front. Certified query cuts reuse size-independent accuracy -//! failures; full-assignment cuts remain a correctness fallback; -//! they make no claim of scalable demand modeling or support for every cost model. -use crate::search::milp::direct_cost::DirectCostProfile; -use crate::search::milp::direct_price_options::{ - solve_joint_price_proposal_with_metrics, JointPriceProfile, -}; - -#[derive(Clone, Copy)] -pub(in crate::search) enum DirectPriceModel<'a> { - Fixed(&'a DirectCostProfile), - Joint(&'a JointPriceProfile), - /// Structural ordering only; every price comes from the evaluator. - Structural, -} -use std::time::{Duration, Instant}; - -use crate::search::candidates::dag::{CandidateSelectionError, DagOfPhysicalCandidatesWithCosts}; -use crate::search::candidates::graph::RejectedQuerySubgraph; -use crate::search::candidates::graph::SelectedCandidateGraph; -use crate::search::milp::model::{ - solve_candidate_proposal_with_metrics, solve_direct_cost_proposal_with_metrics, - CandidateProposal, CandidateSolveMetrics, ObservedAssignment, -}; -use asap_types::stages::PhysicalCandidateId; - -#[derive(Debug, Default)] -pub(in crate::search) struct CandidateEvaluation { - pub actual_cost: Option, - /// Temporarily skip an unpriceable assignment without proving it infeasible. - pub unpriced_reason: Option, - /// A stopped evaluator must not emit any rejection/cost cut. - pub budget_exhausted: bool, - pub rejected_queries: Vec, -} - -impl From> for CandidateEvaluation { - fn from(actual_cost: Option) -> Self { - Self { - actual_cost, - budget_exhausted: false, - unpriced_reason: None, - rejected_queries: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(in crate::search) struct CandidateSearchLimits { - /// Maximum evaluator calls (feasible and infeasible proposals both count). - pub max_refinements: usize, - pub wall_time: Duration, -} - -#[derive(Debug, Clone)] -pub(in crate::search) struct EvaluatedCandidate { - pub selected: SelectedCandidateGraph, - pub actual_cost: f64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::search) enum CandidateSearchStatus { - /// Proven against the refined finite model, subject to solver tolerances. - Optimal, - Infeasible, - RefinementLimit, - TimeLimit, - SolverStopped(String), - EvaluatorStopped(String), - NumericalStall, -} - -// Read by the engine's tests; #1095 replaces the engine. -#[cfg_attr(not(test), allow(dead_code))] -#[derive(Debug)] -pub(in crate::search) struct CandidateSearchResult { - pub incumbent: Option, - pub status: CandidateSearchStatus, - /// Best certified model lower bound observed; zero is the initial bound. - pub lower_bound: f64, - /// None without an incumbent. Zero-cost incumbent has zero gap only at bound zero. - pub relative_gap: Option, - pub refinements: usize, - pub solve_metrics: CandidateSolveMetrics, - /// Includes physical reconstruction, accuracy/latency checks and pricing. - pub validation_seconds: f64, - /// Complete query closures rejected by the original size-independent proof. - pub certified_query_cuts: usize, -} - -#[derive(Clone, Copy, Default)] -struct SeedRestrictions<'a> { - excluded: &'a [PhysicalCandidateId], - materialization_changes: &'a [(PhysicalCandidateId, PhysicalCandidateId)], - direct: &'a [(&'a DagOfPhysicalCandidatesWithCosts, DirectPriceModel<'a>)], -} - -/// The callback returns an actual total cost for a validated feasible plan, or -/// `None` for a proven infeasible assignment. Explicit unpriced feedback skips -/// one assignment and invalidates full-domain certificates. Errors stop search, preserving the -/// incumbent. Its cost must include every configured cost component and must be -/// deterministic for an assignment. Local graph costs are certified lower bounds, -/// not estimates. A violated certification is an error, never silently repaired. -/// Time bounds are cooperative: a blocking evaluator cannot be preempted here. -#[cfg(test)] -pub(in crate::search) fn search_candidates( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - mut evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, String>, -) -> Result { - search_with_structural_baseline( - graph, - limits, - false, - None, - SeedRestrictions::default(), - |selected| evaluate(selected).map(Into::into), - ) -} - -/// Seed the same certified-cost search with a bounded minimum-active-node plan. -/// This structural objective favors shared producers, but is not a cost estimate -/// or lower bound. Its proposals undergo the same evaluator and exact cuts. -#[cfg(test)] -pub(in crate::search) fn search_candidates_with_baseline( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, String>, -) -> Result { - search_candidates_with_weighted_baseline(graph, limits, None, evaluate) -} - -/// Optional weights order baseline proposals only; they never modify cost bounds. -#[cfg(test)] -pub(in crate::search) fn search_candidates_with_weighted_baseline( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - weights: Option<&[f64]>, - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, String>, -) -> Result { - let mut evaluate = evaluate; - search_candidates_with_feedback(graph, limits, weights, |selected| { - evaluate(selected).map(Into::into) - }) -} - -#[cfg(test)] -pub(in crate::search) fn search_candidates_with_feedback( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - weights: Option<&[f64]>, - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, -) -> Result { - search_with_structural_baseline( - graph, - limits, - true, - weights, - SeedRestrictions::default(), - evaluate, - ) -} - -#[cfg(test)] -pub(in crate::search) fn search_candidates_with_restricted_feedback( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - weights: &[f64], - excluded_from_seed: &[PhysicalCandidateId], - materialization_changes: &[(PhysicalCandidateId, PhysicalCandidateId)], - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, -) -> Result { - search_candidates_with_direct_feedback( - graph, - limits, - weights, - excluded_from_seed, - materialization_changes, - &[], - evaluate, - ) -} - -pub(in crate::search) fn search_candidates_with_direct_feedback( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - weights: &[f64], - excluded_from_seed: &[PhysicalCandidateId], - materialization_changes: &[(PhysicalCandidateId, PhysicalCandidateId)], - direct: &[(&DagOfPhysicalCandidatesWithCosts, DirectPriceModel<'_>)], - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, -) -> Result { - search_with_structural_baseline( - graph, - limits, - true, - Some(weights), - SeedRestrictions { - excluded: excluded_from_seed, - materialization_changes, - direct, - }, - evaluate, - ) -} - -fn search_with_structural_baseline( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - structural_baseline: bool, - baseline_weights: Option<&[f64]>, - restrictions: SeedRestrictions<'_>, - evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, -) -> Result { - search_with_solver( - graph, - limits, - structural_baseline, - baseline_weights, - restrictions, - evaluate, - solve_candidate_proposal_with_metrics, - ) -} - -fn search_with_solver( - graph: &DagOfPhysicalCandidatesWithCosts, - limits: CandidateSearchLimits, - structural_baseline: bool, - baseline_weights: Option<&[f64]>, - restrictions: SeedRestrictions<'_>, - mut evaluate: impl FnMut(&SelectedCandidateGraph) -> Result, - mut propose: impl FnMut( - &DagOfPhysicalCandidatesWithCosts, - &[ObservedAssignment], - Option, - &mut CandidateSolveMetrics, - ) -> Result, CandidateSelectionError>, -) -> Result { - graph.validate().map_err(|error| error.to_string())?; - let started = Instant::now(); - let mut observations = Vec::new(); - let mut unpriced_reason = None; - let mut incumbent: Option = None; - let mut lower_bound: f64 = 0.0; - let mut refinements = 0; - let mut solve_metrics = CandidateSolveMetrics::default(); - let mut validation_seconds = 0.0; - if baseline_weights.is_some_and(|weights| weights.len() != graph.candidates.len()) { - return Err("baseline weights must be indexed by candidate ID".into()); - } - if restrictions - .excluded - .iter() - .any(|id| id.0 >= graph.candidates.len()) - { - return Err("baseline exclusion must reference a generated candidate".into()); - } - let mut heuristic_graph = - structural_baseline.then(|| { - let mut heuristic = graph.clone(); - for (id, candidate) in heuristic.candidates.iter_mut().enumerate() { - candidate.local_cost = baseline_weights.map_or(1.0, |weights| weights[id]); - } - // Seed with one copy per configuration to avoid spending the warm-start - // budget on interchangeable execution bindings. The certified search - // retains every copy, including when this restricted seed is infeasible. - use crate::search::candidates::dag::ReaderConstraint; - let mut extra_copies: Vec<_> = heuristic - .reader_constraints - .iter() - .filter_map(|constraint| match constraint { - ReaderConstraint::ActivationRequires { candidate, .. } => Some(*candidate), - _ => None, - }) - .collect(); - // Contract-risk exclusions affect the feasibility seed only. They - // are not accuracy proofs or cuts in the certified full graph. - extra_copies.extend_from_slice(restrictions.excluded); - extra_copies.sort_unstable(); - extra_copies.dedup(); - // Remove disabled-copy edges before model construction, retaining - // global IDs so selected proposals still decode in the full graph. - let mut disabled = vec![false; heuristic.candidates.len()]; - for candidate in &extra_copies { - disabled[candidate.0] = true; - } - for (id, candidate) in heuristic.candidates.iter_mut().enumerate() { - if disabled[id] { - candidate.producer_candidates_per_input.clear(); - } else { - for input in &mut candidate.producer_candidates_per_input { - input.candidates.retain(|producer| !disabled[producer.0]); - } - } - } - for root in &mut heuristic.query_roots { - root.candidates.retain(|candidate| !disabled[candidate.0]); - } - heuristic - .reader_constraints - .extend(extra_copies.into_iter().map(|candidate| { - ReaderConstraint::AllowedOutputs { - candidate, - outputs: Vec::new(), - } - })); - heuristic - }); - if let Some(heuristic) = &heuristic_graph { - heuristic.validate().map_err(|error| error.to_string())?; - } - // At most three validations and one fifth of the shared wall budget, - // capped at two seconds; stop the baseline once it supplies an incumbent. - let baseline_budget = (limits.wall_time / 5).min(Duration::from_secs(2)); - let mut baseline_attempts = 0; - let mut baseline_started: Option = None; - let mut direct_attempts = 0; - let mut direct_finished = false; - let mut direct_index = 0; - let direct_budget = (limits.wall_time / 3).min(Duration::from_secs(2)); - let per_direct_budget = direct_budget / restrictions.direct.len().max(1) as u32; - let mut direct_started = Instant::now(); - // Each local proposal uses the same solver and evaluator. Limit the phase - // to four validations and one fifth of this domain's existing wall budget. - let neighborhood_budget = (limits.wall_time / 5).min(Duration::from_secs(1)); - let mut neighborhood_spent = Duration::ZERO; - let mut neighborhood_attempts = 0; - let mut tried_changes = std::collections::HashSet::new(); - let status = loop { - let remaining = limits.wall_time.saturating_sub(started.elapsed()); - if remaining.is_zero() { - break CandidateSearchStatus::TimeLimit; - } - if refinements >= limits.max_refinements { - break CandidateSearchStatus::RefinementLimit; - } - if direct_index < restrictions.direct.len() - && (direct_finished - || direct_attempts >= 3 - || direct_started.elapsed() >= per_direct_budget) - { - direct_index += 1; - direct_attempts = 0; - direct_finished = false; - direct_started = Instant::now(); - } - let use_direct = - direct_index < restrictions.direct.len() && started.elapsed() < direct_budget; - if !use_direct { - baseline_started.get_or_insert_with(Instant::now); - } - let baseline_elapsed = baseline_started.map_or(Duration::ZERO, |start| start.elapsed()); - let use_baseline = !use_direct - && heuristic_graph.is_some() - && (incumbent.is_none() || solve_metrics.direct_cost_validated_plans > 0) - && baseline_attempts < 3 - && baseline_elapsed < baseline_budget; - let neighborhood_started = Instant::now(); - let neighborhood_deadline = neighborhood_started - + remaining.min(neighborhood_budget.saturating_sub(neighborhood_spent)); - let mut neighborhood = None; - if !use_direct - && !use_baseline - && neighborhood_attempts < 4 - && neighborhood_spent < neighborhood_budget - { - if let Some(best) = &incumbent { - for &(from, to) in restrictions.materialization_changes { - if Instant::now() >= neighborhood_deadline { - break; - } - if !best.selected.selected_candidates.contains(&from) - || !tried_changes.insert((from, to)) - { - continue; - } - if let Some(restricted) = super::materialization::restrict( - graph, - &best.selected, - from, - to, - neighborhood_deadline, - ) { - neighborhood = Some(restricted); - neighborhood_attempts += 1; - break; - } - } - } - } - let use_neighborhood = neighborhood.is_some(); - if !use_neighborhood { - neighborhood_spent += neighborhood_started.elapsed(); - } - let remaining = limits.wall_time.saturating_sub(started.elapsed()); - if remaining.is_zero() { - break CandidateSearchStatus::TimeLimit; - } - let (proposal_graph, proposal_observations, solve_budget) = if use_direct { - direct_attempts += 1; - let restricted = restrictions.direct[direct_index].0; - // A different scan-price class may forbid earlier bindings. Keep - // their observations for the full graph, not this restricted model. - let admitted = observations - .iter() - .filter(|observation: &&ObservedAssignment| { - crate::search::candidates::dag::validate_selected_graph( - restricted, - &observation.selected, - ) - .is_ok() - }) - .cloned() - .map(|mut observation| { - if matches!( - restrictions.direct[direct_index].1, - DirectPriceModel::Structural - ) { - observation.selected.modeled_cost = observation - .selected - .selected_candidates - .iter() - .map(|id| restricted.candidates[id.0].local_cost) - .sum(); - observation.exact_cost = None; - } - observation - }) - .collect(); - ( - restricted, - admitted, - remaining - .min(direct_budget.saturating_sub(started.elapsed())) - .min(per_direct_budget.saturating_sub(direct_started.elapsed())), - ) - } else if let Some(restricted) = &neighborhood { - ( - restricted, - Vec::new(), - remaining.min(neighborhood_deadline.saturating_duration_since(Instant::now())), - ) - } else if use_baseline { - baseline_attempts += 1; - // A structural proposal is never repriced by monetary observations. - // Tried assignments are skipped here only; their validated costs - // remain in the certified model used after this heuristic phase. - let skipped = observations - .iter() - .map(|observation: &ObservedAssignment| { - let mut selected = observation.selected.clone(); - selected.modeled_cost = selected - .selected_candidates - .iter() - .map(|id| { - heuristic_graph - .as_ref() - .expect("baseline graph exists") - .candidates[id.0] - .local_cost - }) - .sum(); - ObservedAssignment { - selected, - exact_cost: None, - rejected_queries: observation.rejected_queries.clone(), - } - }) - .collect::>(); - ( - heuristic_graph.as_ref().expect("baseline graph exists"), - skipped, - remaining.min(baseline_budget.saturating_sub(baseline_elapsed)), - ) - } else { - (graph, observations.clone(), remaining) - }; - if use_neighborhood && solve_budget.is_zero() { - neighborhood_spent += neighborhood_started.elapsed(); - continue; - } - let proposed = if use_direct { - match restrictions.direct[direct_index].1 { - DirectPriceModel::Structural => propose( - proposal_graph, - &proposal_observations, - Some(solve_budget.as_secs_f64()), - &mut solve_metrics, - ), - DirectPriceModel::Fixed(profile) => solve_direct_cost_proposal_with_metrics( - proposal_graph, - &proposal_observations, - Some(solve_budget.as_secs_f64()), - &mut solve_metrics, - profile, - ), - DirectPriceModel::Joint(profile) => solve_joint_price_proposal_with_metrics( - proposal_graph, - &proposal_observations, - Some(solve_budget.as_secs_f64()), - &mut solve_metrics, - profile, - ), - } - } else { - propose( - proposal_graph, - &proposal_observations, - Some(solve_budget.as_secs_f64()), - &mut solve_metrics, - ) - }; - let mut proposal = match proposed { - Ok(None) if use_direct => { - direct_finished = true; - continue; - } - Err(CandidateSelectionError::UnsupportedDirectCost(reason)) if use_direct => { - if solve_metrics.direct_cost_fallback_reasons.len() < 8 { - solve_metrics.direct_cost_fallback_reasons.push(reason); - } - direct_finished = true; - continue; - } - Err(CandidateSelectionError::Solver(reason)) if use_direct => { - if solve_metrics.direct_cost_fallback_reasons.len() < 8 { - solve_metrics - .direct_cost_fallback_reasons - .push(format!("direct solver stopped: {reason}")); - } - direct_finished = true; - continue; - } - Ok(Some(proposal)) => proposal, - Ok(None) if use_neighborhood => { - neighborhood_spent += neighborhood_started.elapsed(); - continue; - } - Ok(None) if use_baseline => { - heuristic_graph = None; - continue; - } - Ok(None) => { - if incumbent.is_some() { - return Err("refined model excluded a validated feasible assignment".into()); - } - break CandidateSearchStatus::Infeasible; - } - Err(CandidateSelectionError::Solver(_)) - if use_baseline - && baseline_started.is_some_and(|start| start.elapsed() >= baseline_budget) => - { - heuristic_graph = None; - continue; - } - Err(CandidateSelectionError::Solver(_)) - if use_neighborhood && Instant::now() >= neighborhood_deadline => - { - neighborhood_spent += neighborhood_started.elapsed(); - continue; - } - Err(error) => { - break if started.elapsed() >= limits.wall_time { - CandidateSearchStatus::TimeLimit - } else { - CandidateSearchStatus::SolverStopped(error.to_string()) - }; - } - }; - let direct_modeled_cost = proposal.direct_modeled_cost; - if use_baseline || use_direct { - proposal.selected.modeled_cost = proposal - .selected - .selected_candidates - .iter() - .map(|id| graph.candidates[id.0].local_cost) - .sum(); - } else if !use_neighborhood { - if let Some(bound) = proposal.model_lower_bound { - lower_bound = lower_bound.max(bound); - } - } - if let Some(best) = &incumbent { - if closes_gap(lower_bound, best.actual_cost) { - break CandidateSearchStatus::Optimal; - } - } - if started.elapsed() >= limits.wall_time { - break CandidateSearchStatus::TimeLimit; - } - // Exact cuts must raise a repeated feasible proposal to its observed cost. - // If numerical tolerances prevent convergence, stop instead of re-evaluating forever. - if observations.iter().any(|observation: &ObservedAssignment| { - same_assignment(&observation.selected, &proposal.selected) - }) { - if use_direct { - direct_finished = true; - continue; - } - if use_neighborhood { - neighborhood_spent += neighborhood_started.elapsed(); - continue; - } - break CandidateSearchStatus::NumericalStall; - } - refinements += 1; - let validation_started = Instant::now(); - let evaluated = evaluate(&proposal.selected); - validation_seconds += validation_started.elapsed().as_secs_f64(); - if use_neighborhood { - neighborhood_spent += neighborhood_started.elapsed(); - } - let feedback = match evaluated { - Ok(cost) => cost, - Err(error) => break CandidateSearchStatus::EvaluatorStopped(error), - }; - if feedback.budget_exhausted { - break CandidateSearchStatus::TimeLimit; - } - if let Some(reason) = feedback.unpriced_reason { - if feedback.actual_cost.is_some() || !feedback.rejected_queries.is_empty() { - return Err( - "unpriceable feedback cannot contain a cost or certified query cut".into(), - ); - } - unpriced_reason.get_or_insert(reason); - } - let actual_cost = feedback.actual_cost; - if actual_cost.is_some() && !feedback.rejected_queries.is_empty() { - return Err("feasible evaluator result cannot reject query subgraphs".into()); - } - if let Some(cost) = actual_cost { - if !cost.is_finite() || cost < 0.0 { - return Err("evaluator returned a nonfinite or negative cost".into()); - } - // The bound and the evaluator add the same per-node prices in - // different orders, so an exact bound may exceed the cost by a - // rounding step. - if cost < proposal.selected.modeled_cost * (1.0 - 1e-12) { - return Err(format!( - "actual cost {cost} is below certified local bound {}", - proposal.selected.modeled_cost - )); - } - if lower_bound > cost && !closes_gap(lower_bound, cost) { - return Err("model lower bound exceeds validated feasible cost".into()); - } - if incumbent - .as_ref() - .is_none_or(|best| cost < best.actual_cost) - { - incumbent = Some(EvaluatedCandidate { - selected: proposal.selected.clone(), - actual_cost: cost, - }); - } - } - if let (Some(modeled), Some(actual)) = (direct_modeled_cost, actual_cost) { - let tolerance = 1e-7 * modeled.abs().max(actual.abs()).max(1e-15); - if (modeled - actual).abs() > tolerance { - return Err(format!( - "direct-cost objective {modeled} disagrees with evaluator {actual}" - )); - } - direct_finished = true; - solve_metrics.direct_cost_validated_plans += 1; - solve_metrics.direct_cost_best_validated_cost = Some( - solve_metrics - .direct_cost_best_validated_cost - .map_or(actual, |previous| previous.min(actual)), - ); - } - if use_direct - && actual_cost.is_some() - && matches!( - restrictions.direct[direct_index].1, - DirectPriceModel::Structural - ) - { - direct_finished = true; - } - observations.push(ObservedAssignment { - selected: proposal.selected, - exact_cost: actual_cost, - rejected_queries: feedback.rejected_queries, - }); - if started.elapsed() >= limits.wall_time { - break CandidateSearchStatus::TimeLimit; - } - if incumbent - .as_ref() - .is_some_and(|best| closes_gap(lower_bound, best.actual_cost)) - { - break CandidateSearchStatus::Optimal; - } - }; - // Excluding an unpriceable assignment helps find a usable incumbent but - // does not prove infeasibility or a bound over the original candidate set. - let status = if let Some(reason) = unpriced_reason { - lower_bound = 0.0; - match status { - CandidateSearchStatus::Optimal | CandidateSearchStatus::Infeasible => { - CandidateSearchStatus::EvaluatorStopped(format!( - "unpriceable assignments excluded: {reason}" - )) - } - other => other, - } - } else { - status - }; - let relative_gap = incumbent.as_ref().map(|best| { - if best.actual_cost == 0.0 { - 0.0 - } else { - ((best.actual_cost - lower_bound) / best.actual_cost).max(0.0) - } - }); - Ok(CandidateSearchResult { - incumbent, - status, - lower_bound, - relative_gap, - refinements, - solve_metrics, - validation_seconds, - certified_query_cuts: observations.iter().map(|o| o.rejected_queries.len()).sum(), - }) -} - -fn closes_gap(lower: f64, upper: f64) -> bool { - (upper - lower).abs() <= 1e-9 * upper.abs().max(lower.abs()) -} - -fn same_assignment(left: &SelectedCandidateGraph, right: &SelectedCandidateGraph) -> bool { - left.selected_candidates.len() == right.selected_candidates.len() - && left - .selected_candidates - .iter() - .all(|id| right.selected_candidates.contains(id)) - && left.connections.len() == right.connections.len() - && left - .connections - .iter() - .all(|edge| right.connections.contains(edge)) - && left.query_roots.len() == right.query_roots.len() - && left - .query_roots - .iter() - .all(|root| right.query_roots.contains(root)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::search::candidates::dag::{ - InputProducerCandidates, PhysicalCandidateWithCost, QueryOutputCandidates, - }; - - use asap_types::stages::PhysicalCandidateId; - - fn choices(costs: &[f64]) -> DagOfPhysicalCandidatesWithCosts { - DagOfPhysicalCandidatesWithCosts { - candidates: costs - .iter() - .map(|&local_cost| PhysicalCandidateWithCost { - local_cost, - producer_candidates_per_input: vec![], - }) - .collect(), - query_roots: vec![QueryOutputCandidates { - candidates: (0..costs.len()).map(PhysicalCandidateId).collect(), - }], - ..Default::default() - } - } - fn limits(max_refinements: usize) -> CandidateSearchLimits { - CandidateSearchLimits { - max_refinements, - wall_time: Duration::from_secs(10), - } - } - - // An unpriceable candidate must not prevent a later validated plan, nor - // allow exclusions of unknown costs to certify a full-domain optimum. - #[test] - fn unpriceable_assignments_preserve_search_without_an_optimality_claim() { - for costs in [vec![1.0, 2.0], vec![1.0]] { - let graph = choices(&costs); - let result = search_candidates_with_direct_feedback( - &graph, - limits(8), - &costs, - &[], - &[], - &[], - |selected| { - if selected - .selected_candidates - .contains(&PhysicalCandidateId(0)) - { - Ok(CandidateEvaluation { - unpriced_reason: Some("analytical arithmetic overflowed".into()), - ..Default::default() - }) - } else { - Ok(Some(2.0).into()) - } - }, - ) - .unwrap(); - assert_eq!( - result.incumbent.as_ref().map(|best| best.actual_cost), - (costs.len() == 2).then_some(2.0) - ); - assert_eq!(result.lower_bound, 0.0); - assert!(matches!( - result.status, - CandidateSearchStatus::EvaluatorStopped(_) - )); - assert_eq!(result.relative_gap, (costs.len() == 2).then_some(1.0)); - } - } - - // A restricted direct-cost seed supplies an incumbent, never a full-domain bound. - #[test] - fn direct_cost_seed_validates_price_without_claiming_global_optimum() { - use crate::search::milp::direct_cost::NodeCostProfile; - let graph = choices(&[0.0, 0.0]); - let mut restricted = graph.clone(); - restricted.query_roots[0].candidates = vec![PhysicalCandidateId(1)]; - let profile = DirectCostProfile { - schedules: None, - nodes: vec![ - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 1.0, - ..Default::default() - }, - NodeCostProfile { - ingestion: false, - standing_cost_per_second: 0.0, - execution_cost: 2.0, - ..Default::default() - }, - ], - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - let result = search_candidates_with_direct_feedback( - &graph, - limits(1), - &[1.0, 1.0], - &[], - &[], - &[(&restricted, DirectPriceModel::Fixed(&profile))], - |selected| Ok(Some(profile.selected_cost(&restricted, selected)).into()), - ) - .unwrap(); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.solve_metrics.direct_cost_validated_plans, 1); - } - - /// A structural seed's artificial objective never becomes a monetary - /// bound or a claim that direct pricing matched the evaluator. - #[test] - fn structural_restricted_seed_uses_only_evaluated_prices() { - let graph = choices(&[0.0, 0.0]); - let mut restricted = graph.clone(); - restricted.query_roots[0].candidates = vec![PhysicalCandidateId(1)]; - restricted.candidates[1].local_cost = 100.0; - let result = search_candidates_with_direct_feedback( - &graph, - limits(1), - &[1.0, 1.0], - &[], - &[], - &[(&restricted, DirectPriceModel::Structural)], - |_| Ok(Some(2.0).into()), - ) - .unwrap(); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.solve_metrics.direct_cost_validated_plans, 0); - } - - // Separate scan-price classes share one budget and retain the best validated DAG. - #[test] - fn direct_price_classes_preserve_the_best_incumbent() { - use crate::search::milp::direct_cost::NodeCostProfile; - let graph = choices(&[0.0, 0.0]); - let mut first = graph.clone(); - first.query_roots[0].candidates = vec![PhysicalCandidateId(0)]; - let mut second = graph.clone(); - second.query_roots[0].candidates = vec![PhysicalCandidateId(1)]; - let profile = DirectCostProfile { - schedules: None, - nodes: vec![ - NodeCostProfile { - execution_cost: 1.0, - ..Default::default() - }, - NodeCostProfile { - execution_cost: 2.0, - ..Default::default() - }, - ], - query_rate: 1.0, - request_latency_seconds: 0.0, - request_cost: 0.0, - }; - let result = search_candidates_with_direct_feedback( - &graph, - limits(2), - &[1.0, 1.0], - &[], - &[], - &[ - (&first, DirectPriceModel::Fixed(&profile)), - (&second, DirectPriceModel::Fixed(&profile)), - ], - |selected| Ok(Some(profile.selected_cost(&graph, selected)).into()), - ) - .unwrap(); - assert_eq!(result.incumbent.unwrap().actual_cost, 1.0); - assert_eq!(result.solve_metrics.direct_cost_validated_plans, 2); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - } - - // A later unproven solve can improve the validated incumbent without replacing - // the earlier certified bound or falsely closing the optimality gap. - #[test] - fn nonoptimal_proposal_preserves_bound_and_improves_incumbent() { - use crate::search::candidates::dag::validate_selected_graph; - use crate::search::candidates::graph::QueryRootAssignment; - let graph = choices(&[2.0, 3.0]); - let mut round = 0; - let result = search_with_solver( - &graph, - limits(2), - false, - None, - SeedRestrictions::default(), - |selected| { - Ok(Some(if selected.selected_candidates[0].0 == 0 { - 20.0 - } else { - 10.0 - }) - .into()) - }, - |graph, observations, _, _| { - assert_eq!(observations.len(), round); - let selected = SelectedCandidateGraph { - selected_candidates: vec![PhysicalCandidateId(round)], - connections: Vec::new(), - query_roots: vec![QueryRootAssignment { - query_root: 0, - candidate: PhysicalCandidateId(round), - }], - modeled_cost: graph.candidates[round].local_cost, - }; - validate_selected_graph(graph, &selected).unwrap(); - let model_lower_bound = (round == 0).then_some(2.0); - round += 1; - Ok(Some(CandidateProposal { - selected, - model_lower_bound, - direct_modeled_cost: None, - })) - }, - ) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.incumbent.unwrap().actual_cost, 10.0); - assert_eq!(result.lower_bound, 2.0); - assert_eq!(result.relative_gap, Some(0.8)); - assert_eq!(result.refinements, 2); - } - - #[test] - fn certified_feedback_survives_heuristic_phase_without_changing_cost_bounds() { - let mut graph = choices(&[0.0, 0.0, 0.0, 0.0]); - graph.query_roots = vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)], - }, - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(2), PhysicalCandidateId(3)], - }, - ]; - let mut bad_evaluations = 0; - let result = - search_candidates_with_feedback( - &graph, - limits(10), - Some(&[1.0, 10.0, 1.0, 2.0]), - |selected| { - assert_eq!(selected.modeled_cost, 0.0); - if selected.query_roots.iter().any(|root| { - root.query_root == 0 && root.candidate == PhysicalCandidateId(0) - }) { - bad_evaluations += 1; - Ok(CandidateEvaluation { - budget_exhausted: false, - unpriced_reason: None, - actual_cost: None, - rejected_queries: vec![RejectedQuerySubgraph::from_query_outputs( - selected, - &[0], - )?], - }) - } else { - Ok(Some(2.0).into()) - } - }, - ) - .unwrap(); - assert_eq!(bad_evaluations, 1); - assert_eq!(result.certified_query_cuts, 1); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - } - - // Structural node counts must never be reported as monetary lower bounds. - #[test] - fn structural_seed_uses_original_bound_and_favors_shared_nodes() { - let mut graph = choices(&[0.25, 0.25, 0.25]); - graph.query_roots = vec![ - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(2)], - }, - QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(1), PhysicalCandidateId(2)], - }, - ]; - let result = search_candidates_with_baseline(&graph, limits(1), |selected| { - assert_eq!(selected.selected_candidates, vec![PhysicalCandidateId(2)]); - assert_eq!(selected.modeled_cost, 0.25); - Ok(Some(0.5)) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.relative_gap, Some(1.0)); - assert_eq!(result.incumbent.unwrap().actual_cost, 0.5); - assert_eq!(result.refinements, 1); - assert_eq!(result.solve_metrics.solve_calls, 1); - } - - // Restricting the warm start must not remove execution copies from the - // certified search when two incompatible query contexts need both copies. - #[test] - fn baseline_scope_restriction_falls_back_to_complete_graph() { - use crate::search::candidates::dag::ReaderConstraint; - let mut graph = choices(&[1.0, 1.0]); - graph.query_roots.push(graph.query_roots[0].clone()); - graph.reader_constraints = vec![ - ReaderConstraint::ActivationRequires { - candidate: PhysicalCandidateId(1), - required_candidate: PhysicalCandidateId(0), - }, - ReaderConstraint::IncompatibleOutputs { - candidate: PhysicalCandidateId(0), - left: 0, - right: 1, - }, - ReaderConstraint::IncompatibleOutputs { - candidate: PhysicalCandidateId(1), - left: 0, - right: 1, - }, - ]; - let mut solves = 0; - let result = search_with_solver( - &graph, - limits(1), - true, - None, - SeedRestrictions::default(), - |selected| { - assert_eq!(selected.selected_candidates.len(), 2); - Ok(Some(2.0).into()) - }, - |proposal_graph, observations, time, metrics| { - solves += 1; - if solves == 1 { - assert!(proposal_graph.reader_constraints.iter().any(|constraint| - matches!(constraint, ReaderConstraint::AllowedOutputs { candidate, outputs } - if candidate.0 == 1 && outputs.is_empty()))); - } else { - assert_eq!( - proposal_graph.reader_constraints.len(), - graph.reader_constraints.len() - ); - } - solve_candidate_proposal_with_metrics(proposal_graph, observations, time, metrics) - }, - ) - .unwrap(); - assert_eq!(solves, 2); - assert!(result.incumbent.is_some()); - } - - // A supplied baseline preference changes proposal order, never the cost model. - #[test] - fn weighted_seed_changes_order_without_becoming_a_bound() { - let graph = choices(&[0.1, 0.2]); - let result = search_candidates_with_weighted_baseline( - &graph, - limits(1), - Some(&[100.0, 1.0]), - |selected| { - assert_eq!(selected.query_roots[0].candidate, PhysicalCandidateId(1)); - assert_eq!(selected.modeled_cost, 0.2); - Ok(Some(0.25)) - }, - ) - .unwrap(); - assert_eq!(result.lower_bound, 0.0); - assert_eq!(result.relative_gap, Some(1.0)); - assert_eq!(result.incumbent.unwrap().actual_cost, 0.25); - assert_eq!(graph.candidates[0].local_cost, 0.1); - assert!(search_candidates_with_weighted_baseline( - &graph, - limits(1), - Some(&[f64::NAN, 1.0]), - |_| panic!("invalid weights must not be evaluated") - ) - .is_err()); - } - - // The same certified bound check applies to the initial structural proposal. - #[test] - fn structural_seed_cannot_violate_original_cost_bound() { - let error = search_candidates_with_baseline(&choices(&[2.0]), limits(1), |selected| { - assert_eq!(selected.modeled_cost, 2.0); - Ok(Some(1.0)) - }) - .unwrap_err(); - assert!(error.contains("certified local bound")); - } - - // Infeasible heuristic proposals are excluded only after actual validation. - #[test] - fn structural_seed_rejections_continue_and_internal_errors_stop() { - let mut calls = 0; - let result = search_candidates_with_baseline(&choices(&[0.0; 2]), limits(2), |_| { - calls += 1; - Ok((calls == 2).then_some(0.5)) - }) - .unwrap(); - assert_eq!(calls, 2); - assert!(result.incumbent.is_some()); - assert_eq!(result.lower_bound, 0.0); - let stopped = search_candidates_with_baseline(&choices(&[0.0; 2]), limits(3), |_| { - Err("broken identity map".into()) - }) - .unwrap(); - assert_eq!(stopped.refinements, 1); - assert!(matches!( - stopped.status, - CandidateSearchStatus::EvaluatorStopped(_) - )); - } - - // Timeout is unknown feasibility: do not add a no-good cut or exhaust the catalog. - #[test] - fn evaluator_budget_stop_is_not_an_infeasibility_observation() { - let result = search_candidates_with_feedback(&choices(&[0.0]), limits(10), None, |_| { - Ok(CandidateEvaluation { - budget_exhausted: true, - ..Default::default() - }) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::TimeLimit); - assert_eq!(result.refinements, 1); - assert!(result.incumbent.is_none()); - assert_eq!(result.certified_query_cuts, 0); - assert_eq!(result.solve_metrics.solve_calls, 1); - } - - // A risky cheap contract cannot displace the feasible seed, but remains in - // the complete graph for normal evaluation rather than being silently pruned. - #[test] - fn seed_contract_exclusion_does_not_remove_full_search_candidates() { - let mut visited = Vec::new(); - let result = search_candidates_with_restricted_feedback( - &choices(&[0.0, 0.0]), - limits(2), - &[1.0, 1_000_000.0], - &[PhysicalCandidateId(0)], - &[], - |selected| { - let id = selected.query_roots[0].candidate.0; - visited.push(id); - Ok((id == 1).then_some(2.0).into()) - }, - ) - .unwrap(); - assert_eq!(visited, vec![1, 0]); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - } - - // A baseline validation that overruns the shared deadline still retains its plan. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn structural_seed_timeout_preserves_validated_incumbent() { - let result = search_candidates_with_baseline( - &choices(&[0.0]), - CandidateSearchLimits { - max_refinements: 10, - wall_time: Duration::from_secs(1), - }, - |_| { - std::thread::sleep(Duration::from_millis(1100)); - Ok(Some(0.5)) - }, - ) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::TimeLimit); - assert_eq!(result.refinements, 1); - assert_eq!(result.incumbent.unwrap().actual_cost, 0.5); - assert_eq!(result.lower_bound, 0.0); - } - - // Actual evaluation can reverse the ordering suggested by certified local bounds. - #[test] - fn evaluator_can_prefer_a_costlier_local_choice() { - let graph = choices(&[1.0, 2.0]); - let result = search_candidates(&graph, limits(10), |selected| { - Ok(Some(if selected.query_roots[0].candidate.0 == 0 { - 100.0 - } else { - 3.0 - })) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - assert_eq!(result.incumbent.unwrap().actual_cost, 3.0); - assert_eq!(result.relative_gap, Some(0.0)); - } - - // An infeasible assignment is excluded, not returned as an incumbent. - #[test] - fn invalid_proposals_are_excluded() { - let graph = choices(&[1.0, 2.0]); - let result = search_candidates(&graph, limits(10), |selected| { - Ok((selected.query_roots[0].candidate.0 == 1).then_some(2.0)) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - assert_eq!(result.refinements, 2); - assert_eq!(result.solve_metrics.solve_calls, 2); - assert!(result.solve_metrics.model_construction_seconds > 0.0); - assert!(result.solve_metrics.solver_seconds > 0.0); - assert!(result.validation_seconds > 0.0); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - let impossible = search_candidates(&graph, limits(10), |_| Ok(None)).unwrap(); - assert_eq!(impossible.status, CandidateSearchStatus::Infeasible); - assert!(impossible.incumbent.is_none()); - assert_eq!(impossible.solve_metrics.solve_calls, 3); - } - - // Adding a candidate changes the context: positive-only activation cuts are unsound. - #[test] - fn observed_cost_cut_includes_absent_candidate_decisions() { - let mut graph = choices(&[1.0, 1.0]); - graph.candidates[1] - .producer_candidates_per_input - .push(InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0)].into(), - }); - let result = search_candidates(&graph, limits(10), |selected| { - Ok(Some(if selected.selected_candidates.len() == 1 { - 100.0 - } else { - 2.0 - })) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - assert_eq!(result.incumbent.unwrap().actual_cost, 2.0); - } - - // A refinement limit preserves the validated incumbent without claiming optimality. - #[test] - fn bounded_refinement_preserves_incumbent_and_reports_gap() { - let result = - search_candidates(&choices(&[1.0, 2.0]), limits(1), |_| Ok(Some(100.0))).unwrap(); - assert_eq!(result.status, CandidateSearchStatus::RefinementLimit); - assert_eq!(result.incumbent.unwrap().actual_cost, 100.0); - assert!(result.relative_gap.unwrap() > 0.9); - let no_time = search_candidates( - &choices(&[1.0]), - CandidateSearchLimits { - wall_time: Duration::ZERO, - ..limits(10) - }, - |_| panic!("deadline must stop evaluation"), - ) - .unwrap(); - assert_eq!(no_time.status, CandidateSearchStatus::TimeLimit); - assert!(no_time.incumbent.is_none()); - assert_eq!(no_time.relative_gap, None); - } - - // All tiny choices are priced independently as an exhaustive optimum reference. - #[test] - fn refined_optimum_matches_tiny_exhaustive_costs() { - let costs = [12.0_f64, 7.0, 18.0, 5.0]; - let result = search_candidates(&choices(&[0.0; 4]), limits(10), |selected| { - Ok(Some(costs[selected.query_roots[0].candidate.0])) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - assert_eq!( - result.incumbent.unwrap().actual_cost, - costs.into_iter().fold(f64::INFINITY, f64::min) - ); - } - - // Root and connection decisions matter even when every candidate stays selected. - #[test] - fn observed_context_distinguishes_connections_and_output_assignments() { - let mut graph = choices(&[0.0; 3]); - graph.candidates[2] - .producer_candidates_per_input - .push(InputProducerCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)].into(), - }); - graph.query_roots = (0..3) - .map(|id| QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(id)], - }) - .collect(); - graph.query_roots.push(QueryOutputCandidates { - candidates: vec![PhysicalCandidateId(0), PhysicalCandidateId(1)], - }); - let mut seen = Vec::new(); - let result = search_candidates(&graph, limits(10), |selected| { - let context = ( - selected.connections[0].producer.0, - selected - .query_roots - .iter() - .find(|root| root.query_root == 3) - .unwrap() - .candidate - .0, - ); - assert!(!seen.contains(&context)); - seen.push(context); - Ok(Some(10.0 + context.0 as f64 + 2.0 * context.1 as f64)) - }) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::Optimal); - assert_eq!(result.incumbent.unwrap().actual_cost, 10.0); - assert_eq!(seen.len(), 4); - } - - // An evaluator failure preserves earlier validated work; invalid bounds are errors. - #[test] - fn evaluator_failure_preserves_incumbent_and_bad_certification_fails() { - let mut evaluations = 0; - let result = search_candidates(&choices(&[1.0, 2.0]), limits(10), |_| { - evaluations += 1; - if evaluations == 1 { - Ok(Some(100.0)) - } else { - Err("validation unavailable".into()) - } - }) - .unwrap(); - assert!(matches!( - result.status, - CandidateSearchStatus::EvaluatorStopped(_) - )); - assert_eq!(result.incumbent.unwrap().actual_cost, 100.0); - assert!( - search_candidates(&choices(&[2.0]), limits(10), |_| Ok(Some(1.0))) - .unwrap_err() - .contains("certified local bound") - ); - } - // The callback cannot be preempted, but a late validated result remains usable. - #[test] - #[ignore = "flaky: depends on wall-clock search budgets (#1067)"] - fn callback_overrun_preserves_validated_incumbent() { - let result = search_candidates( - &choices(&[1.0]), - CandidateSearchLimits { - max_refinements: 10, - wall_time: Duration::from_secs(2), - }, - |_| { - std::thread::sleep(Duration::from_millis(2100)); - Ok(Some(10.0)) - }, - ) - .unwrap(); - assert_eq!(result.status, CandidateSearchStatus::TimeLimit); - assert_eq!(result.incumbent.unwrap().actual_cost, 10.0); - } -} diff --git a/crates/plan-selection/src/search/select.rs b/crates/plan-selection/src/search/select.rs deleted file mode 100644 index 2ca4a1d22..000000000 --- a/crates/plan-selection/src/search/select.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! [`select_plan`]: Stage 3's only entry. Today's engine runs behind it: -//! cost bounds over the whole catalog, then the evaluator-backed MILP -//! refinement loop (cost models 1 and 2 with a frozen calibration) or the -//! bounded objective traversal (cost model 3, or a measured cost table -//! without a frozen factor). #1095 replaces the engine; this interface stays. -//! The group MILP ([`crate::select`]) is the default; this engine runs only -//! with `ASAP_STAGE3_SEARCH=old`. - -use std::time::{Duration, Instant}; - -use asap_cost::{CostInputs, CostModel}; -use asap_types::stages::{ - AssignmentRealizer, DagOfSelectedCandidates, PhysicalASAPMemoCandidates, SelectedNodeId, -}; -use asap_types::workload::{DataWorkload, QueryWorkloadEntry}; - -use crate::models::{AccuracyCheckInputs, SelectionModels}; -use crate::report::{ErrorSelectingPlan, SearchReport, SearchStatus, Selection}; -use crate::search::candidates::dag::query_roots_of; -use crate::search::candidates::graph::SelectedCandidateGraph; -use crate::search::costing::calibration::CandidateCalibration; -use crate::search::milp::model::CandidateSolveMetrics; -use crate::search::refinement::planner::CandidatePlanningContext; -use crate::search::refinement::search::{CandidateSearchLimits, CandidateSearchStatus}; -use crate::WholePlanSelection; -use asap_types::physical::PhysicalWorkloadPlan; - -/// The most plans the engine evaluates in one call; the deadline bounds it -/// too. -const MAX_EVALUATIONS: usize = 128; - -/// The search's wall time without a deadline. -const NO_DEADLINE_WALL_TIME: Duration = Duration::from_secs(365 * 24 * 3600); - -/// Stage 3, plan selection: select one plan among `physical`'s candidates. -/// Each proposed selection is realized through `realizer` and checked -/// against every query's accuracy target (`accuracy_checks`) and latency -/// bound, priced under `cost_inputs`' cost model, and the best valid one is -/// returned. `entries[q]` is query `q`'s workload entry (accuracy target, -/// latency bound, recurrence); `data` the data workload with its bounds. -/// `deadline` is the only budget. -/// -/// Err: invalid cost inputs, an infeasible catalog, no plan by the -/// deadline, or the realizer rejected the selected plan -/// ([`ErrorSelectingPlan::RealizerRejected`]: a Stage 2 catalog bug). -pub fn select_plan( - physical: &PhysicalASAPMemoCandidates, - entries: &[QueryWorkloadEntry], - data: &DataWorkload, - accuracy_checks: &AccuracyCheckInputs<'_>, - cost_inputs: &CostInputs, - realizer: &dyn AssignmentRealizer, - deadline: Option, -) -> Result { - cost_inputs - .validate() - .map_err(ErrorSelectingPlan::InvalidSettings)?; - // #1095 C6: the group MILP is the default. - if crate::select::milp_search_enabled() { - return crate::select::select_plan_with_milp( - physical, - entries, - data, - accuracy_checks, - cost_inputs, - realizer, - deadline, - ); - } - let models = SelectionModels::new(accuracy_checks, cost_inputs); - let calibration_policy = - CandidateCalibration::for_models(models).map_err(ErrorSelectingPlan::Solver)?; - // A query's root group with no implementation candidate left: Pass 1 or - // Stage 2 pruned every one (their pruned candidates say why), so no - // candidate is valid. - let query_roots = - query_roots_of(physical).ok_or(ErrorSelectingPlan::NoValidCandidate(Vec::new()))?; - if query_roots.len() != entries.len() { - return Err(ErrorSelectingPlan::EntryCount { - candidate: "the physical memo".into(), - roots: query_roots.len(), - entries: entries.len(), - }); - } - // The engine's MILP runs each candidate at most once: it selects over - // execution-scope copies and decodes the selected copies into nodes. - let expanded = - crate::search::candidates::scopes::expand_execution_scopes(physical, entries, deadline) - .map_err(ErrorSelectingPlan::Solver)?; - let mut configured = expanded.configured.clone(); - let context = CandidatePlanningContext { - deadline, - refine_materialization: true, - query_roots: &query_roots, - realizer, - entries, - data, - models, - calibration: match calibration_policy { - CandidateCalibration::Frozen(fit) => fit, - CandidateCalibration::CompleteCatalog => asap_cost::CalibrationFit::default(), - }, - }; - crate::search::costing::lower_bounds::apply(&mut configured, &context) - .map_err(ErrorSelectingPlan::Solver)?; - let limits = CandidateSearchLimits { - max_refinements: MAX_EVALUATIONS, - wall_time: deadline.map_or(NO_DEADLINE_WALL_TIME, |deadline| { - deadline.saturating_duration_since(Instant::now()) - }), - }; - let outcome = if matches!(cost_inputs.cost_model, CostModel::ClusterCapacity(_)) - || matches!(calibration_policy, CandidateCalibration::CompleteCatalog) - { - let result = crate::search::refinement::objectives::optimize_objective_candidates( - &configured, - &context, - limits, - calibration_policy, - ) - .map_err(ErrorSelectingPlan::Solver)?; - if result.best.is_none() - && result.status == CandidateSearchStatus::Infeasible - && !result.evaluation_rejections.is_empty() - { - return Err(ErrorSelectingPlan::NoValidCandidate( - result.evaluation_rejections, - )); - } - Outcome { - status: result.status, - metrics: result.solve_metrics, - validation_seconds: result.validation_seconds, - selected: result.selected_graph, - best: result.best, - lower_bound: None, - } - } else { - let result = crate::search::refinement::planner::optimize_configured_candidates( - &configured, - &context, - limits, - ) - .map_err(ErrorSelectingPlan::Solver)?; - Outcome { - status: result.search.status, - metrics: result.search.solve_metrics, - validation_seconds: result.search.validation_seconds, - selected: result.search.incumbent.map(|incumbent| incumbent.selected), - best: result.best, - lower_bound: Some(result.search.lower_bound), - } - }; - let (Some((whole, _)), Some(selected)) = (outcome.best, outcome.selected) else { - return Err(match outcome.status { - CandidateSearchStatus::TimeLimit | CandidateSearchStatus::RefinementLimit => { - ErrorSelectingPlan::NoPlanByDeadline(format!( - "the search stopped ({:?}) before it validated a plan", - outcome.status - )) - } - CandidateSearchStatus::Infeasible => ErrorSelectingPlan::NoValidCandidate(Vec::new()), - status => ErrorSelectingPlan::Solver(format!( - "the search stopped without a validated plan: {status:?}" - )), - }); - }; - let selected_dag = expanded.decode(&selected); - let validation_started = Instant::now(); - let nodes_with_proven_non_negative_weights = - proven_non_negative_weights(physical, &selected_dag, realizer, data)?; - let validation_seconds = - outcome.validation_seconds + validation_started.elapsed().as_secs_f64(); - let cost = whole.costs[&whole.selected].clone(); - let accepted_accuracy_per_query = whole - .accepted - .get(&whole.selected) - .cloned() - .unwrap_or_default(); - let ms = |seconds: f64| (seconds * 1000.0).round() as u64; - Ok(Selection { - selected_dag, - nodes_with_proven_non_negative_weights, - cost, - accepted_accuracy_per_query, - // TODO(#1095): today's engine records no per-candidate prunes. - pruned_candidates: Vec::new(), - search_report: SearchReport { - status: match outcome.status { - CandidateSearchStatus::Optimal => SearchStatus::Optimal, - _ => SearchStatus::DeadlineReached, - }, - cost_lower_bound_per_second: outcome.lower_bound, - milp_variables: outcome.metrics.peak_variables, - milp_constraints: outcome.metrics.peak_constraints, - model_construction_ms: ms(outcome.metrics.model_construction_seconds), - solver_ms: ms(outcome.metrics.solver_seconds), - validation_ms: ms(validation_seconds), - inexact_objective: None, - }, - }) -} - -/// What the engine returned. -struct Outcome { - status: CandidateSearchStatus, - metrics: CandidateSolveMetrics, - validation_seconds: f64, - selected: Option, - best: Option<(WholePlanSelection, PhysicalWorkloadPlan)>, - lower_bound: Option, -} - -/// The selected nodes whose open count-min weight requirement `data` -/// proves: the selected plan realized once more, each node checked. -fn proven_non_negative_weights( - physical: &PhysicalASAPMemoCandidates, - selected: &DagOfSelectedCandidates, - realizer: &dyn AssignmentRealizer, - data: &DataWorkload, -) -> Result, ErrorSelectingPlan> { - let realized = crate::select::realize(physical, selected, realizer)?; - Ok(crate::select::proven_non_negative_weights(&realized, data)) -} diff --git a/crates/plan-selection/src/select.rs b/crates/plan-selection/src/select.rs index 6e742e7a1..5c9628762 100644 --- a/crates/plan-selection/src/select.rs +++ b/crates/plan-selection/src/select.rs @@ -1,8 +1,7 @@ -//! #1095 Steps 3–6: the group selection MILP behind [`select_plan`](crate::select_plan), -//! Stage 3's default search (#1095 C6). Inputs it does not price yet (cost -//! models 2 and 3) are an `UnsupportedCostModel` error; `ASAP_STAGE3_SEARCH=old` -//! runs the old engine instead ([`milp_search_enabled`]). There is no -//! fallback between the two engines. +//! Stage 3, plan selection: [`select_plan`] runs the group selection MILP +//! (#1095). Inputs it does not price yet (cost models 2 and 3, or a measured +//! operation cost table without a frozen calibration factor) are an +//! `UnsupportedCostModel` error (#888). //! //! One MILP solve and one realization, no loop: //! 1. the MILP's inputs (candidate dominance, execution contexts, accuracy @@ -50,26 +49,22 @@ use crate::report::{ Selection, }; -/// The environment variable that switches Stage 3 back to the old engine: -/// `ASAP_STAGE3_SEARCH=old`. Unset, or any other value, runs the group MILP -/// (#1095 C6), which returns `UnsupportedCostModel` for inputs it does not -/// price yet (cost models 2 and 3, or a measured operation cost table -/// without a frozen calibration factor). -pub(crate) const STAGE3_SEARCH_ENV: &str = "ASAP_STAGE3_SEARCH"; - -/// Whether Stage 3 runs the group MILP: always, unless -/// [`STAGE3_SEARCH_ENV`] is `old`. -pub(crate) fn milp_search_enabled() -> bool { - std::env::var(STAGE3_SEARCH_ENV).as_deref() != Ok("old") -} - /// The largest relative difference between the objective and `plan_cost` /// that counts as equal. const COST_EQUALITY_TOLERANCE: f64 = 1e-6; -/// [`select_plan`](crate::select_plan) with the new search (module -/// documentation). `cost_inputs` is already validated. -pub(crate) fn select_plan_with_milp( +/// Stage 3, plan selection: select one plan among `physical`'s candidates +/// with the group selection MILP (module documentation). The selection is +/// realized through `realizer`, checked against every query's accuracy +/// target (`accuracy_checks`) and latency bound, and priced under +/// `cost_inputs`. `entries[q]` is query `q`'s workload entry (accuracy +/// target, latency bound, recurrence); `data` the data workload with its +/// bounds. `deadline` is the only budget. +/// +/// Err: invalid or unsupported cost inputs, an infeasible catalog, no plan +/// by the deadline, or the realizer rejected the selected plan +/// ([`ErrorSelectingPlan::RealizerRejected`]: a Stage 2 catalog bug). +pub fn select_plan( physical: &PhysicalASAPMemoCandidates, entries: &[QueryWorkloadEntry], data: &DataWorkload, @@ -78,6 +73,9 @@ pub(crate) fn select_plan_with_milp( realizer: &dyn AssignmentRealizer, deadline: Option, ) -> Result { + cost_inputs + .validate() + .map_err(ErrorSelectingPlan::InvalidSettings)?; // The MILP's coefficients and `plan_cost` must use one calibration: a // measured cost table needs a frozen factor. if cost_inputs.frozen_calibration_factor.is_none() @@ -278,7 +276,7 @@ fn pruned_before_the_milp( /// `selected` realized through `realizer`; a rejection is a Stage 2 /// catalog bug. -pub(crate) fn realize( +fn realize( physical: &PhysicalASAPMemoCandidates, selected: &DagOfSelectedCandidates, realizer: &dyn AssignmentRealizer, @@ -296,7 +294,7 @@ pub(crate) fn realize( /// The selected nodes whose summary (`SummaryAgg` or `SummaryTumblingWindowChain`) reads /// update weights `data` proves non-negative although the candidate leaves /// the requirement open. -pub(crate) fn proven_non_negative_weights( +fn proven_non_negative_weights( realized: &RealizedPlan, data: &DataWorkload, ) -> Vec { diff --git a/crates/plan-selection/src/select/tests.rs b/crates/plan-selection/src/select/tests.rs index 1f2add61f..4bc561ec1 100644 --- a/crates/plan-selection/src/select/tests.rs +++ b/crates/plan-selection/src/select/tests.rs @@ -18,7 +18,7 @@ fn select( accuracy: &dyn AccuracyModel, realizer: &dyn AssignmentRealizer, ) -> Result { - select_plan_with_milp( + select_plan( &fixture.physical, &fixture.entries, &fixture.data, @@ -39,7 +39,7 @@ fn non_negative(mut fixture: Fixture) -> Fixture { fixture } -// The new search's selection: an optimal, valid plan whose cost is +// The group selection MILP's selection: an optimal, valid plan whose cost is // `plan_cost`'s, the MILP's size and every query's accepted accuracy. The // ratio of quantiles is a regression of #1127: the A1 rows admitted two // KLL sketches at parts = 2, whose rank errors compose no guarantee through @@ -146,7 +146,7 @@ fn an_accuracy_miss_is_an_error() { } } -// Cost models 2 and 3 are not supported by the new search. +// Cost models 2 and 3 are not supported by the group selection MILP (#888). #[test] fn cost_model_2_is_unsupported() { let fixture = shared_sum(); @@ -156,7 +156,7 @@ fn cost_model_2_is_unsupported() { ), ..cost_inputs() }; - let result = select_plan_with_milp( + let result = select_plan( &fixture.physical, &fixture.entries, &fixture.data, diff --git a/crates/plan-selection/src/selector.rs b/crates/plan-selection/src/selector.rs index d1a289bc7..58a83d6ac 100644 --- a/crates/plan-selection/src/selector.rs +++ b/crates/plan-selection/src/selector.rs @@ -1,7 +1,6 @@ //! Choosing among whole checked plans: [`PlanSelector`] and -//! [`select_among_plans`]. Today's engine evaluates each plan it proposes -//! with it, and the reference pricer (feature `reference-pricer`) selects -//! among enumerated plans with it. +//! [`select_among_plans`]. The reference pricer (feature `reference-pricer`) +//! selects among enumerated plans with it. use std::collections::BTreeMap; diff --git a/docs/design_docs/proposals/planner-layering.md b/docs/design_docs/proposals/planner-layering.md index d2dbf6511..91b86bfb3 100644 --- a/docs/design_docs/proposals/planner-layering.md +++ b/docs/design_docs/proposals/planner-layering.md @@ -503,21 +503,19 @@ Hash-cons the workload into groups; generate each group's candidates (Pass 1, then the Pass 2 transformations), and expand shared sizes, tumbling-window horizons and (planned) raw windows into concrete groups, so that choosing a threshold is choosing a group; choose one candidate per needed group; compose the choice -into one plan and check and cost it with Stage 2 and Stage 3. The search is a -candidate-connection search: a MILP (mixed-integer linear program) over the graph of candidates and the -groups they read proposes a choice from local cost bounds, Stage 2 and -Stage 3 validate and cost it, and each validated cost constrains the MILP -again (evaluator-backed refinement). Stage 3 realizes each proposed choice -through Stage 2's `AssignmentRealizer`, which the facade builds and passes in, +into one plan and check and cost it with Stage 2 and Stage 3. The search is +the group selection MILP (mixed-integer linear program, #1095): it chooses one +candidate per memo group and execution context, with each shareable candidate +charged once, and is solved once; it prices cost model 1 only (cost models 2 +and 3 are an `UnsupportedCostModel` error, #888). Stage 3 realizes the chosen +plan through Stage 2's `AssignmentRealizer`, which the facade builds and passes in, so Stage 3 does not depend on the Stage 2 crate. The facade first finds an incumbent (the best validated plan so far) in a round over seed tumbling-window grids, then exposes finer tumbling-window widths in later rounds, each round running Pass 2, Stage 2 and Stage 3; only a completed round over the full tumbling-window-width domain bounds the declared search space, and otherwise the result is the best -validated plan found. On tiny catalogs, tests check that this -bounded search selects the same plan cost as a complete traversal of the same -catalog under every cost model, and that the MILP's selection matches an -independent enumeration of the candidate graph. +validated plan found. Stage 3 checks that `plan_cost` of the realized plan +equals the MILP's objective, and that every query's accuracy and latency hold. #### Memo code interface @@ -525,7 +523,7 @@ The memo rules are in `crates/logical-asap-optimization/src/` (module `pass2::memo`, crate-private: the crate root re-exports each entry point); the memo types Stage 1 hands Stage 2 are in `crates/types/src/stages/` (`asap_types::stages`); the facade that runs the stages is in `crates/planner/src/run.rs`; the search over Stage 2's -candidates is in `crates/plan-selection/src/search/`. +candidates is in `crates/plan-selection/src/` (`select.rs`, `groups/`, `milp/`). | Step | Entry point | Path | |---|---|---| @@ -534,7 +532,7 @@ candidates is in `crates/plan-selection/src/search/`. | Memo types | `DagOfLogicalMemoGroups`, `PerComputationLogicalMemoCandidates`, `HowToComputeAggregateMeasure`, Pass 1's working types (`ComputationTarget`, `FusedComputation`, `HavingEntry`, `HavingPlacement`), `Memo`, `CandidateForMemoGroup`, `OriginOfCandidateForMemoGroup`, `LogicalASAPMemoCandidates`, `CandidateGenerationTimeout`, `root_groups`, `queries_rooted_at` | `crates/types/src/stages/` (`asap_types::stages`) | | Pass 2 | `generate_logical_asap_memo_candidates` → `LogicalASAPMemoCandidates`, one tumbling-window-width round over a `Pass2RoundState` (kept between rounds: the memo after step 1 and before the rules, and the widths already generated). It calls in this order: 1. window composition for the round's new widths (`extend_local_window_composition`); 2–9. the rules in `pass2/memo/rules/`: scan widening, summary inputs, top-k reuse, finer aggregate, rollup, window rollup, summary results, state configurations; 10. `add_shared_summary_state_groups`, the shared builds | `pass2/logical_memo_candidates.rs` (`LogicalASAPMemoCandidates` itself: `asap_types::stages`); `pass2/memo/window_candidates.rs`, `pass2/memo/rules/`, `pass2/memo/state_groups.rs`, `pass2/memo/shared_configurations.rs` | | Memo to candidate DAGs | `enumerate`, `build_logical_asap_dags_from_chosen_candidates`, `ChosenCandidatePerMemoGroup`, `AllChosenCandidatesPerMemoGroup`, `ErrorBuildingLogicalASAPDagsFromChosenCandidates` | `pass2/memo/enumeration.rs` (re-exported at the crate root) | -| Production memo build and search | The facade's `run_planning` builds the DAG of logical memo groups and runs Pass 1 (`generate_per_computation_logical_memo_candidates`) once; then, for each tumbling-window-width round, `generate_logical_asap_memo_candidates` (the `Pass2RoundState` is kept between rounds), `generate_physical_asap_memo_candidates` and Stage 3's `select_plan`. It keeps the best round's selection (`asap_cost::PlanRanking`) and builds that plan with Stage 2's `AssignmentRealizer::realize`. Behind `select_plan`: `candidates/`, `milp/` and `refinement/` | `crates/planner/src/run.rs`; `crates/plan-selection/src/search/` | +| Production memo build and search | The facade's `run_planning` builds the DAG of logical memo groups and runs Pass 1 (`generate_per_computation_logical_memo_candidates`) once; then, for each tumbling-window-width round, `generate_logical_asap_memo_candidates` (the `Pass2RoundState` is kept between rounds), `generate_physical_asap_memo_candidates` and Stage 3's `select_plan`. It keeps the best round's selection (`asap_cost::PlanRanking`) and builds that plan with Stage 2's `AssignmentRealizer::realize`. Behind `select_plan`: the group selection MILP (#1095), `groups/` and `milp/` | `crates/planner/src/run.rs`; `crates/plan-selection/src/select.rs` | ## ASAPPlanner Detailed Design: Planning Stages and their decisions/Strategies