Skip to main content

aiconfigurator_core/fpm/
model.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Forward-pass perf model: native AIC estimate with optional online correction
5//! and regression fallback, plus readiness/diagnostics.
6//!
7//! The `Native` variant holds an `Arc<Engine>` and the native estimate routes
8//! through [`crate::engine::Engine::forward_pass_time_ms`]. The online
9//! correction / regression / diagnostics / readiness logic is engine-agnostic.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::engine::Engine;
17use crate::{AicError, EngineConfig, ForwardPassMetrics};
18
19use super::correction::CorrectionBuckets;
20use super::metrics::validate_forward_pass_metrics;
21use super::options::{validate_options, ForwardPassPerfOptions};
22use super::regression::BucketedRegression;
23use super::samples::{AxisRange, StoreStats, WithOptions};
24
25/// Current readiness and tuning state for a `ForwardPassPerfModel`.
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
27pub struct ForwardPassPerfDiagnostics {
28    /// Active prediction source. Native models become `aic_with_correction`
29    /// after at least one inferred workload kind has enough correction samples.
30    pub source: ForwardPassPerfSource,
31    /// Whether the model can currently produce learned estimates for at least
32    /// one workload kind, or why it cannot.
33    pub readiness: ForwardPassPerfReadiness,
34    /// Number of retained tuning observations across all inferred workload kinds.
35    pub retained_observations: usize,
36    /// Number of populated native-correction regions whose workload kind has at least
37    /// `min_observations` total retained samples.
38    pub correction_ready_buckets: usize,
39    /// Fallback reason when `best_available` had to use regression instead of native AIC.
40    pub last_warning: Option<String>,
41}
42
43/// Prediction backend currently used by `ForwardPassPerfModel`.
44#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum ForwardPassPerfSource {
47    /// Strict native AIC estimator with no correction workload kind ready yet.
48    Aic,
49    /// Workload-specific regression fallback, used without native AIC support.
50    FallbackRegression,
51    /// Native AIC estimator with at least one learned correction workload kind.
52    AicWithCorrection,
53}
54
55/// Readiness state reported by `ForwardPassPerfDiagnostics`.
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum ForwardPassPerfReadiness {
59    /// The model has either native AIC support or enough learned data.
60    Ready,
61    /// Regression fallback exists, but does not yet have enough observations.
62    InsufficientData,
63    /// Native AIC was unavailable and `best_available` fell back to regression.
64    UnsupportedConfig,
65    /// Reserved for callers that surface rejected FPM input as diagnostics.
66    InvalidInput,
67}
68
69/// Forward-pass-level performance model with optional online tuning.
70///
71/// This API intentionally stays at AIC's forward-pass abstraction. It does not
72/// model TTFT, ITL, SLA, engine capacity, queueing policy, or Dynamo engine
73/// limits. Callers pass FPMs for one engine iteration and receive one
74/// forward-pass latency estimate in milliseconds.
75///
76/// The prefill/decode/mixed workload kind is inferred from each iteration's
77/// `scheduled_requests` fields; it is not chosen at construction:
78///
79/// - prefill: scheduled prefill tokens and no scheduled decode work, using
80///   `[sum_prefill_tokens]`
81/// - decode: scheduled decode work and no scheduled prefill tokens, using
82///   `[num_decode_requests, sum_decode_kv_tokens]`
83/// - mixed/agg: both scheduled prefill and decode work, using
84///   `[sum_prefill_tokens, sum_decode_kv_tokens]`
85/// - empty: no scheduled prefill or decode work, estimates `0.0` and is not
86///   used for tuning
87///
88/// Native correction grids use fixed constructor-time ranges from
89/// `ForwardPassPerfOptions`: `max_num_tokens` bounds `sum_prefill_tokens`,
90/// `max_batch_size` bounds `num_decode_requests`, and `max_kv_tokens` bounds
91/// `sum_decode_kv_tokens`. `min_faster_correction_factor` and
92/// `max_slower_correction_factor` place independent absolute bounds on learned
93/// native correction factors in each direction, defaulting to `0.5` and `2.0`.
94/// Callers may explicitly disable either bound.
95///
96/// Queued request fields are accepted for FPM schema parity but ignored by this
97/// forward-pass-level model. `estimate_forward_pass_time_ms` treats FPM as a
98/// workload descriptor: it uses scheduled workload fields and ignores
99/// `wall_time`. `tune_with_fpms` treats FPM as observed telemetry: it uses the
100/// same scheduled workload fields as features and uses positive `wall_time` as
101/// the observation target. For attention-DP configurations, the input for one
102/// iteration is one FPM per attention-DP rank; tuning merges that list into one
103/// observation by taking max-rank load features and max nonzero `wall_time`.
104#[derive(Clone, Debug)]
105pub struct ForwardPassPerfModel {
106    mode: ForwardPassPerfMode,
107    options: ForwardPassPerfOptions,
108    last_warning: Option<String>,
109}
110
111#[derive(Clone, Debug)]
112enum ForwardPassPerfMode {
113    Native {
114        /// Compiled engine. `Arc` so `ForwardPassPerfModel` stays `Clone`
115        /// (the `Engine` itself is not `Clone`); cheap clones share the loaded
116        /// op lists + perf-database tree.
117        engine: Arc<Engine>,
118        corrections: WorkloadStores<CorrectionBuckets>,
119    },
120    Regression {
121        regressions: WorkloadStores<BucketedRegression>,
122    },
123}
124
125impl ForwardPassPerfModel {
126    /// API:
127    /// `ForwardPassPerfModel::from_native(config, options) -> Result<Self, AicError>`
128    ///
129    /// Description: create a strict native AIC forward-pass model.
130    ///
131    /// Compiles `config` into an [`Engine`] by crossing into Python once
132    /// (mirroring [`crate::AicEngineBuilder`]): `compile_engine` walks the model
133    /// and returns bincoded spec bytes, then [`Engine::from_spec_bytes`] loads
134    /// the matching perf database. This constructor fails if `config` cannot be
135    /// compiled. Use `best_available` when unsupported native configs should
136    /// fall back to the learned regression model.
137    pub fn from_native(
138        config: EngineConfig,
139        options: ForwardPassPerfOptions,
140    ) -> Result<Self, AicError> {
141        validate_options(&options)?;
142        let engine = build_engine_via_python(&config, None)?;
143        Ok(Self::from_engine(Arc::new(engine), options))
144    }
145
146    /// API:
147    /// `ForwardPassPerfModel::from_native_with_roots(config, options, systems_root) -> Result<Self, AicError>`
148    ///
149    /// Description: create a strict native AIC forward-pass model with an
150    /// explicit `systems/` data root (forwarded to `compile_engine` and used to
151    /// load the perf database). Same tuning and failure behavior as
152    /// `from_native`.
153    pub fn from_native_with_roots(
154        config: EngineConfig,
155        options: ForwardPassPerfOptions,
156        systems_root: impl AsRef<Path>,
157    ) -> Result<Self, AicError> {
158        validate_options(&options)?;
159        let engine = build_engine_via_python(&config, Some(systems_root.as_ref()))?;
160        Ok(Self::from_engine(Arc::new(engine), options))
161    }
162
163    /// Internal: build a native model directly from an already-compiled
164    /// [`Engine`]. Holds the actual native-mode logic; the public `from_native`
165    /// constructors compile the `Engine` (crossing into Python) and call this.
166    /// Used by the `#[cfg(test)]` suite to construct a native model from a
167    /// hand-built fixture `Engine` without Python.
168    pub(crate) fn from_engine(engine: Arc<Engine>, options: ForwardPassPerfOptions) -> Self {
169        Self {
170            mode: ForwardPassPerfMode::Native {
171                engine,
172                corrections: WorkloadStores::with_options(&options),
173            },
174            options,
175            last_warning: None,
176        }
177    }
178
179    /// API:
180    /// `ForwardPassPerfModel::from_regression(options) -> Result<Self, AicError>`
181    ///
182    /// Description: create a regression-only forward-pass model.
183    ///
184    /// This mode is for native-AIC-unsupported models. It returns `None` from
185    /// `estimate_forward_pass_time_ms` for non-empty iterations until the
186    /// inferred workload kind has at least `options.min_observations` tuning samples.
187    /// Correction factor getters always return `None` in this mode.
188    pub fn from_regression(options: ForwardPassPerfOptions) -> Result<Self, AicError> {
189        validate_options(&options)?;
190        Ok(Self {
191            mode: ForwardPassPerfMode::Regression {
192                regressions: WorkloadStores::with_options(&options),
193            },
194            options,
195            last_warning: None,
196        })
197    }
198
199    /// API:
200    /// `ForwardPassPerfModel::best_available(config, options) -> Result<Self, AicError>`
201    ///
202    /// Description: create a native model when possible, otherwise fall back to
203    /// regression.
204    ///
205    /// Fallback reason is preserved in `diagnostics().last_warning`. The
206    /// resulting model still uses the same FPM workload-kind inference and
207    /// tuning input contract as `from_native` and `from_regression`.
208    pub fn best_available(
209        config: EngineConfig,
210        options: ForwardPassPerfOptions,
211    ) -> Result<Self, AicError> {
212        match Self::from_native(config, options.clone()) {
213            Ok(model) => Ok(model),
214            Err(err) if can_fallback_to_regression(&err) => {
215                Self::regression_with_warning(options, err)
216            }
217            Err(err) => Err(err),
218        }
219    }
220
221    /// API:
222    /// `ForwardPassPerfModel::best_available_with_roots(config, options, systems_root) -> Result<Self, AicError>`
223    ///
224    /// Description: create a `best_available` model with an explicit `systems/`
225    /// data root.
226    pub fn best_available_with_roots(
227        config: EngineConfig,
228        options: ForwardPassPerfOptions,
229        systems_root: impl AsRef<Path>,
230    ) -> Result<Self, AicError> {
231        match Self::from_native_with_roots(config, options.clone(), systems_root) {
232            Ok(model) => Ok(model),
233            Err(err) if can_fallback_to_regression(&err) => {
234                Self::regression_with_warning(options, err)
235            }
236            Err(err) => Err(err),
237        }
238    }
239
240    fn regression_with_warning(
241        options: ForwardPassPerfOptions,
242        err: AicError,
243    ) -> Result<Self, AicError> {
244        let mut model = Self::from_regression(options)?;
245        model.last_warning = Some(format!(
246            "native forward-pass estimator unavailable; using fallback regression: {err}"
247        ));
248        Ok(model)
249    }
250
251    /// API:
252    /// `model.estimate_forward_pass_time_ms(metrics_by_rank) -> Result<Option<f64>, AicError>`
253    ///
254    /// Description: estimate one forward-pass iteration in milliseconds.
255    ///
256    /// `metrics_by_rank` must contain the FPMs for a single engine iteration,
257    /// one entry per attention-DP rank. Single-rank callers pass a one-element
258    /// slice. The inferred workload kind uses only `scheduled_requests` as described on
259    /// `ForwardPassPerfModel`; queued fields and `wall_time` are ignored for
260    /// estimation.
261    ///
262    /// Native models return an AIC estimate immediately, multiplied by the
263    /// correction factor for the matching workload region. Correction factors
264    /// default to `1.0` for inferred workload kinds with fewer than
265    /// `min_observations` total samples, empty regions, and queries outside the
266    /// configured correction-grid workload ranges in
267    /// `ForwardPassPerfOptions`. Regression models return `Ok(None)` until the
268    /// matching inferred workload kind has enough tuning samples. Empty
269    /// scheduled work returns `Ok(Some(0.0))`.
270    ///
271    /// Pure Rust over the `Engine` — no Python re-entry.
272    pub fn estimate_forward_pass_time_ms(
273        &self,
274        metrics_by_rank: &[ForwardPassMetrics],
275    ) -> Result<Option<f64>, AicError> {
276        let feature = IterationFeatures::from_metrics(metrics_by_rank)?;
277        let Some(feature) = feature else {
278            return Ok(Some(0.0));
279        };
280
281        match &self.mode {
282            ForwardPassPerfMode::Native {
283                engine,
284                corrections,
285            } => {
286                let native = engine.forward_pass_time_ms(metrics_by_rank)?;
287                let corrected = native
288                    * corrections
289                        .store(feature.workload_kind)
290                        .correction_factor_for(&feature.x);
291                Ok(Some(corrected))
292            }
293            ForwardPassPerfMode::Regression { regressions } => {
294                Ok(regressions.store(feature.workload_kind).predict(&feature.x))
295            }
296        }
297    }
298
299    /// API:
300    /// `model.tune_with_fpms(iterations) -> Result<(), AicError>`
301    ///
302    /// Description: tune the model from observed FPM iterations.
303    ///
304    /// The outer slice is a list of observed iterations. Each inner slice is
305    /// the per-attention-DP-rank FPM list for one iteration:
306    /// `[[iter0_rank0, iter0_rank1], [iter1_rank0, iter1_rank1]]`.
307    /// Single-rank callers still use one FPM per inner slice.
308    ///
309    /// For each non-empty iteration, this method infers the workload kind from
310    /// scheduled request fields, takes max-rank load features, and uses the max
311    /// finite positive `wall_time` across ranks as the observed latency target
312    /// in milliseconds. Iterations with no scheduled work or no positive
313    /// `wall_time` are ignored. Native models update the matching region's
314    /// median `observed_ms / native_ms` correction factor, with each ratio
315    /// bounded by `min_faster_correction_factor` and
316    /// `max_slower_correction_factor` when configured. Regions are used only
317    /// after their inferred workload kind has `min_observations` total samples;
318    /// empty regions keep the default factor `1.0`. Observations outside the
319    /// configured correction-grid workload ranges are ignored by native
320    /// correction models. Regression models learn a workload-specific linear
321    /// fit.
322    ///
323    /// Pure Rust over the `Engine` — no Python re-entry.
324    pub fn tune_with_fpms(
325        &mut self,
326        iterations: &[Vec<ForwardPassMetrics>],
327    ) -> Result<(), AicError> {
328        for metrics_by_rank in iterations {
329            let observation = IterationObservation::from_metrics(metrics_by_rank)?;
330            let Some(observation) = observation else {
331                continue;
332            };
333
334            match &mut self.mode {
335                ForwardPassPerfMode::Native {
336                    engine,
337                    corrections,
338                } => {
339                    let native = engine.forward_pass_time_ms(metrics_by_rank)?;
340                    corrections
341                        .store_mut(observation.feature.workload_kind)
342                        .add_observation(observation.feature.x, observation.wall_time_ms, native);
343                }
344                ForwardPassPerfMode::Regression { regressions } => {
345                    regressions
346                        .store_mut(observation.feature.workload_kind)
347                        .add_observation(observation.feature.x, observation.wall_time_ms);
348                }
349            }
350        }
351        Ok(())
352    }
353
354    /// API:
355    /// `model.diagnostics() -> ForwardPassPerfDiagnostics`
356    ///
357    /// Description: return the current backend, readiness, retained sample
358    /// count, and fallback warning.
359    pub fn diagnostics(&self) -> ForwardPassPerfDiagnostics {
360        match &self.mode {
361            ForwardPassPerfMode::Native { corrections, .. } => {
362                let ready_buckets = corrections.ready_bucket_count();
363                ForwardPassPerfDiagnostics {
364                    source: if ready_buckets > 0 {
365                        ForwardPassPerfSource::AicWithCorrection
366                    } else {
367                        ForwardPassPerfSource::Aic
368                    },
369                    readiness: ForwardPassPerfReadiness::Ready,
370                    retained_observations: corrections.observation_count(),
371                    correction_ready_buckets: ready_buckets,
372                    last_warning: self.last_warning.clone(),
373                }
374            }
375            ForwardPassPerfMode::Regression { regressions } => {
376                let ready = regressions.any_ready();
377                ForwardPassPerfDiagnostics {
378                    source: ForwardPassPerfSource::FallbackRegression,
379                    readiness: if ready {
380                        ForwardPassPerfReadiness::Ready
381                    } else if self.last_warning.is_some() {
382                        ForwardPassPerfReadiness::UnsupportedConfig
383                    } else {
384                        ForwardPassPerfReadiness::InsufficientData
385                    },
386                    retained_observations: regressions.observation_count(),
387                    correction_ready_buckets: 0,
388                    last_warning: self.last_warning.clone(),
389                }
390            }
391        }
392    }
393
394    /// API:
395    /// `model.min_correction_factor() -> Option<f64>`
396    ///
397    /// Description: return the smallest ready native correction factor across
398    /// all workload kinds.
399    ///
400    /// Returns `None` before any native correction workload kind has enough samples.
401    /// Regression-only models also return `None`.
402    pub fn min_correction_factor(&self) -> Option<f64> {
403        self.correction_factors()
404            .into_iter()
405            .reduce(|a, b| a.min(b))
406    }
407
408    /// API:
409    /// `model.max_correction_factor() -> Option<f64>`
410    ///
411    /// Description: return the largest ready native correction factor across
412    /// all workload kinds.
413    ///
414    /// Returns `None` before any native correction workload kind has enough samples.
415    /// Regression-only models also return `None`.
416    pub fn max_correction_factor(&self) -> Option<f64> {
417        self.correction_factors()
418            .into_iter()
419            .reduce(|a, b| a.max(b))
420    }
421
422    /// API:
423    /// `model.avg_correction_factor() -> Option<f64>`
424    ///
425    /// Description: return the arithmetic mean of ready native correction
426    /// factors across all workload kinds.
427    ///
428    /// Returns `None` before any native correction workload kind has enough samples.
429    /// Regression-only models also return `None`.
430    pub fn avg_correction_factor(&self) -> Option<f64> {
431        let factors = self.correction_factors();
432        if factors.is_empty() {
433            None
434        } else {
435            Some(factors.iter().sum::<f64>() / factors.len() as f64)
436        }
437    }
438
439    /// API:
440    /// `model.options() -> &ForwardPassPerfOptions`
441    ///
442    /// Description: return the immutable tuning options used by this model.
443    pub fn options(&self) -> &ForwardPassPerfOptions {
444        &self.options
445    }
446
447    fn correction_factors(&self) -> Vec<f64> {
448        match &self.mode {
449            ForwardPassPerfMode::Native { corrections, .. } => corrections.correction_factors(),
450            ForwardPassPerfMode::Regression { .. } => Vec::new(),
451        }
452    }
453}
454
455/// Build a compiled [`Engine`] from an [`EngineConfig`] by crossing into Python
456/// once to run `aiconfigurator.sdk.engine.compile_engine`, then loading the
457/// matching perf database via [`Engine::from_spec_bytes`]. This is the internal
458/// `EngineConfig` counterpart to [`crate::AicEngineBuilder`] and maps its
459/// modular fields onto the flat `compile_engine` kwargs.
460///
461/// `systems_root` overrides the bundled `systems/` dir for BOTH the
462/// `compile_engine` call (`systems_path` kwarg) and the Rust-side perf-DB load.
463fn build_engine_via_python(
464    config: &EngineConfig,
465    systems_root: Option<&Path>,
466) -> Result<Engine, AicError> {
467    // `compile_engine`'s `systems_path` kwarg: explicit override -> config's
468    // own `systems_path` -> None (Python resolves it).
469    let systems_path: Option<PathBuf> = systems_root
470        .map(PathBuf::from)
471        .or_else(|| config.systems_path.clone());
472    // A non-UTF-8 override path cannot be passed through the Python kwarg; fail
473    // loudly rather than silently dropping the override.
474    let systems_path_str = match systems_path.as_ref() {
475        Some(p) => Some(p.to_str().ok_or_else(|| {
476            AicError::InvalidEngineConfig(format!(
477                "systems_path is not valid UTF-8: {}",
478                p.display()
479            ))
480        })?),
481        None => None,
482    };
483
484    crate::py::compile_engine_to_engine(config, systems_path_str)
485}
486
487#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
488pub(crate) enum WorkloadKind {
489    Prefill,
490    Decode,
491    Mixed,
492}
493
494#[derive(Clone, Debug)]
495pub(crate) struct IterationFeatures {
496    pub(crate) workload_kind: WorkloadKind,
497    pub(crate) x: Vec<f64>,
498}
499
500impl IterationFeatures {
501    pub(crate) fn from_metrics(
502        metrics_by_rank: &[ForwardPassMetrics],
503    ) -> Result<Option<Self>, AicError> {
504        if metrics_by_rank.is_empty() {
505            return Err(AicError::InvalidForwardPassMetrics(
506                "at least one attention-DP rank metric is required".to_string(),
507            ));
508        }
509        for metrics in metrics_by_rank {
510            validate_forward_pass_metrics(metrics)?;
511        }
512
513        Ok(metrics_by_rank
514            .iter()
515            .filter_map(Self::from_single_rank)
516            .max_by(|left, right| {
517                left.load_score()
518                    .partial_cmp(&right.load_score())
519                    .unwrap_or(std::cmp::Ordering::Equal)
520            }))
521    }
522
523    fn from_single_rank(metrics: &ForwardPassMetrics) -> Option<Self> {
524        let scheduled = &metrics.scheduled_requests;
525        let has_prefill = scheduled.sum_prefill_tokens > 0;
526        let has_decode = scheduled.num_decode_requests > 0 || scheduled.sum_decode_kv_tokens > 0;
527        let feature = match (has_prefill, has_decode) {
528            (false, false) => return None,
529            (true, false) => Self {
530                workload_kind: WorkloadKind::Prefill,
531                x: vec![f64::from(scheduled.sum_prefill_tokens)],
532            },
533            (false, true) => Self {
534                workload_kind: WorkloadKind::Decode,
535                x: vec![
536                    f64::from(scheduled.num_decode_requests),
537                    f64::from(scheduled.sum_decode_kv_tokens),
538                ],
539            },
540            (true, true) => Self {
541                workload_kind: WorkloadKind::Mixed,
542                x: vec![
543                    f64::from(scheduled.sum_prefill_tokens),
544                    f64::from(scheduled.sum_decode_kv_tokens),
545                ],
546            },
547        };
548        Some(feature)
549    }
550
551    fn load_score(&self) -> f64 {
552        self.x.iter().sum()
553    }
554}
555
556#[derive(Clone, Debug)]
557pub(crate) struct IterationObservation {
558    pub(crate) feature: IterationFeatures,
559    pub(crate) wall_time_ms: f64,
560}
561
562impl IterationObservation {
563    pub(crate) fn from_metrics(
564        metrics_by_rank: &[ForwardPassMetrics],
565    ) -> Result<Option<Self>, AicError> {
566        let Some(feature) = IterationFeatures::from_metrics(metrics_by_rank)? else {
567            return Ok(None);
568        };
569        let wall_time = metrics_by_rank
570            .iter()
571            .map(|metrics| metrics.wall_time)
572            .filter(|wall_time| wall_time.is_finite() && *wall_time > 0.0)
573            .fold(0.0_f64, f64::max);
574        if wall_time <= 0.0 {
575            return Ok(None);
576        }
577        Ok(Some(Self {
578            feature,
579            wall_time_ms: wall_time * 1000.0,
580        }))
581    }
582}
583
584#[derive(Clone, Debug)]
585pub(crate) struct WorkloadStores<T> {
586    prefill: T,
587    decode: T,
588    mixed: T,
589}
590
591impl<T: WithOptions> WorkloadStores<T> {
592    fn with_options(options: &ForwardPassPerfOptions) -> Self {
593        Self {
594            prefill: T::with_options(options, &[AxisRange::from_zero_to(options.max_num_tokens)]),
595            decode: T::with_options(
596                options,
597                &[
598                    AxisRange::from_zero_to(options.max_batch_size),
599                    AxisRange::from_zero_to(options.max_kv_tokens),
600                ],
601            ),
602            mixed: T::with_options(
603                options,
604                &[
605                    AxisRange::from_zero_to(options.max_num_tokens),
606                    AxisRange::from_zero_to(options.max_kv_tokens),
607                ],
608            ),
609        }
610    }
611}
612
613impl<T: StoreStats> WorkloadStores<T> {
614    fn observation_count(&self) -> usize {
615        self.prefill.observation_count()
616            + self.decode.observation_count()
617            + self.mixed.observation_count()
618    }
619
620    fn any_ready(&self) -> bool {
621        self.prefill.is_ready() || self.decode.is_ready() || self.mixed.is_ready()
622    }
623}
624
625impl WorkloadStores<CorrectionBuckets> {
626    fn ready_bucket_count(&self) -> usize {
627        self.prefill.ready_bucket_count()
628            + self.decode.ready_bucket_count()
629            + self.mixed.ready_bucket_count()
630    }
631
632    fn correction_factors(&self) -> Vec<f64> {
633        let mut factors = self.prefill.correction_factors();
634        factors.extend(self.decode.correction_factors());
635        factors.extend(self.mixed.correction_factors());
636        factors
637    }
638}
639
640impl<T> WorkloadStores<T> {
641    fn store(&self, workload_kind: WorkloadKind) -> &T {
642        match workload_kind {
643            WorkloadKind::Prefill => &self.prefill,
644            WorkloadKind::Decode => &self.decode,
645            WorkloadKind::Mixed => &self.mixed,
646        }
647    }
648
649    fn store_mut(&mut self, workload_kind: WorkloadKind) -> &mut T {
650        match workload_kind {
651            WorkloadKind::Prefill => &mut self.prefill,
652            WorkloadKind::Decode => &mut self.decode,
653            WorkloadKind::Mixed => &mut self.mixed,
654        }
655    }
656}
657
658/// Decide whether `best_available` should fall back to regression instead of
659/// propagating `err`. Covers the unsupported-model / data-availability errors
660/// that mean "this model can't be served natively". A failed native build via
661/// Python `compile_engine` surfaces as [`AicError::UnsupportedModel`] (see
662/// `py::compile_engine_from_flat`), which is covered here.
663///
664/// [`AicError::InvalidEngineConfig`] is deliberately NOT fallback-safe: it is
665/// used for hard caller/config errors (e.g. a non-UTF-8 `systems_path`, invalid
666/// FPM options, a malformed spec). Those must surface rather than silently
667/// degrade `best_available` to regression mode.
668fn can_fallback_to_regression(err: &AicError) -> bool {
669    matches!(
670        err,
671        AicError::UnsupportedModel(_)
672            | AicError::DataRoot(_)
673            | AicError::ModelConfig(_)
674            | AicError::PerfDatabase(_)
675            | AicError::Io { .. }
676            | AicError::Parquet { .. }
677    )
678}