Skip to main content

aiconfigurator_core/engine/
runtime.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `Engine`: the compiled-spec execution core.
5//!
6//! Mirrors `aiconfigurator.sdk.backends.base_backend`'s static orchestration
7//! (`run_static` / `run_static_latency_only` / `_run_static_breakdown` /
8//! `_run_context_phase` / `_run_generation_phase`) but executes a precompiled
9//! [`EngineSpec`] — Python no longer walks the op list per call. The per-phase
10//! op iteration is the shared logic in [`crate::session`]
11//! ([`run_context_ops`] / [`run_generation_ops_step`]); the `Engine` wraps the
12//! stride quadrature and the `(nextn + 1)` decode-batch multiplier around it.
13//!
14//! The `Engine` is pure-Rust internals; its PyO3 bindings (`run_static`,
15//! `predict_*_latency`, `mixed_step_latency`, `decode_step_latency`) and the
16//! embedded [`crate::AicEngineBuilder`] live in [`crate::py`]. The agg sweep is
17//! orchestrated in Python — there is no Rust `run_agg`.
18
19use std::sync::Arc;
20
21use crate::common::enums::{DatabaseMode, TransferPolicy};
22use crate::common::error::AicError;
23use crate::engine::spec::EngineSpec;
24use crate::operators::base::PerformanceResult;
25use crate::operators::{FpmForwardOp, FpmPhase, Op};
26use crate::perf_database::PerfDatabase;
27use crate::session::{
28    get_mix_step_ops, query_context_op, query_generation_op, run_context_ops, run_context_ops_with,
29    run_generation_ops_step, run_generation_ops_step_beamed_with, ContextOpFilter,
30};
31use crate::{validate_forward_pass_metrics, ForwardPassMetrics};
32
33/// Per-call runtime inputs. Field-for-field mirror of the Python
34/// `sdk/config.RuntimeConfig`.
35///
36/// The imbalance-correction scales thread into the per-op queries exactly
37/// where Python applies them (`base_backend.py:331,372`): context-attention
38/// ops multiply by `seq_imbalance_correction_scale`, generation-attention ops
39/// by `gen_seq_imbalance_correction_scale`. (The FPM telemetry path has no
40/// scale concept and keeps 1.0.)
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct RuntimeConfig {
43    pub batch_size: u32,
44    /// Beam width. The generation phase queries token-major ops at
45    /// `x = batch_size * beam_width` (Python `_run_generation_phase`);
46    /// attention ops key on the raw decode batch.
47    pub beam_width: u32,
48    pub isl: u32,
49    pub osl: u32,
50    /// Cached tokens already in the KV cache (context phase only).
51    pub prefix: u32,
52    /// Context-attention sequence-imbalance correction (default 1.0).
53    pub seq_imbalance_correction_scale: f64,
54    /// Generation-attention sequence-imbalance correction (default 1.0).
55    pub gen_seq_imbalance_correction_scale: f64,
56}
57
58impl Default for RuntimeConfig {
59    fn default() -> Self {
60        Self {
61            batch_size: 1,
62            beam_width: 1,
63            isl: 1,
64            osl: 1,
65            prefix: 0,
66            seq_imbalance_correction_scale: 1.0,
67            gen_seq_imbalance_correction_scale: 1.0,
68        }
69    }
70}
71
72/// Static-inference mode. Mirrors Python's `mode` string in
73/// `_run_static_breakdown`: `"static_ctx"` / `"static_gen"` / `"static"`.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum StaticMode {
76    /// Python `mode="static_ctx"`: context (prefill) phase only.
77    Context,
78    /// Python `mode="static_gen"`: generation (decode) phase only.
79    Generation,
80    /// Python `mode="static"`: both phases.
81    Both,
82}
83
84/// Result of [`Engine::run_static`]. Mirrors the latency portion of Python's
85/// `run_static_latency_only` (`base_backend.py:322`): per-phase latency plus
86/// the total. The latencies are **pre-`latency_correction_scale`** — that param
87/// is intentionally dropped from the `run_static(runtime, mode, stride)`
88/// signature; it is a flat post-multiply the Python bridge applies downstream.
89#[derive(Clone, Debug, PartialEq)]
90pub struct StaticResult {
91    /// Context-phase latency in ms (0.0 for `StaticMode::Generation`).
92    pub context_ms: f64,
93    /// Generation-phase latency in ms (0.0 for `StaticMode::Context`).
94    pub generation_ms: f64,
95    /// `context_ms + generation_ms`. Equals Python `run_static_latency_only`.
96    pub total_ms: f64,
97}
98
99/// Default decode-quadrature stride. Mirrors Python's `stride=32` default in
100/// `run_static` / `_run_generation_phase` (the `DEFAULT_STATIC_STRIDE`).
101pub const DEFAULT_STATIC_STRIDE: u32 = 32;
102
103/// Executed MoE communication fallback as it crosses the private FFI:
104/// `(inference_phase, comm_backend, requested_ep, requested_nodes,
105/// measurement_ep, measurement_nodes)`.
106pub(crate) type MoeCommFallbackValue = (&'static str, &'static str, u32, u32, u32, u32);
107
108/// Inline-first fallback metadata for one name-folded op. The first record
109/// lives inline; `additional` allocates only when a second distinct record is
110/// inserted.
111pub(crate) type MoeCommFallbackValues = (MoeCommFallbackValue, Vec<MoeCommFallbackValue>);
112
113/// One evaluated op as it crosses the FFI: `(name, latency_ms, energy_wms,
114/// source)`. Entries are NAME-FOLDED before crossing — repeated names
115/// accumulate with `+=` and sources merge to `"mixed"` on mismatch, the
116/// exact accumulation semantics of Python's phase dicts (addition is
117/// commutative, so folding here instead of in Python changes nothing) —
118/// because streaming the raw ops × stride-steps tuples through pyo3
119/// measurably slowed the engine step on per-block puzzle nets (hundreds of
120/// String allocations + Python tuple constructions per call). `source` is
121/// the provenance tag (`silicon|empirical|sol|estimated|mixed`).
122pub type PerOpValue = (String, f64, f64, &'static str);
123
124/// Internal per-op value used by the provenance-aware engine walk. The fifth
125/// field is `None` or `(first_record, additional_records)` in deterministic
126/// encounter order; public Rust and Python methods strip it and retain their
127/// documented four-tuple contract.
128pub(crate) type PerOpValueWithMetadata = (
129    String,
130    f64,
131    f64,
132    &'static str,
133    Option<MoeCommFallbackValues>,
134);
135
136/// Per-op values for the shared, context-attention, and decode-attention
137/// buckets returned by the metadata-bearing mixed-step evaluation.
138pub(crate) type MixedStepPerOpValuesWithMetadata = (
139    Vec<PerOpValueWithMetadata>,
140    Vec<PerOpValueWithMetadata>,
141    Vec<PerOpValueWithMetadata>,
142);
143
144/// One SOL-decomposed per-op value: `(name, sol_time_ms, sol_math_ms,
145/// sol_mem_ms)`, mirroring Python's SOL_FULL triple `(sol_time, sol_math,
146/// sol_mem)` per query. `sol_time` is the op's SOL-mode latency (scale
147/// factors and correction scales applied — for a single leaf query it is
148/// exactly the Python triple's `sol_time = max(sol_math, sol_mem)`);
149/// `sol_math`/`sol_mem` are the compute-/memory-bound components composed
150/// the same way. NAME-FOLDED like [`PerOpValue`] (`+=` on all three).
151pub type PerOpSolValue = (String, f64, f64, f64);
152
153/// Name-folding accumulator for [`PerOpValue`] streams. First-encounter
154/// order is preserved (mirrors Python dict insertion order). Linear scan on
155/// purpose: unique-name counts are a few dozen (per-block families repeat
156/// names), far below where a map would win.
157struct PerOpFold {
158    inference_phase: &'static str,
159    entries: Vec<PerOpValueWithMetadata>,
160}
161
162fn insert_per_op_fallback(
163    fallbacks: &mut Option<MoeCommFallbackValues>,
164    fallback: MoeCommFallbackValue,
165) {
166    match fallbacks {
167        None => *fallbacks = Some((fallback, Vec::new())),
168        Some((first, additional)) if *first == fallback || additional.contains(&fallback) => {}
169        Some((_first, additional)) => additional.push(fallback),
170    }
171}
172
173fn extend_per_op_fallbacks(
174    fallbacks: &mut Option<MoeCommFallbackValues>,
175    other: Option<MoeCommFallbackValues>,
176) {
177    let Some((first, additional)) = other else {
178        return;
179    };
180    insert_per_op_fallback(fallbacks, first);
181    for fallback in additional {
182        insert_per_op_fallback(fallbacks, fallback);
183    }
184}
185
186impl PerOpFold {
187    fn new(inference_phase: &'static str) -> Self {
188        Self {
189            inference_phase,
190            entries: Vec::new(),
191        }
192    }
193
194    fn add(&mut self, op: &Op, r: PerformanceResult) {
195        let name = op.name();
196        let source = r.source.as_str();
197        let mut fallbacks = None;
198        for fallback in r.moe_comm_fallbacks.iter() {
199            insert_per_op_fallback(
200                &mut fallbacks,
201                (
202                    self.inference_phase,
203                    fallback.comm_backend,
204                    fallback.requested_ep_size,
205                    fallback.requested_node_num,
206                    fallback.measurement_ep_size,
207                    fallback.measurement_node_num,
208                ),
209            );
210        }
211        if let Some(entry) = self.entries.iter_mut().find(|e| e.0 == name) {
212            entry.1 += r.latency_ms;
213            entry.2 += r.energy_wms;
214            if entry.3 != source {
215                entry.3 = "mixed";
216            }
217            extend_per_op_fallbacks(&mut entry.4, fallbacks);
218            return;
219        }
220        self.entries.push((
221            name.to_string(),
222            r.latency_ms,
223            r.energy_wms,
224            source,
225            fallbacks,
226        ));
227    }
228
229    fn into_values(self) -> Vec<PerOpValueWithMetadata> {
230        self.entries
231    }
232}
233
234fn strip_per_op_metadata(entries: Vec<PerOpValueWithMetadata>) -> Vec<PerOpValue> {
235    entries
236        .into_iter()
237        .map(|(name, latency_ms, energy_wms, source, _fallbacks)| {
238            (name, latency_ms, energy_wms, source)
239        })
240        .collect()
241}
242
243/// Name-folding accumulator for [`PerOpSolValue`] streams (fold semantics of
244/// [`PerOpFold`]: first-encounter order, `+=` accumulation, linear scan).
245#[derive(Default)]
246struct PerOpSolFold {
247    entries: Vec<PerOpSolValue>,
248}
249
250impl PerOpSolFold {
251    fn add(&mut self, op: &Op, r: PerformanceResult) -> Result<(), AicError> {
252        let (sol_math, sol_mem) = match r.sol {
253            Some(c) => (c.math_ms, c.mem_ms),
254            // No-op short-circuits (tp_size=1 allreduce, pp_size=1 P2P)
255            // return plain zero results without a decomposition: a zero
256            // contribution is exact, not a coverage gap.
257            None if r.latency_ms == 0.0 && r.energy_wms == 0.0 => (0.0, 0.0),
258            None => {
259                return Err(AicError::SolNotImplemented(format!(
260                    "evaluate_ops_sol_json: op '{}' has no SOL decomposition \
261                     (family not exported yet — see PerformanceResult::sol)",
262                    op.name()
263                )))
264            }
265        };
266        if let Some(entry) = self.entries.iter_mut().find(|e| e.0 == op.name()) {
267            entry.1 += r.latency_ms;
268            entry.2 += sol_math;
269            entry.3 += sol_mem;
270            return Ok(());
271        }
272        self.entries
273            .push((op.name().to_string(), r.latency_ms, sol_math, sol_mem));
274        Ok(())
275    }
276
277    fn into_values(self) -> Vec<PerOpSolValue> {
278        self.entries
279    }
280}
281
282/// Which of the three mixed-step passes produced a sinked per-op value.
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284enum MixedPass {
285    SharedNonAttention,
286    ContextAttention,
287    DecodeAttention,
288}
289
290/// Compiled engine: precompiled op lists + the matching perf database.
291///
292/// Built from an [`EngineSpec`] (Python's `compile_engine` output) plus a
293/// loaded [`PerfDatabase`]. Holds only the scalars the static composition
294/// reads: the two op lists and `nextn` (the MTP decode-batch multiplier).
295/// Parallelism / quant scalars do not enter the latency sum — they drive
296/// throughput and memory, which `StaticResult` omits — so they are not stored.
297pub struct Engine {
298    /// Context-phase ops in execution order (from `spec.context_ops`).
299    context_ops: Vec<Op>,
300    /// Generation-phase ops in execution order (from `spec.generation_ops`).
301    generation_ops: Vec<Op>,
302    /// Loaded perf database. `Arc` so the `AicEngine` can share it with the
303    /// capacity API; free fns take `&PerfDatabase`, so deref works either way.
304    db: Arc<PerfDatabase>,
305    /// MTP speculative-decoding depth. The decode batch is scaled by
306    /// `(nextn + 1)` exactly as Python `_run_generation_phase:200`
307    /// (`batch_size = batch_size * (model._nextn + 1)`). 0 disables scaling.
308    nextn: u32,
309}
310
311impl std::fmt::Debug for Engine {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("Engine")
314            .field("context_ops", &self.context_ops.len())
315            .field("generation_ops", &self.generation_ops.len())
316            .field("nextn", &self.nextn)
317            .finish_non_exhaustive()
318    }
319}
320
321impl Engine {
322    /// Build an `Engine` from a spec and a pre-loaded database.
323    ///
324    /// Extracts the op lists and the `nextn` scalar from `spec.engine`. The
325    /// caller (`AicEngineBuilder` / `from_spec_bytes`) is responsible for
326    /// having loaded the matching `PerfDatabase` from `spec.engine`'s identity.
327    pub fn build(spec: EngineSpec, db: Arc<PerfDatabase>) -> Result<Engine, AicError> {
328        let nextn = spec
329            .engine
330            .speculative
331            .as_ref()
332            .and_then(|s| s.nextn)
333            .unwrap_or(0);
334        // FPM whole-model specs must be exactly one op per phase (the Python
335        // rewrite guarantees this shape) and never carry MTP: the Python model
336        // builder rejects `nextn > 0` for forward_model="fpm" (commit
337        // ad93e75f) and the collected data has no speculative points. Guarding
338        // here keeps a hand-built or skewed spec from silently mis-composing.
339        // The scan is RECURSIVE: an FpmForward nested inside Overlap/Fallback
340        // (never produced by the Python rewrite, but expressible in a
341        // hand-built spec) would evade a top-level check and ride the
342        // name-filtered mix-step passes with the wrong workload shape — and
343        // FallbackOp swallows the op's PerfDatabase-class misses silently.
344        fn contains_fpm(ops: &[Op]) -> bool {
345            ops.iter().any(|op| match op {
346                Op::FpmForward(_) => true,
347                Op::Overlap(o) => contains_fpm(&o.group_a) || contains_fpm(&o.group_b),
348                Op::Fallback(o) => {
349                    contains_fpm(std::slice::from_ref(&o.primary)) || contains_fpm(&o.fallback)
350                }
351                _ => false,
352            })
353        }
354        let any_fpm = contains_fpm(&spec.context_ops) || contains_fpm(&spec.generation_ops);
355        if any_fpm {
356            let shape_ok = matches!(
357                spec.context_ops.as_slice(),
358                [Op::FpmForward(p)] if p.phase == FpmPhase::Prefill
359            ) && matches!(
360                spec.generation_ops.as_slice(),
361                [Op::FpmForward(d)] if d.phase == FpmPhase::Decode
362            );
363            if !shape_ok {
364                return Err(AicError::InvalidEngineConfig(
365                    "forward_model='fpm' spec must contain exactly one FpmForward op per phase \
366                     (prefill in context_ops, decode in generation_ops)"
367                        .to_string(),
368                ));
369            }
370            if nextn > 0 {
371                return Err(AicError::InvalidEngineConfig(format!(
372                    "forward_model='fpm' does not support MTP speculative decoding (nextn={nextn})"
373                )));
374            }
375        }
376        Ok(Engine {
377            context_ops: spec.context_ops,
378            generation_ops: spec.generation_ops,
379            db,
380            nextn,
381        })
382    }
383
384    /// FPM whole-model engine: both phase lists are exactly one `FpmForward`
385    /// (validated in [`Engine::build`]). Returns `(prefill_op, decode_op)`.
386    fn fpm_ops(&self) -> Option<(&FpmForwardOp, &FpmForwardOp)> {
387        match (self.context_ops.as_slice(), self.generation_ops.as_slice()) {
388            ([Op::FpmForward(p)], [Op::FpmForward(d)]) => Some((p, d)),
389            _ => None,
390        }
391    }
392
393    /// Convenience constructor: deserialize a bincode `EngineSpec` and load the
394    /// matching `PerfDatabase` from its identity, then [`Engine::build`].
395    ///
396    /// Runs the `Engine::from_spec_bytes(bytes) + PerfDatabase::load`
397    /// flow. `systems_root` points at `src/aiconfigurator_core/systems` and is used
398    /// only as a fallback: when the decoded `spec.engine.systems_path` is
399    /// `Some`, that path is authoritative and overrides the `systems_root`
400    /// argument.
401    pub fn from_spec_bytes(
402        bytes: &[u8],
403        systems_root: &std::path::Path,
404    ) -> Result<Engine, AicError> {
405        let spec = EngineSpec::from_bincode(bytes)?;
406        let version = spec.engine.backend_version.as_deref().ok_or_else(|| {
407            AicError::InvalidEngineConfig(
408                "backend_version is required to load the perf database".to_string(),
409            )
410        })?;
411        // The spec's own `systems_path` wins when present; otherwise fall back
412        // to the `systems_root` argument.
413        let systems_root = spec.engine.systems_path.as_deref().unwrap_or(systems_root);
414        let transfer_policy = TransferPolicy::from_wire(spec.engine.transfer_policy.as_deref())
415            .map_err(AicError::InvalidEngineConfig)?;
416        // The shared variant reuses already-parsed perf tables across engines
417        // with the same DB identity: a sweep compiles one engine per
418        // model/parallelism/quant point, and without sharing each of those
419        // engines would lazily re-parse the same parquet files on its first
420        // query (~0.5s per engine on data-rich systems). Mode/policy, memo
421        // caches, and the provenance accumulator stay per-engine.
422        let db = PerfDatabase::load_resolved_shared(
423            systems_root,
424            &spec.engine.system_name,
425            spec.engine.backend.as_str(),
426            version,
427            // Shared-layer inheritance: explicit override when the spec
428            // carries one (Python's `shared_layer=` kwarg), else derived
429            // from the query mode exactly like Python `_shared_layer_enabled`
430            // (SILICON/HYBRID = on).
431            spec.engine.enable_shared_layer.unwrap_or(matches!(
432                spec.engine.database_mode,
433                DatabaseMode::Silicon | DatabaseMode::Hybrid
434            )),
435            spec.engine.strict_provenance,
436            // Estimate-only systems (a spec yaml with no collected data) may
437            // back a SOL view: every SOL answer is analytic from the system
438            // spec, so tolerate a missing perf-data directory under SOL and
439            // let table-backed lookups miss lazily. A directory-less
440            // fleet-`next` spec (validated by the Python slot resolver, which
441            // loaded the same identity through backward fill) also skips the
442            // gate — the source resolver serves every table from sibling
443            // versions. All other loads keep the loud gate.
444            spec.engine.database_mode == DatabaseMode::Sol || spec.engine.tolerate_dirless_version,
445        )?
446        .with_mode(spec.engine.database_mode, transfer_policy);
447        Engine::build(spec, Arc::new(db))
448    }
449
450    /// Shared perf database handle.
451    pub fn database(&self) -> &Arc<PerfDatabase> {
452        &self.db
453    }
454
455    /// Clear the empirical-provenance accumulator (start of a run). The PyO3
456    /// boundary calls this at the top of every compute method so
457    /// [`Self::last_provenance`] carries per-call semantics, mirroring
458    /// Python's `capture_provenance()` scope. Deliberately NOT called inside
459    /// `run_static` itself: `mixed_step_latency` composes multiple internal
460    /// passes whose tiers must accumulate into one answer.
461    pub fn reset_provenance(&self) {
462        self.db.reset_provenance();
463    }
464
465    /// The least-confident empirical tier fired since the last
466    /// [`Self::reset_provenance`], as the Python tag string; `None` when the
467    /// run was answered purely from silicon tables (nothing to note — Python's
468    /// `note_provenance` is skipped for silicon too).
469    pub fn last_provenance(&self) -> Option<&'static str> {
470        match self.db.worst_provenance() {
471            crate::operators::util_empirical::ProvenanceTier::Silicon => None,
472            tier => Some(tier.as_str()),
473        }
474    }
475
476    /// Test-only accessor for the context op list (the field is private, but
477    /// `fpm`'s `#[cfg(test)]` parity tests compare `forward_pass_time_ms`
478    /// against the shared session free fns over these exact ops).
479    #[cfg(test)]
480    pub(crate) fn context_ops_for_test(&self) -> &[Op] {
481        &self.context_ops
482    }
483
484    /// Test-only accessor for the generation op list. See
485    /// [`Self::context_ops_for_test`].
486    #[cfg(test)]
487    pub(crate) fn generation_ops_for_test(&self) -> &[Op] {
488        &self.generation_ops
489    }
490
491    /// Python `run_static` / `run_static_latency_only` (`base_backend.py:347`,
492    /// `:322`) restricted to the latency breakdown. Dispatches on `mode` the
493    /// way `_run_static_breakdown` does and sums context + generation.
494    pub fn run_static(
495        &self,
496        runtime: &RuntimeConfig,
497        mode: StaticMode,
498        stride: u32,
499    ) -> Result<StaticResult, AicError> {
500        let context_ms = match mode {
501            StaticMode::Context | StaticMode::Both => self.run_context_phase(runtime)?,
502            StaticMode::Generation => 0.0,
503        };
504        let generation_ms = match mode {
505            StaticMode::Generation | StaticMode::Both => {
506                self.run_generation_phase(runtime, stride)?
507            }
508            StaticMode::Context => 0.0,
509        };
510        Ok(StaticResult {
511            context_ms,
512            generation_ms,
513            total_ms: context_ms + generation_ms,
514        })
515    }
516
517    /// Python `_run_context_phase` (`base_backend.py:144`): `effective_isl =
518    /// isl - prefix`, validate `> 0`, then one full pass over `context_ops`.
519    fn run_context_phase(&self, runtime: &RuntimeConfig) -> Result<f64, AicError> {
520        // Python raises `ValueError` when `effective_isl <= 0`; mirror that.
521        if runtime.prefix >= runtime.isl {
522            return Err(AicError::InvalidEngineConfig(format!(
523                "isl must be greater than 0 after removing prefix, but got {}",
524                runtime.isl as i64 - runtime.prefix as i64
525            )));
526        }
527        let effective_isl = runtime.isl - runtime.prefix;
528        run_context_ops(
529            &self.context_ops,
530            &self.db,
531            runtime.batch_size,
532            effective_isl,
533            runtime.prefix,
534            runtime.seq_imbalance_correction_scale,
535            ContextOpFilter::All,
536        )
537    }
538
539    /// Python `_run_generation_phase` (`base_backend.py:185`): scale the decode
540    /// batch by `(nextn + 1)`, then integrate over the decode trajectory with
541    /// the stride quadrature.
542    ///
543    /// ```text
544    /// bs = batch_size * (nextn + 1)
545    /// for i in range(0, osl - 1, stride):
546    ///     step = Σ generation_ops  with  batch_size=bs, s = isl + i + 1
547    ///     repeat_count = min(stride, osl - 1 - i)
548    ///     generation += step * repeat_count
549    /// ```
550    ///
551    /// `osl <= 1` yields an empty loop and 0.0 (matches Python).
552    fn run_generation_phase(&self, runtime: &RuntimeConfig, stride: u32) -> Result<f64, AicError> {
553        self.run_generation_phase_with(runtime, stride, |_, _| {})
554    }
555
556    /// [`Self::run_generation_phase`] with a per-op sink. Python builds a
557    /// per-iteration dict (folding same-name results), THEN multiplies the
558    /// folded values by the stride `repeat_count` and merges them into the
559    /// trajectory dicts (`base_backend.py:378-405`) — so the sink here
560    /// observes ONE per-step-folded result per op name, already weighted by
561    /// `repeat_count`, in that exact order: `(r1 + r2) * k`, not
562    /// `r1*k + r2*k` (bit-identical for repeated-name model families).
563    fn run_generation_phase_with(
564        &self,
565        runtime: &RuntimeConfig,
566        stride: u32,
567        mut on_op: impl FnMut(&Op, PerformanceResult),
568    ) -> Result<f64, AicError> {
569        let bs = runtime
570            .batch_size
571            .saturating_mul(self.nextn.saturating_add(1));
572        let stride = stride.max(1);
573        let mut total = 0.0_f64;
574        if runtime.osl <= 1 {
575            return Ok(0.0);
576        }
577        let upper = runtime.osl - 1; // exclusive, matches Python `range(0, osl-1, stride)`
578        let mut i = 0u32;
579        while i < upper {
580            // Python `s = isl + i + 1`. NOTE the `+1` — distinct from the FPM
581            // bridge's `context_length = isl + i` packing convention.
582            let s = runtime.isl + i + 1;
583            let repeat_count = stride.min(upper - i);
584            // Per-step name fold FIRST (Python's per-iteration dict), with
585            // the phase-dict source merge (mismatch -> Mixed, no
586            // zero-identity — mirrors `base_backend.py:391-393`).
587            let mut step_fold: Vec<(&Op, PerformanceResult)> = Vec::new();
588            let step = run_generation_ops_step_beamed_with(
589                &self.generation_ops,
590                &self.db,
591                bs,
592                runtime.beam_width,
593                s,
594                runtime.gen_seq_imbalance_correction_scale,
595                false,
596                |op, r| {
597                    if let Some(entry) = step_fold.iter_mut().find(|(e, _)| e.name() == op.name()) {
598                        entry.1.latency_ms += r.latency_ms;
599                        entry.1.energy_wms += r.energy_wms;
600                        if entry.1.source != r.source {
601                            entry.1.source = crate::operators::base::Source::Mixed;
602                        }
603                        entry.1.moe_comm_fallbacks.extend(r.moe_comm_fallbacks);
604                    } else {
605                        step_fold.push((op, r));
606                    }
607                },
608            )?;
609            for (op, folded) in step_fold {
610                on_op(op, folded.scaled(repeat_count as f64));
611            }
612            total += step * repeat_count as f64;
613            i += stride;
614        }
615        Ok(total)
616    }
617
618    /// Mocker H1: prefill-step latency in ms. Pure-Rust inherent method (no
619    /// PyO3 `py` token), so the Mocker hot path runs without acquiring the GIL.
620    /// Thin shim over [`Self::run_static`] with `mode=Context` (osl is
621    /// irrelevant for the context phase, so it is fixed at 1).
622    pub fn predict_prefill_latency(&self, bs: u32, isl: u32, prefix: u32) -> Result<f64, AicError> {
623        let rt = RuntimeConfig {
624            batch_size: bs,
625            isl,
626            osl: 1,
627            prefix,
628            ..Default::default()
629        };
630        Ok(self
631            .run_static(&rt, StaticMode::Context, DEFAULT_STATIC_STRIDE)?
632            .total_ms)
633    }
634
635    /// Mocker H2: decode-step latency in ms. Pure-Rust inherent method (no
636    /// PyO3 `py` token). Thin shim over [`Self::run_static`] with
637    /// `mode=Generation`. Mocker passes `osl=2` (one decode step at
638    /// `s = isl + 1`).
639    pub fn predict_decode_latency(&self, bs: u32, isl: u32, osl: u32) -> Result<f64, AicError> {
640        let rt = RuntimeConfig {
641            batch_size: bs,
642            isl,
643            osl,
644            ..Default::default()
645        };
646        Ok(self
647            .run_static(&rt, StaticMode::Generation, DEFAULT_STATIC_STRIDE)?
648            .total_ms)
649    }
650
651    /// One mixed (chunked-prefill + decode) step latency. LITERAL mirror of
652    /// Python `_get_mix_step_latency` / `run_mixed`, which composes three
653    /// filtered phase passes (`_run_context_phase` / `_run_generation_phase`
654    /// with `op_filter`) that query ONLY the ops each pass consumes — the
655    /// same name-keyed sets the `ContextOpFilter` /
656    /// `only_generation_attention` walks below visit (issue #1498
657    /// follow-through: Python used to run the full lists and discard, so a
658    /// raise in a discarded query was a one-sided error surface):
659    ///
660    /// ```text
661    /// // Pass 1 — combined non-attention work:
662    /// //   run_static(batch=1, isl=ctx+gen, osl=1,
663    /// //              prefix=prefix*floor(ctx/isl), mode=static_ctx)
664    /// //   sum every op EXCEPT "context_attention"
665    /// // Pass 2 — context attention at the prefill shape:
666    /// //   run_static(batch=ceil(ctx/isl), isl=isl, osl=1, prefix=prefix)
667    /// //   take ONLY "context_attention", divide by ceil(isl/ctx)
668    /// // Pass 3 — decode attention (only when gen_tokens > 0):
669    /// //   run_static(batch=gen, isl=isl+osl//2, osl=2, mode=static_gen)
670    /// //   -> one step at s = isl + osl//2 + 1 with the (nextn+1) batch
671    /// //   take ONLY "generation_attention"
672    /// ```
673    ///
674    /// Note the Python conventions this deliberately preserves (they differed
675    /// from the pre-rewrite FPM packing): pass 1 uses
676    /// `ctx + gen * (nextn + 1)` tokens (the speculative-progress model —
677    /// every decode request verifies one target plus all drafts in the
678    /// combined pass, mirroring Python `run_mixed`'s `decode_query_tokens`),
679    /// the cached prefix multiplier is `floor(ctx/isl)` (not ceil), and the
680    /// pass-3 kv position carries `_run_generation_phase`'s `+1`.
681    ///
682    /// The imbalance-correction scales mirror the `RuntimeConfig` fields
683    /// Python threads into each pass (`base_backend.py:950-1043`).
684    pub fn mixed_step_latency(
685        &self,
686        ctx_tokens: u32,
687        gen_tokens: u32,
688        isl: u32,
689        osl: u32,
690        prefix: u32,
691        seq_imbalance_correction_scale: f64,
692        gen_seq_imbalance_correction_scale: f64,
693    ) -> Result<f64, AicError> {
694        Ok(self.mixed_step_breakdown(
695            ctx_tokens,
696            gen_tokens,
697            isl,
698            osl,
699            prefix,
700            seq_imbalance_correction_scale,
701            gen_seq_imbalance_correction_scale,
702        )?[0])
703    }
704
705    /// Return ``[total, shared_non_attention, context_attention,
706    /// decode_attention]`` for one mixed engine iteration — the three passes
707    /// of the `_get_mix_step_latency` composition reported separately: pass 1
708    /// is the shared non-attention work, pass 2 the context-attention slice
709    /// (already divided by `ceil(isl/ctx)`), pass 3 the decode-attention
710    /// slice. [`Engine::mixed_step_latency`] is their sum; the agg
711    /// speculative scheduler consumes the components.
712    pub fn mixed_step_breakdown(
713        &self,
714        ctx_tokens: u32,
715        gen_tokens: u32,
716        isl: u32,
717        osl: u32,
718        prefix: u32,
719        seq_imbalance_correction_scale: f64,
720        gen_seq_imbalance_correction_scale: f64,
721    ) -> Result<[f64; 4], AicError> {
722        self.mixed_step_breakdown_with(
723            ctx_tokens,
724            gen_tokens,
725            isl,
726            osl,
727            prefix,
728            seq_imbalance_correction_scale,
729            gen_seq_imbalance_correction_scale,
730            |_, _, _| {},
731        )
732    }
733
734    /// [`Self::mixed_step_breakdown`] with a per-op sink. The sink observes
735    /// `(pass, op, result)` for every queried op with RAW (undivided) pass-2
736    /// values; the per-op wrapper applies the `ceil(isl/ctx)` division to the
737    /// FOLDED entries (fold-then-divide, matching Python and the scalar
738    /// bucket bit-for-bit).
739    #[allow(clippy::too_many_arguments)]
740    fn mixed_step_breakdown_with(
741        &self,
742        ctx_tokens: u32,
743        gen_tokens: u32,
744        isl: u32,
745        osl: u32,
746        prefix: u32,
747        seq_imbalance_correction_scale: f64,
748        gen_seq_imbalance_correction_scale: f64,
749        mut on_op: impl FnMut(MixedPass, &Op, PerformanceResult),
750    ) -> Result<[f64; 4], AicError> {
751        if ctx_tokens == 0 && gen_tokens == 0 {
752            return Ok([0.0; 4]);
753        }
754        // Whole-model FPM ops must never reach the name-filtered three-pass
755        // composition below (they match neither attention filter and would
756        // ride pass 1 with the wrong workload shape). Python branches the
757        // same way at `_get_mix_step_latency` -> `_get_fpm_mix_step_latency`.
758        // Component mapping: FPM has no non-attention/attention split, so the
759        // breakdown reports [total, prefill_component, 0, marginal_decode].
760        // The component consumers (speculative agg scheduling) only read the
761        // split under MTP, which FPM rejects at build time.
762        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
763            let (prefill_ms, marginal_decode_ms) = self.fpm_mixed_step_components(
764                prefill_op,
765                decode_op,
766                ctx_tokens,
767                gen_tokens,
768                isl.max(1),
769                osl.max(1),
770                prefix,
771            )?;
772            return Ok([
773                prefill_ms + marginal_decode_ms,
774                prefill_ms,
775                0.0,
776                marginal_decode_ms,
777            ]);
778        }
779        // Python divides by `isl` (`floor(ctx/isl)`, `ceil(ctx/isl)`) without
780        // a guard — callers always pass isl >= 1. Clamp to avoid a Rust
781        // div-by-zero panic on degenerate input Python would crash on.
782        let isl = isl.max(1);
783
784        // ---- Pass 1: combined non-attention work ----
785        // Speculative progress model: every decode request verifies one
786        // target token plus all scheduled drafts, so the combined pass sees
787        // `gen * (nextn + 1)` decode tokens (mirrors Python `run_mixed`'s
788        // `decode_query_tokens`). Acceptance does not reduce this
789        // current-iteration work.
790        let decode_query_tokens = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
791        let combined = ctx_tokens + decode_query_tokens;
792        let prefix1 = prefix * (ctx_tokens / isl); // prefix * floor(ctx/isl)
793        if prefix1 >= combined {
794            return Err(AicError::InvalidEngineConfig(format!(
795                "isl must be greater than 0 after removing prefix, but got {}",
796                combined as i64 - prefix1 as i64
797            )));
798        }
799        let shared_non_attention = run_context_ops_with(
800            &self.context_ops,
801            &self.db,
802            1,
803            combined - prefix1,
804            prefix1,
805            seq_imbalance_correction_scale,
806            ContextOpFilter::SkipContextAttention,
807            |op, r| on_op(MixedPass::SharedNonAttention, op, r),
808        )?;
809
810        // ---- Pass 2: context attention at the prefill shape ----
811        // Python: batch = ceil(ctx/isl), effective_isl = isl - prefix, then
812        // latency["context_attention"] / ceil(isl/ctx). With ctx_tokens == 0
813        // Python's `np.ceil(isl/0)` is +inf and the division yields 0 — skip.
814        let mut context_attention = 0.0_f64;
815        if ctx_tokens > 0 {
816            if prefix >= isl {
817                return Err(AicError::InvalidEngineConfig(format!(
818                    "isl must be greater than 0 after removing prefix, but got {}",
819                    isl as i64 - prefix as i64
820                )));
821            }
822            let batch2 = ctx_tokens.div_ceil(isl);
823            let scale2 = isl.div_ceil(ctx_tokens) as f64;
824            let attn = run_context_ops_with(
825                &self.context_ops,
826                &self.db,
827                batch2,
828                isl - prefix,
829                prefix,
830                seq_imbalance_correction_scale,
831                ContextOpFilter::OnlyContextAttention,
832                // RAW results to the sink; the per-op wrapper divides the
833                // FOLDED values by scale2 with one true division per name
834                // (Python folds `context_attention` into one key, then
835                // `latency_dict["context_attention"] / scale_factor` —
836                // fold-then-divide, `base_backend.py:1244-1246`).
837                |op, r| on_op(MixedPass::ContextAttention, op, r),
838            )?;
839            context_attention = attn / scale2;
840        }
841
842        // ---- Pass 3: decode attention ----
843        let mut decode_attention = 0.0_f64;
844        if gen_tokens > 0 {
845            let bs = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
846            // `_run_generation_phase` queries at s = isl_pass3 + i + 1 with
847            // isl_pass3 = isl + osl//2 and a single step (osl=2, i=0).
848            let s = isl + osl / 2 + 1;
849            decode_attention = run_generation_ops_step_beamed_with(
850                &self.generation_ops,
851                &self.db,
852                bs,
853                1,
854                s,
855                gen_seq_imbalance_correction_scale,
856                true,
857                |op, r| on_op(MixedPass::DecodeAttention, op, r),
858            )?;
859        }
860
861        Ok([
862            shared_non_attention + context_attention + decode_attention,
863            shared_non_attention,
864            context_attention,
865            decode_attention,
866        ])
867    }
868
869    /// One generation-only step latency. LITERAL mirror of Python
870    /// `_get_genonly_step_latency` (`base_backend.py:1040-1100`):
871    /// `run_static(batch=gen_tokens, isl=isl+osl//2, osl=2, mode=static_gen)`
872    /// summed over the FULL generation op list — one step at
873    /// `s = isl + osl//2 + 1` (note `_run_generation_phase`'s `+1`) with the
874    /// decode batch scaled by `(nextn + 1)`.
875    pub fn decode_step_latency(
876        &self,
877        gen_tokens: u32,
878        isl: u32,
879        osl: u32,
880        gen_seq_imbalance_correction_scale: f64,
881    ) -> Result<f64, AicError> {
882        if gen_tokens == 0 {
883            return Ok(0.0);
884        }
885        // FPM keeps the PYTHON static-path convention `s = isl + osl/2 + 1`
886        // (via `run_generation_phase`), not this method's op-level
887        // `isl + osl/2` packing — a documented divergence the FPM port must
888        // not inherit (its parity target is the Python FPM branch, which
889        // routes through `run_static(mode="static_gen")`).
890        if self.fpm_ops().is_some() {
891            let rt = RuntimeConfig {
892                batch_size: gen_tokens,
893                isl: isl.saturating_add(osl / 2),
894                osl: 2,
895                ..Default::default()
896            };
897            return self.run_generation_phase(&rt, DEFAULT_STATIC_STRIDE);
898        }
899        let effective_batch = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
900        let s = isl.max(1).saturating_add(osl.max(1) / 2).saturating_add(1);
901        run_generation_ops_step(
902            &self.generation_ops,
903            &self.db,
904            effective_batch,
905            s,
906            gen_seq_imbalance_correction_scale,
907            false,
908        )
909    }
910
911    /// Mixed-step composition, mirroring Python
912    /// `_get_fpm_mix_step_latency` exactly: the prefill component prices the
913    /// iteration's REAL scheduled totals (chunk + decode tokens — the count
914    /// the engine picks its CUDA-graph/eager regime and GEMM width from) via
915    /// `query_totals`; chunked requests are priced per chunk at their own
916    /// `(chunk + gen, past_kv)` coordinates and averaged. The decode
917    /// component stays the pass-baseline marginal. Correct only when the
918    /// deployed engine configuration (especially the CUDA-graph capture
919    /// surface) matches the collection — the cliffs live in the data.
920    fn fpm_mixed_step_components(
921        &self,
922        prefill_op: &FpmForwardOp,
923        decode_op: &FpmForwardOp,
924        ctx_tokens: u32,
925        gen_tokens: u32,
926        isl: u32,
927        osl: u32,
928        prefix: u32,
929    ) -> Result<(f64, f64), AicError> {
930        let mut prefill_component = 0.0_f64;
931        if ctx_tokens > 0 {
932            let new_tokens = isl.saturating_sub(prefix);
933            if new_tokens == 0 {
934                return Err(AicError::PerfDatabase(format!(
935                    "isl must be greater than prefix, got isl={isl} prefix={prefix}"
936                )));
937            }
938            if ctx_tokens >= new_tokens {
939                // Whole prefills this iteration: the scheduled total picks
940                // the regime row.
941                let batch = ctx_tokens.div_ceil(new_tokens);
942                prefill_component = prefill_op
943                    .query_totals(
944                        &self.db,
945                        &[
946                            batch as f64,
947                            (ctx_tokens + gen_tokens) as f64,
948                            (batch * prefix) as f64,
949                        ],
950                    )?
951                    .latency_ms;
952            } else {
953                // Chunked prefill: per-chunk totals, per-iteration average.
954                let mut total = 0.0_f64;
955                let mut chunks = 0u32;
956                let mut done = 0u32;
957                while done < new_tokens {
958                    let chunk = ctx_tokens.min(new_tokens - done);
959                    total += prefill_op
960                        .query_totals(
961                            &self.db,
962                            &[1.0, (chunk + gen_tokens) as f64, (prefix + done) as f64],
963                        )?
964                        .latency_ms;
965                    done += chunk;
966                    chunks += 1;
967                }
968                prefill_component = total / chunks as f64;
969            }
970        }
971        let mut marginal_decode = 0.0_f64;
972        if gen_tokens > 0 {
973            let rt = RuntimeConfig {
974                batch_size: gen_tokens,
975                isl: isl.saturating_add(osl / 2),
976                osl: 2,
977                ..Default::default()
978            };
979            let gen_ms = self.run_generation_phase(&rt, DEFAULT_STATIC_STRIDE)?;
980            let baseline_ms = if ctx_tokens > 0 {
981                // run_generation_phase scaled the batch by (nextn + 1); the
982                // baseline must be sampled at the same effective batch.
983                decode_op
984                    .query_pass_baseline(
985                        &self.db,
986                        gen_tokens.saturating_mul(self.nextn.saturating_add(1)),
987                    )?
988                    .latency_ms
989            } else {
990                0.0
991            };
992            marginal_decode = (gen_ms - baseline_ms).max(0.0);
993        }
994        Ok((prefill_component, marginal_decode))
995    }
996
997    /// [`Self::run_static`] with the per-op values kept instead of summed:
998    /// `(context, generation)` lists of `(name, latency_ms, energy_wms,
999    /// source)`, NAME-FOLDED (see [`PerOpValue`]): each name crosses once,
1000    /// pre-accumulated with Python's phase-dict semantics. Generation values
1001    /// are per-step-folded, then weighted by the stride `repeat_count`.
1002    pub fn run_static_per_op(
1003        &self,
1004        runtime: &RuntimeConfig,
1005        mode: StaticMode,
1006        stride: u32,
1007    ) -> Result<(Vec<PerOpValue>, Vec<PerOpValue>), AicError> {
1008        let (context, generation) = self.run_static_per_op_impl(runtime, mode, stride)?;
1009        Ok((
1010            strip_per_op_metadata(context),
1011            strip_per_op_metadata(generation),
1012        ))
1013    }
1014
1015    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1016    /// endpoint. Evaluation stays in this single implementation so the value
1017    /// and its fallback records always come from the same query.
1018    pub(crate) fn run_static_per_op_with_metadata(
1019        &self,
1020        runtime: &RuntimeConfig,
1021        mode: StaticMode,
1022        stride: u32,
1023    ) -> Result<(Vec<PerOpValueWithMetadata>, Vec<PerOpValueWithMetadata>), AicError> {
1024        self.run_static_per_op_impl(runtime, mode, stride)
1025    }
1026
1027    fn run_static_per_op_impl(
1028        &self,
1029        runtime: &RuntimeConfig,
1030        mode: StaticMode,
1031        stride: u32,
1032    ) -> Result<(Vec<PerOpValueWithMetadata>, Vec<PerOpValueWithMetadata>), AicError> {
1033        let mut context = PerOpFold::new("context");
1034        if matches!(mode, StaticMode::Context | StaticMode::Both) {
1035            if runtime.prefix >= runtime.isl {
1036                return Err(AicError::InvalidEngineConfig(format!(
1037                    "isl must be greater than 0 after removing prefix, but got {}",
1038                    runtime.isl as i64 - runtime.prefix as i64
1039                )));
1040            }
1041            run_context_ops_with(
1042                &self.context_ops,
1043                &self.db,
1044                runtime.batch_size,
1045                runtime.isl - runtime.prefix,
1046                runtime.prefix,
1047                runtime.seq_imbalance_correction_scale,
1048                ContextOpFilter::All,
1049                |op, r| context.add(op, r),
1050            )?;
1051        }
1052        let mut generation = PerOpFold::new("generation");
1053        if matches!(mode, StaticMode::Generation | StaticMode::Both) {
1054            self.run_generation_phase_with(runtime, stride, |op, r| generation.add(op, r))?;
1055        }
1056        Ok((context.into_values(), generation.into_values()))
1057    }
1058
1059    /// [`Self::mixed_step_breakdown`] with the per-op values kept:
1060    /// `(shared_non_attention, context_attention, decode_attention)` lists of
1061    /// `(name, latency_ms, energy_wms, source)`. Context-attention entries
1062    /// arrive already divided by the `ceil(isl/ctx)` scale.
1063    #[allow(clippy::too_many_arguments)]
1064    pub fn mixed_step_breakdown_per_op(
1065        &self,
1066        ctx_tokens: u32,
1067        gen_tokens: u32,
1068        isl: u32,
1069        osl: u32,
1070        prefix: u32,
1071        seq_imbalance_correction_scale: f64,
1072        gen_seq_imbalance_correction_scale: f64,
1073    ) -> Result<(Vec<PerOpValue>, Vec<PerOpValue>, Vec<PerOpValue>), AicError> {
1074        let (shared, context_attention, decode_attention) = self.mixed_step_breakdown_per_op_impl(
1075            ctx_tokens,
1076            gen_tokens,
1077            isl,
1078            osl,
1079            prefix,
1080            seq_imbalance_correction_scale,
1081            gen_seq_imbalance_correction_scale,
1082        )?;
1083        Ok((
1084            strip_per_op_metadata(shared),
1085            strip_per_op_metadata(context_attention),
1086            strip_per_op_metadata(decode_attention),
1087        ))
1088    }
1089
1090    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1091    /// endpoint. See [`Self::mixed_step_breakdown_per_op`].
1092    #[allow(clippy::too_many_arguments)]
1093    pub(crate) fn mixed_step_breakdown_per_op_with_metadata(
1094        &self,
1095        ctx_tokens: u32,
1096        gen_tokens: u32,
1097        isl: u32,
1098        osl: u32,
1099        prefix: u32,
1100        seq_imbalance_correction_scale: f64,
1101        gen_seq_imbalance_correction_scale: f64,
1102    ) -> Result<MixedStepPerOpValuesWithMetadata, AicError> {
1103        self.mixed_step_breakdown_per_op_impl(
1104            ctx_tokens,
1105            gen_tokens,
1106            isl,
1107            osl,
1108            prefix,
1109            seq_imbalance_correction_scale,
1110            gen_seq_imbalance_correction_scale,
1111        )
1112    }
1113
1114    #[allow(clippy::too_many_arguments)]
1115    fn mixed_step_breakdown_per_op_impl(
1116        &self,
1117        ctx_tokens: u32,
1118        gen_tokens: u32,
1119        isl: u32,
1120        osl: u32,
1121        prefix: u32,
1122        seq_imbalance_correction_scale: f64,
1123        gen_seq_imbalance_correction_scale: f64,
1124    ) -> Result<MixedStepPerOpValuesWithMetadata, AicError> {
1125        // Whole-model FPM: never the name-filtered three-pass split (see
1126        // mixed_step_breakdown_with). Report the scalar path's component
1127        // mapping as per-op entries — the prefill component under the
1128        // prefill op's name in the shared bucket, the decode marginal under
1129        // the decode op's name — so the Python fold sees the same keys as
1130        // its own FPM branch.
1131        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
1132            let (prefill_ms, marginal_decode_ms) = self.fpm_mixed_step_components(
1133                prefill_op,
1134                decode_op,
1135                ctx_tokens,
1136                gen_tokens,
1137                isl.max(1),
1138                osl.max(1),
1139                prefix,
1140            )?;
1141            let mut shared: Vec<PerOpValueWithMetadata> = Vec::new();
1142            if ctx_tokens > 0 {
1143                shared.push((prefill_op.name.clone(), prefill_ms, 0.0, "silicon", None));
1144            }
1145            let mut dec_attn: Vec<PerOpValueWithMetadata> = Vec::new();
1146            if gen_tokens > 0 {
1147                dec_attn.push((
1148                    decode_op.name.clone(),
1149                    marginal_decode_ms,
1150                    0.0,
1151                    "silicon",
1152                    None,
1153                ));
1154            }
1155            return Ok((shared, Vec::new(), dec_attn));
1156        }
1157        let mut shared = PerOpFold::new("context");
1158        let mut ctx_attn = PerOpFold::new("context");
1159        let mut dec_attn = PerOpFold::new("generation");
1160        self.mixed_step_breakdown_with(
1161            ctx_tokens,
1162            gen_tokens,
1163            isl,
1164            osl,
1165            prefix,
1166            seq_imbalance_correction_scale,
1167            gen_seq_imbalance_correction_scale,
1168            |pass, op, r| {
1169                let out = match pass {
1170                    MixedPass::SharedNonAttention => &mut shared,
1171                    MixedPass::ContextAttention => &mut ctx_attn,
1172                    MixedPass::DecodeAttention => &mut dec_attn,
1173                };
1174                out.add(op, r);
1175            },
1176        )?;
1177        let mut ctx_attn = ctx_attn.into_values();
1178        if ctx_tokens > 0 {
1179            // Mirror the scalar bucket and Python's fold-then-single-true-
1180            // division (`base_backend.py:1244-1246`): one `/ scale2` per
1181            // folded name, never a per-entry reciprocal multiply.
1182            let scale2 = isl.max(1).div_ceil(ctx_tokens) as f64;
1183            for entry in &mut ctx_attn {
1184                entry.1 /= scale2;
1185                entry.2 /= scale2;
1186            }
1187        }
1188        Ok((shared.into_values(), ctx_attn, dec_attn.into_values()))
1189    }
1190
1191    /// [`Self::decode_step_latency`] with the per-op values kept.
1192    pub fn decode_step_per_op(
1193        &self,
1194        gen_tokens: u32,
1195        isl: u32,
1196        osl: u32,
1197        gen_seq_imbalance_correction_scale: f64,
1198    ) -> Result<Vec<PerOpValue>, AicError> {
1199        self.decode_step_per_op_impl(gen_tokens, isl, osl, gen_seq_imbalance_correction_scale)
1200            .map(strip_per_op_metadata)
1201    }
1202
1203    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1204    /// endpoint. See [`Self::decode_step_per_op`].
1205    pub(crate) fn decode_step_per_op_with_metadata(
1206        &self,
1207        gen_tokens: u32,
1208        isl: u32,
1209        osl: u32,
1210        gen_seq_imbalance_correction_scale: f64,
1211    ) -> Result<Vec<PerOpValueWithMetadata>, AicError> {
1212        self.decode_step_per_op_impl(gen_tokens, isl, osl, gen_seq_imbalance_correction_scale)
1213    }
1214
1215    fn decode_step_per_op_impl(
1216        &self,
1217        gen_tokens: u32,
1218        isl: u32,
1219        osl: u32,
1220        gen_seq_imbalance_correction_scale: f64,
1221    ) -> Result<Vec<PerOpValueWithMetadata>, AicError> {
1222        let mut out = PerOpFold::new("generation");
1223        if gen_tokens == 0 {
1224            return Ok(out.into_values());
1225        }
1226        let effective_batch = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
1227        let s = isl.max(1).saturating_add(osl.max(1) / 2).saturating_add(1);
1228        run_generation_ops_step_beamed_with(
1229            &self.generation_ops,
1230            &self.db,
1231            effective_batch,
1232            1,
1233            s,
1234            gen_seq_imbalance_correction_scale,
1235            false,
1236            |op, r| out.add(op, r),
1237        )?;
1238        Ok(out.into_values())
1239    }
1240
1241    /// Evaluate an index-addressed sublist of the compiled CONTEXT op list at
1242    /// the context-phase shape (the thin op-list evaluation FFI — Python-side
1243    /// orchestration like AFD partitions the compiled list and sources per-op
1244    /// values here instead of walking `Operation.query()`).
1245    #[allow(clippy::too_many_arguments)]
1246    pub fn evaluate_context_ops(
1247        &self,
1248        indices: &[usize],
1249        batch_size: u32,
1250        s: u32,
1251        prefix: u32,
1252        seq_imbalance_correction_scale: f64,
1253        x_override: Option<u32>,
1254    ) -> Result<Vec<PerOpValue>, AicError> {
1255        let mut out = PerOpFold::new("context");
1256        for &i in indices {
1257            let op = self.context_ops.get(i).ok_or_else(|| {
1258                AicError::InvalidEngineConfig(format!(
1259                    "evaluate_context_ops: index {i} out of range ({} context ops)",
1260                    self.context_ops.len()
1261                ))
1262            })?;
1263            let r = query_context_op(
1264                op,
1265                &self.db,
1266                batch_size,
1267                s,
1268                prefix,
1269                seq_imbalance_correction_scale,
1270                x_override,
1271            )?;
1272            out.add(op, r);
1273        }
1274        Ok(strip_per_op_metadata(out.into_values()))
1275    }
1276
1277    /// Evaluate an index-addressed sublist of the compiled GENERATION op list
1278    /// at the decode-step shape (see [`Self::evaluate_context_ops`]).
1279    #[allow(clippy::too_many_arguments)]
1280    pub fn evaluate_generation_ops(
1281        &self,
1282        indices: &[usize],
1283        batch_size: u32,
1284        s: u32,
1285        gen_seq_imbalance_correction_scale: f64,
1286        prefix: u32,
1287        x_override: Option<u32>,
1288    ) -> Result<Vec<PerOpValue>, AicError> {
1289        let mut out = PerOpFold::new("generation");
1290        for &i in indices {
1291            let op = self.generation_ops.get(i).ok_or_else(|| {
1292                AicError::InvalidEngineConfig(format!(
1293                    "evaluate_generation_ops: index {i} out of range ({} generation ops)",
1294                    self.generation_ops.len()
1295                ))
1296            })?;
1297            let r = query_generation_op(
1298                op,
1299                &self.db,
1300                batch_size,
1301                1,
1302                s,
1303                gen_seq_imbalance_correction_scale,
1304                prefix,
1305                x_override,
1306            )?;
1307            out.add(op, r);
1308        }
1309        Ok(strip_per_op_metadata(out.into_values()))
1310    }
1311
1312    /// Evaluate an ad-hoc op list (a JSON array of `OpSpec` objects, the same
1313    /// externally-tagged encoding `EngineSpec` uses) against this engine's
1314    /// database. Serves op lists that are deliberately NOT in the compiled
1315    /// spec — the VL encoder phase — while the shape math stays Python-side.
1316    #[allow(clippy::too_many_arguments)]
1317    pub fn evaluate_ops_json(
1318        &self,
1319        ops_json: &str,
1320        is_context: bool,
1321        batch_size: u32,
1322        s: u32,
1323        prefix: u32,
1324        imbalance_correction_scale: f64,
1325        x_override: Option<u32>,
1326    ) -> Result<Vec<PerOpValue>, AicError> {
1327        let ops: Vec<Op> = serde_json::from_str(ops_json).map_err(|e| {
1328            AicError::InvalidEngineConfig(format!("evaluate_ops_json: invalid op list JSON: {e}"))
1329        })?;
1330        let mut out = PerOpFold::new(if is_context { "context" } else { "generation" });
1331        for op in &ops {
1332            let r = if is_context {
1333                query_context_op(
1334                    op,
1335                    &self.db,
1336                    batch_size,
1337                    s,
1338                    prefix,
1339                    imbalance_correction_scale,
1340                    x_override,
1341                )?
1342            } else {
1343                query_generation_op(
1344                    op,
1345                    &self.db,
1346                    batch_size,
1347                    1,
1348                    s,
1349                    imbalance_correction_scale,
1350                    prefix,
1351                    x_override,
1352                )?
1353            };
1354            out.add(op, r);
1355        }
1356        Ok(strip_per_op_metadata(out.into_values()))
1357    }
1358
1359    /// [`Self::evaluate_ops_json`] under the SOL_FULL view: evaluate an
1360    /// ad-hoc op list (JSON array of `OpSpec` objects) with every operator
1361    /// forced onto its analytic SOL branch, and keep the roofline
1362    /// decomposition. Returns `(name, sol_time_ms, sol_math_ms, sol_mem_ms)`
1363    /// per op (see [`PerOpSolValue`]) — the compiled-engine replacement for
1364    /// Python's per-call `query_*(..., database_mode=SOL_FULL)` triples.
1365    /// Errors when an op's family does not export its decomposition yet.
1366    #[allow(clippy::too_many_arguments)]
1367    pub fn evaluate_ops_sol_json(
1368        &self,
1369        ops_json: &str,
1370        is_context: bool,
1371        batch_size: u32,
1372        s: u32,
1373        prefix: u32,
1374        imbalance_correction_scale: f64,
1375        x_override: Option<u32>,
1376    ) -> Result<Vec<PerOpSolValue>, AicError> {
1377        let ops: Vec<Op> = serde_json::from_str(ops_json).map_err(|e| {
1378            AicError::InvalidEngineConfig(format!(
1379                "evaluate_ops_sol_json: invalid op list JSON: {e}"
1380            ))
1381        })?;
1382        let sol_db = self.db.sol_full_view();
1383        let mut out = PerOpSolFold::default();
1384        for op in &ops {
1385            let r = if is_context {
1386                query_context_op(
1387                    op,
1388                    &sol_db,
1389                    batch_size,
1390                    s,
1391                    prefix,
1392                    imbalance_correction_scale,
1393                    x_override,
1394                )?
1395            } else {
1396                query_generation_op(
1397                    op,
1398                    &sol_db,
1399                    batch_size,
1400                    1,
1401                    s,
1402                    imbalance_correction_scale,
1403                    prefix,
1404                    x_override,
1405                )?
1406            };
1407            out.add(op, r)?;
1408        }
1409        Ok(out.into_values())
1410    }
1411
1412    /// Compute one forward-pass latency from a list of per-rank FPM entries.
1413    ///
1414    /// Re-platformed from the (deleted) `SessionEstimator::forward_pass_time_ms`
1415    /// (commit 520dcfff `session.rs:289`): validate every rank, dispatch each
1416    /// rank on its scheduled workload via [`Self::rank_latency_ms`], and take the
1417    /// max across ranks (attention-DP ranks run in lockstep, so the slowest rank
1418    /// gates the iteration).
1419    ///
1420    /// Unlike [`Self::mixed_step_latency`] / [`Self::decode_step_latency`], this
1421    /// consumes ALREADY-PACKED telemetry: the FPM fields are the observed
1422    /// per-iteration counts, so the `(nextn + 1)` MTP multiplier is NOT applied
1423    /// here (it is already baked into the scheduled-decode counts the engine
1424    /// emitted). The dispatch reuses the shared [`run_context_ops`] /
1425    /// [`run_generation_ops_step`] / [`get_mix_step_ops`] free fns so this path
1426    /// and the live engine-step path stay numerically identical.
1427    pub fn forward_pass_time_ms(
1428        &self,
1429        metrics_by_rank: &[ForwardPassMetrics],
1430    ) -> Result<f64, AicError> {
1431        if metrics_by_rank.is_empty() {
1432            return Err(AicError::InvalidForwardPassMetrics(
1433                "at least one attention-DP rank metric required".to_string(),
1434            ));
1435        }
1436        for metrics in metrics_by_rank {
1437            validate_forward_pass_metrics(metrics)?;
1438        }
1439        let mut max_latency = 0.0_f64;
1440        for metrics in metrics_by_rank {
1441            let rank_latency = self.rank_latency_ms(metrics)?;
1442            if rank_latency > max_latency {
1443                max_latency = rank_latency;
1444            }
1445        }
1446        Ok(max_latency)
1447    }
1448
1449    /// Dispatch one rank's FPM on its scheduled workload. Literal port of
1450    /// `SessionEstimator::rank_latency_ms` (520dcfff `session.rs:308`):
1451    /// prefill+decode -> mix step ([`get_mix_step_ops`]); prefill-only ->
1452    /// [`run_context_ops`]; decode-only -> [`run_generation_ops_step`]. The FPM
1453    /// counts pass through unscaled (no `nextn` multiplier — see
1454    /// [`Self::forward_pass_time_ms`]).
1455    fn rank_latency_ms(&self, metrics: &ForwardPassMetrics) -> Result<f64, AicError> {
1456        let sched = &metrics.scheduled_requests;
1457        // Token-based dispatch, aligned with `IterationFeatures` (fpm/model.rs):
1458        // a fully prefix-cached payload can retain prefill request/KV metadata
1459        // (`num_prefill_requests = 1, sum_prefill_tokens = 0`) while scheduling
1460        // no fresh prefill compute — that iteration is decode-only. A count
1461        // check would query prefill at zero tokens (outside the FPM domain)
1462        // and price decode as marginal work riding a pass that does not exist.
1463        let has_prefill = sched.sum_prefill_tokens > 0;
1464        let has_decode = sched.num_decode_requests > 0 || sched.sum_decode_kv_tokens > 0;
1465
1466        // FPM engines never enter the three-pass mix composition (its op-name
1467        // filters cannot see a whole-model op). Prefill-only and decode-only
1468        // dispatch through the same shared free fns as op-level (the FpmForward
1469        // op consumes batch/s/prefix from the RuntimeContext naturally); a
1470        // mixed rank composes prefill + marginal decode, mirroring
1471        // `_get_fpm_mix_step_latency` at the telemetry counts (already packed,
1472        // so no `(nextn + 1)` anywhere — and FPM engines enforce nextn == 0).
1473        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
1474            // The telemetry sums ARE the fpm_forward tables' native coordinate
1475            // system (per-rank iteration totals) — query them via
1476            // `query_totals` instead of the op-level per-request-average
1477            // convention, which loses up to (n - 1) tokens to integer
1478            // division on each axis.
1479            let mut total = 0.0_f64;
1480            if has_prefill {
1481                total += prefill_op
1482                    .query_totals(
1483                        &self.db,
1484                        &[
1485                            sched.num_prefill_requests as f64,
1486                            sched.sum_prefill_tokens as f64,
1487                            sched.sum_prefill_kv_tokens as f64,
1488                        ],
1489                    )?
1490                    .latency_ms;
1491            }
1492            if has_decode {
1493                let decode_ms = decode_op
1494                    .query_totals(
1495                        &self.db,
1496                        &[
1497                            sched.num_decode_requests as f64,
1498                            sched.sum_decode_kv_tokens as f64,
1499                        ],
1500                    )?
1501                    .latency_ms;
1502                if has_prefill {
1503                    // Mixed rank: marginal-decode composition, mirroring
1504                    // `_get_fpm_mix_step_latency` (counts already packed, no
1505                    // `(nextn + 1)` — FPM engines enforce nextn == 0).
1506                    let baseline_ms = decode_op
1507                        .query_pass_baseline(&self.db, sched.num_decode_requests)?
1508                        .latency_ms;
1509                    total += (decode_ms - baseline_ms).max(0.0);
1510                } else {
1511                    total += decode_ms;
1512                }
1513            }
1514            return Ok(total);
1515        }
1516
1517        if has_prefill && has_decode {
1518            // Mix step (continuous batching): compose like Python's
1519            // `_get_mix_step_latency`. `sum_prefill_kv_tokens` is exactly the
1520            // combined-prefix value the pass-1 non-attention call needs; pass
1521            // it through unchanged.
1522            let n_prefill = sched.num_prefill_requests.max(1);
1523            let new_tokens_per_req = sched.sum_prefill_tokens / n_prefill;
1524            let prefix_per_req = sched.sum_prefill_kv_tokens / n_prefill;
1525            let n_decode = sched.num_decode_requests.max(1);
1526            let kv_per_req = sched.sum_decode_kv_tokens / n_decode;
1527            let ctx_tokens = sched.sum_prefill_tokens;
1528            let gen_tokens = sched.num_decode_requests;
1529            return get_mix_step_ops(
1530                &self.context_ops,
1531                &self.generation_ops,
1532                &self.db,
1533                ctx_tokens,
1534                gen_tokens,
1535                new_tokens_per_req.max(1),
1536                prefix_per_req,
1537                sched.sum_prefill_kv_tokens,
1538                kv_per_req,
1539                n_decode,
1540            );
1541        }
1542
1543        let mut total = 0.0_f64;
1544
1545        if has_prefill {
1546            let n_prefill = sched.num_prefill_requests.max(1);
1547            let new_tokens_per_req = sched.sum_prefill_tokens / n_prefill;
1548            let prefix_per_req = sched.sum_prefill_kv_tokens / n_prefill;
1549            total += run_context_ops(
1550                &self.context_ops,
1551                &self.db,
1552                n_prefill,
1553                new_tokens_per_req,
1554                prefix_per_req,
1555                1.0,
1556                ContextOpFilter::All,
1557            )?;
1558        }
1559
1560        if has_decode {
1561            let n_decode = sched.num_decode_requests.max(1);
1562            let kv_per_req = sched.sum_decode_kv_tokens / n_decode;
1563            total += run_generation_ops_step(
1564                &self.generation_ops,
1565                &self.db,
1566                n_decode,
1567                kv_per_req,
1568                1.0,
1569                false,
1570            )?;
1571        }
1572
1573        Ok(total)
1574    }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use super::*;
1580    use std::collections::BTreeMap;
1581    use std::path::PathBuf;
1582
1583    use crate::common::enums::{FmhaQuantMode, GemmQuantMode, KvCacheQuantMode};
1584    use crate::engine::spec::EngineSpec;
1585    use crate::operators::op::Op;
1586    use crate::operators::{
1587        ContextAttentionOp, ElementwiseOp, GemmOp, GenerationAttentionOp, MoeAllToAllOp,
1588    };
1589    use crate::{BackendKind, EngineConfig, ParallelMapping, QuantizationConfig};
1590
1591    fn systems_root() -> PathBuf {
1592        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../src/aiconfigurator_core/systems")
1593    }
1594
1595    const TEST_MODEL: &str = "MiniMaxAI/MiniMax-M2.5";
1596
1597    /// Hand-built context op list against the b200_sxm/vllm/0.24.0 perf tables.
1598    /// `Elementwise` is DB-free (pure mem-bandwidth SOL); `Gemm` and
1599    /// `ContextAttention` hit existing perf tables. The (deleted) model layer
1600    /// previously sourced these lists from the HF config.
1601    fn context_ops() -> Vec<Op> {
1602        vec![
1603            Op::Elementwise(ElementwiseOp {
1604                name: "rmsnorm".into(),
1605                scale_factor: 1.0,
1606                bytes_per_token: 8192.0,
1607                scale_num_tokens: 1,
1608                seq_split: 1,
1609            }),
1610            Op::Gemm(GemmOp {
1611                name: "qkv_gemm".into(),
1612                scale_factor: 1.0,
1613                n: 4096,
1614                k: 4096,
1615                quant_mode: GemmQuantMode::Fp8Block,
1616                scale_num_tokens: 0,
1617                low_precision_input: false,
1618                seq_split: 1,
1619                below_grid_sol: false,
1620            }),
1621            Op::ContextAttention(ContextAttentionOp {
1622                name: "context_attention".into(),
1623                scale_factor: 1.0,
1624                n: 32,
1625                n_kv: 8,
1626                head_size: 128,
1627                window_size: 0,
1628                kv_cache_dtype: KvCacheQuantMode::Fp8,
1629                fmha_quant_mode: FmhaQuantMode::Bfloat16,
1630                use_qk_norm: false,
1631                cp_size: 1,
1632                lane_order: crate::operators::attention::b200_vllm_context_lane_order(),
1633            }),
1634        ]
1635    }
1636
1637    fn generation_ops() -> Vec<Op> {
1638        vec![
1639            Op::Elementwise(ElementwiseOp {
1640                name: "rmsnorm".into(),
1641                scale_factor: 1.0,
1642                bytes_per_token: 8192.0,
1643                scale_num_tokens: 1,
1644                seq_split: 1,
1645            }),
1646            Op::GenerationAttention(GenerationAttentionOp {
1647                name: "generation_attention".into(),
1648                scale_factor: 1.0,
1649                n: 32,
1650                n_kv: 8,
1651                head_size: 128,
1652                window_size: 0,
1653                kv_cache_dtype: KvCacheQuantMode::Fp8,
1654                lane_order: crate::operators::attention::b200_vllm_generation_lane_order(),
1655            }),
1656        ]
1657    }
1658
1659    fn fixture_engine_config(nextn: Option<u32>) -> EngineConfig {
1660        EngineConfig {
1661            schema_version: crate::ENGINE_CONFIG_SCHEMA_VERSION,
1662            model_name: TEST_MODEL.to_string(),
1663            system_name: "b200_sxm".to_string(),
1664            systems_path: None,
1665            backend: BackendKind::Vllm,
1666            backend_version: Some("0.24.0".to_string()),
1667            forward_model: None,
1668            kv_block_size: None,
1669            parallel: ParallelMapping {
1670                tp_size: 8,
1671                pp_size: 1,
1672                attention_dp_size: Some(1),
1673                moe_tp_size: Some(1),
1674                moe_ep_size: Some(8),
1675                cp_size: None,
1676            },
1677            quantization: QuantizationConfig {
1678                weight_dtype: None,
1679                moe_dtype: None,
1680                activation_dtype: None,
1681                kv_cache_dtype: None,
1682            },
1683            speculative: nextn.map(|n| crate::SpeculativeConfig { nextn: Some(n) }),
1684            enable_shared_layer: None,
1685            strict_provenance: false,
1686            tolerate_dirless_version: false,
1687            database_mode: Default::default(),
1688            transfer_policy: None,
1689            extra: BTreeMap::new(),
1690        }
1691    }
1692
1693    /// Build an `Engine` from the hand-built op lists over the real fixture DB.
1694    fn build_engine(nextn: Option<u32>) -> Engine {
1695        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
1696        let spec = EngineSpec::new(
1697            fixture_engine_config(nextn),
1698            context_ops(),
1699            generation_ops(),
1700        );
1701        Engine::build(spec, Arc::new(db)).unwrap()
1702    }
1703
1704    fn runtime(batch_size: u32, isl: u32, osl: u32) -> RuntimeConfig {
1705        RuntimeConfig {
1706            batch_size,
1707            isl,
1708            osl,
1709            ..Default::default()
1710        }
1711    }
1712
1713    #[test]
1714    fn per_op_fold_attaches_the_inference_phase_only_to_executed_fallbacks() {
1715        use crate::operators::base::{MoeCommFallback, Source};
1716
1717        let op = context_ops().remove(0);
1718        let fallback = MoeCommFallback {
1719            comm_backend: "deepep_ht",
1720            requested_ep_size: 32,
1721            requested_node_num: 8,
1722            measurement_ep_size: 8,
1723            measurement_node_num: 1,
1724        };
1725        for inference_phase in ["context", "generation"] {
1726            let mut fold = PerOpFold::new(inference_phase);
1727            fold.add(
1728                &op,
1729                PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1730            );
1731            assert_eq!(
1732                fold.into_values()[0].4,
1733                Some(((inference_phase, "deepep_ht", 32, 8, 8, 1), vec![]))
1734            );
1735        }
1736
1737        let mut repeated_name = PerOpFold::new("context");
1738        repeated_name.add(
1739            &op,
1740            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1741        );
1742        repeated_name.add(
1743            &op,
1744            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(
1745                MoeCommFallback {
1746                    comm_backend: "deepep_ll",
1747                    ..fallback
1748                },
1749            ),
1750        );
1751        assert_eq!(
1752            repeated_name.into_values()[0].4,
1753            Some((
1754                ("context", "deepep_ht", 32, 8, 8, 1),
1755                vec![("context", "deepep_ll", 32, 8, 8, 1)],
1756            ))
1757        );
1758
1759        let mut exact = PerOpFold::new("context");
1760        exact.add(&op, PerformanceResult::new(1.0, Source::Silicon));
1761        assert_eq!(exact.into_values()[0].4, None);
1762    }
1763
1764    #[test]
1765    fn per_op_fold_allocates_additional_storage_only_for_distinct_fallbacks_after_the_first() {
1766        use crate::operators::base::{MoeCommFallback, Source};
1767
1768        let op = context_ops().remove(0);
1769        let ht = MoeCommFallback {
1770            comm_backend: "deepep_ht",
1771            requested_ep_size: 32,
1772            requested_node_num: 8,
1773            measurement_ep_size: 8,
1774            measurement_node_num: 1,
1775        };
1776        let ll = MoeCommFallback {
1777            comm_backend: "deepep_ll",
1778            ..ht
1779        };
1780
1781        let mut empty = PerOpFold::new("context");
1782        empty.add(&op, PerformanceResult::new(1.0, Source::Silicon));
1783        assert!(empty.into_values().pop().unwrap().4.is_none());
1784
1785        let mut single = PerOpFold::new("context");
1786        single.add(
1787            &op,
1788            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(ht),
1789        );
1790        let (first, additional) = single.into_values().pop().unwrap().4.unwrap();
1791        assert_eq!(first, ("context", "deepep_ht", 32, 8, 8, 1));
1792        assert_eq!(additional.capacity(), 0);
1793
1794        let mut multiple = PerOpFold::new("generation");
1795        for fallback in [ht, ht, ll, ll] {
1796            multiple.add(
1797                &op,
1798                PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1799            );
1800        }
1801        let (first, additional) = multiple.into_values().pop().unwrap().4.unwrap();
1802        assert_eq!(first, ("generation", "deepep_ht", 32, 8, 8, 1));
1803        assert_eq!(additional, vec![("generation", "deepep_ll", 32, 8, 8, 1)]);
1804    }
1805
1806    #[test]
1807    fn generation_step_preserves_distinct_same_name_deepep_fallbacks() {
1808        let mut config = fixture_engine_config(None);
1809        config.system_name = "gb200".to_string();
1810        config.backend = BackendKind::Sglang;
1811        config.backend_version = Some("0.5.16".to_string());
1812
1813        let a2a = |moe_ep_size, node_num| {
1814            Op::MoeAllToAll(MoeAllToAllOp {
1815                name: "generation_moe_dispatch".to_string(),
1816                scale_factor: 1.0,
1817                phase: "dispatch".to_string(),
1818                comm_backend: "deepep_ll".to_string(),
1819                comm_dtype: "default".to_string(),
1820                hidden_size: 7168,
1821                topk: 8,
1822                num_experts: 256,
1823                moe_ep_size,
1824                node_num,
1825                sms: 0,
1826                attention_tp_size: 1,
1827            })
1828        };
1829        let spec = EngineSpec::new(config, Vec::new(), vec![a2a(32, 8), a2a(64, 16)]);
1830        let engine = Engine::from_spec_bytes(&spec.to_bincode().unwrap(), &systems_root())
1831            .expect("shipped GB200 SGLang DeepEP data must load");
1832        let runtime = RuntimeConfig {
1833            batch_size: 1,
1834            isl: 1024,
1835            osl: 2,
1836            ..Default::default()
1837        };
1838
1839        let (_, generation) = engine
1840            .run_static_per_op_with_metadata(&runtime, StaticMode::Generation, 32)
1841            .unwrap();
1842        assert_eq!(generation.len(), 1, "same-name ops must remain name-folded");
1843        assert_eq!(
1844            generation[0].4,
1845            Some((
1846                ("generation", "deepep_ll", 32, 8, 8, 1),
1847                vec![("generation", "deepep_ll", 64, 16, 8, 1)],
1848            ))
1849        );
1850    }
1851
1852    #[test]
1853    fn from_spec_bytes_shares_parsed_tables_across_engines() {
1854        use crate::operators::util_empirical::ProvenanceTier;
1855
1856        // Two DIFFERENT engine identities (nextn differs) over the SAME db
1857        // identity: the sweep pattern that motivates the shared-tables memo.
1858        let spec1 = EngineSpec::new(fixture_engine_config(None), context_ops(), generation_ops());
1859        let spec2 = EngineSpec::new(
1860            fixture_engine_config(Some(1)),
1861            context_ops(),
1862            generation_ops(),
1863        );
1864        let e1 = Engine::from_spec_bytes(&spec1.to_bincode().unwrap(), &systems_root()).unwrap();
1865        let e2 = Engine::from_spec_bytes(&spec2.to_bincode().unwrap(), &systems_root()).unwrap();
1866        assert!(
1867            std::sync::Arc::ptr_eq(e1.database().tables_arc(), e2.database().tables_arc()),
1868            "engines over the same db identity must share parsed tables"
1869        );
1870        // ... while their run state stays per-engine: provenance noted through
1871        // one engine's database must not appear on the other's accumulator.
1872        e1.database().note_provenance(ProvenanceTier::Empirical);
1873        assert_eq!(e2.database().worst_provenance(), ProvenanceTier::Silicon);
1874    }
1875
1876    #[test]
1877    fn both_equals_context_plus_generation() {
1878        let engine = build_engine(None);
1879        let rt = runtime(1, 1024, 8);
1880        let both = engine.run_static(&rt, StaticMode::Both, 32).unwrap();
1881        let ctx = engine.run_static(&rt, StaticMode::Context, 32).unwrap();
1882        let gen = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1883
1884        assert!((both.context_ms - ctx.context_ms).abs() < 1e-9);
1885        assert!((both.generation_ms - gen.generation_ms).abs() < 1e-9);
1886        assert!((both.total_ms - (ctx.context_ms + gen.generation_ms)).abs() < 1e-9);
1887        // total of `Both` is the sum of the two single-phase totals.
1888        assert!((both.total_ms - (ctx.total_ms + gen.total_ms)).abs() < 1e-9);
1889    }
1890
1891    #[test]
1892    fn context_mode_has_zero_generation() {
1893        let engine = build_engine(None);
1894        let rt = runtime(1, 1024, 8);
1895        let ctx = engine.run_static(&rt, StaticMode::Context, 32).unwrap();
1896        assert!(ctx.context_ms > 0.0, "context latency must be non-trivial");
1897        assert_eq!(ctx.generation_ms, 0.0);
1898        assert_eq!(ctx.total_ms, ctx.context_ms);
1899    }
1900
1901    #[test]
1902    fn generation_mode_has_zero_context() {
1903        let engine = build_engine(None);
1904        let rt = runtime(1, 1024, 8);
1905        let gen = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1906        assert!(
1907            gen.generation_ms > 0.0,
1908            "generation latency must be non-trivial"
1909        );
1910        assert_eq!(gen.context_ms, 0.0);
1911        assert_eq!(gen.total_ms, gen.generation_ms);
1912    }
1913
1914    #[test]
1915    fn stride_honored() {
1916        let engine = build_engine(None);
1917        // osl=9 → range(0,8,stride). stride=1 visits i=0..7 (8 steps each
1918        // repeat_count=1); stride=32 visits only i=0 (repeat_count=8). The
1919        // per-step latency grows with the decode position (s = isl+i+1), so
1920        // the fine-grained integration differs from the single-sample one.
1921        let rt = runtime(1, 1024, 9);
1922        let fine = engine.run_static(&rt, StaticMode::Generation, 1).unwrap();
1923        let coarse = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1924        assert!(fine.generation_ms > 0.0 && coarse.generation_ms > 0.0);
1925        assert!(
1926            (fine.generation_ms - coarse.generation_ms).abs() > 1e-9,
1927            "stride=1 ({}) and stride=32 ({}) must differ for osl=9",
1928            fine.generation_ms,
1929            coarse.generation_ms
1930        );
1931
1932        // Hand-rolled expected sum for stride=32, osl=9: one step at i=0
1933        // (s = isl + 1), repeat_count = min(32, 8) = 8.
1934        let one_step = run_generation_ops_step(
1935            &engine.generation_ops,
1936            engine.database(),
1937            1, // batch_size * (nextn+1), nextn=0
1938            1024 + 0 + 1,
1939            1.0,
1940            false,
1941        )
1942        .unwrap();
1943        assert!((coarse.generation_ms - one_step * 8.0).abs() < 1e-6);
1944    }
1945
1946    #[test]
1947    fn osl_one_yields_zero_generation() {
1948        let engine = build_engine(None);
1949        let rt = runtime(1, 1024, 1);
1950        let gen = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1951        assert_eq!(gen.generation_ms, 0.0);
1952    }
1953
1954    #[test]
1955    fn prefix_ge_isl_errors() {
1956        let engine = build_engine(None);
1957        let rt = RuntimeConfig {
1958            batch_size: 1,
1959            isl: 512,
1960            osl: 2,
1961            prefix: 512,
1962            ..Default::default()
1963        };
1964        assert!(engine.run_static(&rt, StaticMode::Context, 32).is_err());
1965    }
1966
1967    #[test]
1968    fn mixed_step_empty_is_zero() {
1969        let engine = build_engine(None);
1970        assert_eq!(
1971            engine
1972                .mixed_step_latency(0, 0, 1024, 8, 0, 1.0, 1.0)
1973                .unwrap(),
1974            0.0
1975        );
1976    }
1977
1978    #[test]
1979    fn mixed_step_nonempty_is_positive() {
1980        // The full three-pass composition (non-attention + context-attn +
1981        // gen-attn) over the hand-built fixture must produce a real latency.
1982        // End-to-end parity is covered by the mixed-step parity cases; this is
1983        // the fast pure-Rust smoke that the composition actually computes.
1984        let engine = build_engine(None);
1985        let ms = engine
1986            .mixed_step_latency(1024, 2, 1024, 8, 0, 1.0, 1.0)
1987            .unwrap();
1988        assert!(
1989            ms > 0.0 && ms.is_finite(),
1990            "mixed-step latency must be > 0, got {ms}"
1991        );
1992        let breakdown = engine
1993            .mixed_step_breakdown(1024, 2, 1024, 8, 0, 1.0, 1.0)
1994            .unwrap();
1995        assert_eq!(breakdown[0], breakdown[1] + breakdown[2] + breakdown[3]);
1996        assert_eq!(ms, breakdown[0]);
1997    }
1998
1999    // ---- FPM whole-model engine branches ----
2000
2001    /// FPM engine over the synthetic pair fixture: context = [FpmForward
2002    /// prefill], generation = [FpmForward decode], empty sol_ops (grid-exact
2003    /// queries never call SOL).
2004    fn build_fpm_engine(tmp: &std::path::Path, nextn: Option<u32>) -> Result<Engine, AicError> {
2005        use crate::perf_database::fpm_forward::tests::{
2006            default_identity, default_rows, write_pair,
2007        };
2008        write_pair(tmp, &default_rows());
2009        let mut db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2010        db.set_fpm_forward_for_test(crate::perf_database::FpmForwardTable::new(
2011            tmp.to_path_buf(),
2012            "b200_sxm",
2013            "vllm",
2014            "0.25.1",
2015        ));
2016        let fpm_op = |phase: FpmPhase| {
2017            Op::FpmForward(FpmForwardOp {
2018                name: format!("fpm_forward_{}", phase.as_str()),
2019                phase,
2020                model_path: "org/model-a".to_string(),
2021                match_identity: default_identity(4),
2022                weight_bytes: 0.0,
2023                sol_ops: vec![],
2024            })
2025        };
2026        let spec = EngineSpec::new(
2027            fixture_engine_config(nextn),
2028            vec![fpm_op(FpmPhase::Prefill)],
2029            vec![fpm_op(FpmPhase::Decode)],
2030        );
2031        Engine::build(spec, Arc::new(db))
2032    }
2033
2034    #[test]
2035    fn fpm_build_rejects_mtp_and_bad_shape() {
2036        let tmp = tempfile::tempdir().unwrap();
2037        let err = build_fpm_engine(tmp.path(), Some(1)).unwrap_err();
2038        assert!(err.to_string().contains("MTP"), "{err}");
2039
2040        // Mixed granular + FPM list is invalid.
2041        use crate::perf_database::fpm_forward::tests::default_identity;
2042        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2043        let fpm_op = Op::FpmForward(FpmForwardOp {
2044            name: "fpm_forward_prefill".into(),
2045            phase: FpmPhase::Prefill,
2046            model_path: "org/model-a".into(),
2047            match_identity: default_identity(4),
2048            weight_bytes: 0.0,
2049            sol_ops: vec![],
2050        });
2051        let spec = EngineSpec::new(
2052            fixture_engine_config(None),
2053            vec![fpm_op, context_ops().remove(0)],
2054            generation_ops(),
2055        );
2056        let err = Engine::build(spec, Arc::new(db)).unwrap_err();
2057        assert!(err.to_string().contains("exactly one FpmForward"), "{err}");
2058    }
2059
2060    /// The marginal-decode mixed composition, exact arithmetic over the
2061    /// fixture rows: the prefill component prices the step's SCHEDULED TOTAL
2062    /// (ctx + gen tokens) on the prefill curve; decode is the in-curve lerp
2063    /// minus the (8, 8) -> 6.0 baseline floor.
2064    #[test]
2065    fn fpm_mixed_step_is_prefill_plus_marginal_decode() {
2066        let tmp = tempfile::tempdir().unwrap();
2067        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2068        // ctx: 2048 tokens / isl 2048 -> batch 1, totals (1, 2048+8, 0):
2069        // in-curve lerp between (1,2048)->20.0 and (1,4096)->40.0.
2070        // gen: batch 8; osl=0 clamps to 1 -> isl' = 2048, one step at
2071        // s = 2049 -> kv = 8*2049 = 16392: lerp between (8,4096)->7.0 and
2072        // (8,65536)->9.0, minus baseline (8, kv_floor=8) -> 6.0.
2073        let ms = engine
2074            .mixed_step_latency(2048, 8, 2048, 0, 0, 1.0, 1.0)
2075            .unwrap();
2076        let pre = 20.0 + (40.0 - 20.0) * (2056.0 - 2048.0) / (4096.0 - 2048.0);
2077        let w = (16392.0 - 4096.0) / (65536.0 - 4096.0);
2078        let decode = 7.0 + (9.0 - 7.0) * w;
2079        let expected = pre + (decode - 6.0);
2080        assert!((ms - expected).abs() < 1e-9, "got {ms}, want {expected}");
2081    }
2082
2083    /// FPM engine over CUSTOM rows (cliff pair + chunk coordinates); same
2084    /// wiring as [`build_fpm_engine`].
2085    fn build_fpm_engine_with_rows(
2086        tmp: &std::path::Path,
2087        rows: &[crate::perf_database::fpm_forward::tests::RowSpec],
2088    ) -> Result<Engine, AicError> {
2089        use crate::perf_database::fpm_forward::tests::{default_identity, write_pair};
2090        write_pair(tmp, rows);
2091        let mut db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2092        db.set_fpm_forward_for_test(crate::perf_database::FpmForwardTable::new(
2093            tmp.to_path_buf(),
2094            "b200_sxm",
2095            "vllm",
2096            "0.25.1",
2097        ));
2098        let fpm_op = |phase: FpmPhase| {
2099            Op::FpmForward(FpmForwardOp {
2100                name: format!("fpm_forward_{}", phase.as_str()),
2101                phase,
2102                model_path: "org/model-a".to_string(),
2103                match_identity: default_identity(4),
2104                weight_bytes: 0.0,
2105                sol_ops: vec![],
2106            })
2107        };
2108        let spec = EngineSpec::new(
2109            fixture_engine_config(None),
2110            vec![fpm_op(FpmPhase::Prefill)],
2111            vec![fpm_op(FpmPhase::Decode)],
2112        );
2113        Engine::build(spec, Arc::new(db))
2114    }
2115
2116    fn cliff_rows() -> Vec<crate::perf_database::fpm_forward::tests::RowSpec> {
2117        use crate::perf_database::fpm_forward::tests::RowSpec;
2118        let mk = |kind: &'static str, batch: u32, prefill: u32, kv: u32, lat: f64| RowSpec {
2119            workload_kind: kind,
2120            batch_size: batch,
2121            total_prefill_tokens: prefill,
2122            total_kv_read_tokens: kv,
2123            latency_ms: lat,
2124            ..RowSpec::default()
2125        };
2126        vec![
2127            // CUDA-graph cliff pair at capture=2048, plus the eager plateau.
2128            mk("prefill", 1, 2048, 0, 47.0),
2129            mk("prefill", 1, 2049, 0, 99.0),
2130            mk("prefill", 1, 4096, 0, 99.0),
2131            // Chunk coordinates for the multi-chunk average test.
2132            mk("prefill", 1, 1032, 0, 10.0),
2133            mk("prefill", 1, 1032, 1024, 14.0),
2134            mk("decode", 8, 0, 8, 6.0),
2135            mk("decode", 8, 0, 4096, 7.0),
2136            mk("decode", 8, 0, 65536, 9.0),
2137        ]
2138    }
2139
2140    /// Spec test 1+2: the step's total (ctx + gen) picks the regime side.
2141    /// ctx=2048 alone sits ON the capture boundary (graph side, 47 ms); the
2142    /// same chunk with ANY decode riders crosses it and must price eager.
2143    #[test]
2144    fn fpm_mixed_step_total_crosses_the_graph_cliff() {
2145        let tmp = tempfile::tempdir().unwrap();
2146        let engine = build_fpm_engine_with_rows(tmp.path(), &cliff_rows()).unwrap();
2147        // In-graph: pure prefill step, totals (1, 2048, 0) -> exact 47.0.
2148        let graph = engine
2149            .mixed_step_breakdown(2048, 0, 2048, 0, 0, 1.0, 1.0)
2150            .unwrap();
2151        assert!(
2152            (graph[1] - 47.0).abs() < 1e-9,
2153            "graph-side prefill {}",
2154            graph[1]
2155        );
2156        // Crossing: 8 decode riders push the total to 2056 -> eager plateau.
2157        let eager = engine
2158            .mixed_step_breakdown(2048, 8, 2048, 0, 0, 1.0, 1.0)
2159            .unwrap();
2160        assert!(
2161            (eager[1] - 99.0).abs() < 1e-9,
2162            "eager-side prefill {}",
2163            eager[1]
2164        );
2165        assert!(eager[1] > graph[1] * 2.0 - 1e-9);
2166    }
2167
2168    /// Spec test 4: chunked requests price each chunk at its own
2169    /// (chunk + gen, past_kv) coordinates; the component is their average.
2170    #[test]
2171    fn fpm_mixed_step_chunks_average_exact_coordinates() {
2172        let tmp = tempfile::tempdir().unwrap();
2173        let engine = build_fpm_engine_with_rows(tmp.path(), &cliff_rows()).unwrap();
2174        // ctx=1024 of isl=2048: chunk 1 -> (1, 1032, 0) = 10.0,
2175        // chunk 2 -> (1, 1032, 1024) = 14.0; average 12.0.
2176        let parts = engine
2177            .mixed_step_breakdown(1024, 8, 2048, 0, 0, 1.0, 1.0)
2178            .unwrap();
2179        assert!((parts[1] - 12.0).abs() < 1e-9, "chunk average {}", parts[1]);
2180    }
2181
2182    /// A generation-only step keeps the FULL decode latency (no pass to ride
2183    /// on) and uses the Python static-path convention s = isl + osl/2 + 1.
2184    #[test]
2185    fn fpm_genonly_step_keeps_full_decode() {
2186        let tmp = tempfile::tempdir().unwrap();
2187        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2188        // gen_tokens=8, isl=511, osl=0 -> isl'=511, one step at s=512 ->
2189        // kv = 8*512 = 4096: exact decode row -> 7.0, NOT 7.0 - 6.0.
2190        let ms = engine.decode_step_latency(8, 511, 0, 1.0).unwrap();
2191        assert!((ms - 7.0).abs() < 1e-12, "got {ms}");
2192        // mixed with ctx_tokens=0 must agree with the genonly convention
2193        let mixed = engine
2194            .mixed_step_latency(0, 8, 511, 0, 0, 1.0, 1.0)
2195            .unwrap();
2196        assert!((mixed - 7.0).abs() < 1e-12, "got {mixed}");
2197        assert_eq!(engine.decode_step_latency(0, 511, 0, 1.0).unwrap(), 0.0);
2198    }
2199
2200    /// A fully prefix-cached payload retains prefill request/KV metadata
2201    /// while scheduling no fresh prefill compute: dispatch must be
2202    /// token-based (aligned with `IterationFeatures`) — a count-based check
2203    /// would query prefill at zero tokens (outside the FPM domain) and
2204    /// price decode as marginal work riding a pass that does not exist.
2205    #[test]
2206    fn fpm_rank_prefix_cached_payload_is_decode_only() {
2207        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2208        let tmp = tempfile::tempdir().unwrap();
2209        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2210        let metrics = ForwardPassMetrics {
2211            scheduled_requests: ScheduledRequestMetrics {
2212                num_prefill_requests: 1,
2213                sum_prefill_tokens: 0,
2214                sum_prefill_kv_tokens: 4096,
2215                num_decode_requests: 8,
2216                sum_decode_kv_tokens: 4096, // exact decode row -> 7.0
2217                ..Default::default()
2218            },
2219            ..Default::default()
2220        };
2221        // FULL decode latency (decode-only), not the marginal composition.
2222        let ms = engine.forward_pass_time_ms(&[metrics]).unwrap();
2223        assert!((ms - 7.0).abs() < 1e-12, "{ms}");
2224    }
2225
2226    /// Telemetry dispatch: single-workload FPM ranks flow through the shared
2227    /// free fns; a mixed rank composes prefill + marginal decode.
2228    #[test]
2229    fn fpm_rank_latency_marginal_composition() {
2230        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2231        let tmp = tempfile::tempdir().unwrap();
2232        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2233
2234        let mixed = ForwardPassMetrics {
2235            scheduled_requests: ScheduledRequestMetrics {
2236                num_prefill_requests: 2,
2237                sum_prefill_tokens: 2 * 1024,
2238                sum_prefill_kv_tokens: 0,
2239                num_decode_requests: 8,
2240                sum_decode_kv_tokens: 8 * 4096,
2241                ..Default::default()
2242            },
2243            ..Default::default()
2244        };
2245        // prefill: totals coords (2, 2048, 0) -> exact 21.0. decode: totals
2246        // coords (8, 32768): lerp between (8,4096)->7.0 and (8,65536)->9.0,
2247        // minus baseline (8, 8) -> 6.0.
2248        let w = (32768.0 - 4096.0) / (65536.0 - 4096.0);
2249        let decode = 7.0 + (9.0 - 7.0) * w;
2250        let expected = 21.0 + (decode - 6.0);
2251        let got = engine.forward_pass_time_ms(&[mixed]).unwrap();
2252        assert!((got - expected).abs() < 1e-9, "got {got}, want {expected}");
2253    }
2254
2255    /// The FPM rank dispatch queries RAW iteration totals — the tables'
2256    /// native coordinate system — not the op-level per-request averages,
2257    /// which floor-divide away up to (n - 1) tokens per axis.
2258    #[test]
2259    fn fpm_rank_uses_iteration_totals_not_averages() {
2260        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2261        let tmp = tempfile::tempdir().unwrap();
2262        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2263
2264        // 8 decode requests, 32,773 total KV: NOT divisible by 8. Totals
2265        // convention queries (8, 32773); the old average convention floored
2266        // to kv_per_req = 4096 -> (8, 32768).
2267        let decode_only = ForwardPassMetrics {
2268            scheduled_requests: ScheduledRequestMetrics {
2269                num_decode_requests: 8,
2270                sum_decode_kv_tokens: 32_773,
2271                ..Default::default()
2272            },
2273            ..Default::default()
2274        };
2275        let w = (32_773.0 - 4096.0) / (65_536.0 - 4096.0);
2276        let expected = 7.0 + (9.0 - 7.0) * w;
2277        let got = engine.forward_pass_time_ms(&[decode_only]).unwrap();
2278        assert!((got - expected).abs() < 1e-9, "got {got}, want {expected}");
2279    }
2280
2281    /// The FPM shape guard must see through Overlap/Fallback nesting: a
2282    /// hand-built spec hiding an FpmForward inside a composite would
2283    /// otherwise ride the name-filtered mix-step passes with the wrong
2284    /// workload shape (and FallbackOp swallows its PerfDatabase misses).
2285    #[test]
2286    fn nested_fpm_op_is_rejected_at_build() {
2287        use crate::perf_database::fpm_forward::tests::default_identity;
2288        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2289        let hidden = Op::Overlap(crate::operators::OverlapOp::new(
2290            "hidden",
2291            vec![Op::FpmForward(FpmForwardOp {
2292                name: "fpm_forward_prefill".into(),
2293                phase: FpmPhase::Prefill,
2294                model_path: "org/model-a".into(),
2295                match_identity: default_identity(4),
2296                weight_bytes: 0.0,
2297                sol_ops: vec![],
2298            })],
2299            vec![],
2300        ));
2301        let spec = EngineSpec::new(fixture_engine_config(None), vec![hidden], generation_ops());
2302        let err = Engine::build(spec, Arc::new(db)).unwrap_err();
2303        assert!(
2304            err.to_string()
2305                .contains("exactly one FpmForward op per phase"),
2306            "{err}"
2307        );
2308    }
2309
2310    /// Lock the one piece of orchestration that lives ONLY in the Engine: the
2311    /// `(nextn + 1)` decode-batch multiplier (Python `_run_generation_phase:200`).
2312    /// Builds an Engine with `nextn=1` over the hand-built ops and asserts the
2313    /// generation phase queries the perf-DB at the doubled decode batch — i.e.
2314    /// it equals the shared `run_generation_ops_step` free fn at `2 *
2315    /// batch_size`. Proves `nextn` threads from `spec.engine.speculative` into
2316    /// the gen batch (the one behavior genuinely unique to the Engine layer).
2317    #[test]
2318    fn nextn_scales_decode_batch() {
2319        let engine_nextn1 = build_engine(Some(1));
2320        assert_eq!(engine_nextn1.nextn, 1);
2321
2322        // osl=2 → one decode step at s = isl + 1. With nextn=1 the engine must
2323        // query at batch_size * 2; mirror that with the free fn at 2*batch.
2324        let rt = runtime(1, 1024, 2);
2325        let gen = engine_nextn1
2326            .run_static(&rt, StaticMode::Generation, 32)
2327            .unwrap();
2328        let doubled = run_generation_ops_step(
2329            &engine_nextn1.generation_ops,
2330            engine_nextn1.database(),
2331            2,
2332            1024 + 1,
2333            1.0,
2334            false,
2335        )
2336        .unwrap();
2337        assert!(
2338            (gen.generation_ms - doubled).abs() < 1e-9,
2339            "nextn=1 gen ({}) must equal the gen-step at 2*batch ({})",
2340            gen.generation_ms,
2341            doubled
2342        );
2343    }
2344
2345    /// The SOL-decomposition FFI must agree with a Sol-view evaluation of
2346    /// the same ops: each entry's `sol_time` IS the op's Sol-mode latency
2347    /// (shared query path, shared shape math), and for single-leaf ops the
2348    /// leaf identity `sol_time = max(sol_math, sol_mem)` holds. The GEMM
2349    /// triple is additionally pinned to its closed-form roofline — the
2350    /// Python SOL_FULL `get_sol` verbatim.
2351    #[test]
2352    fn evaluate_ops_sol_json_matches_sol_view() {
2353        use crate::perf_database::gemm::quant_tc_flops;
2354        use crate::session::query_context_op;
2355
2356        let engine = build_engine(None);
2357        let ops = context_ops();
2358        let ops_json = serde_json::to_string(&ops).unwrap();
2359        let (batch, s) = (4u32, 512u32);
2360        let sol = engine
2361            .evaluate_ops_sol_json(&ops_json, true, batch, s, 0, 1.0, None)
2362            .unwrap();
2363        assert_eq!(sol.len(), ops.len());
2364
2365        let sol_db = engine.database().sol_full_view();
2366        for (op, entry) in ops.iter().zip(&sol) {
2367            let r = query_context_op(op, &sol_db, batch, s, 0, 1.0, None).unwrap();
2368            assert_eq!(entry.0, op.name());
2369            assert!(
2370                (entry.1 - r.latency_ms).abs() < 1e-12,
2371                "{}: sol_time {} != Sol-view latency {}",
2372                entry.0,
2373                entry.1,
2374                r.latency_ms
2375            );
2376        }
2377
2378        // Single-leaf ops: sol_time = max(sol_math, sol_mem). (Composed ops
2379        // like context attention add fused-extras leaves AFTER the max, so
2380        // the identity intentionally does not hold there.)
2381        for entry in sol.iter().take(2) {
2382            assert!(
2383                (entry.1 - entry.2.max(entry.3)).abs() < 1e-12,
2384                "{}: leaf max identity broken: {:?}",
2385                entry.0,
2386                entry
2387            );
2388        }
2389
2390        // GEMM triple == the closed-form roofline at m = batch * s
2391        // (Python `GEMM._query_gemm_table::get_sol`).
2392        let spec = &engine.database().system_spec;
2393        let quant = GemmQuantMode::Fp8Block;
2394        let tc_flops = quant_tc_flops(spec, quant.mapping()).unwrap();
2395        let (m, n, k) = ((batch * s) as f64, 4096.0, 4096.0);
2396        let math = 2.0 * m * n * k / tc_flops * 1000.0;
2397        let mem = quant.mapping().memory * (m * n + m * k + n * k) / spec.gpu.mem_bw * 1000.0;
2398        let gemm = &sol[1];
2399        assert!(
2400            (gemm.2 - math).abs() < 1e-12,
2401            "sol_math {} != {math}",
2402            gemm.2
2403        );
2404        assert!((gemm.3 - mem).abs() < 1e-12, "sol_mem {} != {mem}", gemm.3);
2405    }
2406
2407    /// GLM-5.2 DSA full/skip amortization (`full_frac < 1`) must blend the
2408    /// SOL decomposition componentwise alongside the latency — the blended
2409    /// result reaches `PerOpSolFold::add` with components, and each component
2410    /// equals `w*full + (1-w)*skip` of the closed-form rooflines.
2411    #[test]
2412    fn evaluate_ops_sol_json_blends_dsa_full_skip() {
2413        use crate::common::enums::{FmhaQuantMode, KvCacheQuantMode};
2414        use crate::operators::DsaModuleOp;
2415        use crate::perf_database::dsa::{dsa_context_sol, dsa_context_sol_flops, dsa_dims};
2416
2417        let engine = build_engine(None);
2418        let spec = &engine.database().system_spec;
2419        let mut op = DsaModuleOp::new(
2420            "dsa_context",
2421            128,
2422            KvCacheQuantMode::Bfloat16,
2423            FmhaQuantMode::Bfloat16,
2424            GemmQuantMode::Bfloat16,
2425            "DeepseekV32ForCausalLM",
2426            2048,
2427        );
2428        let w = 0.5;
2429        op.full_frac = w;
2430        let (b, s) = (1u32, 4096u32);
2431        let ops_json = serde_json::to_string(&vec![Op::DsaContext(op.clone())]).unwrap();
2432        let sol = engine
2433            .evaluate_ops_sol_json(&ops_json, true, b, s, 0, 1.0, None)
2434            .unwrap();
2435        assert_eq!(sol.len(), 1);
2436
2437        let dims = dsa_dims(&op.architecture);
2438        let flops = dsa_context_sol_flops(spec, op.gemm_quant_mode, op.fmha_quant_mode).unwrap();
2439        let leaf = |skip: bool| {
2440            dsa_context_sol(
2441                spec,
2442                dims,
2443                op.index_topk as i64,
2444                op.kv_cache_dtype,
2445                op.fmha_quant_mode,
2446                op.gemm_quant_mode,
2447                b as i64,
2448                s as i64,
2449                0,
2450                op.num_heads as i64,
2451                skip,
2452                flops,
2453            )
2454        };
2455        let (full, skip) = (leaf(false), leaf(true));
2456        let expected_math = w * full.math_ms + (1.0 - w) * skip.math_ms;
2457        let expected_mem = w * full.mem_ms + (1.0 - w) * skip.mem_ms;
2458        let expected_time = w * full.time_ms() + (1.0 - w) * skip.time_ms();
2459        let (_, sol_time, sol_math, sol_mem) = &sol[0];
2460        assert!(
2461            (sol_time - expected_time).abs() < 1e-12,
2462            "{sol_time} vs {expected_time}"
2463        );
2464        assert!(
2465            (sol_math - expected_math).abs() < 1e-12,
2466            "{sol_math} vs {expected_math}"
2467        );
2468        assert!(
2469            (sol_mem - expected_mem).abs() < 1e-12,
2470            "{sol_mem} vs {expected_mem}"
2471        );
2472        // The skip leaf must actually differ from the full leaf, or this
2473        // test would pass vacuously on a broken blend.
2474        assert!(skip.time_ms() < full.time_ms());
2475    }
2476
2477    /// CP DSA currently composes latency-only sparse MQA/top-k deltas, so the
2478    /// SOL_FULL API must reject that configuration at the DSA boundary. It
2479    /// must not run the composition and fail later with PerOpSolFold's generic
2480    /// `no SOL decomposition` error. The adjacent non-CP blend test pins the
2481    /// supported `cp_size=1` contract.
2482    #[test]
2483    fn evaluate_ops_sol_json_rejects_cp_dsa_explicitly() {
2484        use crate::operators::DsaModuleOp;
2485
2486        let engine = build_engine(None);
2487        let mut op = DsaModuleOp::new(
2488            "dsa_context",
2489            64,
2490            KvCacheQuantMode::Bfloat16,
2491            FmhaQuantMode::Bfloat16,
2492            GemmQuantMode::Bfloat16,
2493            "GlmMoeDsaForCausalLM",
2494            2048,
2495        );
2496        op.cp_size = 2;
2497        op.full_frac = 0.5;
2498        let ops_json = serde_json::to_string(&vec![Op::DsaContext(op)]).unwrap();
2499        let err = engine
2500            .evaluate_ops_sol_json(&ops_json, true, 1, 4096, 0, 1.0, None)
2501            .unwrap_err();
2502
2503        match err {
2504            AicError::InvalidEngineConfig(message) => {
2505                assert!(
2506                    message.contains("DSA context SOL_FULL decomposition is not supported")
2507                        && message.contains("cp_size=2")
2508                        && message.contains("sparse MQA/top-k deltas are latency-only"),
2509                    "unexpected message: {message}"
2510                );
2511            }
2512            other => panic!("expected explicit CP DSA configuration error, got {other}"),
2513        }
2514    }
2515
2516    /// Op families whose SOL branch does not export its decomposition yet
2517    /// must error loudly (never silently chart a wrong breakdown).
2518    #[test]
2519    fn evaluate_ops_sol_json_rejects_unexported_families() {
2520        let engine = build_engine(None);
2521        let ops = vec![Op::Mamba2(crate::operators::Mamba2Op {
2522            name: "mamba2".into(),
2523            scale_factor: 1.0,
2524            kernel_source: "causal_conv1d_fn".into(),
2525            phase: "context".into(),
2526            d_model: 4096,
2527            d_state: 128,
2528            d_conv: 4,
2529            nheads: 128,
2530            head_dim: 64,
2531            n_groups: 8,
2532            chunk_size: 256,
2533        })];
2534        let ops_json = serde_json::to_string(&ops).unwrap();
2535        let err = engine
2536            .evaluate_ops_sol_json(&ops_json, true, 1, 128, 0, 1.0, None)
2537            .unwrap_err();
2538        assert!(matches!(&err, AicError::SolNotImplemented(_)));
2539        assert!(
2540            err.to_string().contains("no SOL decomposition"),
2541            "unexpected error: {err}"
2542        );
2543    }
2544}