From a57c268fe9104a7450d058b77fc530bdf5af04f1 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Sun, 25 Oct 2026 14:17:06 -0700 Subject: [PATCH 1/5] perf(planner): search V6 schedules under widening padded setup budgets V6 ranks schedules by padded setup first. With no explicit policy budget, V6 planning now searches under a power-of-two setup budget that starts at one field element. A pass that finds no schedule records the smallest setup it rejected, and the next pass uses that setup padded to a power of two. Fold candidates whose own level setup exceeds the budget are dropped when they are generated, and pending schedules over the budget are dropped in `child_choice`. An explicit policy budget keeps its single pass, and V5 is unchanged. Exactness: - Admission under a power-of-two budget B is the same as padded setup <= B. Every V6 comparison that removes a candidate keeps a side whose padded setup is no larger: both projection dominance rules, both primary strict-dominance checks, the root and recursive guided bounds (`is_strictly_worse_than`, `setup_envelope_is_strictly_worse_than`, `is_strictly_worse_for_recursive_parent`, `..._payload`), and the level prune, whose coordinates include the level setup. So an over-budget candidate never removes an in-budget one. - No selection is a bounded top-k. The setup-first and payload-first lists in each `ParentObservableKey` bucket are dominance frontiers with no size limit, built by retain-then-push. The bounded split domain (`BoundedBalancedExtremesV1`), the precommit opening-product cap and the grinding query limit depend only on geometry, never on candidate outcomes. Memo eviction only forces recomputation, and every pass starts with a fresh memo, including the setup-prefix cache, so nothing computed under one budget is read under another. - Together, these mean the in-budget part of every frontier equals the in-budget part of the unbudgeted frontier. Insertion order is unchanged, an in-budget insert is rejected or evicts members only because of in-budget members, and an over-budget insert never touches in-budget members. - Setup is a maximum over levels, the terminal and offloaded children, so it never decreases as a partial schedule grows. Offloaded edges charge the same level setup as direct edges. Rejecting a partial schedule or level fold over B therefore never rejects a completion within B. Terminal candidates are not filtered, but they reach the root only through `child_choice`. - A pass that selects W returns the unbudgeted winner, because that winner's padded setup is at most W's, so it fits and stays in the frontier. A pass that rejects nothing is exactly the unbudgeted search. A skipped budget below the next padded rejected setup admits nothing new. If the next budget overflows, the last pass runs without a budget. The starting budget affects only the number of passes. Diagnostics report each pass's budget, generated candidates and descriptor builds (`setup_budget_passes=`). The search-work counters sum over all passes. Tests: one-hot recursive nv 14/16/18 on the three restricted domains of the unpruned-oracle fixtures must select the same descriptor bytes and estimate as one pass with a non-binding explicit budget. The final budget must equal the selected padded setup, and earlier budgets must increase strictly and stay below it. An ignored test checks the same for fp32_dense_recursive nv20/22 and fp128_dense_recursive nv20; it takes about 100 s. fp64_dense_recursive nv21: 100.6 s -> 1.85 s. nv25: 129 s -> 15.4 s. The output is byte-identical to main. --- crates/akita-planner/src/diagnostics.rs | 85 +++++++++ crates/akita-planner/src/planner.rs | 109 ++++++++---- crates/akita-planner/src/schedule_params.rs | 4 +- .../src/schedule_params/suffix_dp.rs | 6 +- .../src/schedule_params/suffix_dp/search.rs | 15 +- .../src/schedule_params/suffix_dp/state.rs | 38 +++- .../akita-planner/src/test/adaptive_search.rs | 3 + .../src/test/adaptive_search/setup_budget.rs | 167 ++++++++++++++++++ 8 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 crates/akita-planner/src/test/adaptive_search/setup_budget.rs diff --git a/crates/akita-planner/src/diagnostics.rs b/crates/akita-planner/src/diagnostics.rs index e157b9a80..89ca8fc63 100644 --- a/crates/akita-planner/src/diagnostics.rs +++ b/crates/akita-planner/src/diagnostics.rs @@ -56,6 +56,15 @@ pub(crate) struct SelectedScheduleDiagnostics { folds: Vec, } +/// Search work of one root search pass under one padded setup budget. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SetupBudgetPass { + /// `None` for a pass without a setup budget. + pub(crate) budget: Option, + pub(crate) generated_candidates: usize, + pub(crate) descriptor_builds: usize, +} + #[derive(Clone, Debug, Default)] pub(crate) struct PlannerDiagnosticsSnapshot { pub(crate) suffix_dp_time: Duration, @@ -81,6 +90,7 @@ pub(crate) struct PlannerDiagnosticsSnapshot { pub(crate) setup_prefix_cache_hits: usize, pub(crate) setup_prefix_cache_misses: usize, pub(crate) guided_direct_edge_prunes: usize, + pub(crate) setup_budget_passes: Vec, selected: Option, } @@ -129,6 +139,26 @@ impl fmt::Display for PlannerDiagnosticsSnapshot { self.setup_prefix_cache_hits .saturating_add(self.setup_prefix_cache_misses), )?; + write!( + formatter, + " setup_budget_passes={}[", + self.setup_budget_passes.len() + )?; + for (index, pass) in self.setup_budget_passes.iter().enumerate() { + if index != 0 { + formatter.write_str(",")?; + } + match pass.budget { + Some(budget) => write!(formatter, "{budget}")?, + None => formatter.write_str("unbounded")?, + } + write!( + formatter, + ":candidates={}/descriptors={}", + pass.generated_candidates, pass.descriptor_builds + )?; + } + formatter.write_str("]")?; if let Some(selected) = &self.selected { write!( formatter, @@ -198,6 +228,7 @@ pub(crate) struct PlannerDiagnostics { setup_prefix_cache_hits: Cell, setup_prefix_cache_misses: Cell, guided_direct_edge_prunes: Cell, + setup_budget_passes: RefCell>, selected: RefCell>, } @@ -297,6 +328,24 @@ impl PlannerDiagnostics { .set(self.guided_direct_edge_prunes.get().saturating_add(1)); } + /// Candidates generated and descriptors built so far. + pub(crate) fn search_work(&self) -> [usize; 2] { + [ + self.generated_candidates.get(), + self.descriptor_builds.get(), + ] + } + + /// Record one root search pass that started at `search_work` `start`. + pub(crate) fn record_setup_budget_pass(&self, budget: Option, start: [usize; 2]) { + let [candidates, descriptors] = self.search_work(); + self.setup_budget_passes.borrow_mut().push(SetupBudgetPass { + budget, + generated_candidates: candidates.saturating_sub(start[0]), + descriptor_builds: descriptors.saturating_sub(start[1]), + }); + } + pub(crate) fn record_selected( &self, objective: SelectionPolicyId, @@ -349,6 +398,7 @@ impl PlannerDiagnostics { setup_prefix_cache_hits: self.setup_prefix_cache_hits.get(), setup_prefix_cache_misses: self.setup_prefix_cache_misses.get(), guided_direct_edge_prunes: self.guided_direct_edge_prunes.get(), + setup_budget_passes: self.setup_budget_passes.borrow().clone(), selected: self.selected.borrow().clone(), } } @@ -428,4 +478,39 @@ mod tests { assert_eq!(snapshot.guided_direct_edge_prunes, 1); assert!(active().is_none()); } + + #[test] + fn setup_budget_passes_record_work_since_their_start() { + let (_, snapshot) = capture(true, || { + let diagnostics = active().expect("diagnostics must be active inside capture"); + diagnostics.record_candidates(12, 4); + let start = diagnostics.search_work(); + diagnostics.record_candidates(5, 2); + diagnostics.record_descriptor(Duration::ZERO); + diagnostics.record_setup_budget_pass(Some(64), start); + let start = diagnostics.search_work(); + diagnostics.record_candidates(3, 1); + diagnostics.record_setup_budget_pass(None, start); + }); + let snapshot = snapshot.expect("enabled capture must return diagnostics"); + assert_eq!( + snapshot.setup_budget_passes, + [ + SetupBudgetPass { + budget: Some(64), + generated_candidates: 5, + descriptor_builds: 1, + }, + SetupBudgetPass { + budget: None, + generated_candidates: 3, + descriptor_builds: 0, + }, + ] + ); + let line = snapshot.to_string(); + assert!(line.contains( + " setup_budget_passes=2[64:candidates=5/descriptors=1,unbounded:candidates=3/descriptors=0]" + )); + } } diff --git a/crates/akita-planner/src/planner.rs b/crates/akita-planner/src/planner.rs index 32e443d23..ed4824151 100644 --- a/crates/akita-planner/src/planner.rs +++ b/crates/akita-planner/src/planner.rs @@ -22,7 +22,7 @@ use crate::schedule_params::{ derive_ab_commitment_candidate, derive_selected_suffix_schedule, materialize_candidate_schedule, recursive_split_search_domain, select_complete_candidate, AbCommitmentCandidateRequest, CandidateMaterializationCost, PlannerOpeningCandidate, - RingChallengeConfigFn, ScheduleMemo, SuffixCtx, SuffixState, + RingChallengeConfigFn, ScheduleMemo, SetupFieldBudget, SuffixCtx, SuffixState, }; use crate::PlannerPolicy; @@ -829,26 +829,9 @@ pub(crate) fn find_schedule_in_relation_order( )); } let ring_challenge_config: RingChallengeConfigFn<'_> = &ring_challenge_config; - let setup_field_budget = policy.setup_field_budget; let root_input_witness_len = checked::pow2(key.final_group.num_vars()).ok_or_else(|| { AkitaError::InvalidSetup("multi-group root-fold witness length overflow".to_string()) })?; - let suffix_ctx = SuffixCtx { - policy, - challenge_order: policy.transcript_grinding_order()?, - diagnostics, - ring_challenge_config, - key: PolynomialGroupLayout::singleton(key.final_group.num_vars()), - setup_field_budget, - root_lookup_key: Some(key), - root_main_constraint: options.root_main_constraint, - adaptation_guide: options.adaptation_guide, - root_source_contract: Some(final_source_contract), - precommitted_source_contracts, - level_zero_is_root: true, - relation_traversal_order: options.relation_traversal_order, - relation_mode_filter: options.relation_mode_filter, - }; let dimension_ceiling = super::schedule_params::initial_dimension_ceiling(policy)?; let initial_state = SuffixState { input_chunks: None, @@ -862,28 +845,82 @@ pub(crate) fn find_schedule_in_relation_order( relation_phase: super::schedule_params::RingRelationPhase::QuotientPrefix, }, }; - let mut memo = ScheduleMemo::new(); - let suffix_started = diagnostics.map(|_| Instant::now()); #[cfg(test)] let query_prefix_count = options.query_prefix_count; #[cfg(not(test))] let query_prefix_count = 0; - let suffix = derive_selected_suffix_schedule( - &suffix_ctx, - &mut memo, - initial_state, - 0, - super::schedule_params::QuerySearch::Root(query_prefix_count), - ); - if let (Some(diagnostics), Some(started)) = (diagnostics, suffix_started) { - diagnostics.add_suffix_dp_time(started.elapsed()); - let (hits, misses) = memo.setup_prefix_cache_diagnostics(); - diagnostics.record_setup_prefix_cache(hits, misses); - } - let suffix = suffix?; - let best = select_complete_candidate(policy, suffix.setup_candidates(), diagnostics)?; - - let Some(best) = best.cloned() else { + // V6 searches under widening power-of-two setup budgets. Exactness: + // - Admission under a power-of-two budget `B` is padded setup `<= B`, and + // every V6 dominance, bound and level prune keeps a side whose padded + // setup is no larger. A rejected candidate can therefore displace only + // candidates that are also over `B`. Frontiers are unbounded dominance + // sets, split domains are fixed by geometry, and each pass gets a fresh + // memo, so the in-budget part of every frontier is the unbudgeted one. + // - Setup is a maximum over levels, terminal and offloaded children, so + // rejecting a partial schedule never rejects a completion within `B`. + // - A pass that selects `W` returns the unbudgeted winner, which fits + // because its padded setup is at most `W`'s. A pass that rejects + // nothing is the unbudgeted search. Otherwise the next budget is the + // smallest rejected setup, padded; if that overflows, the last pass runs + // without a budget. The starting budget changes only the pass count. + let mut padded_setup_limit = (policy.setup_field_budget.is_none() + && policy.selection_policy + == crate::SelectionPolicyId::MinPaddedSetupEnvelopeThenFirstDirectThenExactProofAndWorkV6) + .then_some(1); + let best = loop { + let pass_budget = padded_setup_limit.or(policy.setup_field_budget); + let setup_field_budget = pass_budget.map(SetupFieldBudget::new); + let suffix_ctx = SuffixCtx { + policy, + challenge_order: policy.transcript_grinding_order()?, + diagnostics, + ring_challenge_config, + key: PolynomialGroupLayout::singleton(key.final_group.num_vars()), + setup_field_budget: setup_field_budget.as_ref(), + root_lookup_key: Some(key), + root_main_constraint: options.root_main_constraint, + adaptation_guide: options.adaptation_guide, + root_source_contract: Some(final_source_contract), + precommitted_source_contracts, + level_zero_is_root: true, + relation_traversal_order: options.relation_traversal_order, + relation_mode_filter: options.relation_mode_filter, + }; + let mut memo = ScheduleMemo::new(); + let suffix_started = diagnostics.map(|_| Instant::now()); + let pass_start = diagnostics.map(|diagnostics| diagnostics.search_work()); + let suffix = derive_selected_suffix_schedule( + &suffix_ctx, + &mut memo, + initial_state, + 0, + super::schedule_params::QuerySearch::Root(query_prefix_count), + ); + if let (Some(diagnostics), Some(started)) = (diagnostics, suffix_started) { + diagnostics.add_suffix_dp_time(started.elapsed()); + let (hits, misses) = memo.setup_prefix_cache_diagnostics(); + diagnostics.record_setup_prefix_cache(hits, misses); + } + let suffix = suffix?; + let best = select_complete_candidate(policy, suffix.setup_candidates(), diagnostics)?; + if let (Some(diagnostics), Some(start)) = (diagnostics, pass_start) { + diagnostics.record_setup_budget_pass(pass_budget, start); + } + // `Some(None)` asks for a final pass without a budget. + let widened = match (best, padded_setup_limit, setup_field_budget.as_ref()) { + (None, Some(limit), Some(budget)) => budget.min_rejected().map(|rejected| { + rejected + .checked_next_power_of_two() + .filter(|&next| next > limit) + }), + _ => None, + }; + match widened { + Some(next) => padded_setup_limit = next, + None => break best.cloned(), + } + }; + let Some(best) = best else { if key.precommitteds.is_empty() { return Err(AkitaError::UnsupportedSchedule(format!( "no mixed-D schedule in the audited fold domain for num_vars={}, num_polynomials={}", diff --git a/crates/akita-planner/src/schedule_params.rs b/crates/akita-planner/src/schedule_params.rs index 5ee62d13d..6c033c50b 100644 --- a/crates/akita-planner/src/schedule_params.rs +++ b/crates/akita-planner/src/schedule_params.rs @@ -80,8 +80,8 @@ pub(crate) use relation_transition::{ }; pub(crate) use setup_score::{level_setup_field_elements, terminal_setup_field_elements}; pub(crate) use suffix_dp::{ - derive_selected_suffix_schedule, QuerySearch, ScheduleMemo, SuffixCtx, SuffixState, - SuffixTopology, MAX_PRECOMMIT_OPENING_PRODUCTS, + derive_selected_suffix_schedule, QuerySearch, ScheduleMemo, SetupFieldBudget, SuffixCtx, + SuffixState, SuffixTopology, MAX_PRECOMMIT_OPENING_PRODUCTS, }; pub(crate) fn root_inner_basis_source( diff --git a/crates/akita-planner/src/schedule_params/suffix_dp.rs b/crates/akita-planner/src/schedule_params/suffix_dp.rs index 75e27a228..37cd08f45 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp.rs @@ -41,7 +41,7 @@ use frontier::{consider_child_suffixes, price_child_edge, ProjectedFrontier, Pro pub(crate) use search::derive_selected_suffix_schedule; use source::attach_source_moments; use state::*; -pub(crate) use state::{ScheduleMemo, SuffixCtx, SuffixState, SuffixTopology}; +pub(crate) use state::{ScheduleMemo, SetupFieldBudget, SuffixCtx, SuffixState, SuffixTopology}; pub(crate) use terminal::try_terminal_direct_suffix_cost; const SETUP_AND_PAYLOAD_PROJECTIONS: &[Projection] = @@ -87,7 +87,7 @@ struct ChildEdge<'a> { level_setup_field_elements: usize, offloaded: bool, require_child_fold: bool, - setup_field_budget: Option, + setup_field_budget: Option<&'a SetupFieldBudget>, } impl ChildEdge<'_> { @@ -490,7 +490,7 @@ fn child_choice( .max(suffix.setup_field_elements); if edge .setup_field_budget - .is_some_and(|budget| setup_field_elements > budget) + .is_some_and(|budget| !budget.admits(setup_field_elements)) { return Ok(None); } diff --git a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs index a9a3035ee..ecad172d1 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs @@ -293,7 +293,20 @@ pub(super) fn process_candidate_batch( let natural_len = active_setup_field_len(&candidate, opening_layout)?; price_terminal_candidate(ctx, state, query_search, &candidate, natural_len, frontiers)?; } - let candidates_with_source = attach_source_moments(ctx, state, is_root_level, generated.folds)?; + let folds = match ctx.setup_field_budget { + // A fold whose own level setup exceeds the budget fits no schedule. + Some(budget) => { + let mut admitted = Vec::with_capacity(generated.folds.len()); + for candidate in generated.folds { + if budget.admits(level_setup_field_elements(&candidate.params)?) { + admitted.push(candidate); + } + } + admitted + } + None => generated.folds, + }; + let candidates_with_source = attach_source_moments(ctx, state, is_root_level, folds)?; // Complete-root candidates are traversed in exact lower-bound order. // Recursive states do not have that global admission rule and retain // local Pareto pruning. diff --git a/crates/akita-planner/src/schedule_params/suffix_dp/state.rs b/crates/akita-planner/src/schedule_params/suffix_dp/state.rs index 43a1b4313..0ef845988 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp/state.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp/state.rs @@ -330,6 +330,42 @@ pub(super) fn empty_suffix_result() -> Arc { }) } +/// Upper bound on a schedule's total setup field elements for one search. +/// +/// Every rejection records its setup size, so a caller whose search found +/// nothing can learn the smallest size it excluded. +pub(crate) struct SetupFieldBudget { + limit: usize, + min_rejected: std::cell::Cell>, +} + +impl SetupFieldBudget { + pub(crate) const fn new(limit: usize) -> Self { + Self { + limit, + min_rejected: std::cell::Cell::new(None), + } + } + + /// Whether `setup_field_elements` fits, recording it when it does not. + pub(crate) fn admits(&self, setup_field_elements: usize) -> bool { + if setup_field_elements <= self.limit { + return true; + } + self.min_rejected.set(Some( + self.min_rejected + .get() + .map_or(setup_field_elements, |min| min.min(setup_field_elements)), + )); + false + } + + /// Smallest setup size this budget rejected, if any. + pub(crate) fn min_rejected(&self) -> Option { + self.min_rejected.get() + } +} + /// DP-invariant inputs for the suffix search. /// /// Values that remain constant across the whole recursion are carried in one @@ -342,7 +378,7 @@ pub(crate) struct SuffixCtx<'a> { pub(crate) ring_challenge_config: &'a dyn Fn(usize) -> Result, pub(crate) key: PolynomialGroupLayout, - pub(crate) setup_field_budget: Option, + pub(crate) setup_field_budget: Option<&'a SetupFieldBudget>, pub(crate) root_lookup_key: Option<&'a ScheduleLookupKey>, /// Optional exact main-group root selected by an earlier scalar plan. /// diff --git a/crates/akita-planner/src/test/adaptive_search.rs b/crates/akita-planner/src/test/adaptive_search.rs index 7d1f8ff5b..125695ef4 100644 --- a/crates/akita-planner/src/test/adaptive_search.rs +++ b/crates/akita-planner/src/test/adaptive_search.rs @@ -3,6 +3,9 @@ use super::*; mod relation_cutover; #[path = "adaptive_search/selective_l2.rs"] mod selective_l2; +#[cfg(feature = "catalog-gen")] +#[path = "adaptive_search/setup_budget.rs"] +mod setup_budget; fn onehot_group(num_vars: usize, num_polynomials: usize) -> PolynomialGroupLayout { PolynomialGroupLayout::new(num_vars, num_polynomials) } diff --git a/crates/akita-planner/src/test/adaptive_search/setup_budget.rs b/crates/akita-planner/src/test/adaptive_search/setup_budget.rs new file mode 100644 index 000000000..f11dbd45c --- /dev/null +++ b/crates/akita-planner/src/test/adaptive_search/setup_budget.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::diagnostics::{capture, PlannerDiagnosticsSnapshot}; + +fn plan_with_diagnostics( + key: PolynomialGroupLayout, + policy: &PlannerPolicy, +) -> (PlannedFoldSchedule, PlannerDiagnosticsSnapshot) { + let (planned, snapshot) = capture(true, || { + find_schedule( + key, + policy, + C::committed_source_contract().unwrap(), + C::ring_challenge_config, + ) + }); + ( + planned.expect("setup search"), + snapshot.expect("enabled capture"), + ) +} + +/// Plan `key` with widening setup budgets and with one pass whose explicit +/// budget never binds, and require the same schedule. +fn assert_budgeted_search_matches_unbounded_pass( + policy: PlannerPolicy, + key: PolynomialGroupLayout, + label: &str, +) { + assert_eq!(policy.setup_field_budget, None, "{label}"); + assert_eq!( + policy.selection_policy, + crate::SelectionPolicyId::MinPaddedSetupEnvelopeThenFirstDirectThenExactProofAndWorkV6, + "{label}" + ); + let mut unbounded = policy; + unbounded.setup_field_budget = Some(usize::MAX); + let (budgeted, budgeted_passes) = plan_with_diagnostics::(key, &policy); + let (single, single_passes) = plan_with_diagnostics::(key, &unbounded); + + assert_eq!( + budgeted.schedule.canonical_descriptor_bytes(), + single.schedule.canonical_descriptor_bytes(), + "{label}" + ); + assert_eq!(budgeted.estimate, single.estimate, "{label}"); + + let budgets = budgeted_passes + .setup_budget_passes + .iter() + .map(|pass| pass.budget) + .collect::>(); + let padded_setup = + akita_params::padded_setup_prefix_len(budgeted.estimate.estimated_num_setup_field_elements); + let (last, earlier) = budgets.split_last().expect("at least one pass"); + assert_eq!(*last, Some(padded_setup), "{label}: {budgets:?}"); + assert!( + earlier.windows(2).all(|pair| pair[0] + .zip(pair[1]) + .is_some_and(|(left, right)| left < right)), + "{label}: {budgets:?}" + ); + assert!( + earlier + .iter() + .all(|budget| budget.is_some_and(|budget| budget < padded_setup)), + "{label}: {budgets:?}" + ); + assert_eq!( + single_passes + .setup_budget_passes + .iter() + .map(|pass| pass.budget) + .collect::>(), + [Some(usize::MAX)], + "{label}" + ); +} + +#[test] +fn padded_setup_budget_passes_match_one_unbounded_pass() { + use akita_config::{policy_of, proof_optimized::fp128::OneHot, RecursiveCommitmentConfig}; + + type Recursive = RecursiveCommitmentConfig; + + // Restricted domains from the unpruned-oracle fixtures. The oracle itself + // does not model setup-prefix offloading, so it cannot rank V6 schedules. + let oracle_domains = [ + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + ]) + .unwrap(), + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + CommitmentRingDims { + inner: 128, + outer: 128, + opening: 64, + }, + ]) + .unwrap(), + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 128, + }, + ]) + .unwrap(), + ]; + for (domain_index, domain) in oracle_domains.iter().enumerate() { + let mut policy = policy_for_domain( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + domain, + ); + policy.selective_l2_response_model = crate::SelectiveL2ResponseModelId::Disabled; + for num_vars in [14, 16, 18] { + assert_budgeted_search_matches_unbounded_pass::( + policy, + onehot_group(num_vars, 1), + &format!("one-hot domain {domain_index} nv={num_vars}"), + ); + } + } +} + +// The drift job already checks every V6 catalog row against artifacts that the +// unbudgeted search produced. Only the unbounded comparison passes are slow. +#[test] +#[ignore = "plans three catalog rows without a setup budget; takes about 100 s"] +fn padded_setup_budget_matches_one_unbounded_pass_on_catalog_rows() { + use akita_config::{ + policy_of, + proof_optimized::{fp128, fp32}, + RecursiveCommitmentConfig, + }; + + type Fp32Dense = RecursiveCommitmentConfig; + type Fp128Dense = RecursiveCommitmentConfig; + for num_vars in [20, 22] { + assert_budgeted_search_matches_unbounded_pass::( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + PolynomialGroupLayout::singleton(num_vars), + &format!("fp32_dense_recursive nv={num_vars}"), + ); + } + assert_budgeted_search_matches_unbounded_pass::( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + PolynomialGroupLayout::singleton(20), + "fp128_dense_recursive nv=20", + ); +} From dfe4da32334392d16f1c434759ec585716144202 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Sun, 25 Oct 2026 14:35:35 -0700 Subject: [PATCH 2/5] feat(planner): guard per-row planner search work in the drift job The generator now records four deterministic search-work counters per row (suffix calls, completed states, generated fold candidates and descriptor builds). `--planner-cost-snapshot` writes them and `--planner-cost-baseline` fails when a row is added or removed or any counter grows by more than a quarter. Preplanned scalar rows carry the counters of the search that produced them. The tracked baseline is artifacts/schedule-planner-cost.tsv. The drift job checks it and bounds the regeneration step at 20 minutes. --- .github/workflows/ci.yml | 7 +- artifacts/schedule-planner-cost.tsv | 116 ++++++++ crates/akita-planner/README.md | 15 ++ .../src/bin/gen_schedule_artifacts.rs | 103 +++++++- .../src/bin/planner_cost_snapshot.rs | 250 ++++++++++++++++++ crates/akita-planner/src/diagnostics.rs | 23 ++ crates/akita-planner/src/emit/materialize.rs | 32 ++- crates/akita-planner/src/emit/mod.rs | 14 +- .../akita-planner/src/generated_families.rs | 40 +-- .../src/generated_families/tests.rs | 15 +- 10 files changed, 582 insertions(+), 33 deletions(-) create mode 100644 artifacts/schedule-planner-cost.tsv create mode 100644 crates/akita-planner/src/bin/planner_cost_snapshot.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 532654fde..4b507e5a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,10 @@ jobs: scripts/check-external-schedule-artifacts.sh cargo test --release -p akita-config --test trusted_schedule_artifact - name: Regenerate schedule artifacts in temporary storage + # Planning takes about 10 minutes with two workers. The bound stops a + # runaway search early; smaller per-row cost regressions fail the + # --planner-cost-baseline check instead. + timeout-minutes: 20 env: AKITA_SCHEDULE_GEN_JOBS: 2 run: | @@ -196,7 +200,8 @@ jobs: --bin gen_schedule_artifacts -- "$artifact_dir" --row-progress \ --catalog-baseline artifacts/schedule-catalog.tsv \ --require-catalog-baseline-match \ - --catalog-report "$RUNNER_TEMP/akita-schedule-baseline-comparison.tsv" + --catalog-report "$RUNNER_TEMP/akita-schedule-baseline-comparison.tsv" \ + --planner-cost-baseline artifacts/schedule-planner-cost.tsv diff -ru artifacts/schedules "$artifact_dir" - name: Direct modeled SIS cost for every schedule run: | diff --git a/artifacts/schedule-planner-cost.tsv b/artifacts/schedule-planner-cost.tsv new file mode 100644 index 000000000..3c154e953 --- /dev/null +++ b/artifacts/schedule-planner-cost.tsv @@ -0,0 +1,116 @@ +family logical_key lookup_key_digest suffix_calls completed_states generated_candidates descriptor_builds +fp128_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 2247440 99989 2995371 1449197 +fp128_dense final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 657605 34247 913895 589085 +fp128_dense final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 247793 17002 371830 289633 +fp128_dense final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 2388372 108397 3176185 1490169 +fp128_dense final=16:1;precommitted=14:1 5e8cc820e837ba71e8dcecb767b2a2f326973198d8ffadfa57c546eaf2ebde57 819818 34149 1118513 595851 +fp128_dense final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 408255 25032 595250 436283 +fp128_dense final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 1183584 58190 1619798 965784 +fp128_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 718185 42933 1032342 657818 +fp128_dense final=17:4;precommitted= b996d72cefb7a0a3262ac30ce0fd4e4200b27eff277fd1922a3c219011e6383f 765827 40147 1063914 686046 +fp128_dense final=15:2;precommitted= c087e777d755574ba60cc3ed9d2768052d7510780d99c0fe235c0a00f0202e57 391927 24311 571368 423140 +fp128_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 1062504 55206 1474557 882136 +fp128_dense_bounded final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 198589 14077 301699 230767 +fp128_dense_bounded final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 876199 46599 1221908 783691 +fp128_dense_bounded final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 1572033 70071 2105995 1143987 +fp128_dense_multi_chunk final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 680239 36557 942400 693295 +fp128_dense_multi_chunk_w8r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 469631 38977 770963 470308 +fp128_dense_recursive final=22:1;precommitted= 43c57a9c37004f9565033f2c8ae56fdc6891e2e8ee936abeb6267fa9a84e3696 79693 10043 654905 450703 +fp128_dense_recursive final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 2220 1648 128062 21249 +fp128_dense_recursive final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 130260 14194 894194 630959 +fp128_dense_recursive final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 174474 18989 1099269 773172 +fp128_dense_recursive final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 190819 19897 1240596 795981 +fp128_onehot final=20:2;precommitted= 024daea0560fa394f9aaded7cdd706e307cff8e2828bed1c0d981aefbbbf6ef4 241974 17425 362573 296896 +fp128_onehot final=20:2;precommitted=14:1 04e3f11e898185d2fb813267b7354610ca97fb872364415877d77e1692abfdd4 248641 17713 371467 302515 +fp128_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 231868 19295 365793 303197 +fp128_onehot final=18:1;precommitted= 0a64fd27f49046793d7d00ee8c2bda3757f24e60e86752300778d44cd119e709 54394 6236 91392 91242 +fp128_onehot final=20:4;precommitted= 1af5783e63010bd55e2ba781c36f9bea6db9cbe9fa4389d166ef22c347deb309 171237 14257 269429 234151 +fp128_onehot final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 14380 2352 26399 30027 +fp128_onehot final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 546 175 1803 1323 +fp128_onehot final=50:1;precommitted= 270f16c2966b092473ac7c4025e71c8e4bb127f40148ad4d279309ce24502a77 550833 37516 815218 518057 +fp128_onehot final=20:4;precommitted=14:1,14:1,14:1 2b4186a8d8e57afc1f67bb27df7600e0f6551e07dc5c359c1cc3f7f61319405a 172569 14372 271160 234265 +fp128_onehot final=34:2;precommitted=16:1,16:1 30424e0ca54d42b83af9180851eb3c3bf76481bd1b3487fbbad1c7bcaaecb7c9 556650 32466 790728 488722 +fp128_onehot final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 255704 20568 397150 316347 +fp128_onehot final=16:1;precommitted=14:1 4a803519410706a55e7c34508587b356c40d472b50b203f3d9f7936a26005f47 34249 4279 58019 59969 +fp128_onehot final=44:1;precommitted= 59108197889e16b1363957b8a7c59570842f3b60502857dee9a4456586d7081e 411290 28248 610327 402711 +fp128_onehot final=20:1;precommitted=14:2 6130d294b9a75f2417eefbed23ea8deae124c1394f9e773c3f1dd956a2a9f901 135841 12291 219655 199365 +fp128_onehot final=30:4;precommitted= 619a78159ae3df6e54d9411603c43aba002307069fda024793c83803f33af61c 255704 20568 397090 316347 +fp128_onehot final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 6573 1239 12835 14245 +fp128_onehot final=15:4;precommitted= 68fb5e2e5571f333ccc18953a6f62b93350179816ef43b814506341ba44a8ef1 31587 4054 53987 56901 +fp128_onehot final=20:1;precommitted=14:1 69a43114e91993b4fff4b1e8f3ac3dd5684c65225ba7032276e0fefca119ae3a 131516 11739 210723 189673 +fp128_onehot final=32:2;precommitted=16:1,16:1 7af0b4d281bc91485cb708c45d09dbeff67604e5e932e20cdb84ee742250c355 365292 23737 534353 365659 +fp128_onehot final=12:1;precommitted= 7cea46be72a904b2bacfbccb7a070499ba20889044145649c3db3f45a73dc084 413 152 1446 1050 +fp128_onehot final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 128319 11620 206659 186573 +fp128_onehot final=14:2;precommitted= 8a40a8595c14901ece876c6c020dbcdba59bed81270cf9431da74003d3a138aa 4490 899 8957 10084 +fp128_onehot final=32:4;precommitted= a2afe07e7869624cdd9ab4baf165bb0251f452bca51d8a64041fbe6d462a9864 422750 27864 618815 424599 +fp128_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 276902 20951 421631 337445 +fp128_onehot final=16:1;precommitted=14:1,15:2 a7edb74ec411b6db7215811c008a5e33f5c7d27b18a1564efe1eb407abac02f5 143813 12366 230287 199468 +fp128_onehot final=40:1;precommitted= b5096196e31d7c94b6e34c5a093fd4bb202586423c3e6d14f78920e3d5d954c3 414591 28932 613048 420697 +fp128_onehot final=36:1;precommitted= b9f19e5b22668a057b75ac8efda23eb70064baa0dde95e015463454c195a7713 410504 27581 602141 407473 +fp128_onehot final=16:1;precommitted=14:1 d64b0853a3c757d5b955880f6d01429c70590908506fb6eb13a7b59925b1e908 134006 11277 211779 180393 +fp128_onehot final=20:4;precommitted=14:1,14:1 e49edca76b971eac0a8dcf6f18334c902e0c9046fedc6b5b451165236b547d0d 174784 14438 274188 236609 +fp128_onehot final=15:1;precommitted= fe784ff5321ce50dc781655bbe7fc9a71f08d9527d5445702d0e8e2a2d41fc87 1363 350 3377 3176 +fp128_onehot_multi_chunk final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 733388 38403 1021044 659284 +fp128_onehot_multi_chunk final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 251764 20672 391278 346515 +fp128_onehot_multi_chunk final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 6062 1413 18105 12387 +fp128_onehot_multi_chunk final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 508138 30385 721253 499821 +fp128_onehot_multi_chunk_w2r2 final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 7384 1352 15956 14797 +fp128_onehot_multi_chunk_w2r2 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 259392 20717 402707 368353 +fp128_onehot_multi_chunk_w2r2 final=14:1;precommitted=14:1 ee81d011f70c1a8be6740527e9b9a2229ad141fbbc72338db34d08fc7c2984c7 8697 1391 16100 17304 +fp128_onehot_multi_chunk_w2r3 final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 2524 761 8277 4679 +fp128_onehot_multi_chunk_w2r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 232277 20760 376280 340788 +fp128_onehot_multi_chunk_w2r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2189 652 7645 3955 +fp128_onehot_multi_chunk_w4r2 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 246679 19955 383201 343572 +fp128_onehot_multi_chunk_w4r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 254075 21436 411279 346154 +fp128_onehot_multi_chunk_w4r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2582 780 9978 4661 +fp128_onehot_multi_chunk_w8r3 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 626754 39611 945217 545057 +fp128_onehot_multi_chunk_w8r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 280044 22328 446053 358227 +fp128_onehot_multi_chunk_w8r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2290 787 11716 3779 +fp128_onehot_multi_chunk_w8r3 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 475471 32661 734939 471523 +fp128_onehot_recursive final=34:2;precommitted=16:1,16:1 30424e0ca54d42b83af9180851eb3c3bf76481bd1b3487fbbad1c7bcaaecb7c9 272141 26881 1481695 1042115 +fp128_onehot_recursive final=32:2;precommitted=16:1,16:1 7af0b4d281bc91485cb708c45d09dbeff67604e5e932e20cdb84ee742250c355 265717 24960 1421054 1004071 +fp128_onehot_recursive final=36:1;precommitted= b9f19e5b22668a057b75ac8efda23eb70064baa0dde95e015463454c195a7713 569916 46503 2608572 1692037 +fp128_onehot_recursive_multi_chunk_w2r3 final=32:2;precommitted=16:1,16:1 476da39485af2590ec71abe1e36612662c9310b641229a42399c0e341ca1cf0d 226832 23463 1220640 909882 +fp128_onehot_recursive_multi_chunk_w2r3 final=34:2;precommitted=16:1,16:1 7dbc9ca4c3042bfff07849c2f82a20e5dc4faed1b7e14bdc3c66dca199c0aa46 344554 33204 1779499 1319078 +fp128_onehot_recursive_multi_chunk_w4r3 final=32:2;precommitted=16:1,16:1 7bacfcee37e74c80f7b1c900e1efd2da313d46aac18501aa058dc854f71c8cbf 216914 22487 1183928 841893 +fp128_onehot_recursive_multi_chunk_w4r3 final=34:2;precommitted=16:1,16:1 a566276dafd88ca52207ba1f4784f4d83086d9fad91b86e4018094ba450ead4c 234542 25088 1272026 934312 +fp128_onehot_recursive_multi_chunk_w8r2 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 206734 22034 1185090 875189 +fp128_onehot_recursive_multi_chunk_w8r2 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 198963 19244 1125957 868688 +fp128_onehot_recursive_multi_chunk_w8r3 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 200907 21978 1131619 825709 +fp128_onehot_recursive_multi_chunk_w8r3 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 161926 17265 959179 716412 +fp32_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 699072 45704 1144764 1192934 +fp32_dense final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 787653 43959 1221525 1321605 +fp32_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 1019788 58356 1576813 1472353 +fp32_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 923794 58120 1474690 1520901 +fp32_dense final=20:1;precommitted=20:1 eb9006da2ba11329054b41effb1a570facd47539ad53c5a94d61c08461fd2e64 814538 40790 1242380 1163478 +fp32_dense_recursive final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 461847 45434 2629507 1947417 +fp32_dense_recursive final=22:1;precommitted= 43c57a9c37004f9565033f2c8ae56fdc6891e2e8ee936abeb6267fa9a84e3696 1354 367 52803 5232 +fp32_dense_recursive final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 574 187 43193 3066 +fp32_dense_recursive final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 1884 1034 63021 10144 +fp32_dense_recursive final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 617756 67339 3339947 1931437 +fp32_dense_recursive final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 101173 12759 995437 746420 +fp32_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 461517 35611 810217 982547 +fp32_onehot final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 55194 6784 112815 173075 +fp32_onehot final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 57 29 1101 152 +fp32_onehot final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 12222 2256 27257 42998 +fp32_onehot final=34:1;precommitted= 66a207dd2ca7efff383d2cbfb09722aa71c0b3e44ba990a146833ebba9496679 422785 33508 753944 901578 +fp32_onehot final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 198675 17211 371359 526395 +fp32_onehot final=20:1;precommitted=14:1 7ec38a4dfd9004990b8cc0ead2652f3ddf8a9f2525ff9b8630bba57c112c8232 258305 19661 456471 609862 +fp32_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 450542 33641 779101 959122 +fp64_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 3253550 146969 4641847 3354348 +fp64_dense final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 688425 34926 1050553 978865 +fp64_dense final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 1428231 58713 2028862 1676603 +fp64_dense final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 2461415 102786 3488245 2745146 +fp64_dense final=29:1;precommitted= a082674574a73638bfa1560ac8173d4e86d4e1bc9f62665c28e7c58fcc4f0d9c 2183445 119661 3234233 2504898 +fp64_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 2878891 129628 4117694 3041167 +fp64_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 3485342 146801 4879826 3456691 +fp64_dense final=20:1;precommitted=16:1 d57b503a3845f43d2227dc5e32aafbd3b24bbfb950a69fffc34820e0a8021c01 2517868 99072 3582658 2630391 +fp64_dense_recursive final=23:1;precommitted= 2fcfd5127478a426862f40c03ac86273c00151e8c0068bc578b1578281e1aee2 257519 24173 2432578 1678476 +fp64_dense_recursive final=21:1;precommitted= 4f021af291010debd97a561631f77ec725a00685e86c7a86cbbba0d0d21f2afa 15059 4446 447863 128866 +fp64_dense_recursive final=25:1;precommitted= 8b93e9363d0087a49725b9a1ca103902ab185fff9c1a43773c7025ac4bc11bc9 429804 33478 3221376 2444074 +fp64_dense_recursive final=29:1;precommitted= a082674574a73638bfa1560ac8173d4e86d4e1bc9f62665c28e7c58fcc4f0d9c 501342 42594 3529050 2689459 +fp64_dense_recursive final=27:1;precommitted= a6c2f122b8b925f416c9bbe519d0f2bedbd9063119ff80f478764499448c6dc8 184573 22116 1478364 1022950 +fp64_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 1007858 60998 1594193 1547162 +fp64_onehot final=34:1;precommitted= 66a207dd2ca7efff383d2cbfb09722aa71c0b3e44ba990a146833ebba9496679 1696051 87592 2564031 2212311 +fp64_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 964308 57537 1525602 1498979 +fp64_onehot final=35:1;precommitted= bfb5adcb7db566d2caa4ca8d06480c0b099cf7f3e72a2b1f762b149ecb03e830 1448921 75956 2200605 1908853 diff --git a/crates/akita-planner/README.md b/crates/akita-planner/README.md index e8d52bd42..e8d338e66 100644 --- a/crates/akita-planner/README.md +++ b/crates/akita-planner/README.md @@ -321,6 +321,21 @@ repository permits catalog-breaking revisions, so baseline/current policy is reviewed from the checked evidence. Same-head drift remains an automatic failure under `--check-catalog`. +Planner cost is guarded the same way. `--planner-cost-snapshot ` writes +one row per regenerated row with four deterministic search-work counters: +suffix calls, completed states, generated fold candidates, and descriptor +builds. A reused preplanned row reports the work of the search that produced +it. The counts do not depend on timing or worker count. The tracked baseline is +`artifacts/schedule-planner-cost.tsv`; CI passes it through +`--planner-cost-baseline `, which fails when a row is added or +removed or when any counter grows by more than a quarter. When a planner change +intentionally moves rows or costs, regenerate the baseline with the complete +family set: + +```bash +scripts/generate-schedule-artifacts.sh --planner-cost-snapshot artifacts/schedule-planner-cost.tsv +``` + Targeted generation leaves the shared `mod.rs` wiring complete. Before a planner change is committed, run the unfiltered command above so every tracked family is regenerated. diff --git a/crates/akita-planner/src/bin/gen_schedule_artifacts.rs b/crates/akita-planner/src/bin/gen_schedule_artifacts.rs index 672ce08fe..3613a2169 100644 --- a/crates/akita-planner/src/bin/gen_schedule_artifacts.rs +++ b/crates/akita-planner/src/bin/gen_schedule_artifacts.rs @@ -3,6 +3,7 @@ mod catalog_policy_report; mod catalog_snapshot; mod generation_output_path; +mod planner_cost_snapshot; use catalog_policy_report::catalog_policy_signature; use generation_output_path::validate_explicit_output_isolation; @@ -40,6 +41,8 @@ struct ParsedArgs { catalog_snapshot: Option, catalog_baseline: Option, require_catalog_baseline_match: bool, + planner_cost_snapshot: Option, + planner_cost_baseline: Option, row_progress: bool, family_filter: Option>, explicit_rows: ExplicitRows, @@ -63,6 +66,7 @@ fn usage() -> &'static str { --bin gen_schedule_artifacts -- [--check-catalog] \ [--catalog-report ] [--catalog-snapshot ] \ [--catalog-baseline ] [--require-catalog-baseline-match] \ + [--planner-cost-snapshot ] [--planner-cost-baseline ] \ [--row-progress] \ [family_name ...]\n\ positional family names select only those generated families; omit them \ @@ -136,6 +140,8 @@ fn parse_args_from(raw_args: Vec) -> Result { let mut catalog_snapshot = None; let mut catalog_baseline = None; let mut require_catalog_baseline_match = false; + let mut planner_cost_snapshot = None; + let mut planner_cost_baseline = None; let mut row_progress = false; let mut family_args = Vec::new(); let mut explicit_rows = ExplicitRows::default(); @@ -184,6 +190,26 @@ fn parse_args_from(raw_args: Vec) -> Result { require_catalog_baseline_match = true; i += 1; } + "--planner-cost-snapshot" => { + let value = raw_args + .get(i + 1) + .ok_or_else(|| "--planner-cost-snapshot requires a path".to_string())?; + if planner_cost_snapshot.is_some() { + return Err("--planner-cost-snapshot may be supplied only once".to_string()); + } + planner_cost_snapshot = Some(PathBuf::from(value)); + i += 2; + } + "--planner-cost-baseline" => { + let value = raw_args + .get(i + 1) + .ok_or_else(|| "--planner-cost-baseline requires a path".to_string())?; + if planner_cost_baseline.is_some() { + return Err("--planner-cost-baseline may be supplied only once".to_string()); + } + planner_cost_baseline = Some(PathBuf::from(value)); + i += 2; + } "--final-group" => { let value = raw_args .get(i + 1) @@ -235,8 +261,11 @@ fn parse_args_from(raw_args: Vec) -> Result { } else { Some(family_args) }; - let catalog_rows_requested = - check_catalog || catalog_snapshot.is_some() || catalog_baseline.is_some(); + let catalog_rows_requested = check_catalog + || catalog_snapshot.is_some() + || catalog_baseline.is_some() + || planner_cost_snapshot.is_some() + || planner_cost_baseline.is_some(); if catalog_rows_requested && explicit_rows.final_group.is_some() { return Err("catalog checks and snapshots require the standard artifact rows".to_string()); } @@ -252,6 +281,11 @@ fn parse_args_from(raw_args: Vec) -> Result { if require_catalog_baseline_match && catalog_baseline.is_none() { return Err("--require-catalog-baseline-match requires --catalog-baseline".to_string()); } + if planner_cost_baseline.is_some() && family_filter.is_some() { + return Err( + "--planner-cost-baseline requires the complete generated family set".to_string(), + ); + } Ok(ParsedArgs { base_dir, check_catalog, @@ -259,6 +293,8 @@ fn parse_args_from(raw_args: Vec) -> Result { catalog_snapshot, catalog_baseline, require_catalog_baseline_match, + planner_cost_snapshot, + planner_cost_baseline, row_progress, family_filter, explicit_rows, @@ -485,6 +521,31 @@ fn materialized_snapshot_rows( .collect() } +fn planner_cost_rows( + spec: &EmitSpec, + entries: &[akita_planner::emit::MaterializedEntry], +) -> Result, String> { + entries + .iter() + .map(|entry| { + let key = entry.key(); + let cost = entry.planner_cost().ok_or_else(|| { + format!( + "{}: {} has no planner cost", + spec.family_name, + catalog_logical_key(&key) + ) + })?; + Ok(planner_cost_snapshot::PlannerCostRow { + family: spec.family_name.to_string(), + logical_key: catalog_logical_key(&key), + lookup_key_digest: catalog_lookup_key_digest(&key), + cost, + }) + }) + .collect() +} + impl ExplicitRows { fn has_family(&self, family: &GeneratedFamily) -> bool { self.final_group @@ -679,10 +740,14 @@ fn main() -> Result<(), String> { }; let mut changed_catalog_rows = 0usize; let mut current_catalog_rows = Vec::new(); + let collect_planner_cost = + args.planner_cost_snapshot.is_some() || args.planner_cost_baseline.is_some(); + let mut current_planner_cost_rows = Vec::new(); let outputs = render_schedule_artifact_outputs_with_validation( &specs, MaterializationDiagnostics { row_progress: args.row_progress, + planner_cost: collect_planner_cost, }, |spec, entries| { if check_catalog { @@ -695,6 +760,9 @@ fn main() -> Result<(), String> { if collect_catalog_snapshot { current_catalog_rows.extend(materialized_snapshot_rows(spec, entries)?); } + if collect_planner_cost { + current_planner_cost_rows.extend(planner_cost_rows(spec, entries)?); + } Ok(()) }, )?; @@ -754,6 +822,37 @@ fn main() -> Result<(), String> { )); } } + if let Some(path) = &args.planner_cost_snapshot { + let snapshot = planner_cost_snapshot::write_snapshot(current_planner_cost_rows.clone())?; + fs::write(path, snapshot).map_err(|error| format!("write {}: {error}", path.display()))?; + eprintln!("wrote planner cost snapshot {}", path.display()); + } + if let Some(path) = &args.planner_cost_baseline { + let baseline = fs::read_to_string(path) + .map_err(|error| format!("read {}: {error}", path.display()))?; + let comparison = planner_cost_snapshot::compare_snapshots( + planner_cost_snapshot::parse_snapshot(&baseline)?, + current_planner_cost_rows, + )?; + eprint!("{}", comparison.report); + for ((name, baseline_total), current_total) in planner_cost_snapshot::COUNTER_NAMES + .into_iter() + .zip(comparison.baseline_totals) + .zip(comparison.current_totals) + { + eprintln!( + "planner cost total {name}: baseline {baseline_total}, current {current_total}" + ); + } + if comparison.failures != 0 { + return Err(format!( + "planner search work differs from {} in {} places; \ + regenerate it with --planner-cost-snapshot if the change is intended", + path.display(), + comparison.failures, + )); + } + } let publish_started = args.row_progress.then(Instant::now); if args.row_progress { eprintln!( diff --git a/crates/akita-planner/src/bin/planner_cost_snapshot.rs b/crates/akita-planner/src/bin/planner_cost_snapshot.rs new file mode 100644 index 000000000..0f7c2820f --- /dev/null +++ b/crates/akita-planner/src/bin/planner_cost_snapshot.rs @@ -0,0 +1,250 @@ +//! Per-row planner search-work snapshot and its regression guard. + +use akita_planner::emit::PlannerCost; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +const SNAPSHOT_HEADER: &str = "family\tlogical_key\tlookup_key_digest\tsuffix_calls\tcompleted_states\tgenerated_candidates\tdescriptor_builds\n"; + +/// A row fails the guard when one counter exceeds its baseline by more than +/// `ALLOWED_GROWTH_NUMERATOR / ALLOWED_GROWTH_DENOMINATOR`. +const ALLOWED_GROWTH_NUMERATOR: usize = 5; +const ALLOWED_GROWTH_DENOMINATOR: usize = 4; + +pub(super) const COUNTER_NAMES: [&str; 4] = [ + "suffix_calls", + "completed_states", + "generated_candidates", + "descriptor_builds", +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PlannerCostRow { + pub(super) family: String, + pub(super) logical_key: String, + pub(super) lookup_key_digest: String, + pub(super) cost: PlannerCost, +} + +impl PlannerCostRow { + fn map_key(&self) -> (String, String) { + (self.family.clone(), self.lookup_key_digest.clone()) + } + + const fn counters(&self) -> [usize; 4] { + [ + self.cost.suffix_calls, + self.cost.completed_states, + self.cost.generated_candidates, + self.cost.descriptor_builds, + ] + } +} + +fn indexed_rows( + rows: Vec, + context: &str, +) -> Result, String> { + let mut indexed = BTreeMap::new(); + for row in rows { + let key = row.map_key(); + if indexed.insert(key.clone(), row).is_some() { + return Err(format!( + "{context}: duplicate planner cost row {} / {}", + key.0, key.1 + )); + } + } + Ok(indexed) +} + +pub(super) fn write_snapshot(rows: Vec) -> Result { + let mut out = SNAPSHOT_HEADER.to_string(); + for row in indexed_rows(rows, "write planner cost snapshot")?.values() { + let [suffix_calls, completed_states, generated_candidates, descriptor_builds] = + row.counters(); + writeln!( + out, + "{}\t{}\t{}\t{suffix_calls}\t{completed_states}\t{generated_candidates}\t{descriptor_builds}", + row.family, row.logical_key, row.lookup_key_digest, + ) + .map_err(|error| format!("write planner cost snapshot: {error}"))?; + } + Ok(out) +} + +pub(super) fn parse_snapshot(input: &str) -> Result, String> { + let mut lines = input.lines(); + if lines.next().map(|header| format!("{header}\n")).as_deref() != Some(SNAPSHOT_HEADER) { + return Err("planner cost snapshot has an unsupported header".to_string()); + } + lines + .enumerate() + .map(|(offset, line)| { + let line_number = offset + 2; + let columns = line.split('\t').collect::>(); + let [family, logical_key, lookup_key_digest, counters @ ..] = columns.as_slice() else { + return Err(format!( + "planner cost snapshot line {line_number}: expected 7 TSV columns, got {}", + columns.len() + )); + }; + let counters = counters + .iter() + .map(|raw| { + raw.parse::().map_err(|error| { + format!( + "planner cost snapshot line {line_number}: invalid counter `{raw}`: {error}" + ) + }) + }) + .collect::, _>>()?; + let [suffix_calls, completed_states, generated_candidates, descriptor_builds] = + counters.as_slice() + else { + return Err(format!( + "planner cost snapshot line {line_number}: expected 7 TSV columns, got {}", + columns.len() + )); + }; + Ok(PlannerCostRow { + family: (*family).to_string(), + logical_key: (*logical_key).to_string(), + lookup_key_digest: (*lookup_key_digest).to_string(), + cost: PlannerCost { + suffix_calls: *suffix_calls, + completed_states: *completed_states, + generated_candidates: *generated_candidates, + descriptor_builds: *descriptor_builds, + }, + }) + }) + .collect() +} + +pub(super) struct PlannerCostComparison { + pub(super) report: String, + pub(super) failures: usize, + /// Counter sums in [`COUNTER_NAMES`] order. + pub(super) baseline_totals: [usize; 4], + pub(super) current_totals: [usize; 4], +} + +fn add_counters(totals: &mut [usize; 4], row: &PlannerCostRow) -> Result<(), String> { + for (total, value) in totals.iter_mut().zip(row.counters()) { + *total = total + .checked_add(value) + .ok_or_else(|| "planner cost total overflow".to_string())?; + } + Ok(()) +} + +/// Compare the current per-row search work with a checked-in baseline. +/// +/// Added and removed rows fail, as does any counter that grows by more than a +/// quarter. Either way the fix is to regenerate the baseline deliberately. +pub(super) fn compare_snapshots( + baseline: Vec, + current: Vec, +) -> Result { + let baseline = indexed_rows(baseline, "planner cost baseline")?; + let current = indexed_rows(current, "current planner cost")?; + let mut comparison = PlannerCostComparison { + report: String::new(), + failures: 0, + baseline_totals: [0; 4], + current_totals: [0; 4], + }; + for row in baseline.values() { + add_counters(&mut comparison.baseline_totals, row)?; + if !current.contains_key(&row.map_key()) { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tremoved", + row.family, row.logical_key + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + } + } + for row in current.values() { + add_counters(&mut comparison.current_totals, row)?; + let Some(baseline_row) = baseline.get(&row.map_key()) else { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tadded", + row.family, row.logical_key + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + continue; + }; + for ((name, current_value), baseline_value) in COUNTER_NAMES + .into_iter() + .zip(row.counters()) + .zip(baseline_row.counters()) + { + let scaled_current = current_value + .checked_mul(ALLOWED_GROWTH_DENOMINATOR) + .ok_or_else(|| "planner cost comparison overflow".to_string())?; + let allowed = baseline_value + .checked_mul(ALLOWED_GROWTH_NUMERATOR) + .ok_or_else(|| "planner cost comparison overflow".to_string())?; + if scaled_current > allowed { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tgrew\t{name}\t{baseline_value}\t{current_value}", + row.family, row.logical_key, + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + } + } + } + Ok(comparison) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(digest: &str, suffix_calls: usize) -> PlannerCostRow { + PlannerCostRow { + family: "fp64_dense".to_string(), + logical_key: "final=20:1;precommitted=".to_string(), + lookup_key_digest: digest.to_string(), + cost: PlannerCost { + suffix_calls, + completed_states: 10, + generated_candidates: 100, + descriptor_builds: 7, + }, + } + } + + #[test] + fn snapshot_round_trips() { + let rows = vec![row("bb", 3), row("aa", 4)]; + let parsed = parse_snapshot(&write_snapshot(rows.clone()).unwrap()).unwrap(); + assert_eq!(parsed, vec![row("aa", 4), row("bb", 3)]); + } + + #[test] + fn growth_beyond_a_quarter_fails_and_shrinking_passes() { + let within = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 125)]).unwrap(); + assert_eq!(within.failures, 0, "{}", within.report); + let shrink = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 1)]).unwrap(); + assert_eq!(shrink.failures, 0, "{}", shrink.report); + let grew = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 126)]).unwrap(); + assert_eq!(grew.failures, 1); + assert!(grew.report.contains("grew\tsuffix_calls\t100\t126")); + } + + #[test] + fn row_set_changes_fail() { + let changed = compare_snapshots(vec![row("aa", 1)], vec![row("bb", 1)]).unwrap(); + assert_eq!(changed.failures, 2); + assert!(changed.report.contains("removed")); + assert!(changed.report.contains("added")); + } +} diff --git a/crates/akita-planner/src/diagnostics.rs b/crates/akita-planner/src/diagnostics.rs index 89ca8fc63..e4617ccfc 100644 --- a/crates/akita-planner/src/diagnostics.rs +++ b/crates/akita-planner/src/diagnostics.rs @@ -65,6 +65,18 @@ pub(crate) struct SetupBudgetPass { pub(crate) descriptor_builds: usize, } +/// Deterministic search-work counters for one planned schedule row. +/// +/// The counts depend only on the planner code and its inputs, never on timing +/// or worker scheduling, so a checked-in baseline can guard planner cost. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlannerCost { + pub suffix_calls: usize, + pub completed_states: usize, + pub generated_candidates: usize, + pub descriptor_builds: usize, +} + #[derive(Clone, Debug, Default)] pub(crate) struct PlannerDiagnosticsSnapshot { pub(crate) suffix_dp_time: Duration, @@ -94,6 +106,17 @@ pub(crate) struct PlannerDiagnosticsSnapshot { selected: Option, } +impl PlannerDiagnosticsSnapshot { + pub(crate) const fn planner_cost(&self) -> PlannerCost { + PlannerCost { + suffix_calls: self.suffix_calls, + completed_states: self.completed_states, + generated_candidates: self.generated_candidates, + descriptor_builds: self.descriptor_builds, + } + } +} + impl fmt::Display for PlannerDiagnosticsSnapshot { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let memo_total = self.memo_hits.saturating_add(self.memo_misses); diff --git a/crates/akita-planner/src/emit/materialize.rs b/crates/akita-planner/src/emit/materialize.rs index b84236fc0..43ebae4ca 100644 --- a/crates/akita-planner/src/emit/materialize.rs +++ b/crates/akita-planner/src/emit/materialize.rs @@ -19,6 +19,7 @@ struct IndexedPlanningRequest { pub struct MaterializedEntry { request: PlanningRequest, schedule: FoldSchedule, + planner_cost: Option, } impl MaterializedEntry { @@ -35,6 +36,13 @@ impl MaterializedEntry { &self.schedule } + /// Search work for this row, when [`MaterializationDiagnostics::planner_cost`] + /// was requested or the row reused a caller preplan. + #[must_use] + pub const fn planner_cost(&self) -> Option { + self.planner_cost + } + /// Producer declarations for the frozen groups in opening order. #[must_use] pub fn precommitted_producers(&self) -> &[PrecommittedProducer] { @@ -136,10 +144,15 @@ pub(crate) fn materialized_entries_for_specs( ); (Instant::now(), label) }); - let (outcome, planner_diagnostics) = - crate::diagnostics::capture(diagnostics.row_progress, || { - materialized_entry(spec, &indexed.request) - }); + let (mut outcome, planner_diagnostics) = crate::diagnostics::capture( + diagnostics.row_progress || diagnostics.planner_cost, + || materialized_entry(spec, &indexed.request), + ); + if let (Ok(MaterializedRequestOutcome::Planned(entry)), Some(planner_diagnostics)) = + (&mut outcome, &planner_diagnostics) + { + entry.planner_cost = Some(planner_diagnostics.planner_cost()); + } if let Some((started, label)) = progress { let counters = &counters.as_ref().expect("progress counters")[indexed.spec_index]; match &outcome { @@ -236,10 +249,10 @@ fn materialized_entry( let preplanned = spec .preplanned .iter() - .find(|(planned_key, _)| *planned_key == key); + .find(|preplanned| preplanned.key == key); let reused_preplan = preplanned.is_some(); let result = match preplanned { - Some((_, schedule)) => Ok(schedule.clone()), + Some(preplanned) => Ok(preplanned.schedule.clone()), None => crate::find_schedule( &key, spec.source_contract, @@ -252,6 +265,7 @@ fn materialized_entry( let entry = |schedule| MaterializedEntry { request: request.clone(), schedule, + planner_cost: preplanned.map(|preplanned| preplanned.planner_cost), }; match result { Ok(schedule) if reused_preplan => { @@ -378,7 +392,11 @@ mod tests { ) .unwrap(); spec.keys = vec![group]; - spec.preplanned = vec![(ScheduleLookupKey::single(group), row.schedule().clone())]; + spec.preplanned = vec![PreplannedSchedule { + key: ScheduleLookupKey::single(group), + schedule: row.schedule().clone(), + planner_cost: PlannerCost::default(), + }]; render_schedule_artifact_outputs_with_validation( std::slice::from_ref(&spec), MaterializationDiagnostics::default(), diff --git a/crates/akita-planner/src/emit/mod.rs b/crates/akita-planner/src/emit/mod.rs index b6f0b1f9b..3ae5cbb6e 100644 --- a/crates/akita-planner/src/emit/mod.rs +++ b/crates/akita-planner/src/emit/mod.rs @@ -18,6 +18,7 @@ mod materialize; mod publish; mod render; +pub use crate::diagnostics::PlannerCost; pub(super) use materialize::materialized_entries_for_specs; pub use materialize::MaterializedEntry; pub use publish::publish_artifact_outputs; @@ -27,6 +28,17 @@ pub use render::{render_schedule_artifact_outputs_with_validation, ArtifactOutpu #[derive(Clone, Copy, Debug, Default)] pub struct MaterializationDiagnostics { pub row_progress: bool, + /// Record [`PlannerCost`] counters on every planned row. + pub planner_cost: bool, +} + +/// One exact schedule planned by the caller before row materialization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreplannedSchedule { + pub key: ScheduleLookupKey, + pub schedule: FoldSchedule, + /// The search work spent when the caller planned this schedule. + pub planner_cost: PlannerCost, } /// One frozen precommit descriptor with its canonical producer facts. @@ -135,7 +147,7 @@ pub struct EmitSpec { pub keys: Vec, pub grouped_requests: Vec, /// Exact schedules already planned by the caller, audited against `policy` on emission. - pub preplanned: Vec<(ScheduleLookupKey, FoldSchedule)>, + pub preplanned: Vec, pub output_path: PathBuf, pub ring_challenge_config: fn(usize) -> Result, } diff --git a/crates/akita-planner/src/generated_families.rs b/crates/akita-planner/src/generated_families.rs index e860ce6e9..b7054bf1a 100644 --- a/crates/akita-planner/src/generated_families.rs +++ b/crates/akita-planner/src/generated_families.rs @@ -14,6 +14,7 @@ use std::any::TypeId; use std::sync::{Arc, Mutex, OnceLock}; pub use crate::emit::{GroupedGenerationRequest, PrecommittedProducer}; +use crate::emit::{PlannerCost, PreplannedSchedule}; use crate::{find_schedule, EmitSpec, PlannerPolicy}; use akita_challenges::SparseChallengeConfig; use akita_error::AkitaError; @@ -28,7 +29,7 @@ struct ScalarPreplan { source: TypeId, chunking: ChunkedWitnessCfg, key: PolynomialGroupLayout, - result: OnceLock>, + result: OnceLock>, } /// Exact scalar schedules already needed while preparing one generator run. @@ -75,16 +76,24 @@ impl GenerationPreplans { preplanned .result .get_or_init(|| { - find_schedule( - &ScheduleLookupKey::single(key), - Cfg::committed_source_contract()?, - &[], - &policy_of::(chunking), - Cfg::ring_challenge_config, - ) - .map(|planned| planned.schedule) + // Preplans run before row materialization, so they always + // record their search work for the row that reuses them. + let (planned, diagnostics) = crate::diagnostics::capture(true, || { + find_schedule( + &ScheduleLookupKey::single(key), + Cfg::committed_source_contract()?, + &[], + &policy_of::(chunking), + Cfg::ring_challenge_config, + ) + }); + let planner_cost = diagnostics + .map(|diagnostics| diagnostics.planner_cost()) + .unwrap_or_default(); + planned.map(|planned| (planned.schedule, planner_cost)) }) .clone() + .map(|(schedule, _)| schedule) } /// Return an exact scalar result previously planned for `family`. @@ -93,7 +102,7 @@ impl GenerationPreplans { family: &GeneratedFamily, key: PolynomialGroupLayout, chunking: ChunkedWitnessCfg, - ) -> Option { + ) -> Option { let source = (family.scalar_plan_source)(); self.scalar .lock() @@ -106,7 +115,11 @@ impl GenerationPreplans { }) .and_then(|preplanned| preplanned.result.get()) .and_then(|result| result.as_ref().ok()) - .cloned() + .map(|(schedule, planner_cost)| PreplannedSchedule { + key: ScheduleLookupKey::single(key), + schedule: schedule.clone(), + planner_cost: *planner_cost, + }) } /// Copy exact producer results into a completed spec before rendering. @@ -114,10 +127,7 @@ impl GenerationPreplans { spec.preplanned = spec .keys .iter() - .filter_map(|key| { - self.scalar_for_family(family, *key, spec.policy.witness_chunk) - .map(|schedule| (ScheduleLookupKey::single(*key), schedule)) - }) + .filter_map(|key| self.scalar_for_family(family, *key, spec.policy.witness_chunk)) .collect(); } } diff --git a/crates/akita-planner/src/generated_families/tests.rs b/crates/akita-planner/src/generated_families/tests.rs index 4565325a8..4c47f0a90 100644 --- a/crates/akita-planner/src/generated_families/tests.rs +++ b/crates/akita-planner/src/generated_families/tests.rs @@ -41,10 +41,10 @@ fn scalar_preplans_deduplicate_by_exact_producer_and_layout() { .expect("shipped families declare a valid producer contract"); spec.keys = vec![key]; preplans.attach_to_spec(family, &mut spec); - assert_eq!( - spec.preplanned, - vec![(ScheduleLookupKey::single(key), first)] - ); + assert_eq!(spec.preplanned.len(), 1); + assert_eq!(spec.preplanned[0].key, ScheduleLookupKey::single(key)); + assert_eq!(spec.preplanned[0].schedule, first); + assert_ne!(spec.preplanned[0].planner_cost, PlannerCost::default()); } #[test] @@ -90,13 +90,14 @@ fn generation_uses_requested_geometry_for_preplans_and_grouped_producers() { emit_spec_for_family(family, &preplans, chunking, std::path::PathBuf::new()).unwrap(); preplans.attach_to_spec(family, &mut spec); assert!(!spec.preplanned.is_empty()); - for (_, schedule) in &spec.preplanned { - assert_eq!(schedule.root.params.witness_chunk, chunking); + for preplanned in &spec.preplanned { + assert_eq!(preplanned.schedule.root.params.witness_chunk, chunking); } let producer_group = PolynomialGroupLayout::singleton(16); let planned = preplans .scalar_for_family(family, producer_group, chunking) - .unwrap(); + .unwrap() + .schedule; let producer = GroupCommitPhaseParams::try_from_params(producer_group, &planned.root.params).unwrap(); let request = spec From fb60b8a44926d4017fdf17b3547186e8ed8b7dae Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Sun, 25 Oct 2026 17:06:37 -0700 Subject: [PATCH 3/5] test(planner): check the combined candidate search under the direct rule The test that compares the combined terminal-and-fold search with the two independent searches used a recursive configuration. Under the recursive selection rule the combined search returns the independent searches as they are, so the comparison held trivially, and the two-walk search that direct configurations use had no equivalence test. The test now runs both selection rules, each with and without keeping the split frontier. Every case must match the independent searches exactly and keep an L2-routed fold, as before. --- .../schedule_params/candidate/recursive/tests.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs b/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs index 89c1f47a5..128afe839 100644 --- a/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs +++ b/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs @@ -8,17 +8,27 @@ fn combined_terminal_and_fold_views_match_independent_searches() { use akita_params::InnerCommitSecurityRoute; type Recursive = RecursiveCommitmentConfig; - let policy = policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()); + let recursive_policy = + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()); + // The recursive selection rule returns the independent searches as they + // are. Only the direct rule runs the combined search, so test both. + let direct_policy = PlannerPolicy { + selection_policy: crate::SelectionPolicyId::MinFirstDirectSetupThenExactProofAndWorkV5, + ..recursive_policy + }; let opening = PlannerOpeningCandidate::evaluation_trace( Recursive::ring_challenge_config(64).expect("challenge config"), ); let dimensions = CommitmentRingDims::uniform(64); let source = crate::InnerBasisSource::BalancedDigits { log_basis: 4 }; let source_moment = crate::response_model::SourceMomentEstimate::new(1_000_000); - for retain_split_frontier in [false, true] { + for (policy, retain_split_frontier) in [&recursive_policy, &direct_policy] + .into_iter() + .flat_map(|policy| [(policy, false), (policy, true)]) + { let request = RecursiveCandidateRequest { input_chunks: None, - policy: &policy, + policy, payload_mode: akita_params::CommitmentPayloadMode::Compressed, opening, dimensions, From af430d53a1e02e98123df84b2b13ab34ee348bb0 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Sun, 25 Oct 2026 18:21:23 -0700 Subject: [PATCH 4/5] fix(planner): raise drift timeout and narrow cost-guard claims - Raise the schedule regeneration timeout from 20 to 35 minutes. CI measured the step at about 18 minutes, and runner variance alone has moved it by about 1.3x, so 20 minutes left little headroom. - Say what the planner cost baseline actually counts: search size (suffix calls, completed states, candidates, descriptors). Slower per-candidate pricing does not move those counters; only the timeout catches it. - Correct the suffix memo comment: entries are optima under the context's setup budget, which is not in the memo key, so each budget pass needs a fresh memo. - Accumulate setup-prefix cache hits and misses across budget passes, like the other diagnostics counters, instead of keeping only the last pass. Diagnostics only; planner output is unchanged. --- .github/workflows/ci.yml | 10 ++++++---- crates/akita-planner/README.md | 16 ++++++++++------ crates/akita-planner/src/diagnostics.rs | 10 +++++++--- .../src/schedule_params/suffix_dp/search.rs | 7 +++++-- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b507e5a6..4a4e7427a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,10 +187,12 @@ jobs: scripts/check-external-schedule-artifacts.sh cargo test --release -p akita-config --test trusted_schedule_artifact - name: Regenerate schedule artifacts in temporary storage - # Planning takes about 10 minutes with two workers. The bound stops a - # runaway search early; smaller per-row cost regressions fail the - # --planner-cost-baseline check instead. - timeout-minutes: 20 + # Planning measured about 18 minutes on CI with two workers, and runner + # variance alone has moved this step by about 1.3x. The bound stops a + # runaway search. Growth in search size (states, candidates, + # descriptors) fails the --planner-cost-baseline check instead; slower + # per-candidate pricing is caught only by this timeout. + timeout-minutes: 35 env: AKITA_SCHEDULE_GEN_JOBS: 2 run: | diff --git a/crates/akita-planner/README.md b/crates/akita-planner/README.md index e8d338e66..3d2e1c974 100644 --- a/crates/akita-planner/README.md +++ b/crates/akita-planner/README.md @@ -321,12 +321,16 @@ repository permits catalog-breaking revisions, so baseline/current policy is reviewed from the checked evidence. Same-head drift remains an automatic failure under `--check-catalog`. -Planner cost is guarded the same way. `--planner-cost-snapshot ` writes -one row per regenerated row with four deterministic search-work counters: -suffix calls, completed states, generated fold candidates, and descriptor -builds. A reused preplanned row reports the work of the search that produced -it. The counts do not depend on timing or worker count. The tracked baseline is -`artifacts/schedule-planner-cost.tsv`; CI passes it through +Planner search size is guarded the same way. `--planner-cost-snapshot ` +writes one row per regenerated row with four deterministic search-work +counters: suffix calls, completed states, generated fold candidates, and +descriptor builds. A reused preplanned row reports the work of the search that +produced it. The counts do not depend on timing or worker count. They measure +how large the search is, not how expensive each candidate is to price. A change +that makes edge, terminal, or grinding pricing slower without visiting more +states or building more candidates passes this check; the regeneration step's +CI timeout is the only backstop for that kind of slowdown. The tracked +baseline is `artifacts/schedule-planner-cost.tsv`; CI passes it through `--planner-cost-baseline `, which fails when a row is added or removed or when any counter grows by more than a quarter. When a planner change intentionally moves rows or costs, regenerate the baseline with the complete diff --git a/crates/akita-planner/src/diagnostics.rs b/crates/akita-planner/src/diagnostics.rs index e4617ccfc..32c28147d 100644 --- a/crates/akita-planner/src/diagnostics.rs +++ b/crates/akita-planner/src/diagnostics.rs @@ -68,7 +68,9 @@ pub(crate) struct SetupBudgetPass { /// Deterministic search-work counters for one planned schedule row. /// /// The counts depend only on the planner code and its inputs, never on timing -/// or worker scheduling, so a checked-in baseline can guard planner cost. +/// or worker scheduling, so a checked-in baseline can guard search size. They +/// do not count per-candidate pricing work, so a slower edge, terminal, or +/// grinding pricing step leaves them unchanged. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PlannerCost { pub suffix_calls: usize, @@ -342,8 +344,10 @@ impl PlannerDiagnostics { } pub(crate) fn record_setup_prefix_cache(&self, hits: usize, misses: usize) { - self.setup_prefix_cache_hits.set(hits); - self.setup_prefix_cache_misses.set(misses); + self.setup_prefix_cache_hits + .set(self.setup_prefix_cache_hits.get().saturating_add(hits)); + self.setup_prefix_cache_misses + .set(self.setup_prefix_cache_misses.get().saturating_add(misses)); } pub(crate) fn record_guided_direct_edge_prune(&self) { diff --git a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs index ecad172d1..fbfb02623 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs @@ -367,8 +367,11 @@ pub(crate) fn derive_selected_suffix_schedule( diagnostics.record_suffix_call(relation_phase); } let memo_key = state.memo_key(policy); - // Memo entries remain budget-independent objective optima. A restricted - // caller reuses that result when it fits its exact root prefix; otherwise + // Memo entries are objective optima under the context's setup budget, + // independent of any root query-prefix restriction. The setup budget is + // not part of the memo key, so a memo must not outlive its budget; the V6 + // planner builds a fresh memo for every budget pass. A restricted caller + // reuses the cached result when it fits its exact root prefix; otherwise // it recomputes this state locally and never replaces the primary entry. if let QuerySearch::Restricted(prefix) = &query_search { let cached = memo.get(&memo_key).cloned().map_or_else( From 5c1b07db7bb8b1469af09c4ec836764294474082 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Mon, 26 Oct 2026 09:48:15 -0700 Subject: [PATCH 5/5] refactor(planner): assert widening rejection exceeds the pass budget The setup budget records only sizes above the pass limit, so the `next > limit` filter on the padded widening step could never remove a value. Replace it with a debug assertion that documents the invariant; overflow remains the only path to the final unbudgeted pass. --- crates/akita-planner/src/planner.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/akita-planner/src/planner.rs b/crates/akita-planner/src/planner.rs index ed4824151..3b405905a 100644 --- a/crates/akita-planner/src/planner.rs +++ b/crates/akita-planner/src/planner.rs @@ -909,9 +909,9 @@ pub(crate) fn find_schedule_in_relation_order( // `Some(None)` asks for a final pass without a budget. let widened = match (best, padded_setup_limit, setup_field_budget.as_ref()) { (None, Some(limit), Some(budget)) => budget.min_rejected().map(|rejected| { - rejected - .checked_next_power_of_two() - .filter(|&next| next > limit) + // This pass's budget is `limit`, so every recorded rejection exceeds it. + debug_assert!(rejected > limit); + rejected.checked_next_power_of_two() }), _ => None, };