Skip to main content

cortiq_engine/
pipeline.rs

1//! Full inference pipeline: tokenize → embed → layers → lm_head → sample → decode.
2//!
3//! Prefill/decode contract: every token is forwarded exactly once and
4//! enters the KV cache exactly once. Logits for the next token are
5//! computed from the hidden state of the LAST forwarded token — the
6//! decode loop forwards the freshly sampled token, never re-embeds the
7//! prompt tail (v1 duplicated the last prompt token in the cache).
8
9use crate::attention::{self, QwenAttnCfg};
10use crate::inference;
11use crate::kv_cache::KvCache;
12use crate::linear_core::{
13    GdnCfg, GdnWeights, ShortConvCfg, ShortConvWeights, VmfPhaseCfg, VmfPhaseWeights, gdn_forward,
14    gdn_pair, short_conv_forward, short_conv_forward_batch, short_conv_pair, vmf_phase_forward,
15    vmf_phase_pair,
16};
17use crate::pool::Pool;
18use crate::qtensor::QTensor;
19use crate::sampler::{self, SamplerConfig, SamplerScratch, SplitMix64};
20use crate::tokenizer::Tokenizer;
21use cortiq_core::mask::TaskMask;
22use cortiq_core::types::NormStyle;
23
24pub static GLOBAL_USE_GPU: std::sync::atomic::AtomicBool =
25    std::sync::atomic::AtomicBool::new(false);
26
27/// Reusable per-pipeline forward scratch: the four norm outputs the
28/// decode paths recompute every layer (single: n1/p1; pair: all four).
29/// Plain buffers, resized once — steady-state decode reuses them.
30struct ForwardScratch {
31    n1: Vec<f32>,
32    n2: Vec<f32>,
33    p1: Vec<f32>,
34    p2: Vec<f32>,
35}
36
37impl ForwardScratch {
38    fn new(hidden: usize) -> Self {
39        Self {
40            n1: vec![0.0; hidden],
41            n2: vec![0.0; hidden],
42            p1: vec![0.0; hidden],
43            p2: vec![0.0; hidden],
44        }
45    }
46}
47
48/// Complete inference pipeline state.
49pub struct Pipeline {
50    /// In-process layer split across local GPUs: (device, first layer,
51    /// last layer) per segment, in execution order. `None` = one device.
52    /// Arc so cloning the plan out of `&mut self` does not fight the
53    /// borrow checker on the hot path.
54    gpu_plan: Option<std::sync::Arc<Vec<(usize, usize, usize)>>>,
55    /// Arc: the server shares one tokenizer handle across request
56    /// handlers without borrowing a pipeline slot.
57    pub tokenizer: std::sync::Arc<Tokenizer>,
58    pub kv_cache: KvCache,
59    pub sampler_config: SamplerConfig,
60    pub weights: PipelineWeights,
61    pub hidden_size: usize,
62    pub intermediate_size: usize,
63    pub num_heads: usize,
64    pub num_kv_heads: usize,
65    pub head_dim: usize,
66    /// Total virtual layers (num_layers × num_loops for looped models).
67    pub num_layers: usize,
68    /// Physical layers in weights.layers (≤ num_layers for looped models).
69    pub physical_layers: usize,
70    /// Looped Transformer: apply final norm after each loop iteration.
71    pub loop_final_norm: bool,
72    pub vocab_size: usize,
73    pub rms_eps: f64,
74    pub rope_base: f32,
75    pub norm_style: NormStyle,
76    /// RoPE dims actually rotated (≤ head_dim; Qwen3.5 uses head_dim/4).
77    pub rotary_dim: usize,
78    /// Optional Q-head count override for each attention layer (Laguna).
79    pub attention_heads_per_layer: Option<Vec<usize>>,
80    /// Linear-core geometry (present when the model has linear layers).
81    pub vmf_cfg: Option<VmfPhaseCfg>,
82    /// GatedDeltaNet geometry (faithful vendor operator).
83    pub gdn_cfg: Option<GdnCfg>,
84    /// MiniCPM-class logit scale (tied lm_head → cannot fold into weights).
85    pub logit_multiplier: Option<f32>,
86    /// Cooperative cancel: set from any thread (FFI `cortiq_cancel`,
87    /// a dropped server connection); the generate loop checks it at
88    /// every prefill chunk and decode step and finishes with
89    /// `finish_reason: "cancelled"`. Auto-cleared when honoured.
90    pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
91    /// A GPU graph failure is distinct from a user/request cancellation.
92    /// Graph code sets this before raising the cooperative cancel flag so the
93    /// generation API can return an error instead of reporting a successful
94    /// `finish_reason: cancelled` result.
95    graph_failed: std::sync::atomic::AtomicBool,
96    /// Token ids currently materialized in the KV cache (the forwarded
97    /// prompt + all generated tokens except the last, which is sampled
98    /// but not yet forwarded). Lets the next generate call prefill only
99    /// the suffix when a chat app resends the whole history.
100    pub kv_history: Vec<u32>,
101    /// KDA geometry (Kimi Linear / Kimi-K3) — shared by every Kda layer.
102    pub kda_cfg: Option<crate::linear_core::KdaCfg>,
103    /// Gemma-3n stack (AltUp/LAuReL/PLE/KV-sharing): its own forward —
104    /// weights.layers stays empty, the KV caches are the shared ones.
105    pub g3n: Option<Box<(crate::g3n::G3nGlobals, Vec<crate::g3n::G3nLayer>)>>,
106    /// DeepSeek-V4 runs its own stack too: its hidden state is `hc_mult`
107    /// copies of a vector, so no loop written for a single residual
108    /// stream can carry it.
109    pub dsv4: Option<
110        Box<(
111            crate::dsv4::Dsv4Globals,
112            Vec<crate::dsv4::Dsv4Layer>,
113            crate::dsv4::Dsv4Cfg,
114            crate::dsv4::Dsv4State,
115        )>,
116    >,
117    /// DeepSeek-V4.1 owns the shared CED/CSA2 attention state, raw Engram
118    /// lookup and four-stream mHC handoff. It cannot use the V4 cache
119    /// layout, so it has a dedicated executor and state tuple.
120    pub dsv41: Option<
121        Box<(
122            crate::dsv41::Dsv41Globals,
123            Vec<crate::dsv41::Dsv41Layer>,
124            crate::dsv41::Dsv41Cfg,
125            crate::dsv41::Dsv41State,
126        )>,
127    >,
128    /// Optional V4.1 vision tower. Text-only files leave this unset.
129    pub dsv41_vision: Option<crate::dsv41_vision::VisionModel>,
130    /// Prepared image rows consumed by the next V4.1 prefill.
131    dsv41_prefill: Option<(Vec<Option<Vec<f32>>>, Vec<bool>)>,
132    /// Qwen3.8-Flash-Next owns four residual streams plus QSA/PLE state;
133    /// the generic single-residual layer loop cannot represent it.
134    pub qwen4_exp: Option<
135        Box<(
136            crate::qwen4_exp::Globals,
137            Vec<crate::qwen4_exp::Layer>,
138            crate::qwen4_exp::Cfg,
139            crate::qwen4_exp::State,
140        )>,
141    >,
142    /// DeepSeek-V4's own speculation stack: three draft modules, each a full
143    /// layer, plus a confidence head on the last. Empty when the file has
144    /// none, which is the only signal the decode path needs.
145    pub dsv4_mtp: Vec<crate::dsv4::Dsv4Mtp>,
146    /// The draft's per-sequence state (KV rings, captured trunk hidden).
147    pub dspark: Option<crate::dsv4::DsparkState>,
148    /// Drafts awaiting their verdict: (position, proposals, still matching,
149    /// accepted so far).
150    pub dspark_pending: Vec<(usize, Vec<u32>, bool, usize)>,
151    /// Accepted prefix length of every graded draft.
152    pub dspark_hist: Vec<usize>,
153    /// The real tokens the drafts were graded against — a degenerate,
154    /// repeating output would make any acceptance number meaningless, and
155    /// the cheapest guard against believing one is to count them.
156    pub dspark_real: Vec<u32>,
157    /// The trunk's expert picks for the last few tokens, per layer. The
158    /// union over a window of them is what a batched verify would have to
159    /// read, and the ratio to the pick count is all it could save.
160    pub dspark_trunk_picks: Vec<Vec<(usize, Vec<usize>)>>,
161    /// (unique, total) expert picks per draft, trunk side and draft side.
162    pub dspark_exp: Vec<(usize, usize, usize, usize)>,
163    /// Wall time spent in the deliberately out-of-core draft. Kept separate
164    /// from trunk decode so block batching can be judged without conflating
165    /// it with GPU chain variance.
166    pub dspark_draft_ns: u128,
167    /// LFM2 short-convolution geometry (present when the model has
168    /// `ShortConv` mixer layers).
169    pub short_conv_cfg: Option<ShortConvCfg>,
170    /// Multi-token-prediction head (None = absent).
171    pub mtp: Option<MtpModule>,
172    /// Speculative decode via MTP (greedy only; `CMF_MTP=0` disables).
173    pub speculative: bool,
174    rng: SplitMix64,
175    sampler_scratch: SamplerScratch,
176    /// Speculative SAMPLING state (graph_spec_step, temperature > 0): the
177    /// correction token a rejected draft produced — committed by the loop
178    /// top in place of a fresh draw — and the per-round draft
179    /// distributions / target scratch, reused so a round allocates
180    /// nothing at the vocab size.
181    spec_forced: Option<u32>,
182    spec_q: Vec<Vec<f32>>,
183    spec_p: Vec<f32>,
184    spec_res: Vec<f32>,
185    /// The same three for the sparse chain (top-k configs).
186    spec_qs: Vec<sampler::Sparse>,
187    spec_ps: sampler::Sparse,
188    spec_ress: sampler::Sparse,
189    /// Which arm the MTP draft block runs on this generation: Some(true)
190    /// = the whole-token graph (device attention, one submit a step),
191    /// Some(false) = the per-op path; None = not decided yet. Decided
192    /// on the first draft and held, because the two arms keep the MTP
193    /// KV in different places (device mirror vs the CPU cache) and a
194    /// mid-run switch would read the wrong one.
195    mtp_graph_mode: Option<bool>,
196    /// The Metal verify graph of the round in flight, between its sync
197    /// (logits read) and the commit that replays the accepted prefix.
198    #[cfg(target_os = "macos")]
199    metal_verify: Option<MetalVerifyPending>,
200    /// Precomputed RoPE inverse frequencies [head_dim/2]. Arc: the
201    /// forward path clones a handle to escape the &mut self borrow —
202    /// cloning the table itself was a per-forward allocation.
203    pub(crate) inv_freq: std::sync::Arc<Vec<f32>>,
204    /// Reusable norm buffers for the decode hot path (roadmap §3 P0:
205    /// steady-state forward should not heap-allocate). Disjoint field
206    /// from `weights`/`kv_cache`, so split borrows keep working.
207    ws: ForwardScratch,
208    /// Persistent worker pool (None = serial; see CMF_THREADS).
209    pool: Option<std::sync::Arc<Pool>>,
210    // ── Dynamic per-token skill routing (spec §9, claim 14/16) ──
211    /// Source model, retained so a skill switch can re-resolve the
212    /// touched layers' FFN tensors (Mapped = mmap pointers, cheap).
213    pub(crate) model: Option<std::sync::Arc<cortiq_core::CmfModel>>,
214    /// Masks present → weights are dequantized f32 (rebuild path).
215    pub(crate) dyn_force_f32: bool,
216    /// Per-skill FFN layers actually replaced (derived from tensors, not
217    /// the meta `layers` field — ru2 replaces down_proj in 0..23 while
218    /// its meta says [20..23]). None = skill touches non-FFN tensors →
219    /// ineligible for cheap dynamic switching (honest refusal).
220    pub(crate) dyn_skill_layers: Vec<Option<Vec<usize>>>,
221    /// Currently overlaid skill (index into model.header.skills); None =
222    /// backbone. Set at load time to the statically-overlaid skill so
223    /// `set_active_skill(None)` correctly reverts it (else a static
224    /// skill would silently persist — the union-diff assumes dyn_active
225    /// always mirrors the live overlay). Switched by `set_active_skill`.
226    pub(crate) dyn_active: Option<usize>,
227    /// Pipeline was loaded with a soft blend (materialized working
228    /// tensors, not a single skill index) → dynamic routing refuses:
229    /// there is no single index to revert the blend from.
230    pub(crate) dyn_blend_loaded: bool,
231    /// Layer whose post-residual hidden feeds the router φ (shared by
232    /// swarm skills). None = φ capture off.
233    pub(crate) dyn_phi_layer: Option<usize>,
234    /// EMA of φ at `dyn_phi_layer` over the decode window (on-policy).
235    dyn_phi_ema: Vec<f32>,
236    dyn_phi_seen: usize,
237    /// Hysteresis router driving per-token skill switches during decode
238    /// (None = static/no dynamic routing). Taken out during generation.
239    pub dyn_router: Option<crate::swarm::DynRouter>,
240    /// O(1) Nyström attention setting (CLI/env/header-hint resolved by
241    /// the caller; None = plain cache attention everywhere).
242    o1_cfg: Option<crate::nystrom::O1Cfg>,
243    /// Bumped once per collecting→sealed transition — the GPU state mirror
244    /// re-uploads when it sees a new epoch (each fresh sealed state).
245    o1_epoch: u64,
246    /// Per-layer o1 flags derived from `o1_cfg` (Full layers only).
247    o1_flags: Vec<bool>,
248    /// Emit a structured per-token trace (B4 telemetry channel). Off by
249    /// default — the runtime is silent unless observation is requested.
250    trace: bool,
251    /// Confidence-calibration temperature (B1): reported probability is
252    /// softmax(logits / calib_temp). 1.0 = raw. Set from header.calibration.
253    calib_temp: f32,
254    /// Process-unique id keying this pipeline's device KV mirrors.
255    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
256    graph_kv_id: u64,
257    /// Decode asks the token graph to also run final-norm + lm_head on
258    /// the device (drops the separate per-op lm_head round trip).
259    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
260    graph_want_logits: bool,
261    /// Logits the graph produced for the token just forwarded (taken by
262    /// the decode loop; None = compute on the CPU path).
263    graph_logits: Option<Vec<f32>>,
264    /// Token embeddings are multiplied by this at input (Gemma: √hidden).
265    pub embed_multiplier: f32,
266    /// Attention score scale (1/√head_dim unless the arch overrides —
267    /// Gemma's query_pre_attn_scalar).
268    pub attn_scale: f32,
269    /// Sliding-window attention: (window, every-Nth-layer-is-global
270    /// pattern) — Gemma-3.
271    pub swa: Option<(usize, usize)>,
272    /// Explicit local/global schedule for architectures that cannot be
273    /// represented by Gemma's every-Nth-global convention.
274    pub sliding_layers: Option<Vec<bool>>,
275    /// RoPE table of the sliding (local) layers, when they use their
276    /// own base frequency (Gemma-3: 10k local vs 1M global).
277    pub inv_freq_local: Option<std::sync::Arc<Vec<f32>>>,
278    pub rotary_dim_local: Option<usize>,
279    pub rope_scale: f32,
280    pub rope_scale_local: f32,
281    /// Gemma-4: global layers run their own geometry — (head_dim,
282    /// num_kv_heads); sliding layers keep the base fields.
283    pub global_attn: Option<(usize, usize)>,
284    /// Gemma-4: the global layers' proportional RoPE table (len
285    /// global_head_dim/2, zero-padded tail = identity rotation).
286    pub inv_freq_global: Option<std::sync::Arc<Vec<f32>>>,
287    /// Scale-less RMS normalization of V heads before caching (Gemma-4).
288    pub attn_v_norm: bool,
289    /// Final-logit soft-capping C: logits = C·tanh(logits/C) (Gemma-4).
290    pub final_softcap: Option<f32>,
291    /// Cortiq Embryo hierarchical head: cluster matrix [C, hidden]. The
292    /// flat logits h·Eᵀ are turned into the two-level log-probabilities
293    /// log softmax_c(h·Cᵀ)[c(v)] + log softmax_{s∈c(v)}(h·E_c(v)ᵀ)[v].
294    pub head_clusters: Option<std::sync::Arc<Vec<f32>>>,
295    /// Gemma-2 attention-logit soft-capping (0.0 = off).
296    pub attn_softcap: f32,
297    /// Compute per-token confidence (a full-vocab softmax each
298    /// token). On by default; `bench --core` turns it off to match
299    /// llama-bench's core timing.
300    confidence_on: bool,
301    /// Test-only one-shot forward failure, scoped to this pipeline so
302    /// parallel scoring tests cannot consume one another's injection.
303    #[cfg(test)]
304    nll_test_fail_at: Option<usize>,
305    /// Test-only route override; avoids mutating the process-wide
306    /// `CMF_PREFILL` environment variable while forcing the serial path.
307    #[cfg(test)]
308    nll_test_force_serial: bool,
309}
310
311#[cfg(target_os = "macos")]
312impl Drop for Pipeline {
313    fn drop(&mut self) {
314        crate::gpu::kv_mirror_drop(self.graph_kv_id);
315    }
316}
317
318/// Model weights. Matrices are `QTensor` (owned f32 for small models
319/// and tests — bit-identical to the historical paths — or quantized
320/// bytes zero-copy from the CMF mmap for big models). 1-D norms are
321/// always small and stay f32.
322pub struct PipelineWeights {
323    /// Embedding table: [vocab_size, hidden_size]
324    pub embed_tokens: QTensor,
325    /// Per-layer weights
326    pub layers: Vec<LayerWeights>,
327    /// LM head: [vocab_size, hidden_size]
328    pub lm_head: QTensor,
329    /// Final norm: [hidden_size]
330    pub final_norm: Vec<f32>,
331}
332
333/// One transformer layer: shared norms + MLP, attention by kind.
334pub struct LayerWeights {
335    pub input_norm: Vec<f32>,
336    /// The pre-FFN norm (`post_attention_layernorm` classically;
337    /// `pre_feedforward_layernorm` on Gemma-2/3 sandwich layers).
338    pub post_norm: Vec<f32>,
339    /// Gemma-2/3 sandwich: norm applied to the ATTENTION OUTPUT before
340    /// its residual add (`post_attention_layernorm` there).
341    pub attn_out_norm: Option<Vec<f32>>,
342    /// Gemma-4: the whole layer output is multiplied by this scalar.
343    pub layer_scale: Option<f32>,
344    /// Gemma-2/3 sandwich: norm applied to the FFN OUTPUT before its
345    /// residual add (`post_feedforward_layernorm`).
346    pub ffn_out_norm: Option<Vec<f32>>,
347    pub ffn: FfnKind,
348    pub attn: AttnKind,
349}
350
351/// FFN gate activation: SiLU (SwiGLU family) or tanh-GELU (Gemma's
352/// GeGLU). A property of the model, carried on every FFN triple.
353#[derive(Clone, Copy, PartialEq, Debug, Default)]
354pub enum Act {
355    #[default]
356    Silu,
357    GeluTanh,
358    /// Kimi-K3 SituAndMul: BOTH halves transform —
359    /// a = β·tanh(g/β)·σ(g), up' = linβ·tanh(u/linβ) (linβ>0), out = a·up'.
360    Situ {
361        beta: f32,
362        linear_beta: f32,
363    },
364}
365
366impl Act {
367    pub fn from_arch(name: &str) -> Self {
368        if name == "gelu_tanh" {
369            Self::GeluTanh
370        } else {
371            Self::Silu
372        }
373    }
374
375    /// Arch-driven constructor (activation name + situ betas).
376    pub fn from_arch_full(arch: &cortiq_core::ModelArch) -> Self {
377        match arch.hidden_act.as_str() {
378            "situ" => Self::Situ {
379                beta: arch.activation_situ_beta.unwrap_or(1.0) as f32,
380                linear_beta: arch.activation_situ_linear_beta.unwrap_or(0.0) as f32,
381            },
382            other => Self::from_arch(other),
383        }
384    }
385
386    #[inline]
387    pub fn apply(self, x: f32) -> f32 {
388        match self {
389            Self::Silu => inference::silu(x),
390            Self::GeluTanh => inference::gelu_tanh(x),
391            Self::Situ { beta, .. } => beta * (x / beta).tanh() * (1.0 / (1.0 + (-x).exp())),
392        }
393    }
394
395    /// Gated combine — the FFN contract. Situ transforms the UP half
396    /// too, so callers must use this instead of apply(g)·u.
397    #[inline]
398    pub fn combine(self, g: f32, u: f32) -> f32 {
399        match self {
400            Self::Situ { linear_beta, .. } if linear_beta > 0.0 => {
401                self.apply(g) * (linear_beta * (u / linear_beta).tanh())
402            }
403            _ => self.apply(g) * u,
404        }
405    }
406}
407
408/// Dense gated triple — the FFN of a dense layer or of one expert.
409pub struct DenseFfn {
410    pub gate_proj: QTensor,
411    pub up_proj: QTensor,
412    pub down_proj: QTensor,
413    /// Gate activation (SiLU default; Gemma: tanh-GELU).
414    pub act: Act,
415    /// `down_proj` stored transposed (`[inter, hidden]`), when the file
416    /// carries it. Only the per-token sparse path reads it: a neuron's
417    /// down weights are a contiguous ROW there, so the token's chosen
418    /// neurons are the only bytes touched. `None` = the ordinary layout,
419    /// and the sparse path stays off.
420    pub down_t: Option<QTensor>,
421    /// Task tubes (spec: defragged task-conditional width). The three
422    /// matrices above are the CORE — the neurons every task computes;
423    /// each tube is an independently quantized slice of the SAME layer
424    /// holding the neurons only some tasks need. A tube is a normal
425    /// tensor triple, so every kernel runs it unchanged, and the bytes
426    /// of an inactive tube are never read. Empty = ordinary dense FFN.
427    pub segs: Vec<FfnSeg>,
428}
429
430/// One task tube: a contiguous slice of a layer's FFN neurons, stored
431/// as its own `[w, hidden]` / `[hidden, w]` triple. `start` is the
432/// neuron's index in the layer's FULL space (core first, then tubes in
433/// order) — the bit a task mask sets to switch this tube on.
434pub struct FfnSeg {
435    pub gate: QTensor,
436    pub up: QTensor,
437    pub down: QTensor,
438    pub start: usize,
439    pub width: usize,
440}
441
442/// FFN operator of a layer, decided by tensor presence at load time
443/// (router `mlp.gate.weight` in the directory = MoE layer).
444pub enum FfnKind {
445    Dense(DenseFfn),
446    /// Mixture-of-Experts (Qwen2-MoE / Qwen3-MoE): softmax over ALL
447    /// expert logits → top-k, optional renorm; experts stay quantized
448    /// in mmap — only the selected ones are touched per token.
449    Moe(MoeFfn),
450    /// Gemma-4 MoE: a dense MLP branch AND a routed-expert branch in
451    /// the SAME layer, each with its own norm sandwich. The dense
452    /// branch reads the pre-FFN-normed input; the expert branch (and
453    /// the router) read the RAW residual through `pre_norm_2`:
454    ///   d = post_norm_1(dense(x̂));  m = post_norm_2(Σwₑ·FFNₑ(pre_norm_2(h)))
455    ///   ffn_out = d + m   (the caller's ffn_out_norm + residual follow)
456    DenseMoe(Box<DenseMoeFfn>),
457}
458
459/// Gemma-4 dual-branch FFN (see `FfnKind::DenseMoe`).
460pub struct DenseMoeFfn {
461    pub dense: DenseFfn,
462    pub moe: MoeFfn,
463    /// post_feedforward_layernorm_1 — dense-branch output norm.
464    pub post_norm_1: Vec<f32>,
465    /// pre_feedforward_layernorm_2 — expert-branch input norm (applied
466    /// to the RAW residual, not the pre-FFN-normed activation).
467    pub pre_norm_2: Vec<f32>,
468    /// post_feedforward_layernorm_2 — expert-branch output norm.
469    pub post_norm_2: Vec<f32>,
470}
471
472pub struct MoeFfn {
473    /// Router `mlp.gate.weight` [num_experts, hidden].
474    pub router: QTensor,
475    pub experts: Vec<DenseFfn>,
476    pub top_k: usize,
477    pub norm_topk_prob: bool,
478    /// Router scores per-expert with a sigmoid (LFM2-MoE / DeepSeek-V3
479    /// `noaux_tc`) instead of a softmax over all experts (Qwen).
480    pub router_sigmoid: bool,
481    /// Per-expert selection bias `mlp.expert_bias` [num_experts]
482    /// (LFM2-MoE): added to the sigmoid scores for the top-k CHOICE only;
483    /// the gathered weights use the unbiased scores. None = no bias.
484    pub expert_bias: Option<Vec<f32>>,
485    /// Top-k weights are multiplied by this after the optional renorm
486    /// (LFM2-MoE `routed_scaling_factor`; 1.0 = off).
487    pub routed_scaling: f32,
488    /// Adaptive routing (CMF_MOE_TAU, opt-in): keep the smallest
489    /// prefix of the top-k whose renormalized mass reaches τ —
490    /// confident tokens touch 1–2 experts, flat ones keep all k.
491    /// MoE decode is memory-bound, so skipped experts are skipped
492    /// weight traffic. None = classic fixed top-k (bit-identical).
493    pub route_tau: Option<f32>,
494    /// Always-on shared expert. Qwen2-MoE carries an additional sigmoid
495    /// gate; Laguna adds the shared expert unconditionally (`None`).
496    pub shared: Option<(DenseFfn, Option<QTensor>)>,
497    /// Expert-selection counters (truncated Fisher B-field of claim 12:
498    /// routing frequency during calibration). Filled by every forward,
499    /// read by the CLI via CMF_MOE_STATS. RefCell: decode is single-threaded.
500    pub stats: std::cell::RefCell<Vec<u64>>,
501    /// Per-CHANNEL sum of squares of this FFN's input, accumulated over a
502    /// calibration run (`CMF_RMS_TRACE`). These are the RMS activation
503    /// traces AWNP needs: raw weight magnitude says every channel matters
504    /// equally, and the question AWNP asks is whether the ACTIVATIONS
505    /// disagree. Off unless the env var is set — an f64 add per channel
506    /// per token is cheap, but not free.
507    pub act_sq: std::cell::RefCell<Vec<f64>>,
508    /// Raw FFN-input rows captured for the layers named by `CMF_ACT_DUMP`
509    /// (`"9,19"`). AWNP is nullspace PROJECTION: after dropping channels the
510    /// survivors are refitted to absorb what was removed, and how much they
511    /// can absorb depends on the activation COVARIANCE, not on per-channel
512    /// RMS. Per-channel numbers can only bound the cost from above.
513    pub act_rows: std::cell::RefCell<Vec<f32>>,
514    /// Task mask over routed experts (DTG-MA over MoE, claim-12 B-field
515    /// applied): `false` experts are excluded from selection, the
516    /// softmax renormalizes over the allowed set. Built by the loader
517    /// from CMF_MOE_MASK=<stats.json> + CMF_MOE_MASK_COVER. None = all.
518    pub mask: Option<Vec<bool>>,
519    /// Gemma-4: per-expert weight scale applied AFTER the top-k renorm
520    /// (`router.per_expert_scale`). None = 1.0 everywhere.
521    pub per_expert_scale: Option<Vec<f32>>,
522    /// Gemma-4: the router reads a SCALE-LESS rms-norm of its input
523    /// (the constant gain router.scale·√hidden is folded into the
524    /// router weights at convert time).
525    pub router_input_norm: bool,
526    /// Cortiq Embryo: resonance routing (P1) — the "logits" are
527    /// bias_e − ‖(x−μ_e) − U_eᵀU_e(x−μ_e)‖², argmax = the expert whose
528    /// descriptor reconstructs the input best. `router` is a placeholder.
529    pub resonance: Option<Resonance>,
530}
531
532/// Per-expert resonance descriptors of one MoE layer (`mlp.desc.*`).
533pub struct Resonance {
534    /// [E, hidden]
535    pub mu: Vec<f32>,
536    /// [E, k, hidden] orthonormal directions (k may be 0)
537    pub u: Vec<f32>,
538    pub k: usize,
539    /// [E] selection bias (loss-free balancing, trained online)
540    pub bias: Vec<f32>,
541}
542
543impl Resonance {
544    /// Routing scores for one input row (higher = better).
545    pub fn scores(&self, x: &[f32], out: &mut [f32]) {
546        let h = x.len();
547        let ne = out.len();
548        for e in 0..ne {
549            let mu = &self.mu[e * h..(e + 1) * h];
550            let mut d2 = 0.0f32;
551            for j in 0..h {
552                let d = x[j] - mu[j];
553                d2 += d * d;
554            }
555            let mut proj = 0.0f32;
556            for i in 0..self.k {
557                let u = &self.u[(e * self.k + i) * h..(e * self.k + i + 1) * h];
558                let mut p = 0.0f32;
559                for j in 0..h {
560                    p += (x[j] - mu[j]) * u[j];
561                }
562                proj += p * p;
563            }
564            out[e] = self.bias.get(e).copied().unwrap_or(0.0) - (d2 - proj);
565        }
566    }
567}
568
569/// Attention operator of a layer. Extension point: new operators are
570/// new variants here + a forward in their own module.
571pub enum AttnKind {
572    /// GQA softmax attention (+ optional Qwen3.5 qk-norm / output gate).
573    Full {
574        wq: QTensor,
575        wk: QTensor,
576        wv: QTensor,
577        wo: QTensor,
578        q_norm: Option<Vec<f32>>,
579        k_norm: Option<Vec<f32>>,
580        output_gate: bool,
581        /// Laguna: a separate softplus projection applied to the attention
582        /// output before O. The bool means one scalar per head (broadcast
583        /// across head_dim); false means one scalar per element.
584        softplus_gate: Option<(QTensor, bool)>,
585        /// Qwen2-family projection biases (q, k, v).
586        bias: Option<(Vec<f32>, Vec<f32>, Vec<f32>)>,
587    },
588    /// Canonical linear core (VMF phase attention).
589    Linear(VmfPhaseWeights),
590    /// Faithful vendor linear operator (Qwen3.5 GatedDeltaNet).
591    LinearGdn(GdnWeights),
592    /// LFM2 gated short-convolution mixer (no KV cache; conv ring state
593    /// lives in the layer's `linear_state`).
594    ShortConv(ShortConvWeights),
595    /// DeepSeek-V2 Multi-head Latent Attention. v1 executes it as
596    /// expand-to-MHA: the latent is projected per token, K/V expand to
597    /// every head and live in the ordinary cache (K head layout
598    /// [rope | nope] so the standard partial rotary covers the shared
599    /// rope key; V rows are zero-padded to the K head_dim and the pad
600    /// is sliced off before O). Latent-resident cache is a later
601    /// optimization, not a semantic change.
602    Mla(Box<MlaWeights>),
603    /// Kimi Delta Attention (Kimi Linear / Kimi-K3): per-channel decayed
604    /// delta rule, separate q/k/v short convs, sigmoid-gated output norm.
605    /// State lives in the layer's `linear_state` (no KV cache).
606    Kda(Box<crate::linear_core::KdaWeights>),
607}
608
609/// DeepSeek-V2 MLA projections (see `AttnKind::Mla`).
610pub struct MlaWeights {
611    /// `[nh·(rope+nope), hidden]` (or `[…, q_lora]` when compressed) —
612    /// the converter permutes each head rope-first so rotary_dim =
613    /// qk_rope works unchanged.
614    pub q_proj: QTensor,
615    /// Compressed q (K3/V3 class): x → q_a `[q_lora, hidden]` →
616    /// rms(q_a_norm) → q_proj (= q_b). None = direct q (V2-Lite).
617    pub q_a: Option<QTensor>,
618    pub q_a_norm: Option<Vec<f32>>,
619    /// `kv_a_proj_with_mqa` `[lora + rope, hidden]` (latent first).
620    pub kv_a: QTensor,
621    /// RMS-norm weights over the latent (`kv_a_layernorm`, [lora]).
622    pub kv_a_norm: Vec<f32>,
623    /// `[nh·(nope+v), lora]` — per head [k_nope | v].
624    pub kv_b: QTensor,
625    /// `[hidden, nh·v]`.
626    pub o_proj: QTensor,
627    pub nh: usize,
628    pub qk_rope: usize,
629    pub qk_nope: usize,
630    pub v_dim: usize,
631    pub lora: usize,
632    /// Softmax scale (1/√(rope+nope), YaRN-mscale-corrected at load).
633    pub scale: f32,
634    /// Kimi Linear NoPE: skip the rotary entirely (layout unchanged).
635    pub nope: bool,
636}
637
638/// Multi-token-prediction head (DeepSeek/Qwen style, spec §2.1):
639/// `x = eh_proj·[enorm(embed(next)); hnorm(hidden)]` → one transformer
640/// block over its own KV → shared lm_head. Drafts the token after next;
641/// the main model verifies, so output is exact — MTP only buys speed.
642pub struct MtpModule {
643    pub enorm: Vec<f32>,
644    pub hnorm: Vec<f32>,
645    /// [hidden, 2·hidden]
646    pub eh_proj: QTensor,
647    pub layer: LayerWeights,
648    pub final_norm: Vec<f32>,
649    pub kv: crate::kv_cache::LayerKvCache,
650}
651
652/// A Metal verify graph after its sync: what the commit needs — the
653/// graph (per-layer replay scratch), the GDN layers in encode order (their
654/// CPU states receive the replay), and the attention layers with the CPU
655/// row count they were encoded against (the accepted rows are pulled from
656/// the mirror from there).
657/// One item of the Metal rows-graph plan.
658#[cfg(target_os = "macos")]
659enum MetalRowsItem<'a> {
660    Gdn {
661        run: Vec<crate::gpu_metal::GdnGpuLayer<'a>>,
662        first: usize,
663    },
664    Attn {
665        l: crate::gpu_metal::AttnGpuLayer<'a>,
666        li: usize,
667        q_norm: Option<&'a [f32]>,
668        k_norm: Option<&'a [f32]>,
669        output_gate: bool,
670    },
671}
672
673#[cfg(target_os = "macos")]
674struct MetalVerifyPending {
675    graph: crate::gpu_metal::VerifyGraph,
676    gdn_layers: Vec<usize>,
677    attn_layers: Vec<(usize, usize)>,
678}
679
680/// The speculation trial's phases (see the decode loop): four timed
681/// speculative rounds, eight timed plain tokens, then the faster arm
682/// until a re-check.
683#[derive(Clone, Copy)]
684enum SpecTrial {
685    Spec {
686        t0: std::time::Instant,
687        gen0: usize,
688        rounds: usize,
689    },
690    Plain {
691        t0: std::time::Instant,
692        gen0: usize,
693    },
694    Decided {
695        spec: bool,
696        recheck_at: usize,
697    },
698}
699
700/// The speculation monitor: exponential averages of a round's wall time
701/// and of the tokens it produced, and the plain token's wall time — the
702/// three numbers the keep/stop rule needs. A round pays when
703/// `tokens_per_round · plain_ms > round_ms · 1.03`. The one-shot trial
704/// (four rounds against eight tokens) mis-called prose: the first rounds
705/// after a prompt are formulaic and accept well, the body does not (an
706/// essay measured 39 against a plain 44.8 with the trial saying
707/// "speculate"), so the rule now runs on EVERY round and stops after four
708/// consecutive losing rounds; a stopped speculation is retried 128 tokens
709/// later.
710#[derive(Default, Clone, Copy)]
711struct SpecMon {
712    round_ms: f64,
713    tokens: f64,
714    plain_ms: f64,
715    n: u32,
716    fails: u32,
717}
718
719impl SpecMon {
720    fn round(&mut self, dt_ms: f64, produced: usize) {
721        self.n += 1;
722        if self.n == 1 {
723            return; // round 1 pays the batch scratch and the draft mirror
724        }
725        let a = if self.n == 2 { 1.0 } else { 0.3 };
726        self.round_ms += a * (dt_ms - self.round_ms);
727        self.tokens += a * (produced as f64 - self.tokens);
728    }
729    fn pays(&self) -> bool {
730        self.plain_ms > 0.0 && self.tokens * self.plain_ms > self.round_ms * 1.03
731    }
732}
733
734/// Result of a generation call.
735pub struct GenerateResult {
736    pub text: String,
737    pub token_ids: Vec<u32>,
738    pub prompt_tokens: usize,
739    pub tokens_generated: usize,
740    pub finish_reason: String,
741    /// Speculative-decode stats (0/0 when MTP is absent or inactive).
742    pub mtp_drafted: usize,
743    pub mtp_accepted: usize,
744    /// Per-generated-token confidence = softmax probability of the token
745    /// that was actually emitted (softmax probability on the chosen state). High =
746    /// the model was sure; low = it was guessing. Same length as the
747    /// generated slice of `token_ids`.
748    pub token_confidence: Vec<f32>,
749    /// Structured per-token telemetry (B4 channel). Empty unless
750    /// `set_trace(true)`; otherwise same length as the generated slice.
751    pub traces: Vec<TokenTrace>,
752}
753
754/// One row of the structured telemetry trace (B4): the model's internal
755/// routing state at the moment a token was emitted. Every field is a
756/// quantity the runtime already computes — nothing is inferred or
757/// estimated (anti-principle: only measured bytes).
758#[derive(Clone, Debug)]
759pub struct TokenTrace {
760    /// 0-based index within the generated slice.
761    pub t: usize,
762    /// The emitted token id.
763    pub token_id: u32,
764    /// Softmax probability on the emitted token — how sure the model was.
765    pub confidence: f32,
766    /// Skill in force while this token was generated (None = backbone).
767    pub active_skill: Option<String>,
768    /// Recon error E = ‖r−BBᵀr‖²/‖φ‖² at the last routing eval — coherence
769    /// with the active skill's subspace (low = coherent). None = no router
770    /// or not yet evaluated.
771    pub recon: Option<f32>,
772    /// The router changed the active skill right after this token (a
773    /// domain boundary crossed under the hysteresis barrier).
774    pub switched: bool,
775}
776
777/// Calibrated softmax probability of `id` under `logits` (the confidence on
778/// the emitted token) — the confidence signal, cheap from logits already
779/// computed for sampling. `temp` is the calibration temperature (B1):
780/// softmax(logits / temp); 1.0 = raw.
781#[cfg_attr(not(test), allow(dead_code))]
782fn top1_prob_t(logits: &[f32], id: u32, temp: f32) -> f32 {
783    let t = if temp > 1e-3 { temp } else { 1.0 };
784    let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
785    let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
786    if sum > 0.0 {
787        (((logits[id as usize] - max) / t).exp()) / sum
788    } else {
789        0.0
790    }
791}
792
793/// prefill-GEMM enabled? (CMF_PREFILL=seq — emergency fallback to the
794/// sequential path.)
795fn prefill_batched() -> bool {
796    std::env::var("CMF_PREFILL")
797        .map(|v| v != "seq")
798        .unwrap_or(true)
799}
800
801/// Input to the layer-major batched span walk: token ids (embeds itself,
802/// full-stack and coordinator prefill) or ready boundary hiddens (the
803/// network worker's side of a split).
804#[derive(Clone, Copy)]
805enum PrefillIn<'a> {
806    Ids(&'a [u32]),
807    Hidden(&'a [f32]),
808}
809
810/// The batched prefill walks `weights.layers`. Architectures that load
811/// their own stack (gemma-3n's AltUp replicas, DeepSeek-V4's hyper-
812/// connections) leave that empty and must go position by position — asking
813/// otherwise indexes an empty vector, which is a panic rather than a
814/// fallback. Every call site goes through here so the next such
815/// architecture is one line, not four.
816impl Pipeline {
817    fn can_prefill_batched(&self) -> bool {
818        #[cfg(test)]
819        let force_serial = self.nll_test_force_serial;
820        #[cfg(not(test))]
821        let force_serial = false;
822        prefill_batched() && !force_serial && !self.weights.layers.is_empty()
823    }
824
825    /// The backend's automatic capacity split for a mapped transformer.
826    /// Kept as a method so prefill and decode use the exact same boundary.
827    fn automatic_gpu_prefix(&self) -> Option<usize> {
828        let (model, _, _, _) = self.weights.embed_tokens.graph_weight()?;
829        crate::gpu::automatic_layer_prefix(&model, self.num_layers, self.physical_layers)
830    }
831}
832
833/// Prefill chunk (positions per batched pass). On macOS the AMX GEMM
834/// path wants tall panels — M=48 starves the matrix units (ggml uses
835/// ubatch 512); elsewhere the historical 48 stays. CMF_PREFILL_CHUNK
836/// overrides. Pub: the network split MUST chunk identically to the
837/// local path — panel width reorders float accumulation, so a different
838/// chunk is a different (equally valid) generation.
839pub fn prefill_chunk() -> usize {
840    if let Some(n) = std::env::var("CMF_PREFILL_CHUNK")
841        .ok()
842        .and_then(|v| v.parse::<usize>().ok())
843    {
844        return n.max(1);
845    }
846    if cfg!(target_os = "macos") {
847        512
848    } else if cfg!(target_arch = "aarch64") {
849        // Mobile: big enough to feed the batched attend (gate b ≥ 32)
850        // and the blocked SDOT GEMM without the memory of 512.
851        256
852    } else {
853        48
854    }
855}
856
857/// Number of prompt rows that have a real teacher-forced next-token pair in a
858/// prefill span.  The final prompt row has no successor token, so it must not
859/// be handed to the MTP warm-up.  Keeping this arithmetic in one helper makes
860/// the full-chunk and tail-chunk boundaries explicit for both the graph and
861/// CPU implementations.
862#[inline]
863fn mtp_prefill_pair_count(start: usize, end: usize, input_len: usize) -> usize {
864    if end <= start || start >= input_len {
865        return 0;
866    }
867    let rows = (end.min(input_len) - start).min(input_len - start);
868    if end < input_len {
869        rows
870    } else {
871        rows.saturating_sub(1)
872    }
873}
874
875/// Callback for streaming tokens. Return `false` to cancel.
876pub type TokenCallback = Box<dyn FnMut(&str) -> bool + Send>;
877
878impl Pipeline {
879    /// Clear all per-sequence state, including backend device mirrors.
880    ///
881    /// The host KV/history buffers are only half of the request lifecycle on
882    /// wgpu: GDN/O(1) state and cached graph bind groups are keyed by the
883    /// pipeline id and otherwise survive a pooled request.  Keep every fresh
884    /// sequence entry point on this one reset path so a new request cannot
885    /// inherit the prior request's device state.
886    fn clear_sequence_state(&mut self) {
887        self.kv_cache.clear();
888        self.kv_history.clear();
889        if let Some(b) = &mut self.dsv41 {
890            b.3.clear();
891        }
892        crate::gpu::graph_kv_reset(self.graph_kv_id);
893        // MTP is detached from `self` for the duration of generation, so its
894        // device mirror is not covered by the trunk reset above.  Reset the
895        // derived id as well: a failed/aborted warm-up must never leave a
896        // mirror that a later request can mistake for a current MTP cache.
897        crate::gpu::graph_kv_reset(self.mtp_kv_id());
898    }
899
900    /// Finish a generation lifecycle after the MTP/router owners were
901    /// detached.  Every terminal path must put those owners back before the
902    /// pooled pipeline can serve another request.  Graph side channels and
903    /// device mirrors are cleared on errors and cancellations; a successful
904    /// generation keeps its decode-ready host cache for KV reuse.
905    fn finish_generation(
906        &mut self,
907        mtp: &mut Option<MtpModule>,
908        router: &mut Option<crate::swarm::DynRouter>,
909        clear_sequence: bool,
910    ) {
911        // A dynamic route may have switched the overlay before the terminal
912        // path. Restore the backbone while the detached router is still
913        // available, because set_active_skill also owns the overlay reset.
914        if router.is_some() {
915            let _ = self.set_active_skill(None);
916        }
917        if clear_sequence {
918            self.clear_sequence_state();
919            if let Some(m) = mtp.as_mut() {
920                // The MTP owner is detached while generation runs, so the
921                // trunk reset above cannot clear its host cache.  Drop its
922                // partial rows before reattaching it to the pooled pipeline;
923                // the next request must start from the same empty anchor on
924                // CPU and on the device mirror.
925                m.kv.clear();
926            }
927            if let Some(m) = self.mtp.as_mut() {
928                // A non-speculative request leaves the configured MTP owner
929                // attached.  Clear that dormant cache too when a shared
930                // generation failure/cancellation resets the sequence.
931                m.kv.clear();
932            }
933        }
934        self.graph_want_logits = false;
935        self.graph_logits = None;
936        self.graph_failed
937            .store(false, std::sync::atomic::Ordering::Relaxed);
938        self.cancel
939            .store(false, std::sync::atomic::Ordering::Relaxed);
940        self.dyn_router = router.take().or(self.dyn_router.take());
941        self.mtp = mtp.take().or(self.mtp.take());
942        self.mtp_graph_mode = None;
943        self.spec_forced = None;
944    }
945
946    /// Consume a graph failure reported by a forward that returns only a
947    /// hidden vector.  `forward_ids` is a public Result API, so it must not
948    /// turn the graph's zero hidden sentinel into a valid lm_head result.
949    fn check_forward_graph(&mut self, phase: &str, pos: usize) -> Result<(), String> {
950        if self
951            .graph_failed
952            .swap(false, std::sync::atomic::Ordering::Relaxed)
953        {
954            self.cancel
955                .store(false, std::sync::atomic::Ordering::Relaxed);
956            self.clear_sequence_state();
957            self.graph_logits = None;
958            self.graph_want_logits = false;
959            return Err(format!("GPU graph failed during {phase} at position {pos}"));
960        }
961        Ok(())
962    }
963
964    /// Start an NLL/PPL request with all graph side channels in a known
965    /// state.  A graph failure also raises the cooperative cancel bit; it is
966    /// consumed here and that graph-induced bit is cleared so an independent
967    /// request can be reused.  A caller-owned cancellation remains intact.
968    fn nll_begin(&mut self) -> Result<(), String> {
969        if self
970            .graph_failed
971            .swap(false, std::sync::atomic::Ordering::Relaxed)
972        {
973            self.cancel
974                .store(false, std::sync::atomic::Ordering::Relaxed);
975            self.clear_sequence_state();
976            self.graph_logits = None;
977            self.graph_want_logits = false;
978            return Err("GPU graph failed before NLL scoring".to_string());
979        }
980        self.clear_sequence_state();
981        self.graph_logits = None;
982        self.graph_want_logits = false;
983        Ok(())
984    }
985
986    /// End an NLL/PPL request, including the side channels that are not part
987    /// of the host KV cache.  This is intentionally explicit instead of
988    /// relying on a tuple/sentinel return: callers must see every failure.
989    fn nll_end(&mut self) {
990        self.clear_sequence_state();
991        self.graph_logits = None;
992        self.graph_want_logits = false;
993        self.graph_failed
994            .store(false, std::sync::atomic::Ordering::Relaxed);
995    }
996
997    /// Check the graph failure channel at a scoring boundary and leave the
998    /// pipeline reusable when the device path failed.
999    fn nll_check_graph(&mut self, phase: &str, pos: usize) -> Result<(), String> {
1000        #[cfg(test)]
1001        if self.nll_test_fail_at == Some(pos) {
1002            self.nll_test_fail_at = None;
1003            self.graph_failed
1004                .store(true, std::sync::atomic::Ordering::Relaxed);
1005            self.cancel
1006                .store(true, std::sync::atomic::Ordering::Relaxed);
1007        }
1008        if self
1009            .graph_failed
1010            .swap(false, std::sync::atomic::Ordering::Relaxed)
1011        {
1012            self.cancel
1013                .store(false, std::sync::atomic::Ordering::Relaxed);
1014            self.clear_sequence_state();
1015            self.graph_logits = None;
1016            self.graph_want_logits = false;
1017            return Err(format!(
1018                "GPU graph failed during NLL {phase} at position {pos}"
1019            ));
1020        }
1021        Ok(())
1022    }
1023
1024    /// Map a virtual layer index to its physical weight index.
1025    /// Looped Transformer (Nanbeige 4.2): 22 physical layers × 2 loops = 44 virtual;
1026    /// virtual layer 23 maps back to physical layer 1 (23 % 22 = 1).
1027    #[inline]
1028    pub fn phys_layer(&self, virtual_idx: usize) -> usize {
1029        virtual_idx % self.physical_layers
1030    }
1031
1032    /// True when `virtual_idx` is the last layer of a loop iteration
1033    /// (used for loop_final_norm insertion).
1034    #[inline]
1035    pub fn is_loop_end(&self, virtual_idx: usize) -> bool {
1036        self.loop_final_norm && (virtual_idx + 1) % self.physical_layers == 0
1037    }
1038
1039    /// Build a pipeline from parts (used by the loader and tests).
1040    #[allow(clippy::too_many_arguments)]
1041
1042    /// Whole-block q1 token graph on the GPU (macOS/Metal): the run of
1043    /// consecutive q1 layers — GDN *and* full attention — starting at
1044    /// `start` executes as few command buffers as the CPU truly needs.
1045    /// Hidden stays device-resident across every layer; the only syncs
1046    /// are before each CPU attend (it needs q/k/v and owns the KV
1047    /// cache) and the final hidden readback. Recurrent states
1048    /// round-trip through shared memory (the CPU stays their owner, so
1049    /// every other path remains coherent). Returns the first layer
1050    /// index NOT covered (== `start` → refused, caller falls through
1051    /// to the per-layer CPU path).
1052    /// Should prefill run position-by-position through the GPU token
1053    /// graph instead of the batched CPU chunk-GEMM? True for q1 GDN
1054    /// hybrids on native Metal: their chunk prefill is walled by the
1055    /// sequential scalar recurrence, so the graph's decode rate wins.
1056    /// NOT for Looped Transformers, despite the per-chunk loop_final_norm
1057    /// sync: the chunk-GEMM amortizes each weight over the whole chunk,
1058    /// which the per-position graph cannot (Nanbeige 4.2 on M4, 512-token
1059    /// prompt: 85 tok/s chunked vs 14 through the graph).
1060    #[cfg(target_os = "macos")]
1061    fn graph_prefill_preferred(&self) -> bool {
1062        if !crate::gpu::enabled_here()
1063            || !crate::gpu::q1_force()
1064            || std::env::var("CMF_GPU_BLOCK")
1065                .map(|v| v == "0")
1066                .unwrap_or(false)
1067            // CMF_PREFILL_GRAPH=0: the chunked prefill (GEMM projections,
1068            // CPU recurrence) instead of the per-position token graph.
1069            || std::env::var("CMF_PREFILL_GRAPH").as_deref() == Ok("0")
1070        {
1071            return false;
1072        }
1073        self.weights
1074            .layers
1075            .iter()
1076            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(w) if w.in_proj_qkv.is_q1()))
1077    }
1078
1079    #[cfg(not(target_os = "macos"))]
1080    fn graph_prefill_preferred(&self) -> bool {
1081        // Discrete-GPU wgpu whole-token graph: GDN layers carry recurrent state
1082        // (conv ring + delta-rule S) resident on the GPU. A batched CPU prefill
1083        // builds that state on the CPU only, leaving the GPU buffers zeroed at
1084        // decode → garbage. Route GDN-hybrid prefill through the graph one
1085        // position at a time so the resident state is seeded exactly as decode
1086        // will read it. Pure-attention models keep the batched CPU prefill (its
1087        // KV mirror re-syncs from the CPU cache, so no seeding gap).
1088        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Prefill);
1089        if !graph_on || !crate::gpu::enabled_here() {
1090            return false;
1091        }
1092        // O(1) needs the CPU prefill: the q-trace that seals the Nyström
1093        // skeleton is recorded there and nowhere else. The GDN half of
1094        // the hybrid loses nothing — the graph's first decode creates
1095        // its (ring, S) entries seeded from `cpu_state`, the same
1096        // handoff every graph run relies on when the entry is fresh.
1097        // Without this line the two designs collide on hybrids and o1
1098        // never becomes graph-portable: prefill through the graph
1099        // records no trace, so views stay None forever.
1100        if self.o1_active() {
1101            return false;
1102        }
1103        self.weights
1104            .layers
1105            .iter()
1106            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(_)))
1107    }
1108
1109    #[cfg(target_os = "macos")]
1110    fn q1_graph_gpu(
1111        &mut self,
1112        start: usize,
1113        upto: Option<usize>,
1114        position: usize,
1115        h: &mut [f32],
1116    ) -> usize {
1117        let _mt0 = std::time::Instant::now(); // CMF_METAL_HOSTPROF
1118        use crate::gpu::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GraphDims, MetalFfn, TokenGraph};
1119        if self.attn_softcap > 0.0 // capped scores: no graph kernel — CPU path
1120            || !crate::gpu::enabled_here()
1121            || !crate::gpu::q1_force()
1122            || std::env::var("CMF_GPU_BLOCK")
1123                .map(|v| v == "0")
1124                .unwrap_or(false)
1125        {
1126            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1127                eprintln!(
1128                    "block-graph: front gate (softcap={} enabled_here={} q1_force={})",
1129                    self.attn_softcap > 0.0,
1130                    crate::gpu::enabled_here(),
1131                    crate::gpu::q1_force(),
1132                );
1133            }
1134            return start;
1135        }
1136        // The graph encodes SiLU FFN and full-context attention with an
1137        // explicit model scale. Architectures with sliding windows,
1138        // sandwich norms or non-SiLU FFNs still fall back to the CPU path.
1139        if self.swa.is_some()
1140            || self.global_attn.is_some()
1141            || self.attention_heads_per_layer.is_some()
1142            || self.attn_v_norm
1143            || self.weights.layers.iter().any(|lw| {
1144                lw.attn_out_norm.is_some()
1145                    || lw.ffn_out_norm.is_some()
1146                    || lw.layer_scale.is_some()
1147                    || matches!(&lw.ffn, FfnKind::Dense(d) if d.act != Act::Silu)
1148            })
1149        {
1150            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1151                eprintln!(
1152                    "block-graph: arch ineligible (swa={} gattn={} hpl={} vnorm={} scale_delta={:.2e})",
1153                    self.swa.is_some(),
1154                    self.global_attn.is_some(),
1155                    self.attention_heads_per_layer.is_some(),
1156                    self.attn_v_norm,
1157                    (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs(),
1158                );
1159            }
1160            return start;
1161        }
1162        // Looped Transformer: the graph covers ALL loop iterations;
1163        // encode_loop_norm is inserted on-device at each boundary.
1164        let limit = upto
1165            .map(|u| u + 1)
1166            .unwrap_or(self.num_layers)
1167            .min(self.num_layers);
1168
1169        enum Item<'a> {
1170            Gdn {
1171                run: Vec<GdnGpuLayer<'a>>,
1172                first: usize,
1173            },
1174            Attn {
1175                l: AttnGpuLayer<'a>,
1176                li: usize,
1177                q_norm: Option<&'a [f32]>,
1178                k_norm: Option<&'a [f32]>,
1179                output_gate: bool,
1180                bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
1181                /// Attend on the device too (no sync): F32 KV, no
1182                /// o1/bias, dims inside the kernels' contract.
1183                full_gpu: bool,
1184            },
1185        }
1186
1187        // Device-attend KERNEL contract, shared by every Full layer. The
1188        // hd>128 default-off POLICY is applied after the scan: it was
1189        // measured on dense models, and a MoE plan inverts it — with the
1190        // experts on device each CPU-attend sandwich costs a
1191        // commit+wait, ~30 submits/token (W2 on M4: 14.7 tok/s
1192        // sandwiched vs 27.1 device-attend vs 18.8 pure CPU).
1193        let attend_mode = std::env::var("CMF_GPU_ATTEND").unwrap_or_else(|_| "auto".into());
1194        let attend_contract = attend_mode != "0"
1195            && attend_mode != "off"
1196            && self.head_dim % 4 == 0
1197            && self.head_dim <= 256
1198            && self.rotary_dim >= 2
1199            && self.rotary_dim <= self.head_dim
1200            && (self.rotary_dim / 2) % 32 == 0
1201            && self.num_kv_heads > 0
1202            && self.num_heads % self.num_kv_heads == 0;
1203
1204        let mut plan: Vec<Item> = Vec::new();
1205        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
1206        // Break-reason diagnostics ride the same env as the plan summary.
1207        let block_diag = std::env::var("CMF_GRAPH_DBG").is_ok();
1208        let mut scan = start;
1209        while scan < limit {
1210            let lw = &self.weights.layers[self.phys_layer(scan)];
1211            let ffn = match &lw.ffn {
1212                FfnKind::Dense(d) if d.segs.is_empty() => {
1213                    let (Some(g), Some(u), Some(dn)) = (
1214                        d.gate_proj.q1_parts(),
1215                        d.up_proj.q1_parts(),
1216                        d.down_proj.q1_parts(),
1217                    ) else {
1218                        if block_diag {
1219                            eprintln!(
1220                                "block-graph: L{scan} FFN trio not graph-mappable — run ends"
1221                            );
1222                        }
1223                        break;
1224                    };
1225                    MetalFfn::Dense {
1226                        gate: g,
1227                        up: u,
1228                        down: dn,
1229                    }
1230                }
1231                FfnKind::Moe(m) => {
1232                    let Some(moe) = metal_moe_graph_parts(m, self.hidden_size) else {
1233                        if block_diag {
1234                            eprintln!(
1235                                "block-graph: L{scan} MoE outside the graph contract — run ends"
1236                            );
1237                        }
1238                        break;
1239                    };
1240                    if let QTensor::Mapped { model, .. } = &m.experts[0].gate_proj {
1241                        model_ref.get_or_insert_with(|| model.clone());
1242                    }
1243                    MetalFfn::Moe(moe)
1244                }
1245                _ => {
1246                    if block_diag {
1247                        eprintln!("block-graph: L{scan} non-graph FFN — run ends");
1248                    }
1249                    break;
1250                }
1251            };
1252            match &lw.attn {
1253                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
1254                    let parts = (
1255                        w.in_proj_qkv.q1_parts(),
1256                        w.in_proj_z.q1_parts(),
1257                        w.in_proj_a.f32_parts(),
1258                        w.in_proj_b.f32_parts(),
1259                        w.out_proj.q1_parts(),
1260                    );
1261                    let (Some(qkv), Some(z), Some(a), Some(b), Some(out)) = parts else {
1262                        if block_diag {
1263                            eprintln!(
1264                                "block-graph: L{scan} GDN parts refused (qkv={} z={} a_f32={} b_f32={} out={})",
1265                                w.in_proj_qkv.q1_parts().is_some(),
1266                                w.in_proj_z.q1_parts().is_some(),
1267                                w.in_proj_a.f32_parts().is_some(),
1268                                w.in_proj_b.f32_parts().is_some(),
1269                                w.out_proj.q1_parts().is_some(),
1270                            );
1271                        }
1272                        break;
1273                    };
1274                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
1275                        model_ref.get_or_insert_with(|| model.clone());
1276                    }
1277                    let gl = GdnGpuLayer {
1278                        attn_norm: &lw.input_norm,
1279                        post_norm: &lw.post_norm,
1280                        qkv,
1281                        z,
1282                        a,
1283                        b,
1284                        out,
1285                        ffn,
1286                        conv1d: &w.conv1d,
1287                        a_log: &w.a_log,
1288                        dt_bias: &w.dt_bias,
1289                        gnorm: &w.norm,
1290                    };
1291                    match plan.last_mut() {
1292                        Some(Item::Gdn { run, .. }) => run.push(gl),
1293                        _ => plan.push(Item::Gdn {
1294                            run: vec![gl],
1295                            first: scan,
1296                        }),
1297                    }
1298                }
1299                AttnKind::Full {
1300                    wq,
1301                    wk,
1302                    wv,
1303                    wo,
1304                    q_norm,
1305                    k_norm,
1306                    output_gate,
1307                    softplus_gate: None,
1308                    bias,
1309                } if !self.kv_cache.layers[scan].o1_sealed()
1310                    // Sealed o1 stays plannable when the Metal o1 port
1311                    // is on: full_gpu attends through the device state,
1312                    // and any refusal falls to the sandwich, whose CPU
1313                    // core routes sealed layers through the nystrom step.
1314                    || std::env::var("CMF_O1_METAL").as_deref() == Ok("1") =>
1315                {
1316                    let parts = (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts());
1317                    let (Some(pq), Some(pk), Some(pv), Some(po)) = parts else {
1318                        break;
1319                    };
1320                    if let QTensor::Mapped { model, .. } = wq {
1321                        model_ref.get_or_insert_with(|| model.clone());
1322                    }
1323                    let cache = &self.kv_cache.layers[scan];
1324                    // O(1) layer on Metal: the device attends through the
1325                    // sealed Nystrom state (opt-in while the port proves
1326                    // itself). Unsealed -> sandwich path = the CPU o1 step.
1327                    let o1_metal = cache.o1.is_some()
1328                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1")
1329                        && cache.o1_views().is_some();
1330                    let full_gpu = attend_contract
1331                        && cache.mode == crate::kv_cache::KvMode::F32
1332                        && (cache.o1.is_none() || o1_metal)
1333                        && bias.is_none()
1334                        && pq.1 == self.num_heads * self.head_dim * (1 + *output_gate as usize)
1335                        && pk.1 == self.num_kv_heads * self.head_dim
1336                        && pv.1 == self.num_kv_heads * self.head_dim
1337                        && po.2 == self.num_heads * self.head_dim;
1338                    plan.push(Item::Attn {
1339                        l: AttnGpuLayer {
1340                            attn_norm: &lw.input_norm,
1341                            post_norm: &lw.post_norm,
1342                            wq: pq,
1343                            wk: pk,
1344                            wv: pv,
1345                            wo: po,
1346                            ffn,
1347                        },
1348                        li: scan,
1349                        q_norm: q_norm.as_deref(),
1350                        k_norm: k_norm.as_deref(),
1351                        output_gate: *output_gate,
1352                        bias: bias
1353                            .as_ref()
1354                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
1355                        full_gpu,
1356                    });
1357                }
1358                _ => break,
1359            }
1360            scan += 1;
1361        }
1362        let Some(model) = model_ref else {
1363            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1364                eprintln!("q1-graph: no model ref (start {start}, scanned to {scan})");
1365            }
1366            return start;
1367        };
1368        if plan.is_empty() {
1369            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1370                eprintln!("q1-graph: empty plan at layer {start}");
1371            }
1372            return start;
1373        }
1374        let has_moe = plan.iter().any(|it| match it {
1375            Item::Gdn { run, .. } => run.iter().any(|l| matches!(l.ffn, MetalFfn::Moe(_))),
1376            Item::Attn { l, .. } => matches!(l.ffn, MetalFfn::Moe(_)),
1377        });
1378        let has_gdn = plan.iter().any(|it| matches!(it, Item::Gdn { .. }));
1379        let dev_attend = attend_contract
1380            && (self.head_dim <= 128
1381                || has_moe
1382                // A GDN hybrid attends on a quarter of its layers: the
1383                // hd>128 caution was measured on pure-dense models where
1384                // gqa_attend dominates, and on Qwen3.8-27B (hd 256, 48
1385                // GDN + 16 attn) the sandwich costs 2x the whole decode
1386                // (1.2 vs 2.21 tok/s measured before the arena fix).
1387                || (self.head_dim <= 256 && has_gdn)
1388                || attend_mode == "force"
1389                || attend_mode == "256");
1390        if !dev_attend {
1391            for it in &mut plan {
1392                if let Item::Attn { li, full_gpu, .. } = it {
1393                    // The hd>128 policy is about gqa_attend; an o1 layer
1394                    // attends through its own kernel set.
1395                    let keep_o1 = self.kv_cache.layers[*li].o1.is_some()
1396                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1");
1397                    if !keep_o1 {
1398                        *full_gpu = false;
1399                    }
1400                }
1401            }
1402        }
1403        if std::env::var("CMF_GRAPH_DBG").is_ok() {
1404            use std::sync::atomic::{AtomicBool, Ordering};
1405            static SAID: AtomicBool = AtomicBool::new(false);
1406            if !SAID.swap(true, Ordering::Relaxed) {
1407                let fg = plan
1408                    .iter()
1409                    .filter(|it| matches!(it, Item::Attn { full_gpu: true, .. }))
1410                    .count();
1411                let att = plan
1412                    .iter()
1413                    .filter(|it| matches!(it, Item::Attn { .. }))
1414                    .count();
1415                eprintln!(
1416                    "q1-graph: plan of {} items from layer {start} to {scan} | dev_attend={dev_attend} full_gpu {fg}/{att} | hd={} rd={} nkv={} nh={}",
1417                    plan.len(),
1418                    self.head_dim,
1419                    self.rotary_dim,
1420                    self.num_kv_heads,
1421                    self.num_heads,
1422                );
1423            }
1424        }
1425        let dims = GraphDims {
1426            hidden: self.hidden_size,
1427            eps: self.rms_eps as f32,
1428            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1429        };
1430        let Some(mut graph) = TokenGraph::new(&model, dims, h) else {
1431            return start;
1432        };
1433        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
1434            nv: cfg.num_v_heads,
1435            nk: cfg.num_k_heads,
1436            dk: cfg.key_head_dim,
1437            dv: cfg.value_head_dim,
1438            kk: cfg.conv_kernel,
1439            hidden: self.hidden_size,
1440            inter: self.intermediate_size,
1441            c_dim: cfg.conv_dim(),
1442            eps: cfg.rms_eps as f32,
1443            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1444        });
1445        // Validate the whole plan BEFORE encoding anything: after the
1446        // first sync a refused layer would leave the token
1447        // half-executed, so truncate to the provably encodable prefix.
1448        let mut valid = 0usize;
1449        let mut end = start;
1450        crate::gpu::stageprof(1, _mt0.elapsed()); // конец планирования
1451        if std::env::var("CMF_PLAN_DUMP").is_ok() {
1452            static ONCE: std::sync::Once = std::sync::Once::new();
1453            ONCE.call_once(|| {
1454                for it in &plan {
1455                    match it {
1456                        Item::Gdn { first, run } => {
1457                            eprintln!("plan: Gdn first={first} len={}", run.len())
1458                        }
1459                        Item::Attn { li, full_gpu, .. } => {
1460                            eprintln!("plan: Attn li={li} full_gpu={full_gpu}")
1461                        }
1462                    }
1463                }
1464            });
1465        }
1466        for item in &plan {
1467            let ok = match item {
1468                Item::Gdn { run, .. } => gcfg
1469                    .as_ref()
1470                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
1471                    .unwrap_or(false),
1472                Item::Attn { l, .. } => graph.attn_ok(l),
1473            };
1474            if !ok {
1475                if block_diag {
1476                    eprintln!(
1477                        "block-graph: plan item {} ({}) failed graph preflight",
1478                        valid,
1479                        match item {
1480                            Item::Gdn { run, first } => format!("GDN run L{first}+{}", run.len()),
1481                            Item::Attn { li, .. } => format!("Attn L{li}"),
1482                        }
1483                    );
1484                }
1485                break;
1486            }
1487            valid += 1;
1488            end += match item {
1489                Item::Gdn { run, .. } => run.len(),
1490                Item::Attn { .. } => 1,
1491            };
1492        }
1493        plan.truncate(valid);
1494        if plan.is_empty() {
1495            return start;
1496        }
1497
1498        let inv_freq = self.inv_freq.clone();
1499        let pool = self.pool.clone();
1500        let (nh, nkv, hd, hs, rd, eps) = (
1501            self.num_heads,
1502            self.num_kv_heads,
1503            self.head_dim,
1504            self.hidden_size,
1505            self.rotary_dim,
1506            self.rms_eps,
1507        );
1508        let norm_style = self.norm_style;
1509        let gemma = norm_style == cortiq_core::NormStyle::Gemma;
1510        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
1511        let kv_id = self.graph_kv_id;
1512        // GDN runs whose states await readback after the next sync
1513        // (device-attended layers add no sync, so several may stack).
1514        let mut pending: Vec<(usize, usize)> = Vec::new();
1515        // Device-attended layers: their K/V/imp are pulled from the
1516        // mirror after the final sync.
1517        let mut dev_attn: Vec<usize> = Vec::new();
1518        for item in &plan {
1519            let _xt0 = std::time::Instant::now();
1520            let _xkind: u32 = match item {
1521                Item::Gdn { .. } => 2,
1522                Item::Attn { .. } => 3,
1523            };
1524            // Looped Transformer: insert on-device norm at loop boundaries.
1525            if self.loop_final_norm {
1526                let item_start = match item {
1527                    Item::Gdn { first, .. } => *first,
1528                    Item::Attn { li, .. } => *li,
1529                };
1530                if item_start > start && self.is_loop_end(item_start - 1) {
1531                    graph.encode_loop_norm(&self.weights.final_norm);
1532                }
1533            }
1534            match item {
1535                Item::Gdn { run, first } => {
1536                    for l in &mut self.kv_cache.layers[*first..*first + run.len()] {
1537                        if l.linear_state.len() != want {
1538                            l.linear_state = vec![0f32; want];
1539                        }
1540                    }
1541                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
1542                        .iter()
1543                        .map(|l| l.linear_state.as_slice())
1544                        .collect();
1545                    let _ig = std::time::Instant::now();
1546                    if !graph.encode_gdn_run(run, &ro, gcfg.as_ref().unwrap()) {
1547                        // Unreachable: the plan was validated above.
1548                        tracing::error!("q1 graph: GDN run refused after validation");
1549                        return start;
1550                    }
1551                    // Early commit: the GPU starts the run while the
1552                    // CPU encodes the next layer (nothing to wait on).
1553                    graph.commit_kind = 2;
1554                    graph.commit();
1555                    crate::gpu::stageprof(0, _ig.elapsed());
1556                    pending.push((*first, run.len()));
1557                }
1558                Item::Attn {
1559                    l,
1560                    li,
1561                    q_norm,
1562                    k_norm,
1563                    output_gate,
1564                    bias,
1565                    full_gpu,
1566                } => {
1567                    let _ia = std::time::Instant::now();
1568                    // ── Fully device-resident attention: no sync at all.
1569                    if *full_gpu {
1570                        let cache = &self.kv_cache.layers[*li];
1571                        let o1p = if cache.o1.is_some() {
1572                            match cache.o1_views() {
1573                                Some(views) => Some(crate::gpu::O1AttnParams {
1574                                    views,
1575                                    epoch: self.o1_epoch,
1576                                }),
1577                                // Sealed state gone mid-run: sandwich.
1578                                None => None,
1579                            }
1580                        } else {
1581                            None
1582                        };
1583                        let o1_layer = cache.o1.is_some();
1584                        if o1_layer && o1p.is_none() {
1585                            // fall to the sandwich (CPU o1 step)
1586                        }
1587                        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
1588                        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
1589                        let cpu_stored = if o1_layer { 0 } else { cpu_k[0].len() / hd };
1590                        let p = crate::gpu::AttnDeviceParams {
1591                            kv_id,
1592                            layer: *li,
1593                            nh,
1594                            nkv,
1595                            hd,
1596                            rd,
1597                            position,
1598                            scale: self.attn_scale,
1599                            eps: eps as f32,
1600                            gemma,
1601                            output_gate: *output_gate,
1602                            q_norm: *q_norm,
1603                            k_norm: *k_norm,
1604                            inv_freq: &inv_freq,
1605                            cpu_k,
1606                            cpu_v,
1607                            cpu_stored,
1608                            o1: o1p,
1609                        };
1610                        let o1_bad = o1_layer && p.o1.is_none();
1611                        if !o1_bad && graph.attn_device_ok(l, &p) && graph.encode_attn_device(l, &p)
1612                        {
1613                            // o1 layers leave no mirror row to pull.
1614                            if p.o1.is_none() {
1615                                dev_attn.push(*li);
1616                            }
1617                            graph.commit_kind = 3;
1618                            graph.commit();
1619                            // The footer below is skipped by `continue`:
1620                            // account the device-attn item here or its
1621                            // cost hides from the stage profile entirely.
1622                            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1623                            continue;
1624                        }
1625                        // Mirror refused (nothing encoded) → sandwich.
1626                    }
1627                    graph.encode_attn_prefix(l);
1628                    graph.sync();
1629                    if !pending.is_empty() {
1630                        let idxs: Vec<usize> =
1631                            pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1632                        let mut outs: Vec<&mut [f32]> = self
1633                            .kv_cache
1634                            .layers
1635                            .iter_mut()
1636                            .enumerate()
1637                            .filter(|(i, _)| idxs.binary_search(i).is_ok())
1638                            .map(|(_, s)| s.linear_state.as_mut_slice())
1639                            .collect();
1640                        graph.read_states(&mut outs);
1641                    }
1642                    let mut q_raw = attention::take_buf(l.wq.1);
1643                    let mut k = attention::take_buf(l.wk.1);
1644                    let mut v = attention::take_buf(l.wv.1);
1645                    graph.read_qkv(&mut q_raw, &mut k, &mut v);
1646                    let cfg = QwenAttnCfg {
1647                        num_heads: nh,
1648                        num_kv_heads: nkv,
1649                        head_dim: hd,
1650                        hidden_size: hs,
1651                        position,
1652                        inv_freq: &inv_freq,
1653                        rotary_dim: rd,
1654                        scale: self.attn_scale,
1655                        softcap: self.attn_softcap,
1656                        window: None,
1657                        v_norm: false,
1658                        q_norm: *q_norm,
1659                        k_norm: *k_norm,
1660                        output_gate: *output_gate,
1661                        softplus_gate: None,
1662                        rope_scale: 1.0,
1663                        bias: *bias,
1664                        rms_eps: eps,
1665                        norm_style,
1666                        pool: pool.as_deref(),
1667                    };
1668                    // CMF_ATTN_ORACLE=1: diff the device attend against
1669                    // this CPU attend on identical inputs (bring-up).
1670                    let oracle = std::env::var("CMF_ATTN_ORACLE").as_deref() == Ok("1")
1671                        || std::env::var("CMF_ATTN_DUMP").is_ok();
1672                    let _ = full_gpu;
1673                    let oracle_in = oracle.then(|| (q_raw.clone(), k.clone(), v.clone()));
1674                    let mut ao = attention::qwen_attention_core(
1675                        q_raw,
1676                        k,
1677                        v,
1678                        &mut self.kv_cache.layers[*li],
1679                        &cfg,
1680                    );
1681                    // CMF_ATTN_DUMP=<dir>: this token's rope'd Q and the layer's whole
1682                    // K/V cache as raw f32 (offline attention-statistics probes:
1683                    // block bounds, mass concentration). Needs CMF_GPU_ATTEND=0.
1684                    if let Ok(dir) = std::env::var("CMF_ATTN_DUMP") {
1685                        if let Some((qr0, k0, v0)) = oracle_in.clone() {
1686                            let (cq, _cg, _ck, _cv) =
1687                                attention::finish_projection_debug(qr0, k0, v0, &cfg, position);
1688                            let cache = &self.kv_cache.layers[*li];
1689                            let n = cache.head_keys(0).len() / hd;
1690                            let mut bytes: Vec<u8> = Vec::new();
1691                            for v in [nh as u32, nkv as u32, hd as u32, n as u32, position as u32] {
1692                                bytes.extend_from_slice(&v.to_le_bytes());
1693                            }
1694                            for v in &cq {
1695                                bytes.extend_from_slice(&v.to_le_bytes());
1696                            }
1697                            for g in 0..nkv {
1698                                for v in cache.head_keys(g) {
1699                                    bytes.extend_from_slice(&v.to_le_bytes());
1700                                }
1701                            }
1702                            for g in 0..nkv {
1703                                for v in cache.head_values(g) {
1704                                    bytes.extend_from_slice(&v.to_le_bytes());
1705                                }
1706                            }
1707                            let _ =
1708                                std::fs::write(format!("{dir}/L{li}_pos{position}.bin"), &bytes);
1709                        }
1710                    }
1711                    if let Some((qr0, k0, v0)) =
1712                        oracle_in.filter(|_| std::env::var("CMF_ATTN_ORACLE").as_deref() == Ok("1"))
1713                    {
1714                        let (cq, _cg, ck, cv) =
1715                            attention::finish_projection_debug(qr0, k0, v0, &cfg, position);
1716                        let mut h_now = vec![0f32; hs];
1717                        graph.read_h(&mut h_now);
1718                        let cache = &self.kv_cache.layers[*li];
1719                        let n_after = cache.head_keys(0).len() / hd;
1720                        // A sealed O(1) cache may have no dense current-row
1721                        // entry. The oracle is a debug probe, so let it see
1722                        // zero stored exact rows instead of underflowing.
1723                        let stored = n_after.saturating_sub(1);
1724                        let cpu_k: Vec<&[f32]> = (0..nkv)
1725                            .map(|g| &cache.head_keys(g)[..stored * hd])
1726                            .collect();
1727                        let cpu_v: Vec<&[f32]> = (0..nkv)
1728                            .map(|g| &cache.head_values(g)[..stored * hd])
1729                            .collect();
1730                        let p = crate::gpu::AttnDeviceParams {
1731                            kv_id,
1732                            layer: *li,
1733                            nh,
1734                            nkv,
1735                            hd,
1736                            rd,
1737                            position,
1738                            scale: self.attn_scale,
1739                            eps: eps as f32,
1740                            gemma,
1741                            output_gate: *output_gate,
1742                            q_norm: *q_norm,
1743                            k_norm: *k_norm,
1744                            inv_freq: &inv_freq,
1745                            cpu_k,
1746                            cpu_v,
1747                            cpu_stored: stored,
1748                            o1: None,
1749                        };
1750                        if let Some((dq, dk, dv, dao)) = graph.debug_attn_device(l, &p, &h_now) {
1751                            let md = |a: &[f32], b: &[f32]| {
1752                                a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs()))
1753                            };
1754                            let nn = |a: &[f32]| a.iter().map(|x| x * x).sum::<f32>().sqrt();
1755                            eprintln!(
1756                                "attn-oracle L{li} pos {position}: |q| {:.2} max|dq| {:.4} | |k| {:.2} max|dk| {:.4} | |v| {:.2} max|dv| {:.4} | |ao| {:.2} max|dao| {:.4}",
1757                                nn(&cq),
1758                                md(&cq, &dq),
1759                                nn(&ck),
1760                                md(&ck, &dk),
1761                                nn(&cv),
1762                                md(&cv, &dv),
1763                                nn(&ao),
1764                                md(&ao, &dao)
1765                            );
1766                        } else {
1767                            eprintln!("attn-oracle L{li}: device probe declined");
1768                        }
1769                    }
1770                    graph.encode_attn_suffix(l, &ao);
1771                    // Early commit: the GPU starts O+FFN while the CPU
1772                    // encodes the following GDN run / attention prefix.
1773                    graph.commit();
1774                    attention::recycle_buf(&mut ao);
1775                }
1776            }
1777
1778            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1779        }
1780        // Ride the final norm + lm_head in the same command buffer when
1781        // this run reaches the model's end and the caller wants logits:
1782        // the separate per-op lm_head submit (a full round trip) folds
1783        // into the sync that already happens here.
1784        let mut lm_rows = None;
1785        if self.graph_want_logits
1786            && upto.is_none()
1787            && end == self.num_layers
1788            && std::env::var("CMF_GPU_LMHEAD")
1789                .map(|v| v != "0")
1790                .unwrap_or(true)
1791        {
1792            if let Some(lm) = self.weights.lm_head.q1_parts() {
1793                if graph.lm_head_ok(lm) {
1794                    graph.encode_lm_head(&self.weights.final_norm, lm);
1795                    lm_rows = Some(lm.1);
1796                }
1797            }
1798        }
1799        let _sy0 = std::time::Instant::now();
1800        graph.sync();
1801        let _rs0 = std::time::Instant::now();
1802        if !pending.is_empty() {
1803            let idxs: Vec<usize> = pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1804            let mut outs: Vec<&mut [f32]> = self
1805                .kv_cache
1806                .layers
1807                .iter_mut()
1808                .enumerate()
1809                .filter(|(i, _)| idxs.binary_search(i).is_ok())
1810                .map(|(_, s)| s.linear_state.as_mut_slice())
1811                .collect();
1812            graph.read_states(&mut outs);
1813        }
1814        if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() == Ok("1") {
1815            use std::sync::atomic::{AtomicU64, Ordering};
1816            static SY: AtomicU64 = AtomicU64::new(0);
1817            static RS: AtomicU64 = AtomicU64::new(0);
1818            static N: AtomicU64 = AtomicU64::new(0);
1819            SY.fetch_add((_rs0 - _sy0).as_nanos() as u64, Ordering::Relaxed);
1820            RS.fetch_add(_rs0.elapsed().as_nanos() as u64, Ordering::Relaxed);
1821            let n = N.fetch_add(1, Ordering::Relaxed) + 1;
1822            if n % 100 == 0 {
1823                eprintln!(
1824                    "postprof: sync-wait {:.1} ms/ток | read_states {:.1} ms/ток ({n})",
1825                    SY.load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
1826                    RS.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
1827                );
1828            }
1829        }
1830        if let Some(rows) = lm_rows {
1831            crate::gpu::hostprof_encode_done(_mt0);
1832            let mut lg = attention::take_buf(rows.min(self.vocab_size));
1833            graph.read_logits(&mut lg);
1834            crate::gpu::hostprof_total(_mt0);
1835            lg.resize(self.vocab_size, 0.0);
1836            if let Some(c) = self.final_softcap {
1837                for l in lg.iter_mut() {
1838                    *l = c * (*l / c).tanh();
1839                }
1840            }
1841            self.graph_logits = Some(lg);
1842        }
1843        graph.finish(h);
1844        // Device-attended layers: replay the CPU bookkeeping — append
1845        // the mirror's new K/V row (rope'd on the GPU) into the owner
1846        // cache, then bank this token's attention-importance mass.
1847        for li in dev_attn {
1848            let mut krow = attention::take_buf(nkv * hd);
1849            let mut vrow = attention::take_buf(nkv * hd);
1850            if crate::gpu::kv_mirror_read_last(kv_id, li, nkv, hd, &mut krow, &mut vrow) {
1851                let cache = &mut self.kv_cache.layers[li];
1852                cache.append(&krow, &vrow, &[]);
1853                let n = cache.seq_len;
1854                let mut imp = attention::take_buf(n);
1855                crate::gpu::kv_mirror_take_imp(kv_id, li, &mut imp);
1856                cache.accumulate_imp(&imp);
1857                attention::recycle_buf(&mut imp);
1858            }
1859            attention::recycle_buf(&mut krow);
1860            attention::recycle_buf(&mut vrow);
1861        }
1862        end
1863    }
1864
1865    pub fn new(
1866        tokenizer: Tokenizer,
1867        weights: PipelineWeights,
1868        hidden_size: usize,
1869        intermediate_size: usize,
1870        num_heads: usize,
1871        num_kv_heads: usize,
1872        head_dim: usize,
1873        num_layers: usize,
1874        physical_layers: usize,
1875        loop_final_norm: bool,
1876        vocab_size: usize,
1877        rms_eps: f64,
1878        rope_base: f32,
1879        norm_style: NormStyle,
1880        max_seq_len: usize,
1881        sampler_config: SamplerConfig,
1882    ) -> Self {
1883        let rng = match sampler_config.seed {
1884            Some(s) => SplitMix64::new(s),
1885            None => SplitMix64::from_entropy(),
1886        };
1887        let inv_freq = std::sync::Arc::new(attention::rope_inv_freq(head_dim, rope_base));
1888        let pool = Pool::from_env();
1889        if let Some(p) = &pool {
1890            tracing::info!("worker pool: {} threads", p.n_workers());
1891        }
1892        Self {
1893            gpu_plan: None,
1894            tokenizer: std::sync::Arc::new(tokenizer),
1895            kv_cache: KvCache::new(num_layers, num_kv_heads, head_dim, max_seq_len),
1896            sampler_config,
1897            weights,
1898            hidden_size,
1899            intermediate_size,
1900            num_heads,
1901            num_kv_heads,
1902            head_dim,
1903            num_layers,
1904            physical_layers,
1905            loop_final_norm,
1906            vocab_size,
1907            rms_eps,
1908            rope_base,
1909            norm_style,
1910            rotary_dim: head_dim,
1911            attention_heads_per_layer: None,
1912            vmf_cfg: None,
1913            gdn_cfg: None,
1914            kda_cfg: None,
1915            g3n: None,
1916            dsv4: None,
1917            dsv41: None,
1918            dsv41_vision: None,
1919            dsv41_prefill: None,
1920            qwen4_exp: None,
1921            dsv4_mtp: Vec::new(),
1922            dspark: None,
1923            dspark_pending: Vec::new(),
1924            dspark_hist: Vec::new(),
1925            dspark_real: Vec::new(),
1926            dspark_trunk_picks: Vec::new(),
1927            dspark_exp: Vec::new(),
1928            dspark_draft_ns: 0,
1929            logit_multiplier: None,
1930            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1931            graph_failed: std::sync::atomic::AtomicBool::new(false),
1932            kv_history: Vec::new(),
1933            short_conv_cfg: None,
1934            mtp: None,
1935            speculative: std::env::var("CMF_MTP").map(|v| v != "0").unwrap_or(true),
1936            rng,
1937            sampler_scratch: SamplerScratch::default(),
1938            spec_forced: None,
1939            spec_q: Vec::new(),
1940            spec_p: Vec::new(),
1941            spec_res: Vec::new(),
1942            spec_qs: Vec::new(),
1943            spec_ps: Vec::new(),
1944            spec_ress: Vec::new(),
1945            mtp_graph_mode: None,
1946            #[cfg(target_os = "macos")]
1947            metal_verify: None,
1948            inv_freq,
1949            ws: ForwardScratch::new(hidden_size),
1950            pool,
1951            model: None,
1952            dyn_force_f32: false,
1953            dyn_skill_layers: Vec::new(),
1954            dyn_active: None,
1955            dyn_blend_loaded: false,
1956            dyn_phi_layer: None,
1957            dyn_phi_ema: Vec::new(),
1958            dyn_phi_seen: 0,
1959            dyn_router: None,
1960            o1_cfg: None,
1961            o1_epoch: 0,
1962            o1_flags: Vec::new(),
1963            trace: false,
1964            calib_temp: 1.0,
1965            confidence_on: true,
1966            embed_multiplier: 1.0,
1967            attn_scale: 1.0 / (head_dim as f32).sqrt(),
1968            swa: None,
1969            sliding_layers: None,
1970            inv_freq_local: None,
1971            rotary_dim_local: None,
1972            rope_scale: 1.0,
1973            rope_scale_local: 1.0,
1974            global_attn: None,
1975            inv_freq_global: None,
1976            attn_v_norm: false,
1977            final_softcap: None,
1978            head_clusters: None,
1979            attn_softcap: 0.0,
1980            graph_want_logits: false,
1981            graph_logits: None,
1982            graph_kv_id: {
1983                static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1984                NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1985            },
1986            #[cfg(test)]
1987            nll_test_fail_at: None,
1988            #[cfg(test)]
1989            nll_test_force_serial: false,
1990        }
1991    }
1992
1993    /// Enable/disable per-layer O(1) Nyström attention. Only Full
1994    /// layers are eligible (a linear layer keeps its own operator).
1995    /// Applies to generation (`generate*`/`forward_ids`): the prompt
1996    /// pass stays exact, then the state seals after prefill or at the
1997    /// deferred skeleton-safe boundary for short prompts; decode runs on
1998    /// the O(1) state. Teacher-forced scoring (`ppl_ids`) intentionally
1999    /// stays exact.
2000    pub fn set_o1(&mut self, cfg: Option<crate::nystrom::O1Cfg>) {
2001        if let Some(c) = &cfg {
2002            if crate::nystrom::o1_deferred_boundary(c.w, c.sink).is_none() {
2003                tracing::error!(
2004                    "o1 disabled: w + sink + slack + 1 overflows usize (w={}, sink={})",
2005                    c.w,
2006                    c.sink
2007                );
2008                self.o1_flags.clear();
2009                self.o1_cfg = None;
2010                return;
2011            }
2012        }
2013        self.o1_flags = match &cfg {
2014            Some(c) => {
2015                let mut flags = c.layer_flags(self.num_layers);
2016                for (li, f) in flags.iter_mut().enumerate() {
2017                    if *f
2018                        && !matches!(
2019                            self.weights.layers[self.phys_layer(li)].attn,
2020                            AttnKind::Full { .. }
2021                        )
2022                    {
2023                        *f = false;
2024                    }
2025                }
2026                flags
2027            }
2028            None => Vec::new(),
2029        };
2030        if let Some(c) = &cfg {
2031            let n = self.o1_flags.iter().filter(|&&f| f).count();
2032            tracing::info!(
2033                "o1 nystrom attention: {n}/{} layer(s), m={} w={} sink={} rect={:?}",
2034                self.num_layers,
2035                c.m,
2036                c.w,
2037                c.sink,
2038                c.rect
2039            );
2040        }
2041        self.o1_cfg = cfg;
2042    }
2043
2044    /// True when at least one layer runs the O(1) kernel.
2045    pub fn o1_active(&self) -> bool {
2046        self.o1_cfg.is_some() && self.o1_flags.iter().any(|&f| f)
2047    }
2048
2049    /// Whether generation's prompt ingest is routed through the whole-token
2050    /// graph.  The bench uses this to label the measured generation prefill
2051    /// honestly; keep the predicate in Pipeline so CLI labels cannot drift
2052    /// from the production route.
2053    pub fn generation_graph_prefill(&self) -> bool {
2054        let graph = self.graph_prefill_preferred();
2055        // On wgpu, an active MTP head now consumes the trunk's graph batches
2056        // and warms its own block from those returned rows.  The selected
2057        // generation measurement is therefore the batched path, even though
2058        // the underlying GDN model still satisfies the graph-prefill
2059        // predicate.  Keep the CLI label tied to the actual route.  Native
2060        // Metal has a separate prefill-batch arm and retains its historical
2061        // label here.
2062        #[cfg(not(target_os = "macos"))]
2063        if graph
2064            && self.mtp.is_some()
2065            && std::env::var("CMF_BATCH_K")
2066                .ok()
2067                .and_then(|v| v.parse::<usize>().ok())
2068                .is_some_and(|k| k > 0)
2069            && std::env::var("CMF_MTP_CHAIN_PROBE").is_err()
2070        {
2071            return false;
2072        }
2073        graph
2074    }
2075
2076    /// Device-side O(1) mirrors currently uploaded for this pipeline's
2077    /// sequence.  The count/bytes are zero before seal or after a fresh
2078    /// reset; callers use this to distinguish logical host state from the
2079    /// GPU allocation that actually serves decode.
2080    pub fn o1_device_stats(&self) -> (usize, u64) {
2081        crate::gpu::o1_device_stats(self.graph_kv_id)
2082    }
2083
2084    /// Arm query collection on the o1 layers (fresh prompt pass).
2085    /// Reset the o1 layers to Collecting for a fresh sequence. Pub for the
2086    /// network split: each side runs the o1 lifecycle over ITS OWN layers
2087    /// (begin before prefill, seal at the prefill barrier).
2088    pub fn o1_begin(&mut self) {
2089        self.o1_begin_with_prefix(None);
2090    }
2091
2092    /// Arm collection and optionally request a positive calibration prefix.
2093    /// The effective barrier is always at least the skeleton-safe floor, so
2094    /// a short requested prefix cannot create an exact-only runtime state.
2095    pub fn o1_begin_with_prefix(&mut self, requested_prefix: Option<usize>) {
2096        if let Some(c) = &self.o1_cfg {
2097            let (m, w, sink, rect) = (c.m, c.w, c.sink, c.rect);
2098            let boundary = requested_prefix.map(|p| {
2099                p.max(
2100                    crate::nystrom::o1_deferred_boundary(w, sink)
2101                        .expect("o1 config boundary validated in set_o1"),
2102                )
2103            });
2104            for (li, &f) in self.o1_flags.iter().enumerate() {
2105                if f {
2106                    self.kv_cache.layers[li].o1_begin_with_boundary(m, w, sink, rect, boundary);
2107                }
2108            }
2109        }
2110    }
2111
2112    /// Effective deferred boundary for a positive prefix request.
2113    fn o1_effective_boundary(&self, requested_prefix: usize) -> Option<usize> {
2114        self.o1_cfg.as_ref().and_then(|c| {
2115            crate::nystrom::o1_deferred_boundary(c.w, c.sink)
2116                .map(|floor| requested_prefix.max(floor))
2117        })
2118    }
2119
2120    fn o1_note_transition(&mut self) {
2121        // Drain every layer's one-shot bit before publishing one pipeline
2122        // epoch. `any()` would short-circuit on the first layer and leak the
2123        // remaining bits into later forwards, causing one epoch per layer.
2124        let mut transitioned = false;
2125        for (li, &flagged) in self.o1_flags.iter().enumerate() {
2126            if flagged {
2127                transitioned |= self.kv_cache.layers[li].take_o1_transition();
2128            }
2129        }
2130        if transitioned {
2131            self.o1_epoch = self.o1_epoch.wrapping_add(1);
2132        }
2133    }
2134
2135    fn o1_pending(&self) -> bool {
2136        self.o1_flags.iter().enumerate().any(|(li, &f)| {
2137            f && self.kv_cache.layers[li].seq_len > 0
2138                && self.kv_cache.layers[li].o1_pending_boundary().is_some()
2139        })
2140    }
2141
2142    fn o1_fail(&mut self, err: String) {
2143        tracing::error!("o1 deferred seal failed; terminating sequence: {err}");
2144        self.clear_sequence_state();
2145        self.graph_failed
2146            .store(true, std::sync::atomic::Ordering::Relaxed);
2147        self.cancel
2148            .store(true, std::sync::atomic::Ordering::Relaxed);
2149    }
2150
2151    /// Seal participating layers while retaining the exact state when the
2152    /// prompt is below the deferred boundary. A split worker may have
2153    /// collecting layers outside its owned span; zero-depth layers remain
2154    /// armed and are intentionally skipped until their peer runs them.
2155    pub fn o1_seal_checked(&mut self) -> Result<bool, String> {
2156        if self.o1_cfg.is_none() {
2157            return Ok(false);
2158        }
2159        let mut participating = false;
2160        for li in 0..self.num_layers {
2161            if !self.o1_flags.get(li).copied().unwrap_or(false) {
2162                continue;
2163            }
2164            if let Some(err) = self.kv_cache.layers[li].take_o1_error() {
2165                return Err(err);
2166            }
2167            if self.kv_cache.layers[li].seq_len == 0 {
2168                continue;
2169            }
2170            participating = true;
2171            let num_heads = self.layer_num_heads(li);
2172            self.kv_cache.layers[li].o1_seal_checked(num_heads)?;
2173        }
2174        self.o1_note_transition();
2175        for li in 0..self.num_layers {
2176            if self.o1_flags.get(li).copied().unwrap_or(false) {
2177                if let Some(err) = self.kv_cache.layers[li].take_o1_error() {
2178                    return Err(err);
2179                }
2180            }
2181        }
2182        Ok(participating
2183            && (0..self.num_layers).all(|li| {
2184                !self.o1_flags.get(li).copied().unwrap_or(false)
2185                    || self.kv_cache.layers[li].seq_len == 0
2186                    || self.kv_cache.layers[li].o1_sealed()
2187            }))
2188    }
2189
2190    /// Complete a deferred boundary after a full position/span forward.
2191    /// This is the pipeline owner for epoch publication and failure cleanup.
2192    fn o1_progress(&mut self) {
2193        if !self.o1_active() {
2194            return;
2195        }
2196        for li in 0..self.num_layers {
2197            if self.o1_flags.get(li).copied().unwrap_or(false) {
2198                if let Some(err) = self.kv_cache.layers[li].take_o1_error() {
2199                    self.o1_fail(err);
2200                    return;
2201                }
2202            }
2203        }
2204        // A qwen_attention row can seal in the middle of a complete layer
2205        // walk. Consume its transition even though the pending boundary has
2206        // already disappeared from the cache.
2207        self.o1_note_transition();
2208        if !self.o1_pending() {
2209            return;
2210        }
2211        if let Err(err) = self.o1_seal_checked() {
2212            self.o1_fail(err);
2213        }
2214    }
2215
2216    /// Turn a deferred O(1) failure raised by a hidden-only forward into the
2217    /// Result error its public batch/span caller must return. The failure
2218    /// path already cleared host/device sequence state; consume only the
2219    /// side-channel marker here and leave the pipeline reusable.
2220    fn check_o1_progress_failure(&mut self, phase: &str) -> Result<(), String> {
2221        if self
2222            .graph_failed
2223            .swap(false, std::sync::atomic::Ordering::Relaxed)
2224        {
2225            self.cancel
2226                .store(false, std::sync::atomic::Ordering::Relaxed);
2227            self.clear_sequence_state();
2228            return Err(format!("{phase}: deferred O(1) transition failed"));
2229        }
2230        Ok(())
2231    }
2232
2233    /// Freeze landmarks + skeleton state after the prompt pass and drop
2234    /// the o1 layers' full KV; decode then runs `step()` per token.
2235    /// Pub for the network split (see `o1_begin`).
2236    pub fn o1_seal(&mut self) {
2237        if let Err(err) = self.o1_seal_checked() {
2238            self.o1_fail(err);
2239        }
2240    }
2241
2242    /// Enable/disable the structured per-token telemetry trace (B4).
2243    pub fn set_trace(&mut self, on: bool) {
2244        self.trace = on;
2245    }
2246
2247    /// Replace all request-scoped sampler options and reset the random stream.
2248    /// This is required for deterministic `seed` semantics in pooled servers.
2249    pub fn set_sampler_config(&mut self, config: SamplerConfig) {
2250        self.rng = match config.seed {
2251            Some(seed) => SplitMix64::new(seed),
2252            None => SplitMix64::from_entropy(),
2253        };
2254        self.sampler_config = config;
2255    }
2256
2257    /// Toggle the per-token confidence reduction (a full-vocab
2258    /// softmax each token). `bench --core` turns it off so the timed
2259    /// loop matches llama-bench's core contract; the result's
2260    /// `confidence` vec is empty while off.
2261    pub fn set_confidence(&mut self, on: bool) {
2262        self.confidence_on = on;
2263    }
2264
2265    /// Set the confidence-calibration temperature (B1). Values ≤0 are
2266    /// clamped to raw (1.0).
2267    pub fn set_calib_temp(&mut self, t: f32) {
2268        self.calib_temp = if t > 1e-3 { t } else { 1.0 };
2269    }
2270
2271    /// The active calibration temperature (1.0 = raw probability).
2272    pub fn calib_temp(&self) -> f32 {
2273        self.calib_temp
2274    }
2275
2276    /// Partial rotary (Qwen3.5): rotate only the first `rotary_dim` dims;
2277    /// the frequency table is rebuilt over the rotary dims.
2278    pub fn set_rotary(&mut self, rotary_dim: usize, base: f32) {
2279        self.rotary_dim = rotary_dim.min(self.head_dim);
2280        self.inv_freq = std::sync::Arc::new(attention::rope_inv_freq(self.rotary_dim, base));
2281    }
2282
2283    fn attn_cfg(&self, position: usize) -> QwenAttnCfg<'_> {
2284        QwenAttnCfg {
2285            num_heads: self.num_heads,
2286            num_kv_heads: self.num_kv_heads,
2287            head_dim: self.head_dim,
2288            hidden_size: self.hidden_size,
2289            position,
2290            inv_freq: &self.inv_freq,
2291            rotary_dim: self.rotary_dim,
2292            scale: self.attn_scale,
2293            softcap: self.attn_softcap,
2294            window: None,
2295            v_norm: false,
2296            q_norm: None,
2297            k_norm: None,
2298            output_gate: false,
2299            softplus_gate: None,
2300            rope_scale: self.rope_scale,
2301            bias: None,
2302            rms_eps: self.rms_eps,
2303            norm_style: self.norm_style,
2304            pool: self.pool.as_deref(),
2305        }
2306    }
2307
2308    /// Generate text from a plain-text prompt. Streams tokens via `on_token`.
2309    pub fn generate(
2310        &mut self,
2311        prompt: &str,
2312        max_tokens: usize,
2313        task_mask: Option<&TaskMask>,
2314        on_token: Option<TokenCallback>,
2315    ) -> Result<GenerateResult, String> {
2316        let input_ids = self.tokenizer.with_bos(self.tokenizer.encode(prompt));
2317        self.generate_from_ids(&input_ids, max_tokens, task_mask, on_token)
2318    }
2319
2320    /// Generate from a V4.1 multimodal prompt prepared by the vision module.
2321    /// Vision rows are encoded once and fed through the same bounded token walk as text.
2322    pub fn generate_from_vl(
2323        &mut self,
2324        input: &crate::dsv41_vision::PreparedVlInputs,
2325        max_tokens: usize,
2326        task_mask: Option<&TaskMask>,
2327        on_token: Option<TokenCallback>,
2328    ) -> Result<GenerateResult, String> {
2329        let Some(dsv41) = &self.dsv41 else {
2330            return Err("V4.1 multimodal input requires a DeepSeek-V4.1 pipeline".into());
2331        };
2332        if input.token_ids.is_empty() {
2333            return Err("empty V4.1 multimodal prompt".into());
2334        }
2335        if input.token_types.len() != input.token_ids.len() {
2336            return Err(format!(
2337                "V4.1 token type count {} != token count {}",
2338                input.token_types.len(),
2339                input.token_ids.len()
2340            ));
2341        }
2342        let dim = dsv41.2.dim;
2343        let mut embeddings = vec![None; input.token_ids.len()];
2344        let mut participates = vec![true; input.token_ids.len()];
2345        if !input.images.is_empty() {
2346            let vision = self
2347                .dsv41_vision
2348                .as_ref()
2349                .ok_or_else(|| "V4.1 image prompt has no loaded vision tower".to_string())?;
2350            for image in &input.images {
2351                let end = image.start.saturating_add(image.types.len());
2352                if end > input.token_ids.len() {
2353                    return Err(format!(
2354                        "V4.1 image span {}..{} exceeds prompt length {}",
2355                        image.start,
2356                        end,
2357                        input.token_ids.len()
2358                    ));
2359                }
2360                let mut span = vec![0.0f32; image.types.len() * dim];
2361                vision.fill_image_span(image, &mut span, self.pool.as_deref())?;
2362                for (offset, &kind) in image.types.iter().enumerate() {
2363                    let pos = image.start + offset;
2364                    if input.token_types[pos] != kind {
2365                        return Err(format!(
2366                            "V4.1 image type mismatch at position {pos}: {} != {kind}",
2367                            input.token_types[pos]
2368                        ));
2369                    }
2370                    embeddings[pos] = Some(span[offset * dim..(offset + 1) * dim].to_vec());
2371                    participates[pos] = false;
2372                }
2373            }
2374        }
2375        for (pos, &kind) in input.token_types.iter().enumerate() {
2376            if kind == crate::dsv41_vision::TEXT && embeddings[pos].is_some() {
2377                return Err(format!("V4.1 text position {pos} has an image embedding"));
2378            }
2379            if kind != crate::dsv41_vision::TEXT && embeddings[pos].is_none() {
2380                return Err(format!("V4.1 image position {pos} has no image embedding"));
2381            }
2382        }
2383        self.dsv41_prefill = Some((embeddings, participates));
2384        let result = self.generate_from_ids(&input.token_ids, max_tokens, task_mask, on_token);
2385        self.dsv41_prefill = None;
2386        result
2387    }
2388
2389    /// `None` when the mask forbids nothing (see `TaskMask::fully_open`).
2390    fn drop_open_mask<'m>(&self, m: Option<&'m TaskMask>) -> Option<&'m TaskMask> {
2391        m.filter(|m| !m.fully_open(self.intermediate_size, self.num_heads))
2392    }
2393
2394    /// Generate from prepared token ids (e.g. a chat template).
2395    ///
2396    /// With an MTP head, greedy generation without a task mask takes the
2397    /// speculative path: the MTP module drafts the token after next and
2398    /// the main model verifies both in one fused two-position forward
2399    /// (weights streamed once). The output is EXACTLY the vanilla greedy
2400    /// sequence — a rejected draft is rolled back — MTP only buys speed.
2401    pub fn generate_from_ids(
2402        &mut self,
2403        input_ids: &[u32],
2404        max_tokens: usize,
2405        task_mask: Option<&TaskMask>,
2406        mut on_token: Option<TokenCallback>,
2407    ) -> Result<GenerateResult, String> {
2408        if std::env::var("CMF_TRACE_H").is_ok() {
2409            eprintln!("input_ids: {input_ids:?}");
2410        }
2411        if input_ids.is_empty() {
2412            return Err("empty prompt: nothing to generate from".to_string());
2413        }
2414        // A prior graph failure is terminal for that sequence but must not
2415        // poison the next independent request.  Keep this flag separate from
2416        // the externally-owned cooperative cancel bit.
2417        self.graph_failed
2418            .store(false, std::sync::atomic::Ordering::Relaxed);
2419        // A mask that forbids nothing still costs every fused path and
2420        // whole-token graph, all of which are gated on `is_none()`. A
2421        // narrowed file whose one segment is always on carries exactly
2422        // such a mask — drop it here rather than pay 5x for a no-op.
2423        let task_mask = self.drop_open_mask(task_mask);
2424
2425        // Cross-turn KV reuse: a chat app resends the whole history
2426        // every turn; when the new ids strictly EXTEND what the cache
2427        // already holds, prefill only the tail — turn latency stays
2428        // proportional to the new text instead of the whole session.
2429        // Extension-only (no rollback), so it is exact for every layer
2430        // kind including recurrent state; MTP/o1/task-mask runs keep
2431        // the fresh-sequence path. CMF_KV_REUSE=0 disables.
2432        let reuse_from = {
2433            let on = !std::env::var("CMF_KV_REUSE").is_ok_and(|v| v == "0");
2434            let h = &self.kv_history;
2435            if on
2436                && task_mask.is_none()
2437                && self.mtp.is_none()
2438                && self.o1_cfg.is_none()
2439                && self.dsv41.is_none()
2440                && !h.is_empty()
2441                && h.len() < input_ids.len()
2442                && input_ids[..h.len()] == h[..]
2443            {
2444                h.len()
2445            } else {
2446                0
2447            }
2448        };
2449        if reuse_from == 0 {
2450            // Fresh sequence — the cache holds absolute positions.
2451            self.clear_sequence_state();
2452        } else if std::env::var("CMF_PREFILL_PROF").is_ok() {
2453            eprintln!(
2454                "kv-reuse: {} of {} prompt positions already cached",
2455                reuse_from,
2456                input_ids.len()
2457            );
2458        }
2459        crate::gpu::graph_race_begin_generation();
2460        // Optional bounded calibration prefix. Keep the requested value
2461        // even when it is longer than the prompt; the collecting layer will
2462        // defer at the effective boundary and remain exact for short input.
2463        let o1_prefill = if self.o1_active() && task_mask.is_none() {
2464            std::env::var("CMF_O1_PREFILL")
2465                .ok()
2466                .and_then(|v| v.parse::<usize>().ok())
2467                .filter(|&p| p > 0)
2468        } else {
2469            None
2470        };
2471        if task_mask.is_none() {
2472            self.o1_begin_with_prefix(o1_prefill);
2473        }
2474
2475        // Speculative decode is off under o1: a rejected draft can't be
2476        // rolled back out of the far accumulators / ring window (the
2477        // Nyström insertion is irreversible by design).
2478        // The wgpu token graph owns a device K/V mirror that speculative
2479        // rollback would desync — the two are mutually exclusive.
2480        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
2481        // Graph speculative decode (`CMF_GRAPH_SPEC=1`): the MTP head
2482        // drafts, ONE batched graph submit verifies the whole chain.
2483        //
2484        // It now PAYS on Qwen3.6-27B / RTX 5090 — 51.1 tok/s against a
2485        // plain 49.4 at k=3, medians of three, 89% of drafts accepted,
2486        // and the greedy continuation is byte-identical to the plain
2487        // path. That took the batch matvec sharing its nibble unpack
2488        // across the batch (`CMF_MV_BK=2`); before it, the same round
2489        // measured 43.6, an 11% LOSS, which is what the earlier note
2490        // here described.
2491        //
2492        // Still opt-in. One model's win is not a default: the verify
2493        // rides `gdn_spec_restore` and a batched frame whose numerics
2494        // are the batch kernels', and that has to be shown on more than
2495        // one architecture before every greedy decode takes it.
2496        // Greedy (with or without penalties) verifies by argmax equality.
2497        // Sampling (temperature > 0) can go through speculative SAMPLING —
2498        // draft from the MTP head's own post-chain distribution, accept
2499        // with min(1, p/q), correct from max(0, p − q); the emitted stream
2500        // is distributed exactly as the plain sampler's — but it is
2501        // OPT-IN (`CMF_GRAPH_SPEC_SAMPLE=1`): measured on Qwen3.8-27B /
2502        // RTX 5090 at the instruct row (0.7 / 0.80 / 20 / presence 1.5)
2503        // it decoded 19-22 tok/s against a plain 40 — nine post-chain
2504        // distributions a round plus a lower acceptance than greedy's,
2505        // against a verify that costs 2.7 single tokens. The greedy arms
2506        // pay +10%; the sampling arm needs a cheaper verify first.
2507        let spec_sampling_ok = self.sampler_config.temperature < 1e-6
2508            || std::env::var("CMF_GRAPH_SPEC_SAMPLE").as_deref() == Ok("1");
2509        // ON by default for greedy on the wgpu graph: with the draft on
2510        // the graph and the verify bit-exact, it measured 58.7 tok/s
2511        // against a plain 48.1 on Qwen3.8-27B q4tp / RTX 5090 (k=4) and
2512        // 51.1 against 49.4 on Qwen3.6-27B, and a round that stops
2513        // paying turns itself off below (acceptance watchdog).
2514        // `CMF_GRAPH_SPEC=0` disables; `=1` was the old opt-in spelling.
2515        // …but only where the batched verify has its register-blocked
2516        // kernel: q4tp dense FFNs (graph kind 6). q4t and q8_2f verify
2517        // through tile GEMMs today and measured a LOSS (q8_2f 22 against
2518        // 29 tok/s), the 2-bit plane the same; those stay opt-in
2519        // (`CMF_GRAPH_SPEC=1`).
2520        // …at least in nine dense FFNs of ten: a healed file carries its
2521        // last two layers at q8_2f, and two tile-GEMM verifies among 64 do
2522        // not change the arithmetic (measured: the healed q4tp file
2523        // decodes at the plain file's rate and would otherwise sit out).
2524        let (mut dense_n, mut dense_q4tp) = (0usize, 0usize);
2525        for lw in &self.weights.layers {
2526            if let FfnKind::Dense(d) = &lw.ffn {
2527                dense_n += 1;
2528                if matches!(d.gate_proj.graph_weight(), Some((_, _, 6, _)))
2529                    && matches!(d.up_proj.graph_weight(), Some((_, _, 6, _)))
2530                    && matches!(d.down_proj.graph_weight(), Some((_, _, 6, _)))
2531                {
2532                    dense_q4tp += 1;
2533                }
2534            }
2535        }
2536        let spec_default_ok = dense_n == 0 || dense_q4tp * 10 >= dense_n * 9;
2537        // Penalties break the draft head's agreement with the trunk (a
2538        // 1.1 repetition penalty measured 2 of 16 accepted): not by
2539        // default there either.
2540        let penalized = self.sampler_config.repetition_penalty != 1.0
2541            || self.sampler_config.presence_penalty != 0.0
2542            || !self.sampler_config.suppress_tokens.is_empty();
2543        // …and not on wgpu-over-Metal: the batched verify graph there
2544        // returned 0 accepted drafts and garbage text on a GDN hybrid
2545        // (16.08, Qwen3.5-0.8B) while Vulkan is bit-exact; the Mac's
2546        // default backend is native Metal without a batch graph anyway.
2547        #[cfg(feature = "gpu")]
2548        let metal_wgpu = graph_on && crate::gpu_wgpu::wgpu_backend_is_metal();
2549        #[cfg(not(feature = "gpu"))]
2550        let metal_wgpu = false;
2551        let spec_env = std::env::var("CMF_GRAPH_SPEC").ok();
2552        let spec_wanted = match spec_env.as_deref() {
2553            Some("0") => false,
2554            Some(_) => {
2555                if metal_wgpu {
2556                    tracing::warn!(
2557                        "CMF_GRAPH_SPEC forced on wgpu/Metal: the batched verify graph is not \
2558                         verified on this backend (garbage measured on Qwen3.5-0.8B)"
2559                    );
2560                }
2561                true
2562            }
2563            None => spec_default_ok && !penalized && !metal_wgpu,
2564        };
2565        // Native Metal: the b-row verify graph (`try_batch_graph_metal`)
2566        // stands where the wgpu batch graph stands on discrete cards.
2567        #[cfg(target_os = "macos")]
2568        let metal_graph = crate::gpu::q1_force()
2569            && crate::gpu::enabled_here()
2570            && std::env::var("CMF_GPU_BLOCK")
2571                .map(|v| v != "0")
2572                .unwrap_or(true);
2573        #[cfg(not(target_os = "macos"))]
2574        let metal_graph = false;
2575        let graph_spec = self.speculative
2576            && (graph_on || metal_graph)
2577            && self.mtp.is_some()
2578            && task_mask.is_none()
2579            && !self.o1_active()
2580            && spec_sampling_ok
2581            && spec_wanted;
2582        // GDN hybrids sit the fused-pair speculation out by default: the
2583        // recurrence is sequential, so the pair lane cannot parallelize
2584        // (the bench's own Pair line reads fused 1.28x TWO singles on the
2585        // 35B) and the draft's full-vocab head rides on top — measured 2x
2586        // SLOWER end to end (16.1 vs 32.4 tok/s on the 48-core stand).
2587        // CMF_MTP=1 forces it back for study.
2588        let pair_pays = self.gdn_cfg.is_none() || std::env::var("CMF_MTP").as_deref() == Ok("1");
2589        let spec_active = self.speculative
2590            && self.mtp.is_some()
2591            && task_mask.is_none()
2592            && !self.o1_active()
2593            && ((!graph_on && pair_pays && self.sampler_config.temperature < 1e-6) || graph_spec);
2594        // The MTP module is detached during generation so its mutable
2595        // state does not fight the borrow on `self`.
2596        let mut mtp = if spec_active { self.mtp.take() } else { None };
2597        if std::env::var("CMF_MTP_CHAIN_PROBE").is_ok() {
2598            eprintln!(
2599                "mtp-probe gate: spec_active={spec_active} mtp={} speculative={} graph_on={graph_on} temp_ok={}",
2600                mtp.is_some(),
2601                self.speculative,
2602                self.sampler_config.temperature < 1e-6,
2603            );
2604        }
2605        if let Some(m) = &mut mtp {
2606            m.kv.clear();
2607            // The MTP block's own device mirror starts over with its cache.
2608            crate::gpu::graph_kv_reset(self.mtp_kv_id());
2609            self.mtp_graph_mode = None;
2610        }
2611        // Dynamic router detached during decode (same borrow trick as MTP).
2612        // Speculative decode and dynamic routing are mutually exclusive
2613        // for now — the fused-pair path doesn't carry per-token φ.
2614        let mut router = if mtp.is_none() {
2615            self.dyn_router.take()
2616        } else {
2617            None
2618        };
2619        if let Some(r) = &mut router {
2620            r.reset(); // active=backbone, matching a fresh overlay
2621            self.dyn_phi_seen = 0; // fresh φ EMA per generation
2622            let _ = self.set_active_skill(None);
2623        }
2624
2625        let mut all_ids = input_ids.to_vec();
2626        let mut generated = 0usize;
2627        let mut finish_reason = "max_tokens".to_string();
2628        let mut drafted = 0usize;
2629        let mut accepted = 0usize;
2630        // DeepSeek-V4's draft quality is strongly content-dependent.  Two
2631        // consecutive paid rounds with no extra token put it on a bounded
2632        // cooldown; predictable text keeps batching, ordinary prose falls
2633        // back to the exact walk instead of paying a slow draft forever.
2634        // Local to one generation so one difficult request cannot poison the
2635        // next one, and deliberately automatic — this is not a user knob.
2636        let mut dsv4_spec_bad = 0usize;
2637        let mut dsv4_spec_retry_at = 0usize;
2638        let mut confidence: Vec<f32> = Vec::new();
2639        let trace_on = self.trace;
2640        let calib_temp = self.calib_temp;
2641        let mut traces: Vec<TokenTrace> = Vec::new();
2642
2643        // ── Prefill: forward each prompt token once, KEEP the last hidden.
2644        //    Dense prefill runs in fused pairs (weights streamed once per
2645        //    two positions — bit-identical to sequential, proven by the
2646        //    pair tests). With MTP: warm the draft head on
2647        //    (hidden_p, token_{p+1}) pairs.
2648        let mut hidden = vec![0.0f32; self.hidden_size];
2649        let mut pos = reuse_from;
2650        // lm_head-in-graph is only sound when the very next logits
2651        // consumer is this loop's own (MTP and skill routing interleave
2652        // other forwards / can swap lm_head between forward and sample).
2653        // CMF_GPU_LMHEAD=0 keeps lm_head off the graph: the token reads back
2654        // the 8 KB hidden instead of ~1 MB of logits, and the head runs on
2655        // the host. A probe for how much of the graph's fixed per-token cost
2656        // is the logits readback (the layer sweep puts that fixed part at
2657        // 3.88 ms of an 18.5 ms frame).
2658        let fuse_lm = mtp.is_none()
2659            && router.is_none()
2660            && std::env::var("CMF_GPU_LMHEAD").as_deref() != Ok("0");
2661        self.graph_logits = None;
2662        self.graph_want_logits = false;
2663        let _tpf = std::time::Instant::now();
2664        let batch_k = std::env::var("CMF_BATCH_K")
2665            .ok()
2666            .and_then(|v| v.parse::<usize>().ok())
2667            .unwrap_or(0);
2668        // DeepSeek-V4 owns a separate hyper-connection stack. Route it
2669        // before the generic prefill choices: those correctly reject an
2670        // empty `weights.layers`, but their final per-position fallback used
2671        // to consume the whole prompt before `dsv4::forward_chunk` could see
2672        // it. The batch implementation therefore existed without a live
2673        // production entry point.
2674        //
2675        // Bounded chunks preserve cancellation responsiveness. Only the
2676        // prompt's final chunk asks for logits; every earlier head projection
2677        // would produce 129 280 values that no caller reads.
2678        while self.qwen4_exp.is_some()
2679            && mtp.is_none()
2680            && pos < input_ids.len()
2681            && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2682        {
2683            let token_id = input_ids[pos];
2684            let want_logits = pos + 1 == input_ids.len();
2685            let mut lg = Vec::new();
2686            if let Some(b) = &mut self.qwen4_exp {
2687                crate::qwen4_exp::forward_token(
2688                    &b.0,
2689                    &b.1,
2690                    &b.2,
2691                    &mut b.3,
2692                    token_id,
2693                    pos,
2694                    &self.inv_freq,
2695                    self.pool.as_deref(),
2696                    &mut lg,
2697                    want_logits,
2698                );
2699            }
2700            if want_logits {
2701                self.graph_logits = Some(lg);
2702            }
2703            pos += 1;
2704            hidden.fill(0.0);
2705        }
2706        while self.dsv4.is_some()
2707            && mtp.is_none()
2708            && pos < input_ids.len()
2709            && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2710        {
2711            let end = (pos + prefill_chunk()).min(input_ids.len());
2712            let ids: Vec<u32> = input_ids[pos..end].to_vec();
2713            let mut lg = Vec::new();
2714            if let Some(b) = &mut self.dsv4 {
2715                let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
2716                crate::dsv4::forward_chunk(
2717                    g,
2718                    layers,
2719                    &cfg,
2720                    st,
2721                    &ids,
2722                    pos,
2723                    &self.inv_freq,
2724                    self.pool.as_deref(),
2725                    &mut lg,
2726                    end == input_ids.len(),
2727                );
2728            }
2729            if end == input_ids.len() {
2730                self.graph_logits = Some(lg);
2731            }
2732            pos = end;
2733            hidden = vec![0.0; self.hidden_size];
2734        }
2735        let dsv41_prefill = self.dsv41_prefill.take();
2736        while self.dsv41.is_some()
2737            && mtp.is_none()
2738            && pos < input_ids.len()
2739            && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2740        {
2741            let end = (pos + prefill_chunk()).min(input_ids.len());
2742            let ids: Vec<u32> = input_ids[pos..end].to_vec();
2743            let mut lg = Vec::new();
2744            if let Some(b) = &mut self.dsv41 {
2745                let (g, layers, cfg, st) = (&b.0, &b.1, &b.2, &mut b.3);
2746                if let Some((embeddings, participates)) = dsv41_prefill.as_ref() {
2747                    crate::dsv41::forward_chunk_masked_with_embeddings(
2748                        g,
2749                        layers,
2750                        cfg,
2751                        st,
2752                        &ids,
2753                        pos,
2754                        &embeddings[pos..end],
2755                        &participates[pos..end],
2756                        self.pool.as_deref(),
2757                        &mut lg,
2758                    );
2759                } else {
2760                    crate::dsv41::forward_chunk(
2761                        g,
2762                        layers,
2763                        cfg,
2764                        st,
2765                        &ids,
2766                        pos,
2767                        self.pool.as_deref(),
2768                        &mut lg,
2769                    );
2770                }
2771            }
2772            if end == input_ids.len() {
2773                self.graph_logits = Some(lg);
2774            }
2775            pos = end;
2776            hidden = vec![0.0; self.hidden_size];
2777        }
2778        // With dynamic routing, prefill sequentially so the φ hook fires
2779        // over the PROMPT — the router enters decode with a warm φ (the
2780        // fused-pair path skips the per-layer φ capture). o1 layers
2781        // collect their query trace in both the single and pair paths.
2782        let dyn_prefill = router.is_some();
2783        // Optional bounded calibration prefix for generation.  The normal
2784        // O(1) path seals after the full prompt; this explicit knob instead
2785        // runs only the requested prefix through exact attention, seals the
2786        // Nyström state, and streams the rest of the prompt through the same
2787        // O(1) step used by decode.  It keeps the O(1) layers' Q trace and
2788        // temporary full KV bounded by the prefix while leaving the default
2789        // full-prompt quality profile untouched.
2790        let o1_prefill_limit = o1_prefill
2791            .and_then(|requested| self.o1_effective_boundary(requested))
2792            .map(|boundary| boundary.min(input_ids.len()));
2793        let mut o1_sealed = false;
2794        if let Some(limit) = o1_prefill_limit {
2795            // Reuse the exact batched prefix machinery when available; it
2796            // records the same per-position Q trace as the full prefill.
2797            if self.can_prefill_batched() && limit > 2 {
2798                let chunk = prefill_chunk();
2799                let hs = self.hidden_size;
2800                while pos < limit && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2801                    let end = (pos + chunk).min(limit);
2802                    let hb = self.prefill_batch(&input_ids[pos..end], pos);
2803                    hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2804                    pos = end;
2805                }
2806            } else {
2807                while pos < limit && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2808                    hidden = self.forward_layers(&self.embed_single(input_ids[pos]), pos, None);
2809                    pos += 1;
2810                }
2811            }
2812            if pos >= limit {
2813                o1_sealed = match self.o1_seal_checked() {
2814                    Ok(sealed) => sealed,
2815                    Err(err) => {
2816                        self.finish_generation(&mut mtp, &mut router, true);
2817                        return Err(err);
2818                    }
2819                };
2820                tracing::info!(
2821                    "o1 bounded prompt prefix: requested={} effective={} processed={} of {} token(s)",
2822                    o1_prefill.unwrap_or(0),
2823                    self.o1_effective_boundary(o1_prefill.unwrap_or(0))
2824                        .unwrap_or(limit),
2825                    limit,
2826                    input_ids.len()
2827                );
2828            }
2829        }
2830        // q1 hybrids on Metal: the per-position GPU token graph beats
2831        // the CPU chunk-GEMM (whose wall is the sequential scalar GDN
2832        // recurrence), so prefill goes position-by-position through the
2833        // same graph as decode. Pure-attention models keep the batched
2834        // path — there the chunk-GEMM amortization wins.
2835        let graph_prefill = self.graph_prefill_preferred();
2836        // Native Metal, q4tp GDN hybrids: the prompt through the b-row
2837        // rows graph — projections as GEMMs over up to 512 positions, the
2838        // GDN recurrence in registers on the device, K/V rows appended by
2839        // the chunk — instead of one token-graph submit per position (the
2840        // 27B: 8 tok/s → GEMM-bound). The MTP warm-up rows come out of one
2841        // batched run of the block per chunk. Any refusal leaves the rest
2842        // of the prompt to the sequential paths below.
2843        #[cfg(target_os = "macos")]
2844        if task_mask.is_none()
2845            && !dyn_prefill
2846            && crate::gpu::q1_force()
2847            && crate::gpu::enabled_here()
2848            && self.gdn_cfg.is_some()
2849            && self.g3n.is_none()
2850            && input_ids.len() > 8
2851            && std::env::var("CMF_MTP_CHAIN_PROBE").is_err()
2852            && std::env::var("CMF_METAL_PREFILL").as_deref() != Ok("0")
2853        {
2854            let chunk: usize = std::env::var("CMF_METAL_PREFILL_CHUNK")
2855                .ok()
2856                .and_then(|v| v.parse().ok())
2857                .filter(|&v| (16..=512).contains(&v))
2858                .unwrap_or(256);
2859            let hs = self.hidden_size;
2860            let _tp = std::time::Instant::now();
2861            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2862                let end = (pos + chunk).min(input_ids.len());
2863                let Some(hb) = self.prefill_batch_metal(&input_ids[pos..end], pos) else {
2864                    break;
2865                };
2866                if let Some(m) = &mut mtp {
2867                    let n_pairs = if end < input_ids.len() {
2868                        end - pos
2869                    } else {
2870                        end - pos - 1
2871                    };
2872                    if n_pairs > 0 {
2873                        let pairs: Vec<(&[f32], u32)> = (0..n_pairs)
2874                            .map(|j| (&hb[j * hs..(j + 1) * hs], input_ids[pos + j + 1]))
2875                            .collect();
2876                        if !self.mtp_warm_batch_metal(m, &pairs, pos) {
2877                            for (j, (h, t)) in pairs.iter().enumerate() {
2878                                let h = h.to_vec();
2879                                let _ = self.mtp_step(m, &h, *t, pos + j);
2880                            }
2881                        }
2882                    }
2883                }
2884                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2885                pos = end;
2886            }
2887            if std::env::var("CMF_PREFILL_PROF").is_ok() {
2888                eprintln!(
2889                    "metal-prefill: {} of {} tokens in {:.1} ms",
2890                    pos,
2891                    input_ids.len(),
2892                    _tp.elapsed().as_secs_f64() * 1e3
2893                );
2894            }
2895        }
2896        if task_mask.is_none()
2897            && !dyn_prefill
2898            && !graph_prefill
2899            && self.can_prefill_batched()
2900            && self.g3n.is_none()
2901            && o1_prefill.is_none()
2902            && input_ids.len() > 2
2903        {
2904            // Production prefill = the same chunked prefill-GEMM that
2905            // bench/PPL measure (roadmap §3 P0: generation used to warm
2906            // the prompt with the slower pair path — the published
2907            // prefill number didn't match real TTFT). MTP warm-up reads
2908            // each position's hidden straight from the chunk result.
2909            let chunk = prefill_chunk();
2910            let hs = self.hidden_size;
2911            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2912                let end = (pos + chunk).min(input_ids.len());
2913                let hb = self.prefill_batch(&input_ids[pos..end], pos);
2914                if let Some(m) = &mut mtp {
2915                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2916                        .ok()
2917                        .and_then(|v| v.parse().ok())
2918                        .unwrap_or(0);
2919                    for p in pos..end {
2920                        if p + 1 < input_ids.len() {
2921                            if probe >= 1 && p + 2 < input_ids.len() {
2922                                // Teacher-forced chain acceptance (see the
2923                                // tail loop's twin): the warm-up row stays,
2924                                // the chain's rows roll back.
2925                                let (d1, mut hx) = self.mtp_step_h(
2926                                    m,
2927                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2928                                    input_ids[p + 1],
2929                                    p,
2930                                );
2931                                let mut ok = d1 == input_ids[p + 2];
2932                                Self::chain_probe_note(0, ok);
2933                                let mut d_prev = d1;
2934                                let mut extra = 0usize;
2935                                for j in 1..probe {
2936                                    if p + 2 + j >= input_ids.len() {
2937                                        break;
2938                                    }
2939                                    let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, p + 1 + j);
2940                                    extra += 1;
2941                                    ok = ok && dj == input_ids[p + 2 + j];
2942                                    Self::chain_probe_note(j, ok);
2943                                    d_prev = dj;
2944                                    hx = hj;
2945                                }
2946                                m.kv.truncate_last(extra);
2947                            } else {
2948                                let _ = self.mtp_step(
2949                                    m,
2950                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2951                                    input_ids[p + 1],
2952                                    p,
2953                                );
2954                            }
2955                        }
2956                    }
2957                }
2958                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2959                pos = end;
2960            }
2961        }
2962        let pair_off = std::env::var("CMF_PAIR").is_ok_and(|v| v == "0");
2963        if task_mask.is_none()
2964            && !dyn_prefill
2965            && !graph_prefill
2966            && !pair_off
2967            && self.pair_supported()
2968            && o1_prefill.is_none()
2969        {
2970            while pos + 1 < input_ids.len()
2971                && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2972            {
2973                let e1 = self.embed_single(input_ids[pos]);
2974                let e2 = self.embed_single(input_ids[pos + 1]);
2975                let (h1, h2) = self.forward_pair(&e1, &e2, pos);
2976                // Both prefill tokens are real → commit lane-2 states.
2977                self.commit_linear_scratch();
2978                if let Some(m) = &mut mtp {
2979                    let _ = self.mtp_step(m, &h1, input_ids[pos + 1], pos);
2980                    if pos + 2 < input_ids.len() {
2981                        let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2982                            .ok()
2983                            .and_then(|v| v.parse().ok())
2984                            .unwrap_or(0);
2985                        if probe >= 1 && pos + 3 < input_ids.len() {
2986                            // Same teacher-forced chain table as the tail
2987                            // loop below, fed from the pair path that owns
2988                            // most prefill positions.
2989                            let (d1, mut hx) = self.mtp_step_h(m, &h2, input_ids[pos + 2], pos + 1);
2990                            let mut ok = d1 == input_ids[pos + 3];
2991                            Self::chain_probe_note(0, ok);
2992                            let mut d_prev = d1;
2993                            let mut extra = 0usize;
2994                            for j in 1..probe {
2995                                if pos + 3 + j >= input_ids.len() {
2996                                    break;
2997                                }
2998                                let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 2 + j);
2999                                extra += 1;
3000                                ok = ok && dj == input_ids[pos + 3 + j];
3001                                Self::chain_probe_note(j, ok);
3002                                d_prev = dj;
3003                                hx = hj;
3004                            }
3005                            m.kv.truncate_last(extra);
3006                        } else {
3007                            let _ = self.mtp_step(m, &h2, input_ids[pos + 2], pos + 1);
3008                        }
3009                    }
3010                }
3011                hidden = h2;
3012                pos += 2;
3013            }
3014        }
3015        // Batched GPU prefill for the wgpu decode graph (GDN hybrids): K prompt
3016        // positions per submit — projections/FFN as GEMMs (weight once per K),
3017        // attention/GDN looped inside — instead of one whole-graph submit per
3018        // position. Falls through to the per-position graph on any refusal.
3019        // Batched prefill is opt-in (CMF_BATCH_K>0). Default 0 = per-position
3020        // graph prefill. (Steady-state decode is provably identical either way —
3021        // token-graph submit and lm_head both unchanged — so this only trades
3022        // prefill wall.)
3023        // A bounded O(1) prefix is the one post-seal prompt interval: only
3024        // admit its batch when the device O(1) route is explicitly enabled and
3025        // every sealed layer exposes a portable view. The same batch size and
3026        // refusal behavior remain the ordinary controls/comparator.
3027        let o1_batch_ready = o1_sealed
3028            && o1_prefill.is_some()
3029            && mtp.is_none()
3030            && std::env::var("CMF_O1_GPU").as_deref() == Ok("1")
3031            && (0..self.num_layers).all(|li| {
3032                let cache = &self.kv_cache.layers[self.phys_layer(li)];
3033                cache.o1.is_none() || cache.o1_views().is_some()
3034            });
3035        // The ordinary graph-prefill route can share each completed trunk
3036        // chunk with an attached MTP head.  Keep chain probing on its
3037        // established per-position path: the probe deliberately needs every
3038        // teacher-forced draft row and its rollback table.
3039        let mtp_batch_prefill = mtp.is_some()
3040            && graph_prefill
3041            && task_mask.is_none()
3042            && !dyn_prefill
3043            && !self.o1_active()
3044            && std::env::var("CMF_MTP_CHAIN_PROBE").is_err();
3045        if batch_k > 0
3046            && (graph_prefill || o1_batch_ready)
3047            && task_mask.is_none()
3048            && (!self.o1_active() || o1_batch_ready)
3049            && (mtp.is_none() || mtp_batch_prefill)
3050            && !dyn_prefill
3051            && pos + 1 < input_ids.len()
3052        {
3053            let hs = self.hidden_size;
3054            let chunk = batch_k;
3055            while pos < input_ids.len() {
3056                let end = (pos + chunk).min(input_ids.len());
3057                let bk = end - pos;
3058                let mut hiddens = vec![0f32; bk * hs];
3059                for (j, &id) in input_ids[pos..end].iter().enumerate() {
3060                    hiddens[j * hs..(j + 1) * hs].copy_from_slice(&self.embed_single(id));
3061                }
3062                let positions: Vec<usize> = (pos..end).collect();
3063                let t_chunk = std::time::Instant::now();
3064                let outcome = self.try_batch_graph_wgpu(&mut hiddens, &positions, bk, None);
3065                let ok_b = outcome == crate::gpu::BatchGraphOutcome::Completed;
3066                if std::env::var("CMF_GRAPH_PROF").is_ok() {
3067                    let ms = t_chunk.elapsed().as_secs_f64() * 1000.0;
3068                    eprintln!(
3069                        "batch-chunk: phase=prompt mode={} k={bk} outcome={outcome:?} {ms:.1} ms ({:.1} tok/s)",
3070                        if o1_batch_ready {
3071                            "o1"
3072                        } else if mtp_batch_prefill {
3073                            "ordinary_mtp"
3074                        } else {
3075                            "ordinary"
3076                        },
3077                        bk as f64 / (ms / 1000.0)
3078                    );
3079                }
3080                {
3081                    use std::sync::atomic::{AtomicBool, Ordering};
3082                    static SAID: AtomicBool = AtomicBool::new(false);
3083                    if !SAID.swap(true, Ordering::Relaxed) {
3084                        if ok_b {
3085                            tracing::info!(
3086                                "batched prefill: ACTIVE mode={} (k={bk})",
3087                                if o1_batch_ready {
3088                                    "o1"
3089                                } else if mtp_batch_prefill {
3090                                    "ordinary_mtp"
3091                                } else {
3092                                    "ordinary"
3093                                }
3094                            );
3095                        } else {
3096                            tracing::warn!("batched prefill {:?} — per-position graph", outcome);
3097                        }
3098                    }
3099                }
3100                if ok_b {
3101                    if mtp_batch_prefill {
3102                        let n_pairs = mtp_prefill_pair_count(pos, end, input_ids.len());
3103                        if n_pairs > 0 {
3104                            // `hiddens` is owned by this chunk, so materialize
3105                            // row slices before borrowing the detached MTP
3106                            // module.  The last prompt row has no successor;
3107                            // the helper above is the single source of that
3108                            // boundary rule.
3109                            let rows: Vec<Vec<f32>> = (0..n_pairs)
3110                                .map(|j| hiddens[j * hs..(j + 1) * hs].to_vec())
3111                                .collect();
3112                            let pairs: Vec<(&[f32], u32)> = rows
3113                                .iter()
3114                                .enumerate()
3115                                .map(|(j, row)| (row.as_slice(), input_ids[pos + j + 1]))
3116                                .collect();
3117                            if std::env::var("CMF_GRAPH_PROF").is_ok() {
3118                                eprintln!(
3119                                    "mtp-warm: phase=prompt mode=ordinary_mtp first_pos={} pairs={} last_pos={}",
3120                                    pos,
3121                                    n_pairs,
3122                                    pos + n_pairs - 1,
3123                                );
3124                            }
3125                            let warm_error = if let Some(m) = mtp.as_mut() {
3126                                self.mtp_warm_prefill_pairs(m, &pairs, pos).err()
3127                            } else {
3128                                None
3129                            };
3130                            if let Some(err) = warm_error {
3131                                // The trunk batch was already admitted.  A
3132                                // failed MTP warm-up therefore clears both
3133                                // mirrors and exits; continuing would pair a
3134                                // current trunk state with a stale MTP cache.
3135                                self.finish_generation(&mut mtp, &mut router, true);
3136                                return Err(err.to_string());
3137                            }
3138                        }
3139                    }
3140                    hidden.copy_from_slice(&hiddens[(bk - 1) * hs..]);
3141                    pos = end;
3142                } else if outcome == crate::gpu::BatchGraphOutcome::Failed {
3143                    // A failed batch may have advanced a device recurrent
3144                    // state (ordinary GDN or sealed O(1)). A CPU fallback
3145                    // would then observe stale accumulators, so clear the
3146                    // request state and make the failure explicit.
3147                    self.finish_generation(&mut mtp, &mut router, true);
3148                    return Err(if o1_batch_ready {
3149                        "sealed O(1) batch graph failed after admission".to_string()
3150                    } else {
3151                        "ordinary recurrent batch graph failed after admission".to_string()
3152                    });
3153                } else {
3154                    break; // unsupported → per-position graph handles the rest
3155                }
3156            }
3157        }
3158        while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
3159            self.graph_want_logits = fuse_lm && pos + 1 == input_ids.len();
3160            hidden = self.forward_layers(&self.embed_single(input_ids[pos]), pos, task_mask);
3161            if let Some(m) = &mut mtp {
3162                if pos + 1 < input_ids.len() {
3163                    // `CMF_MTP_CHAIN_PROBE=k`: teacher-forced acceptance of a
3164                    // CHAINED draft — iterate the head on its own hidden k
3165                    // deep and score every depth against the prompt's real
3166                    // continuation. The economics of a k-token speculative
3167                    // round stand or fall on this table.
3168                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
3169                        .ok()
3170                        .and_then(|v| v.parse().ok())
3171                        .unwrap_or(0);
3172                    if probe >= 1 && pos + 2 < input_ids.len() {
3173                        let (d1, mut hx) = self.mtp_step_h(m, &hidden, input_ids[pos + 1], pos);
3174                        let mut ok = d1 == input_ids[pos + 2];
3175                        Self::chain_probe_note(0, ok);
3176                        let mut d_prev = d1;
3177                        let mut extra = 0usize;
3178                        for j in 1..probe {
3179                            if pos + 2 + j >= input_ids.len() {
3180                                break;
3181                            }
3182                            let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 1 + j);
3183                            extra += 1;
3184                            ok = ok && dj == input_ids[pos + 2 + j];
3185                            Self::chain_probe_note(j, ok);
3186                            d_prev = dj;
3187                            hx = hj;
3188                        }
3189                        // The chain's rows are speculation, not the prompt —
3190                        // keep only the warmup row the plain path would add.
3191                        m.kv.truncate_last(extra);
3192                    } else {
3193                        let _ = self.mtp_step(m, &hidden, input_ids[pos + 1], pos);
3194                    }
3195                }
3196            }
3197            pos += 1;
3198        }
3199        if std::env::var("CMF_PREFILL_PROF").is_ok() {
3200            eprintln!(
3201                "prefill: {} tokens in {:.1} ms (batch_k={batch_k})",
3202                input_ids.len(),
3203                _tpf.elapsed().as_secs_f64() * 1000.0
3204            );
3205        }
3206        if self
3207            .graph_failed
3208            .swap(false, std::sync::atomic::Ordering::Relaxed)
3209        {
3210            // MTP is detached for speculative generation.  Restore the
3211            // module before returning the terminal graph error; otherwise a
3212            // failed request would silently remove the head from a pooled
3213            // pipeline and the next request would lose its configured route.
3214            self.finish_generation(&mut mtp, &mut router, true);
3215            return Err("GPU token graph failed during prefill".to_string());
3216        }
3217        // Cancelled mid-prefill: the cache holds a partial prompt —
3218        // drop the reuse history and return an empty generation.
3219        if self
3220            .cancel
3221            .swap(false, std::sync::atomic::Ordering::Relaxed)
3222        {
3223            // A cancelled prefill can already have advanced the device
3224            // mirror. Drop the whole partial sequence so a pooled pipeline
3225            // cannot carry that state into its next request.
3226            self.finish_generation(&mut mtp, &mut router, true);
3227            return Ok(GenerateResult {
3228                text: String::new(),
3229                token_ids: Vec::new(),
3230                prompt_tokens: input_ids.len(),
3231                tokens_generated: 0,
3232                finish_reason: "cancelled".to_string(),
3233                mtp_drafted: 0,
3234                mtp_accepted: 0,
3235                token_confidence: Vec::new(),
3236                traces: Vec::new(),
3237            });
3238        }
3239
3240        // Prompt absorbed → freeze the o1 layers' skeletons; from here
3241        // every decode step on those layers is O(W + m·dv + m²).
3242        if !o1_sealed {
3243            match self.o1_seal_checked() {
3244                Ok(_) => {}
3245                Err(err) => {
3246                    self.finish_generation(&mut mtp, &mut router, true);
3247                    return Err(err);
3248                }
3249            }
3250        }
3251
3252        // Commit one token: push, check EOS, stream. Returns false = stop.
3253        macro_rules! commit {
3254            ($id:expr) => {{
3255                all_ids.push($id);
3256                generated += 1;
3257                if self.tokenizer.is_eos($id) {
3258                    finish_reason = "stop".to_string();
3259                    false
3260                } else {
3261                    let token_text = self.tokenizer.decode_token($id);
3262                    let mut go = true;
3263                    if let Some(ref mut cb) = on_token {
3264                        if !cb(&token_text) {
3265                            finish_reason = "cancelled".to_string();
3266                            go = false;
3267                        }
3268                    }
3269                    go
3270                }
3271            }};
3272        }
3273
3274        // Speculation is decided by MEASUREMENT, not by an acceptance
3275        // model. A k=4 round costs ~3.8 plain tokens on the 5090 (draft
3276        // 6.6 + verify 66.6 + commit 4.8 ms against a 20.6 ms token), so it
3277        // pays only when the head lands ~2.8 of 4 — predictable text (code,
3278        // structured output) does, free prose often does not, and the
3279        // ratio at which the two cross depends on the card and the context
3280        // depth. So: four speculative rounds timed, then eight plain
3281        // tokens timed, and the faster arm runs until a re-check 256
3282        // tokens later (context growth moves the balance). The trial
3283        // costs at most a few tokens of the slower arm per 256.
3284        let mut spec_trial = SpecTrial::Spec {
3285            t0: std::time::Instant::now(),
3286            gen0: generated,
3287            rounds: 0,
3288        };
3289        let mut spec_mon = SpecMon::default();
3290        let mut spec_watchdog_off = false;
3291        // ── Decode ──
3292        let mut next_pos = input_ids.len();
3293        'decode: while generated < max_tokens {
3294            if self
3295                .graph_failed
3296                .swap(false, std::sync::atomic::Ordering::Relaxed)
3297            {
3298                // Keep the detached MTP module attached after a terminal
3299                // graph error so the pipeline can be reused for a fresh
3300                // sequence.  `clear_sequence_state` only clears mirrors and
3301                // host KV; it cannot recover a module dropped here.
3302                self.finish_generation(&mut mtp, &mut router, true);
3303                return Err("GPU token graph failed during decode".to_string());
3304            }
3305            if self
3306                .cancel
3307                .swap(false, std::sync::atomic::Ordering::Relaxed)
3308            {
3309                finish_reason = "cancelled".to_string();
3310                break 'decode;
3311            }
3312            // A rejected speculative draft already drew this position's
3313            // token from the residual distribution (graph_spec_step); it
3314            // is committed as-is — sampling again from the row's logits
3315            // would bias the stream toward the target's mode.
3316            let forced = self.spec_forced.take();
3317            let mut logits = match (forced, self.graph_logits.take()) {
3318                (Some(_), _) => Vec::new(),
3319                (None, Some(lg)) => lg,
3320                (None, None) => {
3321                    inference::rms_norm_into(
3322                        &hidden,
3323                        &self.weights.final_norm,
3324                        self.rms_eps,
3325                        self.norm_style,
3326                        &mut self.ws.n1,
3327                    );
3328                    self.lm_head_forward(&self.ws.n1)
3329                }
3330            };
3331            // CMF_LOGIT_DUMP=<path>: the first decode step's hidden + logits
3332            // as raw f32 (hidden first) — cross-backend numerics diffing.
3333            if generated
3334                == std::env::var("CMF_LOGIT_DUMP_STEP")
3335                    .ok()
3336                    .and_then(|v| v.parse().ok())
3337                    .unwrap_or(0)
3338            {
3339                if let Ok(path) = std::env::var("CMF_LOGIT_DUMP") {
3340                    let mut bytes: Vec<u8> = Vec::with_capacity((hidden.len() + logits.len()) * 4);
3341                    for v in hidden.iter().chain(logits.iter()) {
3342                        bytes.extend_from_slice(&v.to_le_bytes());
3343                    }
3344                    if let Err(e) = std::fs::write(&path, &bytes) {
3345                        eprintln!("logit dump: failed to write {path}: {e}");
3346                        self.finish_generation(&mut mtp, &mut router, true);
3347                        return Err(format!("logit dump write failed: {e}"));
3348                    }
3349                }
3350            }
3351            let t_next = match forced {
3352                Some(c) => c,
3353                None => sampler::sample_with_scratch_pool(
3354                    &logits,
3355                    &self.sampler_config,
3356                    &all_ids,
3357                    &mut self.rng,
3358                    &mut self.sampler_scratch,
3359                    self.pool.as_deref(),
3360                ),
3361            };
3362            if self.confidence_on {
3363                confidence.push(if logits.is_empty() {
3364                    0.0
3365                } else {
3366                    sampler::top1_prob_pool(
3367                        self.pool.as_deref(),
3368                        &mut self.sampler_scratch,
3369                        &logits,
3370                        t_next,
3371                        calib_temp,
3372                    )
3373                });
3374            }
3375            if !logits.is_empty() {
3376                attention::recycle_buf(&mut logits);
3377            }
3378            if trace_on {
3379                // active_skill = the overlay in force while this token was
3380                // generated; recon/switched are filled after the post-emit
3381                // routing eval below (freshest coherence for this token).
3382                let skill = router.as_ref().and_then(|r| r.active_id());
3383                traces.push(TokenTrace {
3384                    t: generated,
3385                    token_id: t_next,
3386                    confidence: confidence.last().copied().unwrap_or(0.0),
3387                    active_skill: skill,
3388                    recon: None,
3389                    switched: false,
3390                });
3391            }
3392            if !commit!(t_next) {
3393                break 'decode;
3394            }
3395            if generated >= max_tokens {
3396                break 'decode;
3397            }
3398
3399            if self.dsv41.is_none() && self.kv_cache.needs_eviction() {
3400                // Say it ONCE, loudly: past this point the model keeps
3401                // talking but has lost half its context, and on a GDN
3402                // hybrid the graph's device state goes stale on top. The
3403                // Qwen3.8 bring-up spent a day reading this cliff as
3404                // three different model bugs.
3405                static SAID: std::sync::Once = std::sync::Once::new();
3406                SAID.call_once(|| {
3407                    tracing::warn!(
3408                        "KV cache full at {} positions — evicting half; quality \
3409                         will degrade. Raise CMF_MAX_SEQ.",
3410                        self.kv_cache.max_seq_len,
3411                    );
3412                });
3413                let keep = (self.kv_cache.max_seq_len / 2).max(1);
3414                self.kv_cache.evict(keep);
3415            }
3416
3417            // Advance the speculation trial: plain-phase accounting and
3418            // the periodic re-check happen here, on every token.
3419            if graph_spec {
3420                match spec_trial {
3421                    SpecTrial::Plain { t0, gen0 } if generated >= gen0 + 8 => {
3422                        spec_mon.plain_ms =
3423                            t0.elapsed().as_secs_f64() * 1e3 / (generated - gen0) as f64;
3424                        let keep = spec_mon.pays();
3425                        tracing::info!(
3426                            "speculation trial: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
3427                            spec_mon.tokens,
3428                            spec_mon.round_ms,
3429                            spec_mon.plain_ms,
3430                            if keep { "speculating" } else { "plain" }
3431                        );
3432                        spec_mon.fails = 0;
3433                        spec_trial = SpecTrial::Decided {
3434                            spec: keep,
3435                            recheck_at: if keep { usize::MAX } else { generated + 128 },
3436                        };
3437                    }
3438                    SpecTrial::Decided { recheck_at, .. } if generated >= recheck_at => {
3439                        spec_mon.n = 0;
3440                        spec_trial = SpecTrial::Spec {
3441                            t0: std::time::Instant::now(),
3442                            gen0: generated,
3443                            rounds: 0,
3444                        };
3445                    }
3446                    _ => {}
3447                }
3448                spec_watchdog_off = matches!(
3449                    spec_trial,
3450                    SpecTrial::Plain { .. } | SpecTrial::Decided { spec: false, .. }
3451                );
3452            }
3453            match &mut mtp {
3454                // ── Graph speculation: chain-draft, batch-verify on device ──
3455                #[cfg(feature = "gpu")]
3456                Some(m)
3457                    if graph_spec
3458                        && !spec_watchdog_off
3459                        && generated + 1 < max_tokens
3460                        && next_pos > 0 =>
3461                {
3462                    let t_round = std::time::Instant::now();
3463                    if let Some((extra, n_pos, new_h)) = self.graph_spec_step(
3464                        m,
3465                        &hidden,
3466                        t_next,
3467                        next_pos,
3468                        &mut drafted,
3469                        &mut accepted,
3470                        &mut all_ids,
3471                    ) {
3472                        next_pos = n_pos;
3473                        hidden = new_h;
3474                        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
3475                            eprintln!(
3476                                "spec-round wall {:.1} ms → {} tokens",
3477                                t_round.elapsed().as_secs_f64() * 1e3,
3478                                extra.len() + 1
3479                            );
3480                        }
3481                        // One speculative round done: the monitor counts it
3482                        // (round 1 untimed — it pays the batch scratch and
3483                        // the draft mirror), and the trial advances.
3484                        spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, extra.len() + 1);
3485                        // the round's tokens land in `generated` below; the
3486                        // plain phase must start counting AFTER them
3487                        spec_trial = Self::spec_trial_round(
3488                            spec_trial,
3489                            &mut spec_mon,
3490                            generated + extra.len() + 1,
3491                        );
3492                        let mut stopped = false;
3493                        for &id in &extra {
3494                            if self.confidence_on {
3495                                confidence.push(0.0);
3496                            }
3497                            if !commit!(id) {
3498                                stopped = true;
3499                                break;
3500                            }
3501                        }
3502                        if stopped {
3503                            break 'decode;
3504                        }
3505                        continue 'decode;
3506                    }
3507                    if self
3508                        .graph_failed
3509                        .swap(false, std::sync::atomic::Ordering::Relaxed)
3510                    {
3511                        // `graph_spec_step` may have detached MTP while a
3512                        // warm-up was in flight.  Do not reinterpret its
3513                        // terminal device failure as a plain decode step;
3514                        // restore the head, clear both mirrors, and surface
3515                        // one explicit error to the caller.
3516                        self.finish_generation(&mut mtp, &mut router, true);
3517                        return Err("GPU MTP graph failed during speculative decode".to_string());
3518                    }
3519                    // Declined (batch graph refused): plain forward below —
3520                    // and a round that produced one token for the trial's
3521                    // ledger, so a graph that keeps refusing is measured out
3522                    // like a head that keeps missing (it was spinning
3523                    // forever on a file whose batch graph declines).
3524                    // A declined round is not a cheap one-token round — it
3525                    // is a verify that does not exist for this file (a
3526                    // healed q8_2f tail measured 760 drafts, 0 accepted, 33
3527                    // against 48.8 tok/s while the monitor called the draft
3528                    // alone "paying"). Count it as the losing streak in one.
3529                    spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, 1);
3530                    spec_mon.tokens = 0.0;
3531                    spec_mon.fails = 3;
3532                    spec_trial = Self::spec_trial_round(spec_trial, &mut spec_mon, generated + 1);
3533                    hidden = self.forward_layers(&self.embed_single(t_next), next_pos, task_mask);
3534                    next_pos += 1;
3535                    continue 'decode;
3536                }
3537                // ── Speculative: draft t+2, verify in a fused pair ──
3538                Some(m) if !graph_spec && generated + 1 < max_tokens => {
3539                    let draft = self.mtp_step(m, &hidden, t_next, next_pos - 1);
3540                    drafted += 1;
3541                    let emb1 = self.embed_single(t_next);
3542                    let emb2 = self.embed_single(draft);
3543                    let (h1, h2) = self.forward_pair(&emb1, &emb2, next_pos);
3544
3545                    inference::rms_norm_into(
3546                        &h1,
3547                        &self.weights.final_norm,
3548                        self.rms_eps,
3549                        self.norm_style,
3550                        &mut self.ws.n1,
3551                    );
3552                    let mut logits1 = self.lm_head_forward(&self.ws.n1);
3553                    let t_after = sampler::sample_with_scratch_pool(
3554                        &logits1,
3555                        &self.sampler_config,
3556                        &all_ids,
3557                        &mut self.rng,
3558                        &mut self.sampler_scratch,
3559                        self.pool.as_deref(),
3560                    );
3561                    if self.confidence_on {
3562                        confidence.push(sampler::top1_prob_pool(
3563                            self.pool.as_deref(),
3564                            &mut self.sampler_scratch,
3565                            &logits1,
3566                            t_after,
3567                            calib_temp,
3568                        ));
3569                    }
3570                    attention::recycle_buf(&mut logits1);
3571                    if trace_on {
3572                        // Speculative decode is mutually exclusive with
3573                        // dynamic routing (router is None here) — no skill.
3574                        traces.push(TokenTrace {
3575                            t: generated,
3576                            token_id: t_after,
3577                            confidence: confidence.last().copied().unwrap_or(0.0),
3578                            active_skill: None,
3579                            recon: None,
3580                            switched: false,
3581                        });
3582                    }
3583                    let stop = !commit!(t_after);
3584
3585                    if t_after == draft {
3586                        accepted += 1;
3587                        self.commit_linear_scratch();
3588                        let _ = self.mtp_step(m, &h1, t_after, next_pos);
3589                        hidden = h2;
3590                        next_pos += 2;
3591                    } else {
3592                        // The draft lane is wrong: roll its KV entry back.
3593                        for layer in &mut self.kv_cache.layers {
3594                            layer.truncate_last(1);
3595                        }
3596                        if !stop {
3597                            let _ = self.mtp_step(m, &h1, t_after, next_pos);
3598                            hidden = self.forward_layers(
3599                                &self.embed_single(t_after),
3600                                next_pos + 1,
3601                                None,
3602                            );
3603                        }
3604                        next_pos += 2;
3605                    }
3606                    if stop {
3607                        break 'decode;
3608                    }
3609                }
3610                // ── Vanilla: forward the sampled token ──
3611                _ => {
3612                    // ── DeepSeek-V4 speculative decode (CMF_DSV4_SPEC=1):
3613                    // draft five on the card, verify batched, commit the
3614                    // accepted prefix. Greedy only; a rejected token's state
3615                    // is restored and replayed, so output equals the walk. ──
3616                    #[cfg(feature = "gpu")]
3617                    if Self::dsv4_spec_on() && self.dsv4.is_some() {
3618                        static SAID: std::sync::Once = std::sync::Once::new();
3619                        SAID.call_once(|| {
3620                            eprintln!(
3621                                "dsv4-spec гейт: mtp={} mask={} router={} trace={} temp={} rep={} ",
3622                                !self.dsv4_mtp.is_empty(),
3623                                task_mask.is_none(),
3624                                router.is_none(),
3625                                !trace_on,
3626                                self.sampler_config.temperature < 1e-6,
3627                                self.sampler_config.repetition_penalty == 1.0,
3628                            );
3629                        });
3630                    }
3631                    #[cfg(feature = "gpu")]
3632                    if Self::dsv4_spec_on()
3633                        && self.dsv4.is_some()
3634                        && !self.dsv4_mtp.is_empty()
3635                        && task_mask.is_none()
3636                        && router.is_none()
3637                        && !trace_on
3638                        && self.sampler_config.temperature < 1e-6
3639                        && self.sampler_config.repetition_penalty == 1.0
3640                        && generated + 1 < max_tokens
3641                        && all_ids.len() >= 2
3642                        && generated >= dsv4_spec_retry_at
3643                    {
3644                        let tip_token = all_ids[all_ids.len() - 2];
3645                        let drafted0 = drafted;
3646                        let round = self.dsv4_spec_step(
3647                            tip_token,
3648                            t_next,
3649                            next_pos,
3650                            max_tokens.saturating_sub(generated),
3651                            &mut drafted,
3652                            &mut accepted,
3653                        );
3654                        if drafted > drafted0 {
3655                            let useful = round.as_ref().is_some_and(|(extra, _)| !extra.is_empty());
3656                            if useful {
3657                                dsv4_spec_bad = 0;
3658                            } else {
3659                                dsv4_spec_bad += 1;
3660                                if dsv4_spec_bad >= 2 {
3661                                    dsv4_spec_bad = 0;
3662                                    dsv4_spec_retry_at = generated.saturating_add(32);
3663                                    tracing::info!(
3664                                        "dsv4: draft не окупился дважды — точный walk на 32 токена"
3665                                    );
3666                                }
3667                            }
3668                        }
3669                        if let Some((extra, n_pos)) = round {
3670                            next_pos = n_pos;
3671                            let mut stopped = false;
3672                            for &id in &extra {
3673                                if self.confidence_on {
3674                                    confidence.push(0.0);
3675                                }
3676                                if !commit!(id) {
3677                                    stopped = true;
3678                                    break;
3679                                }
3680                            }
3681                            if stopped {
3682                                break 'decode;
3683                            }
3684                            continue 'decode;
3685                        }
3686                    }
3687                    self.graph_want_logits = fuse_lm;
3688                    // Greedy burst (CMF_MULTISTEP, default 8, 1 = off): while
3689                    // nothing observes per-token state — pure argmax sampling,
3690                    // no router/trace/confidence/mask — decode k tokens per
3691                    // submit and commit them wholesale. The trailing normal
3692                    // forward leaves logits for the loop top, as always.
3693                    let mut t_fwd = t_next;
3694                    let pure_greedy = self.sampler_config.temperature < 1e-6
3695                        && self.sampler_config.repetition_penalty == 1.0
3696                        && self.sampler_config.suppress_tokens.is_empty();
3697                    // Off by default: at every k the burst measured at or
3698                    // below the plain path on this graph shape (k=1 loses
3699                    // the argmax dispatches vs a 1 MB readback, k>=8 loses
3700                    // inter-step drains vs the saved sync). Experimental.
3701                    let burst_k = std::env::var("CMF_MULTISTEP")
3702                        .ok()
3703                        .and_then(|v| v.parse::<usize>().ok())
3704                        .unwrap_or(0);
3705                    if pure_greedy
3706                        && burst_k >= 1
3707                        && fuse_lm
3708                        && task_mask.is_none()
3709                        && router.is_none()
3710                        && !trace_on
3711                        && !self.confidence_on
3712                    {
3713                        let mut stopped = false;
3714                        loop {
3715                            let room = max_tokens.saturating_sub(generated);
3716                            if room <= 2 {
3717                                break;
3718                            }
3719                            let k = burst_k.min(room - 1);
3720                            if k < 1 {
3721                                break;
3722                            }
3723                            let Some(ids) = self.try_multi_burst(t_fwd, next_pos, k) else {
3724                                if self
3725                                    .graph_failed
3726                                    .swap(false, std::sync::atomic::Ordering::Relaxed)
3727                                {
3728                                    self.finish_generation(&mut mtp, &mut router, true);
3729                                    return Err(
3730                                        "GPU token graph failed during greedy burst".to_string()
3731                                    );
3732                                }
3733                                break;
3734                            };
3735                            next_pos += k;
3736                            for &id in &ids {
3737                                if !commit!(id) {
3738                                    stopped = true;
3739                                    break;
3740                                }
3741                            }
3742                            if stopped {
3743                                break;
3744                            }
3745                            t_fwd = *ids.last().unwrap();
3746                        }
3747                        if stopped {
3748                            break 'decode;
3749                        }
3750                    }
3751                    hidden = self.forward_layers(&self.embed_single(t_fwd), next_pos, task_mask);
3752                    next_pos += 1;
3753                    // Dynamic routing: the forward updated φ; ask the
3754                    // router whether to switch skills before the next token.
3755                    if let Some(r) = &mut router {
3756                        let phi = self.dyn_phi_ema.clone();
3757                        let decision = r.step(&phi, generated);
3758                        if let Some(new_active) = decision {
3759                            let _ = self.set_active_skill(new_active);
3760                        }
3761                        // Backfill this token's coherence + switch flag from
3762                        // the just-run eval (freshest measured values).
3763                        if trace_on {
3764                            if let Some(last) = traces.last_mut() {
3765                                let e = r.last_best_e();
3766                                last.recon = e.is_finite().then_some(e);
3767                                last.switched = decision.is_some();
3768                            }
3769                        }
3770                    }
3771                }
3772            }
3773        }
3774
3775        let cancelled = finish_reason == "cancelled";
3776        self.finish_generation(&mut mtp, &mut router, cancelled);
3777
3778        let output_ids = &all_ids[input_ids.len()..];
3779        // Forwarded = prompt + all generated but the LAST sampled token
3780        // (emitted without being fed back). Exact only without MTP —
3781        // reuse is gated off when MTP is active.
3782        let forwarded = input_ids.len() + output_ids.len().saturating_sub(1);
3783        if cancelled {
3784            self.kv_history.clear();
3785        } else {
3786            self.kv_history = all_ids[..forwarded.min(all_ids.len())].to_vec();
3787        }
3788        confidence.truncate(output_ids.len()); // guard against any overshoot
3789        traces.truncate(output_ids.len());
3790        Ok(GenerateResult {
3791            text: self.tokenizer.decode(output_ids),
3792            token_ids: output_ids.to_vec(),
3793            prompt_tokens: input_ids.len(),
3794            tokens_generated: generated,
3795            finish_reason,
3796            mtp_drafted: drafted,
3797            mtp_accepted: accepted,
3798            token_confidence: confidence,
3799            traces,
3800        })
3801    }
3802
3803    /// One MTP step: feed `(hidden_p, token_{p+1})` into the draft head,
3804    /// advance its KV cache at position `p`, return the drafted token
3805    /// for position `p+2`.
3806    fn mtp_step(
3807        &mut self,
3808        m: &mut MtpModule,
3809        hidden: &[f32],
3810        next_token: u32,
3811        position: usize,
3812    ) -> u32 {
3813        self.mtp_step_h(m, hidden, next_token, position).0
3814    }
3815
3816    /// Tally for `CMF_MTP_CHAIN_PROBE`: per depth, how often the CHAIN is
3817    /// still an exact prefix of the real continuation. Printed every 128
3818    /// depth-0 samples so a killed run still shows its table.
3819    fn chain_probe_note(depth: usize, prefix_ok: bool) {
3820        use std::sync::Mutex;
3821        static T: Mutex<Vec<(u64, u64)>> = Mutex::new(Vec::new());
3822        let mut t = T.lock().unwrap();
3823        if t.len() <= depth {
3824            t.resize(depth + 1, (0, 0));
3825        }
3826        t[depth].0 += 1;
3827        t[depth].1 += prefix_ok as u64;
3828        if depth == 0 && t[0].0 % 128 == 0 {
3829            let line: Vec<String> = t
3830                .iter()
3831                .enumerate()
3832                .map(|(d, (n, k))| {
3833                    format!(
3834                        "d{}={:.0}%({n})",
3835                        d + 1,
3836                        100.0 * *k as f64 / (*n).max(1) as f64
3837                    )
3838                })
3839                .collect();
3840            eprintln!("mtp-chain: {}", line.join(" "));
3841        }
3842    }
3843
3844    /// `mtp_step` that also hands back the block's own output hidden — the
3845    /// state a CHAINED draft feeds the next step, the way a multi-token
3846    /// speculative round iterates the head on itself.
3847    /// One MTP block step from (trunk hidden, token): the head's LOGITS
3848    /// and the block's own hidden for chaining. The draft is argmax of the
3849    /// logits on the greedy path and a draw from their post-chain
3850    /// distribution on the sampling path.
3851    fn mtp_step_hl(
3852        &mut self,
3853        m: &mut MtpModule,
3854        hidden: &[f32],
3855        next_token: u32,
3856        position: usize,
3857    ) -> (Vec<f32>, Vec<f32>) {
3858        // The graph arm: the MTP block as a one-layer token graph with the
3859        // head fused — device attention over the block's own KV mirror,
3860        // one submit for block + head, hidden and logits back together.
3861        // Decided once per generation (see `mtp_graph_mode`).
3862        #[cfg(target_os = "macos")]
3863        if self.mtp_graph_mode != Some(false) && crate::gpu::q1_force() {
3864            if let Some(r) = self.mtp_step_metal(m, hidden, next_token, position, true) {
3865                self.mtp_graph_mode = Some(true);
3866                return r;
3867            }
3868            if self.mtp_graph_mode == Some(true) {
3869                tracing::error!("mtp Metal graph failed after admission");
3870                self.clear_sequence_state();
3871                self.graph_failed
3872                    .store(true, std::sync::atomic::Ordering::Relaxed);
3873                self.cancel
3874                    .store(true, std::sync::atomic::Ordering::Relaxed);
3875                return (Vec::new(), Vec::new());
3876            }
3877            self.mtp_graph_mode = Some(false);
3878        }
3879        #[cfg(feature = "gpu")]
3880        if self.mtp_graph_mode != Some(false) {
3881            if !self.mtp_graph_ok(m) {
3882                if self.mtp_graph_mode == Some(true) {
3883                    // A mirror was already admitted, so a capability change
3884                    // cannot safely switch this request to the stale CPU
3885                    // cache.  Keep the same terminal contract as a failed
3886                    // token graph.
3887                    tracing::error!("mtp graph became unavailable after admission");
3888                    self.clear_sequence_state();
3889                    self.graph_failed
3890                        .store(true, std::sync::atomic::Ordering::Relaxed);
3891                    self.cancel
3892                        .store(true, std::sync::atomic::Ordering::Relaxed);
3893                    return (Vec::new(), Vec::new());
3894                }
3895                self.mtp_graph_mode = Some(false);
3896            } else {
3897                if let Some(r) = self.mtp_step_graph(m, hidden, next_token, position) {
3898                    self.mtp_graph_mode = Some(true);
3899                    return r;
3900                }
3901                if self.graph_failed.load(std::sync::atomic::Ordering::Relaxed) {
3902                    // A token graph can have admitted a persistent MTP/GDN
3903                    // mirror before its readback failed.  The CPU MTP cache
3904                    // is not a valid continuation in that state; leave the
3905                    // flag set so the generation caller returns through its
3906                    // terminal error path instead of silently switching
3907                    // arithmetic.
3908                    return (Vec::new(), Vec::new());
3909                }
3910                // `mtp_graph_ok` was true, so a None here means a refusal or
3911                // failure after graph admission.  Do not fall through to a
3912                // CPU cache whose rows may lag the device mirror.
3913                tracing::error!("mtp graph failed or declined after admission");
3914                self.clear_sequence_state();
3915                self.graph_failed
3916                    .store(true, std::sync::atomic::Ordering::Relaxed);
3917                self.cancel
3918                    .store(true, std::sync::atomic::Ordering::Relaxed);
3919                return (Vec::new(), Vec::new());
3920            }
3921        }
3922        // fc concat order is [enorm(embed); hnorm(hidden)] — EMBEDDING
3923        // FIRST. Verified by the oracle (converter/mtp_oracle.py):
3924        // [emb;hid] → 45.8% acceptance, [hid;emb] → 0.00%.
3925        let e = self.embed_single(next_token);
3926        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3927        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3928        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3929        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3930        let mut x = vec![0.0f32; self.hidden_size];
3931        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3932
3933        // One standard transformer block over the MTP's own cache.
3934        let lw = &m.layer;
3935        inference::rms_norm_into(
3936            &x,
3937            &lw.input_norm,
3938            self.rms_eps,
3939            self.norm_style,
3940            &mut self.ws.n1,
3941        );
3942        let attn = match &lw.attn {
3943            // MLA models carry no MTP head; this path cannot see them.
3944            AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
3945            AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
3946            AttnKind::Full {
3947                wq,
3948                wk,
3949                wv,
3950                wo,
3951                q_norm,
3952                k_norm,
3953                output_gate,
3954                softplus_gate,
3955                bias,
3956            } => {
3957                let mut cfg = self.attn_cfg(position);
3958                cfg.q_norm = q_norm.as_deref();
3959                cfg.k_norm = k_norm.as_deref();
3960                cfg.output_gate = *output_gate;
3961                cfg.softplus_gate = softplus_gate
3962                    .as_ref()
3963                    .map(|(gate, per_head)| (gate, *per_head));
3964                cfg.bias = bias
3965                    .as_ref()
3966                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
3967                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
3968            }
3969            AttnKind::Linear(_) | AttnKind::LinearGdn(_) | AttnKind::ShortConv(_) => {
3970                unreachable!("MTP block is full attention")
3971            }
3972        };
3973        for (i, &a) in attn.iter().enumerate() {
3974            x[i] += a;
3975        }
3976        inference::rms_norm_into(
3977            &x,
3978            &lw.post_norm,
3979            self.rms_eps,
3980            self.norm_style,
3981            &mut self.ws.p1,
3982        );
3983        let ffn = ffn_forward(&lw.ffn, &self.ws.p1, self.pool.as_deref(), None);
3984        for (i, &f) in ffn.iter().enumerate() {
3985            x[i] += f;
3986        }
3987
3988        inference::rms_norm_into(
3989            &x,
3990            &m.final_norm,
3991            self.rms_eps,
3992            self.norm_style,
3993            &mut self.ws.n1,
3994        );
3995        let lg = self.lm_head_forward(&self.ws.n1);
3996        (lg, x)
3997    }
3998
3999    /// `mtp_step_hl` reduced to the greedy draft: argmax of the head.
4000    fn mtp_step_h(
4001        &mut self,
4002        m: &mut MtpModule,
4003        hidden: &[f32],
4004        next_token: u32,
4005        position: usize,
4006    ) -> (u32, Vec<f32>) {
4007        let (mut lg, x) = self.mtp_step_hl(m, hidden, next_token, position);
4008        let draft = sampler::argmax(&lg);
4009        attention::recycle_buf(&mut lg);
4010        (draft, x)
4011    }
4012
4013    /// One speculative round for the trial: rounds 1..5 of a `Spec` phase
4014    /// advance it (the monitor already averaged this round); after five,
4015    /// the plain phase runs (once — a known plain rate decides at once);
4016    /// a decided speculation keeps re-checking the rule every round and
4017    /// stops after four losing rounds in a row.
4018    fn spec_trial_round(trial: SpecTrial, mon: &mut SpecMon, generated: usize) -> SpecTrial {
4019        match trial {
4020            SpecTrial::Spec { t0, gen0, rounds } => {
4021                let rounds = rounds + 1;
4022                if rounds >= 5 {
4023                    if mon.plain_ms > 0.0 {
4024                        let keep = mon.pays();
4025                        mon.fails = 0;
4026                        tracing::info!(
4027                            "speculation re-check: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
4028                            mon.tokens,
4029                            mon.round_ms,
4030                            mon.plain_ms,
4031                            if keep { "speculating" } else { "plain" }
4032                        );
4033                        SpecTrial::Decided {
4034                            spec: keep,
4035                            recheck_at: if keep { usize::MAX } else { generated + 128 },
4036                        }
4037                    } else {
4038                        SpecTrial::Plain {
4039                            t0: std::time::Instant::now(),
4040                            gen0: generated,
4041                        }
4042                    }
4043                } else {
4044                    SpecTrial::Spec { t0, gen0, rounds }
4045                }
4046            }
4047            SpecTrial::Decided { spec: true, .. } => {
4048                if mon.pays() {
4049                    mon.fails = 0;
4050                    trial
4051                } else {
4052                    mon.fails += 1;
4053                    if mon.fails >= 4 {
4054                        tracing::info!(
4055                            "speculation stopped: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok",
4056                            mon.tokens,
4057                            mon.round_ms,
4058                            mon.plain_ms
4059                        );
4060                        SpecTrial::Decided {
4061                            spec: false,
4062                            recheck_at: generated + 128,
4063                        }
4064                    } else {
4065                        trial
4066                    }
4067                }
4068            }
4069            other => other,
4070        }
4071    }
4072
4073    /// The MTP block's device-mirror id: the trunk's id with a high bit,
4074    /// so the (kv_id, layer) mirror keys never collide.
4075    fn mtp_kv_id(&self) -> u64 {
4076        self.graph_kv_id | (1u64 << 40)
4077    }
4078
4079    /// The MTP block's mirror layer index: 0 — its own kv_id keeps it
4080    /// apart from the trunk, and the BATCH graph (the warm-up path) keys
4081    /// its mirrors at layer 0 with no base of its own, so the draft's
4082    /// token graph must key the same slot.
4083    const MTP_LAYER_BASE: usize = 0;
4084
4085    /// The wgpu MTP draft writes speculative rows straight into its device
4086    /// mirror while the CPU owner retains only the real prompt/decode anchor.
4087    /// After verification, move that mirror cursor back to the anchor before
4088    /// replaying accepted pairs.  The next graph append then sees the same
4089    /// contiguous position as the CPU/Metal path without uploading stale
4090    /// speculative rows.
4091    #[cfg(feature = "gpu")]
4092    fn rewind_mtp_graph_mirror(&self, stored: usize) -> bool {
4093        self.mtp_graph_mode != Some(true)
4094            || crate::gpu::graph_kv_set_stored(self.mtp_kv_id(), Self::MTP_LAYER_BASE, stored)
4095    }
4096
4097    /// A speculative verify graph appends the full `k+1` trunk rows before
4098    /// the acceptance count is known.  GDN state already has a snapshot
4099    /// restore; Full-attention mirrors need the matching logical cursor
4100    /// rewind so the next graph call does not reject an ahead-of-position KV
4101    /// cache after a partial acceptance.
4102    #[cfg(feature = "gpu")]
4103    fn rewind_trunk_graph_mirrors(&self, stored: usize) -> bool {
4104        let mut ok = true;
4105        let mut expected = false;
4106        for li in 0..self.num_layers {
4107            if matches!(
4108                self.weights.layers[self.phys_layer(li)].attn,
4109                AttnKind::Full { .. }
4110            ) {
4111                expected = true;
4112                ok &= crate::gpu::graph_kv_set_stored(self.graph_kv_id, li, stored);
4113            }
4114        }
4115        !expected || ok
4116    }
4117
4118    /// Count the recurrent layers participating in the trunk verify graph.
4119    /// Snapshot restore is all-or-nothing across that set; deriving the count
4120    /// from the model keeps the restore contract valid for looped models too.
4121    fn graph_gdn_layer_count(&self) -> usize {
4122        (0..self.num_layers)
4123            .filter(|&li| {
4124                matches!(
4125                    &self.weights.layers[self.phys_layer(li)].attn,
4126                    AttnKind::LinearGdn(_)
4127                )
4128            })
4129            .count()
4130    }
4131
4132    /// The block's input from (trunk hidden, token): eh_proj · [enorm(e);
4133    /// hnorm(h)] — the same arithmetic the per-op path starts with.
4134    fn mtp_block_input(&mut self, m: &MtpModule, hidden: &[f32], next_token: u32) -> Vec<f32> {
4135        let e = self.embed_single(next_token);
4136        let mut cat = vec![0.0f32; 2 * self.hidden_size];
4137        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
4138        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
4139        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
4140        let mut x = vec![0.0f32; self.hidden_size];
4141        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
4142        x
4143    }
4144
4145    /// Is the MTP block graphable at all (device up, full attention
4146    /// without softplus, dense FFN)? The plan itself is built per call.
4147    #[cfg(feature = "gpu")]
4148    fn mtp_block_graph_ok(&self, m: &MtpModule) -> bool {
4149        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0") {
4150            return false;
4151        }
4152        if !crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode)
4153            || !crate::gpu::enabled_here()
4154            || self.attn_softcap > 0.0
4155            || self.attention_heads_per_layer.is_some()
4156        {
4157            return false;
4158        }
4159        matches!(
4160            &m.layer.attn,
4161            AttnKind::Full {
4162                softplus_gate: None,
4163                ..
4164            }
4165        ) && matches!(&m.layer.ffn, FfnKind::Dense(_))
4166    }
4167
4168    /// Full MTP token-graph eligibility, including the fused lm-head and all
4169    /// block projection weights.  Keep this distinct from the block-only
4170    /// check: prompt warm-up does not need the head, while a draft step does.
4171    #[cfg(feature = "gpu")]
4172    fn mtp_graph_ok(&self, m: &MtpModule) -> bool {
4173        if !self.mtp_block_graph_ok(m) {
4174            return false;
4175        }
4176        let AttnKind::Full { wq, wk, wv, wo, .. } = &m.layer.attn else {
4177            return false;
4178        };
4179        let FfnKind::Dense(d) = &m.layer.ffn else {
4180            return false;
4181        };
4182        d.segs.is_empty()
4183            && wq.graph_weight().is_some()
4184            && wk.graph_weight().is_some()
4185            && wv.graph_weight().is_some()
4186            && wo.graph_weight().is_some()
4187            && d.gate_proj.graph_weight().is_some()
4188            && d.up_proj.graph_weight().is_some()
4189            && d.down_proj.graph_weight().is_some()
4190            && self.weights.lm_head.graph_weight().is_some()
4191    }
4192
4193    /// One MTP block step on the wgpu token graph: block + fused head in
4194    /// one submit, the block hidden and the logits read back together.
4195    /// None = the graph cannot take this block (softplus gate, non-dense
4196    /// FFN, unquantized head, no device) — the caller keeps the per-op
4197    /// path for the whole generation.
4198    #[cfg(feature = "gpu")]
4199    fn mtp_step_graph(
4200        &mut self,
4201        m: &mut MtpModule,
4202        hidden: &[f32],
4203        next_token: u32,
4204        position: usize,
4205    ) -> Option<(Vec<f32>, Vec<f32>)> {
4206        if !self.mtp_graph_ok(m) {
4207            return None;
4208        }
4209        let lw = &m.layer;
4210        let AttnKind::Full {
4211            wq,
4212            wk,
4213            wv,
4214            wo,
4215            q_norm,
4216            k_norm,
4217            output_gate,
4218            softplus_gate,
4219            bias,
4220        } = &lw.attn
4221        else {
4222            return None;
4223        };
4224        if softplus_gate.is_some() {
4225            return None;
4226        }
4227        let FfnKind::Dense(d) = &lw.ffn else {
4228            return None;
4229        };
4230        if !d.segs.is_empty() {
4231            return None; // tube layers run on the segmented path
4232        }
4233        // The block's input first: it borrows `self` mutably (embed scratch,
4234        // pool), the plan below borrows the weights immutably.
4235        let mut x = self.mtp_block_input(m, hidden, next_token);
4236        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
4237            let (_, i, kind, rs) = t.graph_weight()?;
4238            Some(crate::gpu::GraphW {
4239                idx: i,
4240                kind,
4241                row_scale: rs,
4242                data: &[],
4243            })
4244        }
4245        let (model, _, _, _) = wq.graph_weight()?;
4246        let model = model.clone();
4247        let (lm_gw, lm_rows) = {
4248            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
4249            (
4250                crate::gpu::GraphW {
4251                    idx: i,
4252                    kind,
4253                    row_scale: rs,
4254                    data: &[],
4255                },
4256                self.weights.lm_head.rows(),
4257            )
4258        };
4259        let layer = crate::gpu::GraphLayer {
4260            input_norm: &lw.input_norm,
4261            attn: crate::gpu::GraphAttn::Full {
4262                wq: gw(wq)?,
4263                wk: gw(wk)?,
4264                wv: gw(wv)?,
4265                wo: gw(wo)?,
4266                q_norm: q_norm.as_deref(),
4267                k_norm: k_norm.as_deref(),
4268                bias: bias
4269                    .as_ref()
4270                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
4271                output_gate: *output_gate,
4272                cpu_k: m.kv.k_heads(),
4273                cpu_v: m.kv.v_heads(),
4274            },
4275            post_norm: &lw.post_norm,
4276            ffn: crate::gpu::GraphFfn::Dense {
4277                gate: gw(&d.gate_proj)?,
4278                up: gw(&d.up_proj)?,
4279                down: gw(&d.down_proj)?,
4280            },
4281        };
4282        let nh = self.num_heads;
4283        let (nkv, hd, rd) = self.layer_geom(0);
4284        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
4285        let mut logits = Vec::new();
4286        let ok = crate::gpu::forward_token_graph(
4287            &model,
4288            self.mtp_kv_id(),
4289            std::slice::from_ref(&layer),
4290            &[None],
4291            self.o1_epoch,
4292            &self.inv_freq,
4293            &mut x,
4294            nh,
4295            nkv,
4296            hd,
4297            self.attn_scale,
4298            rd,
4299            self.hidden_size,
4300            self.intermediate_size,
4301            position,
4302            self.kv_cache.max_seq_len,
4303            gemma,
4304            self.rms_eps as f32,
4305            Some((&lm_gw, lm_rows)),
4306            &m.final_norm,
4307            &mut logits,
4308            &[],
4309            1,
4310            None,
4311            None,
4312            None,
4313            Self::MTP_LAYER_BASE,
4314            true,
4315        );
4316        match ok {
4317            crate::gpu::TokenGraphOutcome::Completed => {}
4318            crate::gpu::TokenGraphOutcome::Declined => return None,
4319            crate::gpu::TokenGraphOutcome::Failed => {
4320                // The backend has already admitted persistent state.  Keep
4321                // this distinct from a capability refusal so the caller
4322                // cannot switch to the stale CPU MTP cache.
4323                self.clear_sequence_state();
4324                self.graph_failed
4325                    .store(true, std::sync::atomic::Ordering::Relaxed);
4326                self.cancel
4327                    .store(true, std::sync::atomic::Ordering::Relaxed);
4328                return None;
4329            }
4330        }
4331        logits.resize(self.vocab_size, 0.0);
4332        Some((logits, x))
4333    }
4334
4335    /// The warm-ups of one speculative round on the device: every accepted
4336    /// (hidden, token) pair as ONE batched graph run over the MTP block
4337    /// (no head) — its kv_append lands the pairs in the block's mirror.
4338    /// `pairs` are consecutive positions from `first_pos`.  The tri-state
4339    /// result is intentional: a refusal before admission may use the
4340    /// per-row/CPU route, while a failure after admission must terminate the
4341    /// sequence rather than fall through to a stale CPU cache.
4342    #[cfg(feature = "gpu")]
4343    fn mtp_warm_graph(
4344        &mut self,
4345        m: &mut MtpModule,
4346        pairs: &[(&[f32], u32)],
4347        first_pos: usize,
4348    ) -> crate::gpu::BatchGraphOutcome {
4349        if pairs.is_empty() {
4350            return crate::gpu::BatchGraphOutcome::Completed;
4351        }
4352        if !self.mtp_block_graph_ok(m) {
4353            return crate::gpu::BatchGraphOutcome::Declined;
4354        }
4355        let hs = self.hidden_size;
4356        // Block inputs for every pair (eh_proj on the per-op path, one
4357        // matvec each — the plan's own prologue).
4358        let mut hiddens = Vec::with_capacity(pairs.len() * hs);
4359        for (h, t) in pairs {
4360            hiddens.extend_from_slice(&self.mtp_block_input(m, h, *t));
4361        }
4362        let lw = &m.layer;
4363        let AttnKind::Full {
4364            wq,
4365            wk,
4366            wv,
4367            wo,
4368            q_norm,
4369            k_norm,
4370            output_gate,
4371            bias,
4372            ..
4373        } = &lw.attn
4374        else {
4375            return crate::gpu::BatchGraphOutcome::Declined;
4376        };
4377        let FfnKind::Dense(d) = &lw.ffn else {
4378            return crate::gpu::BatchGraphOutcome::Declined;
4379        };
4380        if !d.segs.is_empty() {
4381            return crate::gpu::BatchGraphOutcome::Declined; // tube layers run on the segmented path
4382        }
4383        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
4384            let (_, i, kind, rs) = t.graph_weight()?;
4385            Some(crate::gpu::GraphW {
4386                idx: i,
4387                kind,
4388                row_scale: rs,
4389                data: &[],
4390            })
4391        }
4392        let Some((model, _, _, _)) = wq.graph_weight() else {
4393            return crate::gpu::BatchGraphOutcome::Declined;
4394        };
4395        let model = model.clone();
4396        let (Some(gwq), Some(gwk), Some(gwv), Some(gwo), Some(gg), Some(gu), Some(gd)) = (
4397            gw(wq),
4398            gw(wk),
4399            gw(wv),
4400            gw(wo),
4401            gw(&d.gate_proj),
4402            gw(&d.up_proj),
4403            gw(&d.down_proj),
4404        ) else {
4405            return crate::gpu::BatchGraphOutcome::Declined;
4406        };
4407        let layer = crate::gpu::GraphLayer {
4408            input_norm: &lw.input_norm,
4409            attn: crate::gpu::GraphAttn::Full {
4410                wq: gwq,
4411                wk: gwk,
4412                wv: gwv,
4413                wo: gwo,
4414                q_norm: q_norm.as_deref(),
4415                k_norm: k_norm.as_deref(),
4416                bias: bias
4417                    .as_ref()
4418                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
4419                output_gate: *output_gate,
4420                cpu_k: m.kv.k_heads(),
4421                cpu_v: m.kv.v_heads(),
4422            },
4423            post_norm: &lw.post_norm,
4424            ffn: crate::gpu::GraphFfn::Dense {
4425                gate: gg,
4426                up: gu,
4427                down: gd,
4428            },
4429        };
4430        let positions: Vec<usize> = (first_pos..first_pos + pairs.len()).collect();
4431        let nh = self.num_heads;
4432        let (nkv, hd, rd) = self.layer_geom(0);
4433        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
4434        crate::gpu::forward_batch_graph(
4435            &model,
4436            self.mtp_kv_id(),
4437            std::slice::from_ref(&layer),
4438            &self.inv_freq,
4439            &mut hiddens,
4440            nh,
4441            nkv,
4442            hd,
4443            rd,
4444            hs,
4445            self.intermediate_size,
4446            &positions,
4447            self.kv_cache.max_seq_len,
4448            gemma,
4449            self.rms_eps as f32,
4450            self.attn_scale,
4451            pairs.len(),
4452            &[],
4453            0,
4454            None,
4455        )
4456    }
4457
4458    /// Complete an MTP warm-up after the batched graph has refused.  A
4459    /// graphable block is retried one row at a time; once any device row has
4460    /// been admitted, a CPU fallback would observe a stale mirror, so every
4461    /// token-graph refusal is terminal.  If the block is not graphable and no
4462    /// mirror exists yet, warming on the CPU is safe and records the CPU mode
4463    /// for the rest of the generation.
4464    #[cfg(feature = "gpu")]
4465    fn mtp_warm_graph_fallback(
4466        &mut self,
4467        m: &mut MtpModule,
4468        pairs: &[(&[f32], u32)],
4469        first_pos: usize,
4470    ) -> bool {
4471        if pairs.is_empty() {
4472            return true;
4473        }
4474        let graphable = self.mtp_block_graph_ok(m);
4475        if !graphable {
4476            // A previously admitted mirror cannot be made coherent by
4477            // appending to the host cache.  The caller turns this into a
4478            // terminal generation error and clears both mirrors.
4479            if self.mtp_graph_mode == Some(true) {
4480                return false;
4481            }
4482            self.mtp_graph_mode = Some(false);
4483            for (j, (h, t)) in pairs.iter().enumerate() {
4484                self.mtp_warm(m, h, *t, first_pos + j);
4485            }
4486            return true;
4487        }
4488
4489        // The batch refusal is recoverable only through the same device
4490        // state.  Keep rows owned until each token graph has completed; a
4491        // None is treated as unsafe because the token-graph API deliberately
4492        // collapses its backend refusal/failure into that result.
4493        for (j, (h, t)) in pairs.iter().enumerate() {
4494            if self.mtp_step_graph(m, h, *t, first_pos + j).is_none() {
4495                return false;
4496            }
4497        }
4498        self.mtp_graph_mode = Some(true);
4499        true
4500    }
4501
4502    /// Warm a contiguous set of MTP pairs using the existing graph seam, with
4503    /// an all-or-nothing error contract for callers that already admitted the
4504    /// trunk batch.  The non-GPU build keeps the same pair accounting while
4505    /// using the established CPU warm path.
4506    #[cfg(feature = "gpu")]
4507    fn mtp_warm_prefill_pairs(
4508        &mut self,
4509        m: &mut MtpModule,
4510        pairs: &[(&[f32], u32)],
4511        first_pos: usize,
4512    ) -> Result<(), &'static str> {
4513        // Keep unsupported token-graph heads on the established CPU MTP
4514        // route before admitting any block mirror.  Once a device mirror is
4515        // active, the same condition is terminal because CPU rows cannot
4516        // repair its state.
4517        if self.mtp_graph_mode == Some(false) || !self.mtp_graph_ok(m) {
4518            if self.mtp_graph_mode == Some(true) {
4519                return Err("MTP token graph became unavailable after admission");
4520            }
4521            self.mtp_graph_mode = Some(false);
4522            for (j, (h, t)) in pairs.iter().enumerate() {
4523                self.mtp_warm(m, h, *t, first_pos + j);
4524            }
4525            return Ok(());
4526        }
4527        match self.mtp_warm_graph(m, pairs, first_pos) {
4528            crate::gpu::BatchGraphOutcome::Completed => {
4529                if !pairs.is_empty() {
4530                    self.mtp_graph_mode = Some(true);
4531                }
4532                Ok(())
4533            }
4534            crate::gpu::BatchGraphOutcome::Declined => {
4535                if self.mtp_warm_graph_fallback(m, pairs, first_pos) {
4536                    Ok(())
4537                } else {
4538                    Err("MTP warm-up fallback failed after device admission")
4539                }
4540            }
4541            crate::gpu::BatchGraphOutcome::Failed => {
4542                Err("MTP warm batch graph failed after admission")
4543            }
4544        }
4545    }
4546
4547    #[cfg(not(feature = "gpu"))]
4548    fn mtp_warm_prefill_pairs(
4549        &mut self,
4550        m: &mut MtpModule,
4551        pairs: &[(&[f32], u32)],
4552        first_pos: usize,
4553    ) -> Result<(), &'static str> {
4554        for (j, (h, t)) in pairs.iter().enumerate() {
4555            self.mtp_warm(m, h, *t, first_pos + j);
4556        }
4557        Ok(())
4558    }
4559
4560    /// The MTP block alone — advance its KV with a (hidden, token) pair the
4561    /// verify just proved, without paying the head. What keeps the draft's
4562    /// attention context warm between speculative rounds.
4563    fn mtp_warm(&mut self, m: &mut MtpModule, hidden: &[f32], next_token: u32, position: usize) {
4564        let e = self.embed_single(next_token);
4565        let mut cat = vec![0.0f32; 2 * self.hidden_size];
4566        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
4567        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
4568        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
4569        let mut x = vec![0.0f32; self.hidden_size];
4570        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
4571        inference::rms_norm_into(
4572            &x,
4573            &m.layer.input_norm,
4574            self.rms_eps,
4575            self.norm_style,
4576            &mut self.ws.n1,
4577        );
4578        let attn = match &m.layer.attn {
4579            AttnKind::Full {
4580                wq,
4581                wk,
4582                wv,
4583                wo,
4584                q_norm,
4585                k_norm,
4586                output_gate,
4587                softplus_gate,
4588                bias,
4589            } => {
4590                let mut cfg = self.attn_cfg(position);
4591                cfg.q_norm = q_norm.as_deref();
4592                cfg.k_norm = k_norm.as_deref();
4593                cfg.output_gate = *output_gate;
4594                cfg.softplus_gate = softplus_gate.as_ref().map(|(g, p)| (g, *p));
4595                cfg.bias = bias
4596                    .as_ref()
4597                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
4598                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
4599            }
4600            _ => return,
4601        };
4602        let _ = attn;
4603    }
4604
4605    /// Speculative decode ON the wgpu whole-token graph: draft k with the
4606    /// MTP head, verify all of them plus the tip in ONE batched graph
4607    /// submit whose tail folds the head, commit the accepted prefix and
4608    /// roll the GDN state back to the last real position. Greedy only —
4609    /// output equals the plain graph's token for token, the way the DSV4
4610    /// verify equals the walk.
4611    #[cfg(feature = "gpu")]
4612    #[allow(clippy::too_many_arguments)]
4613    fn graph_spec_step(
4614        &mut self,
4615        m: &mut MtpModule,
4616        hidden: &[f32],
4617        t_next: u32,
4618        next_pos: usize,
4619        drafted: &mut usize,
4620        accepted: &mut usize,
4621        // The committed stream (prompt + generated so far, `t_next`
4622        // included): the sampler chain's penalties read it, and the
4623        // sampling arm extends it with the drafts position by position.
4624        all_ids: &mut Vec<u32>,
4625    ) -> Option<(Vec<u32>, usize, Vec<f32>)> {
4626        // 3 is the measured optimum on Qwen3.6-27B / RTX 5090 (medians
4627        // of three, greedy): 51.1 tok/s against a plain 49.4, where k=2
4628        // gives 46.1, k=4 50.0, k=5 47.4, k=6 45.2. Acceptance is 89-91%
4629        // throughout — what turns the curve over is the verify, which
4630        // costs ~7.4 ms per extra position, and the draft ~3 ms a step.
4631        // 4 since the draft moved onto the graph (Qwen3.8-27B / 5090:
4632        // k=3 51.2, k=4 51.8 with the per-op draft; the graph draft
4633        // halves the draft cost, so the extra draft is cheaper still).
4634        // 5 with the int8 verify (the default: measured 76.5 against
4635        // k=4's 72-74 and k=6's 74 on the 5090), 4 with the f32 one.
4636        #[cfg(target_os = "macos")]
4637        let metal_native = crate::gpu::q1_force();
4638        #[cfg(not(target_os = "macos"))]
4639        let metal_native = false;
4640        #[cfg(feature = "gpu")]
4641        let k_default = if metal_native {
4642            // the Metal verify's GEMM tile is 8 rows wide and flat in b:
4643            // seven drafts + the tip fill it for free
4644            7
4645        } else if crate::gpu_wgpu::verify_i8_on() {
4646            5
4647        } else {
4648            4
4649        };
4650        #[cfg(not(feature = "gpu"))]
4651        let k_default = 4;
4652        let k_spec: usize = std::env::var("CMF_GRAPH_SPEC_K")
4653            .ok()
4654            .and_then(|v| v.parse().ok())
4655            .filter(|&v| (1..=8).contains(&v))
4656            .unwrap_or(k_default);
4657        if next_pos == 0 {
4658            return None;
4659        }
4660        let t_round = std::time::Instant::now();
4661        // Submissions per phase — and they say where the round's money is.
4662        // Qwen3.6-27B on an RTX 5090, k=3:
4663        //
4664        //   draft   9.3 ms / 12 submissions   (four per MTP step)
4665        //   verify 52.8 ms /  1               (the batched graph)
4666        //   commit  5.4 ms /  6               (two per warm)
4667        //
4668        // The verify is already one submit. The draft's own work is 834 MB
4669        // a step — 0.8 ms at this card's measured 1056 GB/s — against 3.1
4670        // ms measured, so ~0.58 ms of every step is round trip, not
4671        // arithmetic, and the same holds for the warms. Eighteen round
4672        // trips a round at roughly half a millisecond each is ~11 ms of a
4673        // 68 ms round: fusing the MTP block into ONE submit the way the
4674        // trunk already is projects to ~64 tok/s against today's 50.9.
4675        // That is the largest measured item left on this path.
4676        let subs = || crate::gpu_wgpu::SUBMITS.load(std::sync::atomic::Ordering::Relaxed);
4677        let sub0 = subs();
4678        // Greedy without penalties verifies by argmax equality (bit-exact
4679        // against the plain path). Anything else is speculative SAMPLING:
4680        // each draft is a DRAW from the MTP head's post-chain distribution
4681        // q_j, kept for the accept test; the verify's rows give p_j.
4682        let cfg = self.sampler_config.clone();
4683        let penalized = !(cfg.repetition_penalty == 1.0
4684            && cfg.presence_penalty == 0.0
4685            && cfg.suppress_tokens.is_empty());
4686        // Three verify regimes: plain greedy (argmax of the raw rows),
4687        // greedy WITH penalties (argmax of the penalized rows — a single
4688        // pass each, no distributions), and sampling (draw / accept /
4689        // correct on post-chain distributions).
4690        let greedy_pen = cfg.temperature < 1e-6 && penalized;
4691        let sampling = cfg.temperature >= 1e-6;
4692        // Sampling with a top-k goes through the SPARSE chain: the dense
4693        // one builds nine 248k-float distributions a round (four drafts,
4694        // five verify rows) and measured 19-22 tok/s against a plain 40 —
4695        // the host, not the card. Sparse, the same nine cost tens of
4696        // microseconds each.
4697        let sparse = sampling && sampler::sparse_ok(&cfg);
4698        let base_len = all_ids.len();
4699        if sampling && !sparse && self.spec_q.len() < k_spec {
4700            self.spec_q.resize_with(k_spec, Vec::new);
4701        }
4702        if sparse && self.spec_qs.len() < k_spec {
4703            self.spec_qs.resize_with(k_spec, Vec::new);
4704        }
4705        // Draft the chain: first from the trunk's tip hidden, then the head
4706        // iterating on itself. Rows land in the MTP KV; the chain rows past
4707        // the first are speculation over speculative state and roll back
4708        // below, replaced by verified pairs.
4709        let mut drafts = Vec::with_capacity(k_spec);
4710        let mut hx = hidden.to_vec();
4711        // CMF_SPEC_DBG=1: draft 0 through BOTH MTP arms (graph and per-op)
4712        // from the same inputs — are the arms the difference, or the inputs?
4713        let spec_dbg = std::env::var("CMF_SPEC_DBG").is_ok();
4714        for j in 0..k_spec {
4715            let tok_in = if j == 0 { t_next } else { drafts[j - 1] };
4716            let mut dbg_ref: Option<(Vec<f32>, Vec<f32>)> = None;
4717            if spec_dbg {
4718                let saved = self.mtp_graph_mode;
4719                self.mtp_graph_mode = Some(false);
4720                let r = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
4721                self.mtp_graph_mode = saved;
4722                if self.graph_failed.load(std::sync::atomic::Ordering::Relaxed) {
4723                    return None;
4724                }
4725                m.kv.truncate_last(1);
4726                dbg_ref = Some(r);
4727            }
4728            let (mut lg, hj) = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
4729            if self.graph_failed.load(std::sync::atomic::Ordering::Relaxed) {
4730                return None;
4731            }
4732            if let Some((lg_cpu, h_cpu)) = dbg_ref {
4733                let n = |v: &[f32]| v.iter().map(|x| x * x).sum::<f32>().sqrt();
4734                let dl = lg
4735                    .iter()
4736                    .zip(&lg_cpu)
4737                    .fold(0f32, |m, (a, b)| m.max((a - b).abs()));
4738                let dh = hj
4739                    .iter()
4740                    .zip(&h_cpu)
4741                    .fold(0f32, |m, (a, b)| m.max((a - b).abs()));
4742                eprintln!(
4743                    "spec-dbg j={j} pos {} tok_in {tok_in}: per-op draft {} graph draft {} | max|dlogit| {dl:.3} | |h_cpu| {:.2} |h_graph| {:.2} max|dh| {dh:.3} | kv rows {}",
4744                    next_pos - 1 + j,
4745                    sampler::argmax(&lg_cpu),
4746                    sampler::argmax(&lg),
4747                    n(&h_cpu),
4748                    n(&hj),
4749                    m.kv.seq_len
4750                );
4751            }
4752            let dj = if sparse {
4753                let mut q = std::mem::take(&mut self.spec_qs[j]);
4754                let ok = sampler::sparse_distribution_into(
4755                    &lg,
4756                    &cfg,
4757                    all_ids,
4758                    &mut self.sampler_scratch,
4759                    self.pool.as_deref(),
4760                    &mut q,
4761                );
4762                let d = if ok {
4763                    sampler::draw_sparse(&q, &mut self.rng)
4764                } else {
4765                    // everything filtered: the dense chain's greedy fallback
4766                    let t = sampler::argmax(&lg);
4767                    q.clear();
4768                    q.push((t, 1.0));
4769                    t
4770                };
4771                self.spec_qs[j] = q;
4772                all_ids.push(d);
4773                d
4774            } else if sampling {
4775                let mut q = std::mem::take(&mut self.spec_q[j]);
4776                sampler::distribution_into(
4777                    &lg,
4778                    &cfg,
4779                    all_ids,
4780                    &mut self.sampler_scratch,
4781                    self.pool.as_deref(),
4782                    &mut q,
4783                );
4784                let d = sampler::draw(&q, &mut self.rng);
4785                self.spec_q[j] = q;
4786                all_ids.push(d); // the next draft's penalties see this one
4787                d
4788            } else if greedy_pen {
4789                let d = sampler::argmax_penalized(
4790                    &lg,
4791                    &cfg,
4792                    all_ids,
4793                    &mut self.sampler_scratch,
4794                    self.pool.as_deref(),
4795                );
4796                all_ids.push(d);
4797                d
4798            } else {
4799                sampler::argmax(&lg)
4800            };
4801            attention::recycle_buf(&mut lg);
4802            drafts.push(dj);
4803            hx = hj;
4804        }
4805        all_ids.truncate(base_len);
4806        *drafted += k_spec;
4807        let t_draft = t_round.elapsed();
4808        let sub_draft = subs();
4809        // Verify batch: [t_next, d1 .. d_{k-1}] at next_pos.. — every row's
4810        // logits come back from the graph's own head.
4811        let b = k_spec + 1;
4812        let mut hiddens = vec![0.0f32; b * self.hidden_size];
4813        for (i, &t) in std::iter::once(&t_next).chain(drafts.iter()).enumerate() {
4814            let e = self.embed_single(t);
4815            hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&e);
4816        }
4817        let positions: Vec<usize> = (next_pos..next_pos + b).collect();
4818        let (lm_gw, lm_rows) = {
4819            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
4820            (
4821                crate::gpu::GraphW {
4822                    idx: i,
4823                    kind,
4824                    row_scale: rs,
4825                    data: &[],
4826                },
4827                self.weights.lm_head.rows(),
4828            )
4829        };
4830        let mut logits = Vec::new();
4831        let final_norm = self.weights.final_norm.clone();
4832        #[cfg(target_os = "macos")]
4833        let verify_outcome = if metal_native {
4834            let lm = self.weights.lm_head.q1_parts()?;
4835            self.try_batch_graph_metal(
4836                &mut hiddens,
4837                &positions,
4838                b,
4839                Some((lm, &final_norm, &mut logits)),
4840            )
4841        } else {
4842            self.try_batch_graph_wgpu(
4843                &mut hiddens,
4844                &positions,
4845                b,
4846                Some(crate::gpu::SpecTail {
4847                    lm: lm_gw,
4848                    lm_rows,
4849                    final_norm: &final_norm,
4850                    logits_out: &mut logits,
4851                }),
4852            )
4853        };
4854        #[cfg(not(target_os = "macos"))]
4855        let verify_outcome = self.try_batch_graph_wgpu(
4856            &mut hiddens,
4857            &positions,
4858            b,
4859            Some(crate::gpu::SpecTail {
4860                lm: lm_gw,
4861                lm_rows,
4862                final_norm: &final_norm,
4863                logits_out: &mut logits,
4864            }),
4865        );
4866        match verify_outcome {
4867            crate::gpu::BatchGraphOutcome::Completed => {}
4868            crate::gpu::BatchGraphOutcome::Declined => {
4869                // The verifier refused before admission.  Its draft MTP
4870                // rows are still device-resident, so rewind the separate
4871                // mirror before the caller takes the exact one-token path.
4872                m.kv.truncate_last(k_spec);
4873                if !metal_native && !self.rewind_mtp_graph_mirror(next_pos) {
4874                    self.clear_sequence_state();
4875                    self.graph_failed
4876                        .store(true, std::sync::atomic::Ordering::Relaxed);
4877                    self.cancel
4878                        .store(true, std::sync::atomic::Ordering::Relaxed);
4879                    tracing::error!("MTP graph mirror rewind failed after verify decline");
4880                }
4881                return None;
4882            }
4883            crate::gpu::BatchGraphOutcome::Failed => {
4884                // A failed batch may have advanced trunk/GDN state.  Clear
4885                // both mirrors and preserve the terminal outcome rather than
4886                // falling through to stale CPU state.
4887                self.clear_sequence_state();
4888                self.graph_failed
4889                    .store(true, std::sync::atomic::Ordering::Relaxed);
4890                self.cancel
4891                    .store(true, std::sync::atomic::Ordering::Relaxed);
4892                tracing::error!("MTP verify batch graph failed after admission");
4893                return None;
4894            }
4895        }
4896        // `CMF_METAL_VERIFY_CHECK=1`: run the same b tokens through the
4897        // plain per-token path and compare each row's argmax + logits with
4898        // the verify's — the bring-up oracle for the batched graph. The
4899        // plain forwards mutate the CPU state; it is snapshotted and put
4900        // back, and the K/V mirrors re-pointed, before the round goes on.
4901        #[cfg(target_os = "macos")]
4902        if metal_native && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("1") {
4903            let snap: Vec<Vec<f32>> = self
4904                .kv_cache
4905                .layers
4906                .iter()
4907                .map(|l| l.linear_state.clone())
4908                .collect();
4909            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
4910            let toks: Vec<u32> = std::iter::once(t_next)
4911                .chain(drafts.iter().copied())
4912                .collect();
4913            let want_save = self.graph_want_logits;
4914            self.graph_want_logits = false;
4915            for (i, &t) in toks.iter().enumerate() {
4916                let hi = self.forward_layers(&self.embed_single(t), next_pos + i, None);
4917                let _ = self.graph_logits.take();
4918                // CMF_SPEC_PLAIN_HIDDEN=1: the next round drafts from the
4919                // plain path's hidden instead of the verify's (an experiment
4920                // on the chain's sensitivity to the half-GEMM noise)
4921                if std::env::var("CMF_SPEC_PLAIN_HIDDEN").as_deref() == Ok("1") {
4922                    hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&hi);
4923                }
4924                let ref_lg = self.logits_from_hidden(&hi);
4925                let row = &logits[i * lm_rows..(i + 1) * lm_rows];
4926                let ra = sampler::argmax(&ref_lg);
4927                let va = sampler::argmax(row);
4928                let mut md = 0f32;
4929                let mut rms = 0f64;
4930                for j in 0..lm_rows.min(ref_lg.len()) {
4931                    let d = (ref_lg[j] - row[j]).abs();
4932                    md = md.max(d);
4933                    rms += (d as f64) * (d as f64);
4934                }
4935                let mut hd = 0f32;
4936                for j in 0..self.hidden_size {
4937                    hd = hd.max((hi[j] - hiddens[i * self.hidden_size + j]).abs());
4938                }
4939                eprintln!(
4940                    "verify-check row {i} tok {t} pos {}: ref argmax {ra} verify argmax {va} {} | max|dlogit| {md:.3} rms {:.4} | max|dhidden| {hd:.4}",
4941                    next_pos + i,
4942                    if ra == va { "OK" } else { "MISMATCH" },
4943                    (rms / lm_rows as f64).sqrt()
4944                );
4945            }
4946            self.graph_want_logits = want_save;
4947            // restore IN PLACE: the pending verify graph wraps these very
4948            // allocations (zero-copy) — replacing the Vec would strand it
4949            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
4950                if l.linear_state.len() == st.len() {
4951                    l.linear_state.copy_from_slice(&st);
4952                } else {
4953                    l.linear_state = st;
4954                }
4955            }
4956            for (li, (l, n0)) in self.kv_cache.layers.iter_mut().zip(attn_lens).enumerate() {
4957                let extra = l.seq_len.saturating_sub(n0);
4958                if extra > 0 {
4959                    l.truncate_last(extra);
4960                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, n0);
4961                }
4962            }
4963        }
4964        let t_verify = t_round.elapsed();
4965        let sub_verify = subs();
4966        // Acceptance. Greedy: row i's argmax is the trunk's token after
4967        // input i. Sampling: accept draft i with min(1, p_i/q_i), and on
4968        // the first rejection draw the correction from max(0, p_i − q_i)
4969        // — that token is committed by the loop top as-is (spec_forced).
4970        let mut a = 0usize;
4971        let mut forced: Option<u32> = None;
4972        let ids: Vec<u32> = if sparse {
4973            let mut p = std::mem::take(&mut self.spec_ps);
4974            let mut res = std::mem::take(&mut self.spec_ress);
4975            while a < k_spec {
4976                let ok = sampler::sparse_distribution_into(
4977                    &logits[a * lm_rows..(a + 1) * lm_rows],
4978                    &cfg,
4979                    all_ids,
4980                    &mut self.sampler_scratch,
4981                    self.pool.as_deref(),
4982                    &mut p,
4983                );
4984                if !ok {
4985                    let t = sampler::argmax(&logits[a * lm_rows..(a + 1) * lm_rows]);
4986                    p.clear();
4987                    p.push((t, 1.0));
4988                }
4989                match sampler::spec_accept_or_correct_sparse(
4990                    &p,
4991                    &self.spec_qs[a],
4992                    drafts[a],
4993                    &mut self.rng,
4994                    &mut res,
4995                ) {
4996                    None => {
4997                        all_ids.push(drafts[a]);
4998                        a += 1;
4999                    }
5000                    Some(c) => {
5001                        forced = Some(c);
5002                        break;
5003                    }
5004                }
5005            }
5006            all_ids.truncate(base_len);
5007            self.spec_ps = p;
5008            self.spec_ress = res;
5009            drafts.clone()
5010        } else if sampling {
5011            let mut p = std::mem::take(&mut self.spec_p);
5012            let mut res = std::mem::take(&mut self.spec_res);
5013            while a < k_spec {
5014                sampler::distribution_into(
5015                    &logits[a * lm_rows..(a + 1) * lm_rows],
5016                    &cfg,
5017                    all_ids,
5018                    &mut self.sampler_scratch,
5019                    self.pool.as_deref(),
5020                    &mut p,
5021                );
5022                match sampler::spec_accept_or_correct(
5023                    &p,
5024                    &self.spec_q[a],
5025                    drafts[a],
5026                    &mut self.rng,
5027                    &mut res,
5028                    self.pool.as_deref(),
5029                ) {
5030                    None => {
5031                        all_ids.push(drafts[a]);
5032                        a += 1;
5033                    }
5034                    Some(c) => {
5035                        forced = Some(c);
5036                        break;
5037                    }
5038                }
5039            }
5040            all_ids.truncate(base_len);
5041            self.spec_p = p;
5042            self.spec_res = res;
5043            // the accepted drafts ARE the verified tokens after inputs 0..a
5044            drafts.clone()
5045        } else if greedy_pen {
5046            // Row i's penalized argmax, penalties over the stream that
5047            // includes the accepted drafts before it — the plain loop's
5048            // exact arithmetic, one pass per row, no working copy.
5049            let mut ids: Vec<u32> = Vec::with_capacity(b);
5050            for i in 0..b {
5051                let t = sampler::argmax_penalized(
5052                    &logits[i * lm_rows..(i + 1) * lm_rows],
5053                    &cfg,
5054                    all_ids,
5055                    &mut self.sampler_scratch,
5056                    self.pool.as_deref(),
5057                );
5058                ids.push(t);
5059                if i < k_spec && t == drafts[i] {
5060                    all_ids.push(t);
5061                } else {
5062                    break;
5063                }
5064            }
5065            all_ids.truncate(base_len);
5066            while a < k_spec && a < ids.len() && ids[a] == drafts[a] {
5067                a += 1;
5068            }
5069            // rows past the first mismatch were never scored; the loop
5070            // top re-samples the last verified row itself.
5071            ids
5072        } else {
5073            let ids: Vec<u32> = (0..b)
5074                .map(|i| sampler::argmax(&logits[i * lm_rows..(i + 1) * lm_rows]))
5075                .collect();
5076            while a < k_spec && ids[a] == drafts[a] {
5077                a += 1;
5078            }
5079            ids
5080        };
5081        if spec_dbg {
5082            eprintln!(
5083                "spec-dbg round: t_next {t_next} drafts {:?} verified {:?} accepted {a}",
5084                drafts, ids
5085            );
5086        }
5087        // CMF_METAL_VERIFY_CHECK=2: the commit oracle — plain-forward the
5088        // a+1 accepted tokens from a snapshot, then diff the replayed GDN
5089        // states and the appended K/V rows against that.
5090        #[cfg(target_os = "macos")]
5091        let commit_ref: Option<(Vec<Vec<f32>>, Vec<(usize, Vec<f32>, Vec<f32>)>)> = if metal_native
5092            && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("2")
5093        {
5094            let snap: Vec<Vec<f32>> = self
5095                .kv_cache
5096                .layers
5097                .iter()
5098                .map(|l| l.linear_state.clone())
5099                .collect();
5100            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
5101            let toks: Vec<u32> = std::iter::once(t_next)
5102                .chain(drafts.iter().copied())
5103                .collect();
5104            let want_save = self.graph_want_logits;
5105            self.graph_want_logits = false;
5106            for (i, &t) in toks.iter().take(a + 1).enumerate() {
5107                let _ = self.forward_layers(&self.embed_single(t), next_pos + i, None);
5108                let _ = self.graph_logits.take();
5109            }
5110            self.graph_want_logits = want_save;
5111            let plain_states: Vec<Vec<f32>> = self
5112                .kv_cache
5113                .layers
5114                .iter()
5115                .map(|l| l.linear_state.clone())
5116                .collect();
5117            let (nkv, hd) = (self.num_kv_heads, self.head_dim);
5118            let mut rows = Vec::new();
5119            for (li, (l, n0)) in self
5120                .kv_cache
5121                .layers
5122                .iter_mut()
5123                .zip(attn_lens.iter())
5124                .enumerate()
5125            {
5126                let extra = l.seq_len.saturating_sub(*n0);
5127                if extra > 0 {
5128                    let mut kk = Vec::new();
5129                    let mut vv = Vec::new();
5130                    for g in 0..nkv {
5131                        kk.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
5132                        vv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
5133                    }
5134                    rows.push((li, kk, vv));
5135                    l.truncate_last(extra);
5136                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, *n0);
5137                }
5138            }
5139            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
5140                if l.linear_state.len() == st.len() {
5141                    l.linear_state.copy_from_slice(&st);
5142                } else {
5143                    l.linear_state = st;
5144                }
5145            }
5146            Some((plain_states, rows))
5147        } else {
5148            None
5149        };
5150        // a fully-accepted round needs no restore: every input was real.
5151        #[cfg(target_os = "macos")]
5152        if metal_native {
5153            // the Metal verify never wrote its states: the commit replays the
5154            // accepted prefix into the CPU owners and appends the K/V rows
5155            self.metal_verify_commit(a);
5156            if let Some((plain_states, rows)) = commit_ref {
5157                crate::gpu_metal::queue_fence();
5158                let (nkv, hd) = (self.num_kv_heads, self.head_dim);
5159                let mut worst_s = 0f32;
5160                let mut worst_li = 0usize;
5161                for (li, (l, ps)) in self.kv_cache.layers.iter().zip(&plain_states).enumerate() {
5162                    if l.linear_state.len() != ps.len() || ps.is_empty() {
5163                        continue;
5164                    }
5165                    let d = l
5166                        .linear_state
5167                        .iter()
5168                        .zip(ps)
5169                        .fold(0f32, |m, (x, y)| m.max((x - y).abs()));
5170                    let n = ps.iter().fold(0f32, |m, y| m.max(y.abs()));
5171                    let rel = d / n.max(1e-6);
5172                    if rel > worst_s {
5173                        worst_s = rel;
5174                        worst_li = li;
5175                    }
5176                }
5177                let mut worst_k = 0f32;
5178                for (li, kk, vv) in &rows {
5179                    let l = &self.kv_cache.layers[*li];
5180                    let n0 = l.seq_len - (kk.len() / (nkv * hd));
5181                    let mut ck = Vec::new();
5182                    let mut cv = Vec::new();
5183                    for g in 0..nkv {
5184                        ck.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
5185                        cv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
5186                    }
5187                    if ck.len() == kk.len() {
5188                        let dk = ck
5189                            .iter()
5190                            .zip(kk)
5191                            .fold(0f32, |m, (x, y)| m.max((x - y).abs()));
5192                        let dv = cv
5193                            .iter()
5194                            .zip(vv)
5195                            .fold(0f32, |m, (x, y)| m.max((x - y).abs()));
5196                        worst_k = worst_k.max(dk).max(dv);
5197                    } else {
5198                        eprintln!(
5199                            "commit-check L{li}: kv row count mismatch {} vs {}",
5200                            ck.len(),
5201                            kk.len()
5202                        );
5203                    }
5204                }
5205                eprintln!(
5206                    "commit-check a={a}: worst GDN state rel-max diff {worst_s:.2e} (L{worst_li}) | worst K/V row abs diff {worst_k:.4}"
5207                );
5208            }
5209        }
5210        if !metal_native && a + 1 < b {
5211            let expected_gdn_layers = self.graph_gdn_layer_count();
5212            if expected_gdn_layers > 0
5213                && !crate::gpu::gdn_spec_restore(self.graph_kv_id, a, next_pos, expected_gdn_layers)
5214            {
5215                self.clear_sequence_state();
5216                self.graph_failed
5217                    .store(true, std::sync::atomic::Ordering::Relaxed);
5218                self.cancel
5219                    .store(true, std::sync::atomic::Ordering::Relaxed);
5220                tracing::error!("GDN speculative restore failed after verify");
5221                return None;
5222            }
5223        }
5224        if !metal_native && !self.rewind_trunk_graph_mirrors(next_pos + a + 1) {
5225            // The verify graph committed the full batch, but one of its
5226            // persistent Full-attention mirrors could not be re-pointed to
5227            // the accepted prefix.  Treat that as terminal state failure;
5228            // an exact CPU fallback would otherwise consume stale GDN/KV.
5229            self.clear_sequence_state();
5230            self.graph_failed
5231                .store(true, std::sync::atomic::Ordering::Relaxed);
5232            self.cancel
5233                .store(true, std::sync::atomic::Ordering::Relaxed);
5234            tracing::error!("trunk graph KV rewind failed after speculative verify");
5235            return None;
5236        }
5237        *accepted += a;
5238        // MTP cache: keep the first draft row (its inputs were real), drop
5239        // the chain's, then append the verified pairs the round produced.
5240        // Each of those is a whole MTP block on the per-op path and they
5241        // cost 5.8 ms of a 69 ms round at k=3 — a third of what the
5242        // round's own draft costs. PRICED, and they earn it: skipping
5243        // them (`CMF_SPEC_WARM=0`) drops acceptance from 89% to 81% at
5244        // k=3 and 85% to 74% at k=4, and the tok/s goes nowhere at k=3
5245        // (50.3 against 50.5) and backwards at k=4 (48.1 against 50.1).
5246        // The knob stays so the next person can re-price it after the
5247        // warms are batched instead of assuming either way.
5248        m.kv.truncate_last(k_spec.saturating_sub(1));
5249        #[cfg(target_os = "macos")]
5250        if metal_native && self.mtp_graph_mode == Some(true) {
5251            // the mirror rows below the cut are the CPU rows: re-point,
5252            // no re-upload
5253            crate::gpu_metal::kv_mirror_set_stored(
5254                self.mtp_kv_id(),
5255                Self::MTP_LAYER_BASE,
5256                m.kv.seq_len,
5257            );
5258        }
5259        if !metal_native
5260            && self.mtp_graph_mode == Some(true)
5261            && !self.rewind_mtp_graph_mirror(next_pos)
5262        {
5263            // The graph draft was admitted, so inability to move its cursor
5264            // back to the real anchor is a state failure, not a capability
5265            // refusal.  Do not warm or continue with a stale mirror.
5266            self.clear_sequence_state();
5267            self.graph_failed
5268                .store(true, std::sync::atomic::Ordering::Relaxed);
5269            self.cancel
5270                .store(true, std::sync::atomic::Ordering::Relaxed);
5271            tracing::error!("MTP graph mirror rewind failed after verify commit");
5272            return None;
5273        }
5274        let warm_off = std::env::var("CMF_SPEC_WARM").is_ok_and(|v| v == "0");
5275        if !warm_off && a > 0 {
5276            // Graph arm: all accepted pairs in ONE batched run over the
5277            // MTP block; the token graph one by one if the batch declines.
5278            let mut warmed = false;
5279            #[cfg(target_os = "macos")]
5280            if metal_native && self.mtp_graph_mode == Some(true) {
5281                // all accepted pairs in ONE b-row graph run over the MTP
5282                // block (its input projection folded in); one by one on
5283                // the token graph if that declines
5284                let pairs: Vec<(&[f32], u32)> = (0..a)
5285                    .map(|j| {
5286                        (
5287                            &hiddens[j * self.hidden_size..(j + 1) * self.hidden_size],
5288                            ids[j],
5289                        )
5290                    })
5291                    .collect();
5292                warmed = self.mtp_warm_batch_metal(m, &pairs, next_pos);
5293                if !warmed {
5294                    warmed = true;
5295                    for j in 0..a {
5296                        let row =
5297                            hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec();
5298                        if self
5299                            .mtp_step_metal(m, &row, ids[j], next_pos + j, false)
5300                            .is_none()
5301                        {
5302                            warmed = false;
5303                            break;
5304                        }
5305                    }
5306                }
5307            }
5308            if !warmed && self.mtp_graph_mode != Some(false) && !metal_native {
5309                let rows: Vec<Vec<f32>> = (0..a)
5310                    .map(|j| hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec())
5311                    .collect();
5312                let pairs: Vec<(&[f32], u32)> = rows
5313                    .iter()
5314                    .zip(ids.iter())
5315                    .map(|(r, &t)| (r.as_slice(), t))
5316                    .collect();
5317                match self.mtp_warm_prefill_pairs(m, &pairs, next_pos) {
5318                    Ok(()) => warmed = true,
5319                    Err(err) => {
5320                        // A warm-up failure after graph admission cannot
5321                        // fall back to `mtp_warm`: the detached CPU cache is
5322                        // not authoritative for the device mirror.  Mark it
5323                        // terminal so the generation caller clears state and
5324                        // returns instead of drafting from stale attention.
5325                        tracing::error!("{err}");
5326                        self.clear_sequence_state();
5327                        self.graph_failed
5328                            .store(true, std::sync::atomic::Ordering::Relaxed);
5329                        self.cancel
5330                            .store(true, std::sync::atomic::Ordering::Relaxed);
5331                        return None;
5332                    }
5333                }
5334            }
5335            if !warmed {
5336                for j in 0..a {
5337                    let row = &hiddens[j * self.hidden_size..(j + 1) * self.hidden_size];
5338                    let row = row.to_vec();
5339                    self.mtp_warm(m, &row, ids[j], next_pos + j);
5340                }
5341            }
5342        }
5343        // The sampler's contract: logits of the LAST verified position —
5344        // unless a rejected draft already drew the correction, in which
5345        // case the loop top commits that token and samples nothing.
5346        if let Some(c) = forced {
5347            self.spec_forced = Some(c);
5348            self.graph_logits = None;
5349        } else {
5350            let mut row = logits[a * lm_rows..(a + 1) * lm_rows].to_vec();
5351            row.resize(self.vocab_size, 0.0);
5352            if let Some(c) = self.final_softcap {
5353                for l in row.iter_mut() {
5354                    *l = c * (*l / c).tanh();
5355                }
5356            }
5357            self.graph_logits = Some(row);
5358        }
5359        let new_hidden = hiddens[a * self.hidden_size..(a + 1) * self.hidden_size].to_vec();
5360        // Three phases, not two. The round's wall clock was 4 ms longer
5361        // than draft+verify and the difference had nowhere to be seen:
5362        // the accepted prefix re-runs the MTP block once per token to
5363        // keep the draft head's attention cache warm, and the GDN state
5364        // rolls back on any rejection. Both live here, after the verify.
5365        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
5366            let end = subs();
5367            eprintln!(
5368                "spec-round: draft {:.1} ms/{} sub | verify {:.1} ms/{} sub | \
5369                 commit {:.1} ms/{} sub (accepted {a} of {k_spec})",
5370                t_draft.as_secs_f64() * 1e3,
5371                sub_draft - sub0,
5372                (t_verify - t_draft).as_secs_f64() * 1e3,
5373                sub_verify - sub_draft,
5374                (t_round.elapsed() - t_verify).as_secs_f64() * 1e3,
5375                end - sub_verify,
5376            );
5377        }
5378        Some((drafts[..a].to_vec(), next_pos + a + 1, new_hidden))
5379    }
5380
5381    /// Micro-benchmark: two single-position forwards vs one fused pair
5382    /// from the current cache state (KV rewound after each probe).
5383    /// Returns (two_singles_ms, fused_pair_ms) per probe, or the (0, 0)
5384    /// sentinel when this model has no pair path to measure — the same
5385    /// answer the o1 arm gives, and the bench prints it the same way.
5386    /// (An architecture that loads its own layers leaves `weights.layers`
5387    /// empty; walking it here was an index panic, found by `bench` on
5388    /// deepseek_v4.)
5389    pub fn measure_pair_fusion(&mut self, iters: usize) -> (f64, f64) {
5390        if !self.pair_supported() {
5391            return (0.0, 0.0);
5392        }
5393        // This is a host-side pair micro-benchmark. It truncates the host KV
5394        // after every probe, so letting the whole-token graph participate
5395        // would leave its device GDN/KV mirror ahead of the next probe and
5396        // poison the process-wide graph verdict before the real generation
5397        // benchmark starts. Keep the existing per-op/GPU arithmetic while
5398        // suppressing only the stateful token graph for this measurement.
5399        let graph_env = std::env::var_os("CMF_GPU_WGPU_GRAPH");
5400        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
5401        let emb1 = self.embed_single(1);
5402        let emb2 = self.embed_single(2);
5403        let pos = self.kv_cache.seq_len();
5404
5405        let t0 = std::time::Instant::now();
5406        for _ in 0..iters {
5407            let _ = self.forward_layers(&emb1, pos, None);
5408            let _ = self.forward_layers(&emb2, pos + 1, None);
5409            for l in &mut self.kv_cache.layers {
5410                l.truncate_last(2);
5411            }
5412        }
5413        let singles_ms = t0.elapsed().as_secs_f64() * 1000.0 / iters as f64;
5414
5415        let t1 = std::time::Instant::now();
5416        for _ in 0..iters {
5417            let _ = self.forward_pair(&emb1, &emb2, pos);
5418            for l in &mut self.kv_cache.layers {
5419                l.truncate_last(2);
5420            }
5421        }
5422        let pair_ms = t1.elapsed().as_secs_f64() * 1000.0 / iters as f64;
5423        match graph_env {
5424            Some(value) => unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", value) },
5425            None => unsafe { std::env::remove_var("CMF_GPU_WGPU_GRAPH") },
5426        }
5427        (singles_ms, pair_ms)
5428    }
5429
5430    /// Fused two-position forward: weight rows are streamed from memory
5431    /// once per layer for both positions. Full layers → fused GQA pair;
5432    /// linear layers → vmf_phase pair (lane 2 state is tentative in the
5433    /// per-layer scratch until the draft is accepted).
5434    /// Whether the fused two-position path covers every layer kind in
5435    /// this model. MLA and KDA run per position (their pair arms are
5436    /// unreachable); the seq prefill falls back to singles for them.
5437    fn pair_supported(&self) -> bool {
5438        // An EMPTY layer stack means the architecture loaded its own and
5439        // this path has nothing to walk. Checking that directly, rather
5440        // than naming each such architecture, is what makes the guard hold
5441        // for the next one: `any()` over no layers is false, so a
5442        // feature-by-feature test says "supported" for a model that has no
5443        // layers here at all.
5444        !self.weights.layers.is_empty()
5445            && self.g3n.is_none()
5446            && !self
5447                .weights
5448                .layers
5449                .iter()
5450                .any(|lw| matches!(&lw.attn, AttnKind::Mla(_) | AttnKind::Kda(_)))
5451    }
5452
5453    fn forward_pair(
5454        &mut self,
5455        emb1: &[f32],
5456        emb2: &[f32],
5457        position: usize,
5458    ) -> (Vec<f32>, Vec<f32>) {
5459        let mut h1 = emb1.to_vec();
5460        let mut h2 = emb2.to_vec();
5461        let (_nkv, _hd, hs, _rd, eps) = (
5462            self.num_kv_heads,
5463            self.head_dim,
5464            self.hidden_size,
5465            self.rotary_dim,
5466            self.rms_eps,
5467        );
5468        let pool = self.pool.clone();
5469
5470        for li in 0..self.num_layers {
5471            let lw = &self.weights.layers[self.phys_layer(li)];
5472            // Norms into pipeline scratch (4 allocs/layer on the MTP
5473            // decode hot path before this).
5474            inference::rms_norm_into(
5475                &h1,
5476                &lw.input_norm,
5477                self.rms_eps,
5478                self.norm_style,
5479                &mut self.ws.n1,
5480            );
5481            inference::rms_norm_into(
5482                &h2,
5483                &lw.input_norm,
5484                self.rms_eps,
5485                self.norm_style,
5486                &mut self.ws.n2,
5487            );
5488
5489            let (a1, a2) = match &lw.attn {
5490                AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
5491                AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
5492                AttnKind::Linear(w) => {
5493                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
5494                    let layer = &mut self.kv_cache.layers[li];
5495                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
5496                    vmf_phase_pair(
5497                        &self.ws.n1,
5498                        &self.ws.n2,
5499                        w,
5500                        &cfg,
5501                        state,
5502                        scratch,
5503                        self.pool.as_deref(),
5504                    )
5505                }
5506                AttnKind::LinearGdn(w) => {
5507                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
5508                    let layer = &mut self.kv_cache.layers[li];
5509                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
5510                    gdn_pair(
5511                        &self.ws.n1,
5512                        &self.ws.n2,
5513                        w,
5514                        &cfg,
5515                        state,
5516                        scratch,
5517                        self.pool.as_deref(),
5518                    )
5519                }
5520                AttnKind::ShortConv(w) => {
5521                    let cfg = self
5522                        .short_conv_cfg
5523                        .expect("short-conv layer without short_conv_cfg");
5524                    let layer = &mut self.kv_cache.layers[li];
5525                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
5526                    short_conv_pair(
5527                        &self.ws.n1,
5528                        &self.ws.n2,
5529                        w,
5530                        &cfg,
5531                        state,
5532                        scratch,
5533                        self.pool.as_deref(),
5534                    )
5535                }
5536                AttnKind::Full {
5537                    wq,
5538                    wk,
5539                    wv,
5540                    wo,
5541                    q_norm,
5542                    k_norm,
5543                    output_gate,
5544                    softplus_gate,
5545                    bias,
5546                } => {
5547                    let inv_freq_l = self.layer_inv_freq(li);
5548                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
5549                    let cfg = QwenAttnCfg {
5550                        num_heads: self.layer_num_heads(li),
5551                        num_kv_heads: nkv_l,
5552                        head_dim: hd_l,
5553                        hidden_size: hs,
5554                        position,
5555                        inv_freq: &inv_freq_l,
5556                        rotary_dim: rd_l,
5557                        scale: self.attn_scale,
5558                        softcap: self.attn_softcap,
5559                        window: self.layer_window(li),
5560                        v_norm: self.attn_v_norm,
5561                        q_norm: q_norm.as_deref(),
5562                        k_norm: k_norm.as_deref(),
5563                        output_gate: *output_gate,
5564                        softplus_gate: softplus_gate
5565                            .as_ref()
5566                            .map(|(gate, per_head)| (gate, *per_head)),
5567                        rope_scale: self.layer_rope_scale(li),
5568                        bias: bias
5569                            .as_ref()
5570                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
5571                        rms_eps: eps,
5572                        norm_style: self.norm_style,
5573                        pool: pool.as_deref(),
5574                    };
5575                    attention::qwen_attention_pair(
5576                        &self.ws.n1,
5577                        &self.ws.n2,
5578                        wq,
5579                        wk,
5580                        wv,
5581                        wo,
5582                        &mut self.kv_cache.layers[li],
5583                        &cfg,
5584                    )
5585                }
5586            };
5587            let (a1, a2) = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
5588                Some(w) => (
5589                    inference::rms_norm(&a1, w, self.rms_eps, self.norm_style),
5590                    inference::rms_norm(&a2, w, self.rms_eps, self.norm_style),
5591                ),
5592                None => (a1, a2),
5593            };
5594            for i in 0..self.hidden_size {
5595                h1[i] += a1[i];
5596                h2[i] += a2[i];
5597            }
5598            let (mut a1, mut a2) = (a1, a2);
5599            attention::recycle_buf(&mut a1);
5600            attention::recycle_buf(&mut a2);
5601
5602            let lw = &self.weights.layers[self.phys_layer(li)];
5603            inference::rms_norm_into(
5604                &h1,
5605                &lw.post_norm,
5606                self.rms_eps,
5607                self.norm_style,
5608                &mut self.ws.p1,
5609            );
5610            inference::rms_norm_into(
5611                &h2,
5612                &lw.post_norm,
5613                self.rms_eps,
5614                self.norm_style,
5615                &mut self.ws.p2,
5616            );
5617            let (f1, f2) = match &lw.ffn {
5618                // Dual-branch layers need the raw residuals — run the
5619                // two positions through the same fn decode uses.
5620                FfnKind::DenseMoe(dm) => (
5621                    dense_moe_ffn(
5622                        dm,
5623                        &self.ws.p1,
5624                        &h1,
5625                        self.rms_eps,
5626                        self.norm_style,
5627                        self.pool.as_deref(),
5628                    ),
5629                    dense_moe_ffn(
5630                        dm,
5631                        &self.ws.p2,
5632                        &h2,
5633                        self.rms_eps,
5634                        self.norm_style,
5635                        self.pool.as_deref(),
5636                    ),
5637                ),
5638                _ => ffn_forward_pair(
5639                    &lw.ffn,
5640                    &self.ws.p1,
5641                    &self.ws.p2,
5642                    self.pool.as_deref(),
5643                    None,
5644                ),
5645            };
5646            let (f1, f2) = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
5647                Some(w) => (
5648                    inference::rms_norm(&f1, w, self.rms_eps, self.norm_style),
5649                    inference::rms_norm(&f2, w, self.rms_eps, self.norm_style),
5650                ),
5651                None => (f1, f2),
5652            };
5653            for i in 0..self.hidden_size {
5654                h1[i] += f1[i];
5655                h2[i] += f2[i];
5656            }
5657            let (mut f1, mut f2) = (f1, f2);
5658            attention::recycle_buf(&mut f1);
5659            attention::recycle_buf(&mut f2);
5660            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
5661                for i in 0..self.hidden_size {
5662                    h1[i] *= sc;
5663                    h2[i] *= sc;
5664                }
5665            }
5666            // Looped Transformer: apply final norm at the end of each loop iteration.
5667            if self.is_loop_end(li) && li + 1 < self.num_layers {
5668                h1 = inference::rms_norm(
5669                    &h1,
5670                    &self.weights.final_norm,
5671                    self.rms_eps,
5672                    self.norm_style,
5673                );
5674                h2 = inference::rms_norm(
5675                    &h2,
5676                    &self.weights.final_norm,
5677                    self.rms_eps,
5678                    self.norm_style,
5679                );
5680            }
5681        }
5682        // Real O(1) prefill pairs may also carry tentative lane-2 recurrent
5683        // state. Commit it before publishing the transition epoch so the
5684        // next serial/device row cannot observe a new attention epoch with an
5685        // old GDN state. Speculative pairs run only when O(1) is inactive and
5686        // retain their existing caller-controlled commit/rollback semantics.
5687        if self.o1_active() {
5688            self.commit_linear_scratch();
5689        }
5690        self.o1_progress();
5691        (h1, h2)
5692    }
5693
5694    /// Commit lane-2 linear states after an accepted draft.
5695    fn commit_linear_scratch(&mut self) {
5696        for layer in &mut self.kv_cache.layers {
5697            if !layer.linear_scratch.is_empty() {
5698                std::mem::swap(&mut layer.linear_state, &mut layer.linear_scratch);
5699                layer.linear_scratch.clear();
5700            }
5701        }
5702    }
5703
5704    /// Forward a full id sequence from a fresh cache and return the
5705    /// logits after the last position (golden-parity harness, bench).
5706    pub fn forward_ids(
5707        &mut self,
5708        ids: &[u32],
5709        task_mask: Option<&TaskMask>,
5710    ) -> Result<Vec<f32>, String> {
5711        if ids.is_empty() {
5712            return Err("empty id sequence".to_string());
5713        }
5714        self.clear_sequence_state();
5715        self.check_forward_graph("forward_ids setup", 0)?;
5716        if task_mask.is_none() {
5717            self.o1_begin();
5718        }
5719        let mut hidden = vec![0.0f32; self.hidden_size];
5720        let mut pos = 0usize;
5721        if let Some(b) = &mut self.dsv41 {
5722            let pool = self.pool.clone();
5723            let mut logits = Vec::new();
5724            crate::dsv41::forward_chunk(
5725                &b.0,
5726                &b.1,
5727                &b.2,
5728                &mut b.3,
5729                ids,
5730                0,
5731                pool.as_deref(),
5732                &mut logits,
5733            );
5734            if let Err(err) = self.o1_seal_checked() {
5735                self.clear_sequence_state();
5736                return Err(err);
5737            }
5738            return Ok(logits);
5739        }
5740        // Same routing predicate generation uses. Two reasons it must be
5741        // the same one: (1) a GDN hybrid's recurrent state is GPU-
5742        // resident, and a batched CPU prefill would build it on the host
5743        // only — decode then reads buffers the prefill never wrote;
5744        // (2) bench times THIS function and calls the result "prefill",
5745        // so a different path here reports a number production never
5746        // sees (W2 on 2×5090: 8.7 tok/s reported against 125 real).
5747        if self.can_prefill_batched() && !self.graph_prefill_preferred() && ids.len() > 2 {
5748            // prefill-GEMM in chunks; only the last position's hidden is
5749            // needed. (o1-compatible: the batch path attends per position
5750            // through qwen_attention, which carries the collection hook.)
5751            let chunk = prefill_chunk();
5752            let hs = self.hidden_size;
5753            while pos < ids.len() {
5754                let end = (pos + chunk).min(ids.len());
5755                let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
5756                self.check_forward_graph("forward_ids batched prefill", end - 1)?;
5757                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
5758                pos = end;
5759            }
5760        }
5761        // Same guards as generation's prefill — INCLUDING the graph one.
5762        // The CPU pair walk was intercepting positions that the resident
5763        // token graph would have run itself: on a GDN hybrid over wgpu
5764        // that is 89 ms of host forward against 7 ms of device submit,
5765        // and it made prefill look 12× slower than it is (W2 on an RTX
5766        // 5090, ctx 512: 11.2 tok/s with the walk, 136.6 without).
5767        // CMF_PAIR=0 opts out; a model whose layers live outside
5768        // `weights.layers` has no pair walk to take.
5769        if task_mask.is_none()
5770            && !self.graph_prefill_preferred()
5771            && !std::env::var("CMF_PAIR").is_ok_and(|v| v == "0")
5772            && self.pair_supported()
5773        {
5774            while pos + 1 < ids.len() {
5775                let e1 = self.embed_single(ids[pos]);
5776                let e2 = self.embed_single(ids[pos + 1]);
5777                let (_, h2) = self.forward_pair(&e1, &e2, pos);
5778                self.check_forward_graph("forward_ids pair", pos + 1)?;
5779                self.commit_linear_scratch();
5780                hidden = h2;
5781                pos += 2;
5782            }
5783        }
5784        while pos < ids.len() {
5785            hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
5786            self.check_forward_graph("forward_ids", pos)?;
5787            pos += 1;
5788        }
5789        // Harness contract: after forward_ids the cache is decode-ready —
5790        // under o1 that means sealed (bench measures the seal as part of
5791        // prefill, honestly).
5792        if let Err(err) = self.o1_seal_checked() {
5793            self.clear_sequence_state();
5794            return Err(err);
5795        }
5796        let normed = inference::rms_norm(
5797            &hidden,
5798            &self.weights.final_norm,
5799            self.rms_eps,
5800            self.norm_style,
5801        );
5802        Ok(self.lm_head_forward(&normed))
5803    }
5804
5805    /// Run the V4.1 stack one token at a time and retain logits for every
5806    /// position. This is a diagnostic surface for comparing a converted
5807    /// checkpoint with a tokenwise reference implementation.
5808    #[doc(hidden)]
5809    pub fn dsv41_serial_logits(&mut self, ids: &[u32]) -> Result<Vec<Vec<f32>>, String> {
5810        #[cfg(target_os = "macos")]
5811        crate::gpu_metal::set_io_namespace(self.graph_kv_id);
5812        if ids.is_empty() {
5813            return Err("empty id sequence".to_string());
5814        }
5815        self.clear_sequence_state();
5816        self.dsv41
5817            .as_ref()
5818            .ok_or_else(|| "dsv41 serial logits require a DeepSeek-V4.1 model".to_string())?;
5819        self.o1_begin();
5820        let rows = {
5821            let pool = self.pool.clone();
5822            let b = self
5823                .dsv41
5824                .as_mut()
5825                .expect("dsv41 checked above; state cannot change during forward");
5826            let mut rows = Vec::with_capacity(ids.len());
5827            for (position, &id) in ids.iter().enumerate() {
5828                let mut logits = Vec::new();
5829                crate::dsv41::forward_token(
5830                    &b.0,
5831                    &b.1,
5832                    &b.2,
5833                    &mut b.3,
5834                    id,
5835                    position,
5836                    pool.as_deref(),
5837                    &mut logits,
5838                );
5839                rows.push(logits);
5840            }
5841            rows
5842        };
5843        self.o1_seal();
5844        Ok(rows)
5845    }
5846
5847    /// Teacher-forced perplexity over a token sequence (phase-C gate:
5848    /// honest quant comparisons instead of prompt vibes).
5849    ///
5850    /// Attention is EXACT even on a model whose layers are flagged for
5851    /// the O(1) kernel — scoring the backbone is the default on purpose
5852    /// (it is the yardstick). `nll_ids_o1` scores the CONVERTED model.
5853    pub fn ppl_ids(&mut self, ids: &[u32]) -> Result<f64, String> {
5854        let (nll, cnt) = self.nll_ids_from(ids, 0)?;
5855        Ok((nll / cnt.max(1) as f64).exp())
5856    }
5857
5858    /// DTG-MA calibration pass (Patent 2): run `ids` through the model
5859    /// (CPU path, per position) and return each layer's per-neuron
5860    /// activation mass Σ|silu(gate)·up| — the statistic the task-guided
5861    /// FFN mask is derived from.
5862    pub fn probe_ffn_mass(&mut self, ids: &[u32]) -> Vec<Vec<f64>> {
5863        self.clear_sequence_state();
5864        FFN_PROBE.with(|p| {
5865            *p.borrow_mut() = Some(vec![vec![0f64; self.intermediate_size]; self.num_layers]);
5866        });
5867        crate::gpu::cpu_scope(|| {
5868            for (pos, &id) in ids.iter().enumerate() {
5869                let emb = self.embed_single(id);
5870                let _ = self.forward_layers(&emb, pos, None);
5871            }
5872        });
5873        self.clear_sequence_state();
5874        FFN_PROBE
5875            .with(|p| p.borrow_mut().take())
5876            .unwrap_or_default()
5877    }
5878
5879    /// `probe_ffn_mass` over the BATCHED prefill: same accumulator, one
5880    /// sweep instead of one forward per token. What makes the statistic
5881    /// affordable on a 27B.
5882    pub fn probe_ffn_mass_batch(&mut self, ids: &[u32]) -> Result<Vec<Vec<f64>>, String> {
5883        if let Err(err) = self.nll_begin() {
5884            // A recorder can be left by a caller that was interrupted before
5885            // this request entered its scoring block.  Consume it even when
5886            // the preflight failure prevents initialization of a new one.
5887            let _ = FFN_PROBE.with(|p| p.borrow_mut().take());
5888            self.nll_end();
5889            return Err(err);
5890        }
5891        FFN_PROBE.with(|p| {
5892            *p.borrow_mut() = Some(vec![vec![0f64; self.intermediate_size]; self.num_layers]);
5893        });
5894        let result: Result<(), String> = (|| {
5895            for chunk in ids.chunks(256) {
5896                if chunk.len() < 2 {
5897                    continue;
5898                }
5899                self.nll_ids_masked(chunk, 0, None)?;
5900            }
5901            Ok(())
5902        })();
5903        self.nll_end();
5904        let probe = FFN_PROBE
5905            .with(|p| p.borrow_mut().take())
5906            .unwrap_or_default();
5907        match result {
5908            Ok(()) => Ok(probe),
5909            Err(err) => {
5910                drop(probe);
5911                Err(err)
5912            }
5913        }
5914    }
5915
5916    /// Teacher-forced PPL with a task mask active (sparse execution) —
5917    /// the quality gate for a DTG-MA-masked skill. Sequential per
5918    /// position: the batched prefill path is dense-only.
5919    pub fn ppl_ids_masked(&mut self, ids: &[u32], mask: &TaskMask) -> Result<f64, String> {
5920        self.nll_begin()?;
5921        let result: Result<f64, String> = (|| {
5922            let mut nll = 0f64;
5923            let mut cnt = 0usize;
5924            let mut hidden = vec![0f32; self.hidden_size];
5925            for (pos, &id) in ids.iter().enumerate() {
5926                if pos > 0 {
5927                    inference::rms_norm_into(
5928                        &hidden,
5929                        &self.weights.final_norm,
5930                        self.rms_eps,
5931                        self.norm_style,
5932                        &mut self.ws.n1,
5933                    );
5934                    let mut logits = self.lm_head_forward(&self.ws.n1);
5935                    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
5936                    let sum: f64 = logits.iter().map(|&v| ((v - max) as f64).exp()).sum();
5937                    let p = ((logits[id as usize] - max) as f64).exp() / sum.max(1e-300);
5938                    nll -= p.max(1e-300).ln();
5939                    cnt += 1;
5940                    attention::recycle_buf(&mut logits);
5941                }
5942                let emb = self.embed_single(id);
5943                hidden = self.forward_layers(&emb, pos, Some(mask));
5944                self.nll_check_graph("masked serial forward", pos)?;
5945                // Consume a possible graph logits side channel before the
5946                // next row.  Masked scoring normally disables that route,
5947                // but stale channel state must never survive a request.
5948                let _ = self.graph_logits.take();
5949            }
5950            Ok((nll / cnt.max(1) as f64).exp())
5951        })();
5952        self.nll_end();
5953        result
5954    }
5955
5956    /// Teacher-forced NLL sum + scored-token count over positions
5957    /// `start..len-1`, attention EXACT. Positions below `start` still
5958    /// run — they are the context — they are just not scored, so this
5959    /// pairs with `nll_ids_o1(ids, start)` over the very same tokens.
5960    ///
5961    /// Returning (nll, cnt) rather than a ppl is what lets a windowed
5962    /// caller combine windows before the exp, so every scored token
5963    /// weighs the same regardless of how the windows are cut.
5964    /// `nll_ids_from` with a task mask held active at every position.
5965    ///
5966    /// The batched prefill path does not thread masks, so this walks the
5967    /// per-position forward — slower, but it scores the file exactly the
5968    /// way `run --task` will serve it, which is the point of the gate
5969    /// that calls it. With `None` it defers to the fast path.
5970    /// Masked scoring rides the SAME batched sweep as unmasked scoring —
5971    /// the masked-inference fast path: `prefill_batch_masked` lands the
5972    /// per-visit FFN rows on the activations inside the fused arms. The
5973    /// per-position loop below remains only as the no-batch fallback.
5974    pub fn nll_ids_masked(
5975        &mut self,
5976        ids: &[u32],
5977        start: usize,
5978        task_mask: Option<&TaskMask>,
5979    ) -> Result<(f64, usize), String> {
5980        let task_mask = self.drop_open_mask(task_mask);
5981        self.nll_ids_inner(ids, start, task_mask)
5982    }
5983
5984    pub fn nll_ids_from(&mut self, ids: &[u32], start: usize) -> Result<(f64, usize), String> {
5985        self.nll_ids_inner(ids, start, None)
5986    }
5987
5988    fn nll_ids_inner(
5989        &mut self,
5990        ids: &[u32],
5991        start: usize,
5992        task_mask: Option<&TaskMask>,
5993    ) -> Result<(f64, usize), String> {
5994        self.nll_begin()?;
5995        let result: Result<(f64, usize), String> = (|| {
5996            let mut nll = 0f64;
5997            let mut cnt = 0usize;
5998            if self.can_prefill_batched() {
5999                // prefill-GEMM: layer-major position chunks, lm_head batched
6000                // (254MB lm_head read once per chunk, not per position).
6001                // The layer chunk is large (grouping positions by MoE experts
6002                // wins with size), lm_head in sub-blocks (logit buffer
6003                // 32×vocab ≈ 32MB instead of 128×).
6004                const CHUNK: usize = 128;
6005                const LM_SUB: usize = 32;
6006                let n = ids.len().saturating_sub(1);
6007                let hs = self.hidden_size;
6008                let rows = self.weights.lm_head.rows();
6009                let mut pos = 0usize;
6010                while pos < n {
6011                    let end = (pos + CHUNK).min(n);
6012                    let bsz = end - pos;
6013                    let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
6014                    self.nll_check_graph("batched prefill", pos)?;
6015                    let mut k0 = 0usize;
6016                    while k0 < bsz {
6017                        let k1 = (k0 + LM_SUB).min(bsz);
6018                        let sb = k1 - k0;
6019                        // Sub-block entirely below the scored range: the KV
6020                        // it just built is all this pass needed from it.
6021                        if pos + k1 <= start {
6022                            k0 = k1;
6023                            continue;
6024                        }
6025                        let mut normed = vec![0.0f32; sb * hs];
6026                        for k in 0..sb {
6027                            let r = inference::rms_norm(
6028                                &hb[(k0 + k) * hs..(k0 + k + 1) * hs],
6029                                &self.weights.final_norm,
6030                                self.rms_eps,
6031                                self.norm_style,
6032                            );
6033                            normed[k * hs..(k + 1) * hs].copy_from_slice(&r);
6034                        }
6035                        let mut logits = vec![0.0f32; sb * rows];
6036                        self.weights
6037                            .lm_head
6038                            .matmat(&normed, sb, &mut logits, self.pool.as_deref());
6039                        for k in 0..sb {
6040                            if pos + k0 + k < start {
6041                                continue;
6042                            }
6043                            self.nll_check_graph("batched score row", pos + k0 + k)?;
6044                            let lg = &mut logits[k * rows..k * rows + self.vocab_size.min(rows)];
6045                            if let Some(mu) = self.logit_multiplier {
6046                                for v in lg.iter_mut() {
6047                                    *v *= mu;
6048                                }
6049                            }
6050                            // Gemma-class final-logit soft-capping: the
6051                            // decode paths apply it; scoring must too, or
6052                            // the uncapped softmax misprices every token.
6053                            if let Some(c) = self.final_softcap {
6054                                for v in lg.iter_mut() {
6055                                    *v = c * (*v / c).tanh();
6056                                }
6057                            }
6058                            // Cortiq Embryo hierarchical head: same correction
6059                            // the decode path applies (lm_head_forward).
6060                            if let Some(cm) = self.head_clusters.clone() {
6061                                self.hierarchical_head_logprobs(
6062                                    &normed[k * hs..(k + 1) * hs],
6063                                    &cm,
6064                                    lg,
6065                                );
6066                            }
6067                            let lg = &logits[k * rows..k * rows + self.vocab_size.min(rows)];
6068                            let target = ids[pos + k0 + k + 1] as usize;
6069                            let max = lg.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
6070                            let lse: f64 = lg
6071                                .iter()
6072                                .map(|&v| ((v - max) as f64).exp())
6073                                .sum::<f64>()
6074                                .ln()
6075                                + max as f64;
6076                            nll += lse - lg[target] as f64;
6077                            cnt += 1;
6078                            if std::env::var("CMF_PPL_TRACE").is_ok() {
6079                                let top = lg
6080                                    .iter()
6081                                    .enumerate()
6082                                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
6083                                    .map(|(i, _)| i)
6084                                    .unwrap_or(0);
6085                                eprintln!(
6086                                    "BTRACE pos {} target {} nll {:.4} top {} lg_t {:.3} lg_top {:.3}",
6087                                    pos + k0 + k,
6088                                    target,
6089                                    lse - lg[target] as f64,
6090                                    top,
6091                                    lg[target],
6092                                    lg[top]
6093                                );
6094                            }
6095                        }
6096                        k0 = k1;
6097                    }
6098                    pos = end;
6099                }
6100                return Ok((nll, cnt));
6101            }
6102            for pos in 0..ids.len().saturating_sub(1) {
6103                let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
6104                self.nll_check_graph("serial forward", pos)?;
6105                // Architectures whose head lives inside their own stack return
6106                // the logits out of band and a zero hidden — DeepSeek-V4 folds
6107                // its hyper-connection copies between the last layer and the
6108                // norm, so it cannot hand back a vector this loop could use.
6109                // Scoring the zeros gave a perplexity of exactly the vocabulary
6110                // size, which is a uniform distribution reported as a
6111                // measurement. `generate` already reads this channel.
6112                let out_of_band = self.graph_logits.take();
6113                if pos < start {
6114                    continue;
6115                }
6116                let logits = match out_of_band {
6117                    Some(lg) => lg,
6118                    None => {
6119                        let normed = inference::rms_norm(
6120                            &hidden,
6121                            &self.weights.final_norm,
6122                            self.rms_eps,
6123                            self.norm_style,
6124                        );
6125                        // lm_head_forward applies the final-logit softcap itself
6126                        // — capping again here double-squashed gemma-class
6127                        // logits (tanh∘tanh) and reported a flattered ppl.
6128                        self.lm_head_forward(&normed)
6129                    }
6130                };
6131                let target = ids[pos + 1] as usize;
6132                let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
6133                let lse: f64 = logits
6134                    .iter()
6135                    .map(|&v| ((v - max) as f64).exp())
6136                    .sum::<f64>()
6137                    .ln()
6138                    + max as f64;
6139                let tok_nll = lse - logits[target] as f64;
6140                if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
6141                    let top = logits
6142                        .iter()
6143                        .enumerate()
6144                        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
6145                        .map(|(i, _)| i)
6146                        .unwrap_or(0);
6147                    eprintln!(
6148                        "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
6149                        logits[target], logits[top]
6150                    );
6151                }
6152                nll += tok_nll;
6153                cnt += 1;
6154            }
6155            Ok((nll, cnt))
6156        })();
6157        self.nll_end();
6158        result
6159    }
6160
6161    /// Score one post-layer hidden with the same final norm/head path used by
6162    /// decode. Keeping this in one helper is important for the production
6163    /// batch scorer: its rows stop before the final norm, just like the
6164    /// per-position O(1) path below.
6165    fn nll_from_hidden(&mut self, hidden: &[f32], target: u32, pos: usize) -> f64 {
6166        let normed = inference::rms_norm(
6167            hidden,
6168            &self.weights.final_norm,
6169            self.rms_eps,
6170            self.norm_style,
6171        );
6172        // lm_head_forward applies the final-logit softcap itself — capping
6173        // again here double-squashed gemma-class logits in earlier scorers.
6174        let mut logits = self.lm_head_forward(&normed);
6175        let target = target as usize;
6176        let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
6177        let lse: f64 = logits
6178            .iter()
6179            .map(|&v| ((v - max) as f64).exp())
6180            .sum::<f64>()
6181            .ln()
6182            + max as f64;
6183        let tok_nll = lse - logits[target] as f64;
6184        if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
6185            let top = logits
6186                .iter()
6187                .enumerate()
6188                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
6189                .map(|(i, _)| i)
6190                .unwrap_or(0);
6191            eprintln!(
6192                "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
6193                logits[target], logits[top]
6194            );
6195        }
6196        attention::recycle_buf(&mut logits);
6197        tok_nll
6198    }
6199
6200    /// Teacher-forced NLL of the CONVERTED model: the O(1) Nyström path
6201    /// is ACTIVE over the scored positions. Returns `Ok((nll sum, scored
6202    /// count))` over `prefill..len-1` and surfaces a post-mutation batch
6203    /// failure instead of returning a partial score.
6204    ///
6205    /// Runtime discipline, deliberately NOT the matrix probe's: the
6206    /// requested prefix plus any required deferred lead-in run the exact
6207    /// prompt pass — that pass is what freezes the landmarks and M — and
6208    /// every post-seal scored position goes through `NystromState::step()`,
6209    /// the same code decode runs.
6210    /// So the landmarks are PREFILL-frozen (what ships), not
6211    /// full-sequence oracles (what the published probe measured). When the
6212    /// requested prefix is shorter than the bounded transition, rows in the
6213    /// exact lead-in are still scored so the shifted target range is stable.
6214    ///
6215    /// Pair with `nll_ids_from(ids, prefill)` for the exact baseline
6216    /// over the identical token set — that ratio is the honest one.
6217    pub fn nll_ids_o1(&mut self, ids: &[u32], prefill: usize) -> Result<(f64, usize), String> {
6218        // This scorer consumes host hiddens, so never request the optional
6219        // token-graph lm_head side channel. `nll_begin` also consumes a
6220        // prior graph failure and clears only the cancel bit that failure
6221        // raised, leaving a caller-owned cancellation observable.
6222        self.nll_begin()?;
6223        let requested_prefix = (prefill > 0).then_some(prefill);
6224        self.o1_begin_with_prefix(requested_prefix);
6225        let n = ids.len().saturating_sub(1);
6226        let requested_start = prefill.min(n);
6227        // The exact prefix must reach the deferred boundary before a
6228        // collecting layer can convert. Rows between the requested start and
6229        // that boundary remain part of the public NLL range and are scored
6230        // from the same hidden pass below.
6231        let exact_end = if self.o1_active() {
6232            match requested_prefix {
6233                Some(requested) => self.o1_effective_boundary(requested),
6234                None => self
6235                    .o1_cfg
6236                    .as_ref()
6237                    .and_then(|c| crate::nystrom::o1_deferred_boundary(c.w, c.sink)),
6238            }
6239            .unwrap_or(requested_start)
6240            .min(n)
6241        } else {
6242            requested_start
6243        };
6244        let mut nll = 0f64;
6245        let mut cnt = 0usize;
6246
6247        // Exact prompt pass over ids[..exact_end]: the seal consumes its
6248        // q/k/v. Rows at or after requested_start are scored here when the
6249        // bounded lead-in is longer than the caller's requested prefix.
6250        let mut pos = 0usize;
6251        if self.can_prefill_batched() {
6252            const CHUNK: usize = 128;
6253            while pos < exact_end {
6254                let end = (pos + CHUNK).min(exact_end);
6255                let hiddens = self.prefill_batch(&ids[pos..end], pos);
6256                if self
6257                    .graph_failed
6258                    .swap(false, std::sync::atomic::Ordering::Relaxed)
6259                {
6260                    self.cancel
6261                        .store(false, std::sync::atomic::Ordering::Relaxed);
6262                    self.nll_end();
6263                    return Err("GPU graph failed during O(1) NLL prefix".into());
6264                }
6265                for row in 0..end - pos {
6266                    let score_pos = pos + row;
6267                    if score_pos >= requested_start && score_pos < n {
6268                        nll += self.nll_from_hidden(
6269                            &hiddens[row * self.hidden_size..(row + 1) * self.hidden_size],
6270                            ids[score_pos + 1],
6271                            score_pos,
6272                        );
6273                        cnt += 1;
6274                    }
6275                }
6276                pos = end;
6277            }
6278        } else {
6279            while pos < exact_end {
6280                let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
6281                if self
6282                    .graph_failed
6283                    .swap(false, std::sync::atomic::Ordering::Relaxed)
6284                {
6285                    self.cancel
6286                        .store(false, std::sync::atomic::Ordering::Relaxed);
6287                    self.nll_end();
6288                    return Err("GPU graph failed during O(1) NLL prefix".into());
6289                }
6290                if pos >= requested_start && pos < n {
6291                    nll += self.nll_from_hidden(&hidden, ids[pos + 1], pos);
6292                    cnt += 1;
6293                }
6294                pos += 1;
6295            }
6296        }
6297        self.o1_seal_checked().map_err(|err| {
6298            self.nll_end();
6299            err
6300        })?;
6301
6302        // Reuse the production whole-token batch graph for the post-seal
6303        // suffix when the caller explicitly enabled both routes. This is a
6304        // teacher-forced scorer, so every row is ids[pos] and its target is
6305        // ids[pos + 1]; no speculative tail or rollback state is involved.
6306        // A first Declined is safe to handle with the established serial O(1)
6307        // path. Once a chunk completes, however, the device recurrent state
6308        // owns the sequence and a later decline must be terminal rather than
6309        // falling back to stale CPU state.
6310        let batch_k = std::env::var("CMF_BATCH_K")
6311            .ok()
6312            .and_then(|v| v.parse::<usize>().ok())
6313            .unwrap_or(0);
6314        let batch_admitted = batch_k > 0
6315            && self.can_prefill_batched()
6316            && self.o1_active()
6317            && std::env::var("CMF_O1_GPU").as_deref() == Ok("1")
6318            && (0..self.num_layers).all(|li| {
6319                let cache = &self.kv_cache.layers[self.phys_layer(li)];
6320                cache.o1.is_none() || cache.o1_views().is_some()
6321            });
6322        if std::env::var("CMF_GRAPH_PROF").is_ok() {
6323            eprintln!(
6324                "nll-batch: phase=post-seal admission={} requested_k={} scored_rows={}",
6325                batch_admitted,
6326                batch_k,
6327                n.saturating_sub(exact_end),
6328            );
6329        }
6330        let mut batch_completed = false;
6331        if batch_admitted && exact_end < n {
6332            let hs = self.hidden_size;
6333            let mut batch_pos = exact_end;
6334            while batch_pos < n {
6335                let end = (batch_pos + batch_k).min(n);
6336                let bk = end - batch_pos;
6337                let mut hiddens = vec![0.0f32; bk * hs];
6338                for (row, &id) in ids[batch_pos..end].iter().enumerate() {
6339                    hiddens[row * hs..(row + 1) * hs].copy_from_slice(&self.embed_single(id));
6340                }
6341                let positions: Vec<usize> = (batch_pos..end).collect();
6342                let t_batch = std::time::Instant::now();
6343                let outcome = self.try_batch_graph_wgpu(&mut hiddens, &positions, bk, None);
6344                if std::env::var("CMF_GRAPH_PROF").is_ok() {
6345                    let ms = t_batch.elapsed().as_secs_f64() * 1000.0;
6346                    eprintln!(
6347                        "nll-batch: phase=post-seal mode=o1 k={bk} pos={}..{} outcome={outcome:?} {ms:.1} ms ({:.1} tok/s)",
6348                        batch_pos,
6349                        end.saturating_sub(1),
6350                        bk as f64 / (ms / 1000.0),
6351                    );
6352                }
6353                if let Err(err) = self.nll_check_graph("batch graph", batch_pos) {
6354                    self.nll_end();
6355                    return Err(err);
6356                }
6357                match outcome {
6358                    crate::gpu::BatchGraphOutcome::Completed => {
6359                        batch_completed = true;
6360                        for row in 0..bk {
6361                            nll += self.nll_from_hidden(
6362                                &hiddens[row * hs..(row + 1) * hs],
6363                                ids[batch_pos + row + 1],
6364                                batch_pos + row,
6365                            );
6366                            cnt += 1;
6367                        }
6368                        batch_pos = end;
6369                    }
6370                    crate::gpu::BatchGraphOutcome::Declined => {
6371                        if batch_completed {
6372                            self.nll_end();
6373                            return Err(format!(
6374                                "O(1) NLL batch declined after completed chunk at position {batch_pos}"
6375                            ));
6376                        }
6377                        break;
6378                    }
6379                    crate::gpu::BatchGraphOutcome::Failed => {
6380                        self.nll_end();
6381                        return Err(format!(
6382                            "O(1) NLL batch graph failed after admission at position {batch_pos}"
6383                        ));
6384                    }
6385                }
6386            }
6387            if batch_completed && cnt == n.saturating_sub(requested_start) {
6388                self.nll_end();
6389                return Ok((nll, cnt));
6390            }
6391        }
6392
6393        // Serial O(1) fallback/reference. It is intentionally retained when
6394        // batch admission declines before mutation; callers must label this
6395        // CMF_BATCH_K=0/per-position path separately from the production
6396        // whole-token batch route.
6397        for pos in exact_end..n {
6398            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
6399            if self
6400                .graph_failed
6401                .swap(false, std::sync::atomic::Ordering::Relaxed)
6402            {
6403                self.cancel
6404                    .store(false, std::sync::atomic::Ordering::Relaxed);
6405                self.nll_end();
6406                return Err(format!(
6407                    "GPU graph failed during O(1) NLL serial scoring at position {pos}"
6408                ));
6409            }
6410            nll += self.nll_from_hidden(&hidden, ids[pos + 1], pos);
6411            cnt += 1;
6412        }
6413        self.nll_end();
6414        Ok((nll, cnt))
6415    }
6416
6417    /// Teacher-forced calibration data (B1): for each position, whether the
6418    /// argmax equals the actual next token, and the top-1 softmax prob
6419    /// (top-1 probability) under EACH temperature in `temps` — all from ONE forward
6420    /// pass (argmax/correctness are temperature-invariant; only p_max
6421    /// reshapes). Feeds `cortiq calibrate` (reliability/ECE + temperature
6422    /// fit): is the model's confidence a true property, or does it need a
6423    /// measured scaling?
6424    pub fn calib_ids(&mut self, ids: &[u32], temps: &[f32]) -> (Vec<bool>, Vec<Vec<f32>>) {
6425        self.clear_sequence_state();
6426        let n = ids.len().saturating_sub(1);
6427        let mut correct = Vec::with_capacity(n);
6428        let mut pmax = Vec::with_capacity(n);
6429        for pos in 0..n {
6430            let emb = self.embed_single(ids[pos]);
6431            let hidden = self.forward_layers(&emb, pos, None);
6432            let normed = inference::rms_norm(
6433                &hidden,
6434                &self.weights.final_norm,
6435                self.rms_eps,
6436                self.norm_style,
6437            );
6438            // lm_head_forward applies the final-logit softcap itself —
6439            // capping again here double-squashed gemma-class logits
6440            // (tanh∘tanh) and reported a flattered ppl.
6441            let logits = self.lm_head_forward(&normed);
6442            let target = ids[pos + 1] as usize;
6443            let (mut amax, mut mval) = (0usize, f32::NEG_INFINITY);
6444            for (i, &v) in logits.iter().enumerate() {
6445                if v > mval {
6446                    mval = v;
6447                    amax = i;
6448                }
6449            }
6450            correct.push(amax == target);
6451            let row: Vec<f32> = temps
6452                .iter()
6453                .map(|&t| {
6454                    let tt = t.max(1e-3);
6455                    let s: f32 = logits.iter().map(|&v| ((v - mval) / tt).exp()).sum();
6456                    1.0 / s.max(1e-12) // numerator at the max is exp(0)=1
6457                })
6458                .collect();
6459            pmax.push(row);
6460        }
6461        self.clear_sequence_state();
6462        (correct, pmax)
6463    }
6464
6465    /// Teacher-forced PPL with the dynamic router driving per-window
6466    /// skill switches (VMF experiment №2 measurement). Sequential (φ
6467    /// must update per token), returns (ppl, switch_count). The router
6468    /// must be enabled (`enable_dynamic_routing`); else this equals
6469    /// plain `ppl_ids`. The active skill when scoring token t shapes the
6470    /// logits for t+1 — on-policy over the held-out text itself.
6471    pub fn ppl_ids_dynamic(&mut self, ids: &[u32]) -> Result<(f64, usize), String> {
6472        if self.dyn_router.is_none() {
6473            return Ok((self.ppl_ids(ids)?, 0));
6474        }
6475        self.nll_begin()?;
6476        let saved_active = self.dyn_active;
6477        let mut router = self
6478            .dyn_router
6479            .take()
6480            .ok_or_else(|| "dynamic router disappeared before PPL scoring".to_string())?;
6481        router.reset();
6482        self.dyn_phi_seen = 0;
6483        let _ = self.set_active_skill(None);
6484
6485        let result: Result<(f64, usize), String> = (|| {
6486            let mut nll = 0f64;
6487            let mut cnt = 0usize;
6488            for pos in 0..ids.len().saturating_sub(1) {
6489                let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
6490                self.nll_check_graph("dynamic serial forward", pos)?;
6491                let out_of_band = self.graph_logits.take();
6492                let mut logits = match out_of_band {
6493                    Some(lg) => lg,
6494                    None => {
6495                        let normed = inference::rms_norm(
6496                            &hidden,
6497                            &self.weights.final_norm,
6498                            self.rms_eps,
6499                            self.norm_style,
6500                        );
6501                        // lm_head_forward applies the final-logit softcap itself —
6502                        // capping again here double-squashed gemma-class logits
6503                        // and reported a flattered ppl.
6504                        self.lm_head_forward(&normed)
6505                    }
6506                };
6507                let target = ids[pos + 1] as usize;
6508                let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
6509                let lse: f64 = logits
6510                    .iter()
6511                    .map(|&v| ((v - max) as f64).exp())
6512                    .sum::<f64>()
6513                    .ln()
6514                    + max as f64;
6515                let tok_nll = lse - logits[target] as f64;
6516                if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
6517                    let top = logits
6518                        .iter()
6519                        .enumerate()
6520                        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
6521                        .map(|(i, _)| i)
6522                        .unwrap_or(0);
6523                    eprintln!(
6524                        "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
6525                        logits[target], logits[top]
6526                    );
6527                }
6528                nll += tok_nll;
6529                cnt += 1;
6530                attention::recycle_buf(&mut logits);
6531                // Route on the evolving phi (drives the NEXT token's skill).
6532                let phi = self.dyn_phi_ema.clone();
6533                if let Some(new_active) = router.step(&phi, pos) {
6534                    let _ = self.set_active_skill(new_active);
6535                }
6536            }
6537            Ok(((nll / cnt.max(1) as f64).exp(), router.switches.len()))
6538        })();
6539
6540        // Restore the detached router and the active overlay on both success
6541        // and failure. The scoring state is cleared independently below.
6542        let _ = self.set_active_skill(saved_active);
6543        self.dyn_router = Some(router);
6544        self.nll_end();
6545        result
6546    }
6547
6548    /// Routing probe φ (spec §9): mean-pooled hidden after `layer`.
6549    pub fn probe_phi(&mut self, ids: &[u32], layer: usize) -> Vec<f32> {
6550        self.clear_sequence_state();
6551        let mut acc = vec![0f32; self.hidden_size];
6552        for (pos, &id) in ids.iter().enumerate() {
6553            let h = self.forward_layers_upto(&self.embed_single(id), pos, None, Some(layer));
6554            for (a, v) in acc.iter_mut().zip(&h) {
6555                *a += v;
6556            }
6557        }
6558        let n = ids.len().max(1) as f32;
6559        for a in acc.iter_mut() {
6560            *a /= n;
6561        }
6562        self.clear_sequence_state();
6563        acc
6564    }
6565
6566    /// Layer-major batched prefill (prefill-GEMM): full-attention —
6567    /// per-position with the existing operators (KV grows naturally,
6568    /// causality preserved), GDN projections / FFN / MoE — batched
6569    /// (a weight row is read from DRAM once per chunk, not per
6570    /// position). Returns the hidden of all positions [b × hidden].
6571    fn prefill_batch(&mut self, ids: &[u32], start_pos: usize) -> Vec<f32> {
6572        self.prefill_batch_masked(ids, start_pos, None)
6573    }
6574
6575    /// `prefill_batch` with a task mask honored on the dense-FFN panels
6576    /// (the masked-inference fast path: full fused compute, mask lands on
6577    /// the activations). The whole-chunk GPU graph is skipped for masked
6578    /// layers by the callers' arms; the per-GEMM device paths stay in
6579    /// play because the zeroing happens on the host between them.
6580    fn prefill_batch_masked(
6581        &mut self,
6582        ids: &[u32],
6583        start_pos: usize,
6584        task_mask: Option<&TaskMask>,
6585    ) -> Vec<f32> {
6586        self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, usize::MAX)
6587    }
6588
6589    /// The layer-major batched walk over a layer span [from..upto_excl):
6590    /// the whole prefill machinery (chunk graph, batched attends, GEMM
6591    /// panels) for a PARTIAL stack — the network split's prefill rides
6592    /// the same canon as the local one. Input is token ids (embeds
6593    /// itself, coordinator side) or ready boundary hiddens (worker side).
6594    fn prefill_batch_span(
6595        &mut self,
6596        input: PrefillIn<'_>,
6597        start_pos: usize,
6598        task_mask: Option<&TaskMask>,
6599        from: usize,
6600        upto_excl: usize,
6601    ) -> Vec<f32> {
6602        let hs = self.hidden_size;
6603        let b = match input {
6604            PrefillIn::Ids(ids) => ids.len(),
6605            PrefillIn::Hidden(hb) => hb.len() / hs,
6606        };
6607        let upto_excl = upto_excl.min(self.num_layers);
6608        // The CPU embed is deferred: when the chunk graph takes the run
6609        // from layer 0 it gathers the embeddings on the device instead.
6610        // A hidden input is ready by definition.
6611        let mut h: Vec<f32>;
6612        let mut h_ready;
6613        match input {
6614            PrefillIn::Ids(_) => {
6615                h = vec![0.0; b * hs];
6616                h_ready = false;
6617            }
6618            PrefillIn::Hidden(hb) => {
6619                h = hb.to_vec();
6620                h_ready = true;
6621            }
6622        }
6623        let fill_h = |h: &mut Vec<f32>, me: &Self| {
6624            if let PrefillIn::Ids(ids) = input {
6625                for (bi, &id) in ids.iter().enumerate() {
6626                    let e = me.embed_single(id);
6627                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&e);
6628                }
6629                if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
6630                    if let Ok(t) = tp.parse::<usize>() {
6631                        if t >= start_pos && t < start_pos + ids.len() {
6632                            let bi = t - start_pos;
6633                            let row = &h[bi * hs..(bi + 1) * hs];
6634                            let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
6635                            eprintln!(
6636                                "BATCH pos {t} embed: id {} |h| = {n:.6} h0 {:.6} h1 {:.6} | b={} start={start_pos} ids[..8]={:?}",
6637                                ids[bi],
6638                                row[0],
6639                                row[1],
6640                                ids.len(),
6641                                &ids[..ids.len().min(8)]
6642                            );
6643                        }
6644                    }
6645                }
6646            }
6647        };
6648        let (_nkv, _hd, _rd, eps) = (
6649            self.num_kv_heads,
6650            self.head_dim,
6651            self.rotary_dim,
6652            self.rms_eps,
6653        );
6654        let pool = self.pool.clone();
6655        let norm_style = self.norm_style;
6656        let automatic_gpu_prefix = self.automatic_gpu_prefix();
6657
6658        #[cfg(target_os = "macos")]
6659        let mut chunk_skip_until = 0usize;
6660        for li in from..upto_excl {
6661            let _capacity_tail = automatic_gpu_prefix
6662                .filter(|&prefix| li >= prefix)
6663                .map(|_| crate::gpu::enter_cpu_scope());
6664            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU
6665            // GPU chunk graph (default-on under CMF_GPU=1): a run of
6666            // consecutive eligible layers for the whole chunk in ONE
6667            // Metal submission — norm, QKV, RoPE with fused mirror
6668            // append, causal attend, O, FFN, hidden device-resident
6669            // across the run. Any refusal falls through to the CPU path.
6670            #[cfg(target_os = "macos")]
6671            if task_mask.is_none() {
6672                if li < chunk_skip_until {
6673                    continue;
6674                }
6675                // Device-side embedding needs a q8_row embedding matrix;
6676                // with any other layout the CPU fills `h` first and the
6677                // graph starts from a ready hidden (refusing the whole
6678                // run over the embedding alone kept q4t models — the
6679                // whole Nanbeige/Bonsai class — on the CPU prefill).
6680                if !h_ready && li == 0 && self.weights.embed_tokens.q8_row_parts().is_none() {
6681                    fill_h(&mut h, self);
6682                    h_ready = true;
6683                }
6684                let ids_for_embed = match input {
6685                    PrefillIn::Ids(ids) => (!h_ready && li == 0).then_some(ids),
6686                    PrefillIn::Hidden(_) => None,
6687                };
6688                let end = self.chunk_run_gpu(li, &mut h, b, start_pos, ids_for_embed, upto_excl);
6689                if end > li {
6690                    h_ready = true;
6691                    chunk_skip_until = end;
6692                    // Looped Transformer: the graph stopped at a loop
6693                    // boundary — apply final norm before the next iteration.
6694                    if self.is_loop_end(end - 1) && end < self.num_layers {
6695                        for bi in 0..b {
6696                            let normed = inference::rms_norm(
6697                                &h[bi * hs..(bi + 1) * hs],
6698                                &self.weights.final_norm,
6699                                eps,
6700                                norm_style,
6701                            );
6702                            h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
6703                        }
6704                    }
6705                    continue;
6706                }
6707            }
6708            if !h_ready {
6709                fill_h(&mut h, self);
6710                h_ready = true;
6711            }
6712            let lw = &self.weights.layers[self.phys_layer(li)];
6713            // ── attention ──
6714            match &lw.attn {
6715                AttnKind::Kda(w) => {
6716                    // Projections batched, recurrence sequential.
6717                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
6718                    let mut normed = vec![0.0f32; b * hs];
6719                    for bi in 0..b {
6720                        inference::rms_norm_into(
6721                            &h[bi * hs..(bi + 1) * hs],
6722                            &lw.input_norm,
6723                            eps,
6724                            norm_style,
6725                            &mut normed[bi * hs..(bi + 1) * hs],
6726                        );
6727                    }
6728                    let attn = crate::linear_core::kda_forward_batch(
6729                        &normed,
6730                        b,
6731                        w,
6732                        &cfg,
6733                        &mut self.kv_cache.layers[li].linear_state,
6734                        pool.as_deref(),
6735                    );
6736                    for (dst, &a) in h.iter_mut().zip(&attn) {
6737                        *dst += a;
6738                    }
6739                }
6740                AttnKind::LinearGdn(w) => {
6741                    // Projections batched, recurrence sequential.
6742                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
6743                    let mut normed = vec![0.0f32; b * hs];
6744                    for bi in 0..b {
6745                        let r = inference::rms_norm(
6746                            &h[bi * hs..(bi + 1) * hs],
6747                            &lw.input_norm,
6748                            eps,
6749                            norm_style,
6750                        );
6751                        normed[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
6752                    }
6753                    let attn = crate::linear_core::gdn_forward_batch(
6754                        &normed,
6755                        b,
6756                        w,
6757                        &cfg,
6758                        &mut self.kv_cache.layers[li].linear_state,
6759                        pool.as_deref(),
6760                    );
6761                    for (dst, &a) in h.iter_mut().zip(&attn) {
6762                        *dst += a;
6763                    }
6764                }
6765                AttnKind::ShortConv(w) => {
6766                    // Projections batched over the chunk; the conv walks the
6767                    // contiguous positions in order (same ring as decode).
6768                    let cfg = self
6769                        .short_conv_cfg
6770                        .expect("short-conv layer without short_conv_cfg");
6771                    let mut normed = vec![0.0f32; b * hs];
6772                    for bi in 0..b {
6773                        inference::rms_norm_into(
6774                            &h[bi * hs..(bi + 1) * hs],
6775                            &lw.input_norm,
6776                            eps,
6777                            norm_style,
6778                            &mut normed[bi * hs..(bi + 1) * hs],
6779                        );
6780                    }
6781                    let attn = short_conv_forward_batch(
6782                        &normed,
6783                        b,
6784                        w,
6785                        &cfg,
6786                        &mut self.kv_cache.layers[li].linear_state,
6787                        pool.as_deref(),
6788                    );
6789                    for (dst, &a) in h.iter_mut().zip(&attn) {
6790                        *dst += a;
6791                    }
6792                }
6793                AttnKind::Mla(w) => {
6794                    // Per-position prefill (correctness first; latent
6795                    // batching is a later optimization).
6796                    let inv_freq_l = self.layer_inv_freq(li);
6797                    let rs = self.layer_rope_scale(li);
6798                    let mut normed = vec![0.0f32; hs];
6799                    for bi in 0..b {
6800                        inference::rms_norm_into(
6801                            &h[bi * hs..(bi + 1) * hs],
6802                            &lw.input_norm,
6803                            eps,
6804                            norm_style,
6805                            &mut normed,
6806                        );
6807                        let ao = mla_attention(
6808                            w,
6809                            &normed,
6810                            &mut self.kv_cache.layers[li],
6811                            start_pos + bi,
6812                            &inv_freq_l,
6813                            rs,
6814                            eps,
6815                            pool.as_deref(),
6816                        );
6817                        for (dst, &a) in h[bi * hs..(bi + 1) * hs].iter_mut().zip(&ao) {
6818                            *dst += a;
6819                        }
6820                    }
6821                }
6822                AttnKind::Full {
6823                    wq,
6824                    wk,
6825                    wv,
6826                    wo,
6827                    q_norm,
6828                    k_norm,
6829                    output_gate,
6830                    softplus_gate,
6831                    bias,
6832                } => {
6833                    // Chunk-GEMM QKV/O; per-position causal attention
6834                    // inside (roadmap §3 P0 — full-attention prefill no
6835                    // longer re-reads the projection weights b times).
6836                    let mut normed = vec![0.0f32; b * hs];
6837                    for bi in 0..b {
6838                        inference::rms_norm_into(
6839                            &h[bi * hs..(bi + 1) * hs],
6840                            &lw.input_norm,
6841                            eps,
6842                            norm_style,
6843                            &mut normed[bi * hs..(bi + 1) * hs],
6844                        );
6845                    }
6846                    let inv_freq_l = self.layer_inv_freq(li);
6847                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
6848                    let cfg = QwenAttnCfg {
6849                        num_heads: self.layer_num_heads(li),
6850                        num_kv_heads: nkv_l,
6851                        head_dim: hd_l,
6852                        hidden_size: hs,
6853                        position: start_pos,
6854                        inv_freq: &inv_freq_l,
6855                        rotary_dim: rd_l,
6856                        scale: self.attn_scale,
6857                        softcap: self.attn_softcap,
6858                        window: self.layer_window(li),
6859                        v_norm: self.attn_v_norm,
6860                        q_norm: q_norm.as_deref(),
6861                        k_norm: k_norm.as_deref(),
6862                        output_gate: *output_gate,
6863                        softplus_gate: softplus_gate
6864                            .as_ref()
6865                            .map(|(gate, per_head)| (gate, *per_head)),
6866                        rope_scale: self.layer_rope_scale(li),
6867                        bias: bias
6868                            .as_ref()
6869                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
6870                        rms_eps: eps,
6871                        norm_style,
6872                        pool: pool.as_deref(),
6873                    };
6874                    let mut attn = attention::qwen_attention_batch(
6875                        &normed,
6876                        b,
6877                        wq,
6878                        wk,
6879                        wv,
6880                        wo,
6881                        &mut self.kv_cache.layers[li],
6882                        &cfg,
6883                    );
6884                    if let Some(w) = &lw.attn_out_norm {
6885                        for bi in 0..b {
6886                            inference::rms_norm_into(
6887                                &attn[bi * hs..(bi + 1) * hs],
6888                                w,
6889                                eps,
6890                                norm_style,
6891                                &mut normed[bi * hs..(bi + 1) * hs],
6892                            );
6893                        }
6894                        attn.copy_from_slice(&normed);
6895                    }
6896                    for (dst, &a) in h.iter_mut().zip(&attn) {
6897                        *dst += a;
6898                    }
6899                }
6900                AttnKind::Linear(w) => {
6901                    for bi in 0..b {
6902                        let normed = inference::rms_norm(
6903                            &h[bi * hs..(bi + 1) * hs],
6904                            &lw.input_norm,
6905                            eps,
6906                            norm_style,
6907                        );
6908                        vmf_phase_forward(
6909                            &normed,
6910                            w,
6911                            &self.vmf_cfg.expect("linear layer without vmf_cfg"),
6912                            &mut self.kv_cache.layers[li].linear_state,
6913                            pool.as_deref(),
6914                        )
6915                        .iter()
6916                        .enumerate()
6917                        .for_each(|(i, &a)| h[bi * hs + i] += a);
6918                    }
6919                }
6920            }
6921
6922            // ── FFN batched ──
6923            let lw = &self.weights.layers[self.phys_layer(li)];
6924            let mut post = vec![0.0f32; b * hs];
6925            for bi in 0..b {
6926                let r =
6927                    inference::rms_norm(&h[bi * hs..(bi + 1) * hs], &lw.post_norm, eps, norm_style);
6928                post[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
6929            }
6930            // A restrictive per-visit FFN row lands on the activations
6931            // inside the dense arm; an all-open row costs nothing.
6932            let mask_row = task_mask
6933                .filter(|m| m.ffn_active_count(li) < self.intermediate_size)
6934                .and_then(|m| m.ffn_masks.get(li))
6935                .map(|v| v.as_slice());
6936            let mut ffn = match &lw.ffn {
6937                FfnKind::Dense(d) if !d.segs.is_empty() => {
6938                    tube_ffn(d, &post, b, pool.as_deref(), mask_row)
6939                }
6940                FfnKind::Dense(d) => dense_ffn_batch(d, &post, b, pool.as_deref(), mask_row),
6941                FfnKind::Moe(m) => moe_ffn_batch(m, &post, b, hs, pool.as_deref(), None),
6942                // Dual-branch layers run per position (the expert branch
6943                // reads the raw residual — nothing to batch yet).
6944                FfnKind::DenseMoe(dm) => {
6945                    let mut out = vec![0.0f32; b * hs];
6946                    for bi in 0..b {
6947                        let r = dense_moe_ffn(
6948                            dm,
6949                            &post[bi * hs..(bi + 1) * hs],
6950                            &h[bi * hs..(bi + 1) * hs],
6951                            eps,
6952                            norm_style,
6953                            pool.as_deref(),
6954                        );
6955                        out[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
6956                    }
6957                    out
6958                }
6959            };
6960            if let Some(w) = &lw.ffn_out_norm {
6961                for bi in 0..b {
6962                    inference::rms_norm_into(
6963                        &ffn[bi * hs..(bi + 1) * hs],
6964                        w,
6965                        eps,
6966                        norm_style,
6967                        &mut post[bi * hs..(bi + 1) * hs],
6968                    );
6969                }
6970                ffn.copy_from_slice(&post);
6971            }
6972            for (dst, &f) in h.iter_mut().zip(&ffn) {
6973                *dst += f;
6974            }
6975            if let Some(sc) = lw.layer_scale {
6976                for v in h.iter_mut() {
6977                    *v *= sc;
6978                }
6979            }
6980            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
6981                if let Ok(t) = tp.parse::<usize>() {
6982                    if t >= start_pos && t < start_pos + b {
6983                        let bi = t - start_pos;
6984                        let row = &h[bi * hs..(bi + 1) * hs];
6985                        let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
6986                        eprintln!(
6987                            "BATCH pos {t} after layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
6988                            row[0], row[1]
6989                        );
6990                    }
6991                }
6992            }
6993            // CMF_DEBUG_LAYERS=1: per-layer hidden-state health of the
6994            // LAST prompt position — the knife for "which layer type
6995            // breaks first" on a new architecture.
6996            if std::env::var("CMF_DEBUG_LAYERS").is_ok() {
6997                let row = &h[(b - 1) * hs..b * hs];
6998                let rms =
6999                    (row.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / hs as f64).sqrt();
7000                let mx = row.iter().fold(0f32, |m, &v| m.max(v.abs()));
7001                eprintln!(
7002                    "layer {li:>3} {:>10} ffn={:<5} rms={rms:>12.4} max={mx:>12.4}",
7003                    match &self.weights.layers[self.phys_layer(li)].attn {
7004                        AttnKind::LinearGdn(_) => "gdn",
7005                        AttnKind::Linear(_) => "vmf",
7006                        AttnKind::ShortConv(_) => "conv",
7007                        _ => "attn",
7008                    },
7009                    match &lw.ffn {
7010                        FfnKind::Moe(_) => "moe",
7011                        FfnKind::Dense(_) => "dense",
7012                        FfnKind::DenseMoe(_) => "dense+moe",
7013                    },
7014                );
7015            }
7016            // Looped Transformer: apply final norm at the end of each loop iteration.
7017            if self.is_loop_end(li) && li + 1 < self.num_layers {
7018                for bi in 0..b {
7019                    let normed = inference::rms_norm(
7020                        &h[bi * hs..(bi + 1) * hs],
7021                        &self.weights.final_norm,
7022                        eps,
7023                        norm_style,
7024                    );
7025                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
7026                }
7027            }
7028            if std::env::var("CMF_TRACE_H").is_ok() {
7029                let n = h[..hs].iter().map(|v| v.abs()).sum::<f32>() / hs as f32;
7030                let mx = h[..hs].iter().fold(0.0f32, |a, &v| a.max(v.abs()));
7031                eprintln!(
7032                    "layer {li}: mean|h|={n:.4} max|h|={mx:.2} scale={:?}",
7033                    lw.layer_scale
7034                );
7035            }
7036        }
7037        crate::gpu::set_layer(-1); // lm_head/final ops outside layer-split
7038        // A batched span owns a complete set of positions. Publish any
7039        // collecting→sealed transition only after every layer has finished;
7040        // callers that cross into serial/device work must see the new epoch
7041        // before this function returns.
7042        self.o1_progress();
7043        h
7044    }
7045
7046    /// Embed a single token.
7047    fn embed_single(&self, id: u32) -> Vec<f32> {
7048        let mut out = vec![0.0f32; self.hidden_size];
7049        if (id as usize) < self.weights.embed_tokens.rows() {
7050            self.weights.embed_tokens.row_f32(id as usize, &mut out);
7051        }
7052        if self.embed_multiplier != 1.0 {
7053            for v in out.iter_mut() {
7054                *v *= self.embed_multiplier;
7055            }
7056        }
7057        // DeepSeek-V4's hash layers route by TOKEN ID, so the id has to
7058        // reach the forward. It rides in slot 0 (the forward re-reads the
7059        // real embedding itself from the table).
7060        if self.dsv4.is_some() || self.dsv41.is_some() || self.qwen4_exp.is_some() {
7061            let mut v = vec![0.0f32; self.hidden_size.max(1)];
7062            v[0] = id as f32;
7063            return v;
7064        }
7065        // Gemma-3n: the per-layer-embedding half needs the token ID, so
7066        // it rides appended to the embedding; the g3n forward splits it.
7067        if let Some(b) = &self.g3n {
7068            return b.0.extend_embedding(id, &out, self.pool.as_deref());
7069        }
7070        out
7071    }
7072
7073    /// A run of consecutive prefill layers on the GPU for the whole
7074    /// chunk (default-on under CMF_GPU=1; CMF_GPU_CHUNK=0 disables).
7075    /// Eligibility per layer: q8_row weights, plain full attention
7076    /// (no output gate), F32 KV, no o1/masks/gemma extras. Returns the
7077    /// first layer index NOT processed (== `li0` when the run is empty).
7078    #[cfg(target_os = "macos")]
7079    fn chunk_run_gpu(
7080        &mut self,
7081        li0: usize,
7082        h: &mut [f32],
7083        b: usize,
7084        pos0: usize,
7085        embed_ids: Option<&[u32]>,
7086        cap: usize,
7087    ) -> usize {
7088        // (The old streaming attend needed a depth bound at ~1k; the
7089        // GEMM attention scales like the CPU path and lifted it.)
7090        // CMF_GPU_CHUNK=0 disables the graph.
7091        if !crate::gpu::enabled_here()
7092            || std::env::var("CMF_GPU_CHUNK")
7093                .map(|v| v == "0")
7094                .unwrap_or(false)
7095            || b < 32
7096            || self.swa.is_some()
7097            || self.global_attn.is_some()
7098            // Collection owns the exact Q trace and boundary conversion;
7099            // this chunk graph appends dense KV without feeding that trace.
7100            || self.o1_active()
7101            || self.attn_v_norm
7102            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
7103        {
7104            return li0;
7105        }
7106        let Some(model) = self.model.clone() else {
7107            return li0;
7108        };
7109        let inv_freq = self.inv_freq.clone();
7110        let (nh, nkv, hd, hs) = (
7111            self.num_heads,
7112            self.num_kv_heads,
7113            self.head_dim,
7114            self.hidden_size,
7115        );
7116        // Collect the longest run of consecutive eligible layers.
7117        // Looped Transformer: stop at the loop boundary so the CPU can
7118        // apply loop_final_norm between iterations.
7119        let loop_end = if self.loop_final_norm {
7120            ((li0 / self.physical_layers) + 1) * self.physical_layers
7121        } else {
7122            self.num_layers
7123        };
7124        let mut layers: Vec<crate::gpu_metal::ChunkLayer> = Vec::new();
7125        let mut stored_at: Vec<usize> = Vec::new();
7126        for li in li0..self.num_layers.min(loop_end).min(cap) {
7127            let lw = &self.weights.layers[self.phys_layer(li)];
7128            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
7129                break;
7130            }
7131            let AttnKind::Full {
7132                wq,
7133                wk,
7134                wv,
7135                wo,
7136                q_norm,
7137                k_norm,
7138                output_gate: false,
7139                softplus_gate: None,
7140                bias,
7141            } = &lw.attn
7142            else {
7143                break;
7144            };
7145            let FfnKind::Dense(d) = &lw.ffn else { break };
7146            if d.act != Act::Silu || !d.segs.is_empty() {
7147                break;
7148            }
7149            // q8_row (row_scale populated), or q4_tiled / q4tp (row_scale
7150            // empty — their scales are in the payload). Mixing across the
7151            // seven projections of one layer is fine; the encoder branches
7152            // per weight on the tensor's dtype. Anything else refuses.
7153            fn cw(t: &QTensor) -> Option<(usize, usize, usize, &[f32])> {
7154                t.q8_row_parts()
7155                    .or_else(|| t.q4t_parts().map(|(i, r, c)| (i, r, c, &[][..])))
7156                    .or_else(|| t.q4tp_parts().map(|(i, r, c)| (i, r, c, &[][..])))
7157            }
7158            let parts = (
7159                cw(wq),
7160                cw(wk),
7161                cw(wv),
7162                cw(wo),
7163                cw(&d.gate_proj),
7164                cw(&d.up_proj),
7165                cw(&d.down_proj),
7166            );
7167            let (Some(pq), Some(pk), Some(pv), Some(po), Some(pg), Some(pu), Some(pd)) = parts
7168            else {
7169                break;
7170            };
7171            let layer = &self.kv_cache.layers[li];
7172            if layer.mode != crate::kv_cache::KvMode::F32 || layer.o1.is_some() {
7173                break;
7174            }
7175            stored_at.push(layer.head_len(0));
7176            layers.push(crate::gpu_metal::ChunkLayer {
7177                model: &model,
7178                kv_id: self.graph_kv_id,
7179                layer: li,
7180                wq: pq,
7181                wk: pk,
7182                wv: pv,
7183                wo: po,
7184                gate: pg,
7185                up: pu,
7186                down: pd,
7187                input_norm: &lw.input_norm,
7188                post_norm: &lw.post_norm,
7189                bias: bias
7190                    .as_ref()
7191                    .map(|(a, bb, cc)| (a.as_slice(), bb.as_slice(), cc.as_slice())),
7192                q_norm: q_norm.as_deref(),
7193                k_norm: k_norm.as_deref(),
7194                inv_freq: &inv_freq,
7195                rd: self.rotary_dim,
7196                nh,
7197                nkv,
7198                hd,
7199                hs,
7200                inter: d.gate_proj.rows(),
7201                gemma: matches!(self.norm_style, cortiq_core::NormStyle::Gemma),
7202                eps: self.rms_eps as f32,
7203            });
7204        }
7205        if layers.is_empty() {
7206            return li0;
7207        }
7208        let row = nkv * hd;
7209        let mut store: Vec<(Vec<f32>, Vec<f32>, Vec<f32>)> = stored_at
7210            .iter()
7211            .map(|&st| (vec![0f32; b * row], vec![0f32; b * row], vec![0f32; st + b]))
7212            .collect();
7213        let mut io: Vec<crate::gpu_metal::ChunkIo> = Vec::with_capacity(layers.len());
7214        for (i, (ok, ov, oi)) in store.iter_mut().enumerate() {
7215            let li = layers[i].layer;
7216            let layer = &self.kv_cache.layers[li];
7217            io.push(crate::gpu_metal::ChunkIo {
7218                cpu_stored: stored_at[i],
7219                cpu_k: (0..nkv).map(|g| layer.head_keys(g)).collect(),
7220                cpu_v: (0..nkv).map(|g| layer.head_values(g)).collect(),
7221                out_k: ok,
7222                out_v: ov,
7223                imp: oi,
7224            });
7225        }
7226        let n_run = layers.len();
7227        let last = layers.last().map(|l| l.layer + 1).unwrap_or(li0);
7228        // Device-side embedding when the run starts the model and the
7229        // embedding matrix is q8_row-mapped.
7230        let ep = embed_ids.and_then(|ids| {
7231            self.weights
7232                .embed_tokens
7233                .q8_row_parts()
7234                .map(|(idx, rows, _c, rs)| crate::gpu_metal::ChunkEmbed {
7235                    idx,
7236                    rows,
7237                    row_scale: rs,
7238                    ids,
7239                    mult: self.embed_multiplier,
7240                })
7241        });
7242        if embed_ids.is_some() && ep.is_none() {
7243            return li0;
7244        }
7245        if !crate::gpu_metal::chunk_run_gpu(&layers, &mut io, h, b, pos0, ep.as_ref()) {
7246            return li0;
7247        }
7248        drop(io);
7249        drop(layers);
7250        // CPU caches stay the owners of record: append the chunk rows
7251        // and bank the importance masses per layer.
7252        for (i, (ok, ov, oi)) in store.iter().enumerate().take(n_run) {
7253            let li = li0 + i;
7254            let layer = &mut self.kv_cache.layers[li];
7255            for bi in 0..b {
7256                layer.append(
7257                    &ok[bi * row..(bi + 1) * row],
7258                    &ov[bi * row..(bi + 1) * row],
7259                    &[],
7260                );
7261            }
7262            layer.accumulate_imp(oi);
7263        }
7264        last
7265    }
7266
7267    /// Is layer `li` a sliding-window (local-RoPE) layer? Gemma-3:
7268    /// every `pattern`-th layer is global, the rest are local.
7269    fn layer_is_local(&self, li: usize) -> bool {
7270        if let Some(layers) = &self.sliding_layers {
7271            return layers.get(li).copied().unwrap_or(false);
7272        }
7273        match self.swa {
7274            Some((_, pattern)) => (li + 1) % pattern.max(1) != 0,
7275            None => false,
7276        }
7277    }
7278
7279    /// The RoPE table for layer `li` (local layers may have their own;
7280    /// Gemma-4 global layers use the proportional padded table).
7281    fn layer_inv_freq(&self, li: usize) -> std::sync::Arc<Vec<f32>> {
7282        if self.layer_is_local(li) {
7283            if let Some(f) = &self.inv_freq_local {
7284                return f.clone();
7285            }
7286        } else if let Some(f) = &self.inv_freq_global {
7287            return f.clone();
7288        }
7289        self.inv_freq.clone()
7290    }
7291
7292    /// The attend window for layer `li` (None = full context).
7293    fn layer_window(&self, li: usize) -> Option<usize> {
7294        self.swa
7295            .and_then(|(w, _)| self.layer_is_local(li).then_some(w))
7296    }
7297
7298    fn layer_num_heads(&self, li: usize) -> usize {
7299        self.attention_heads_per_layer
7300            .as_ref()
7301            .and_then(|v| v.get(li).copied())
7302            .unwrap_or(self.num_heads)
7303    }
7304
7305    fn layer_rope_scale(&self, li: usize) -> f32 {
7306        if self.layer_is_local(li) {
7307            self.rope_scale_local
7308        } else {
7309            self.rope_scale
7310        }
7311    }
7312
7313    /// Attention geometry of layer `li`: (num_kv_heads, head_dim,
7314    /// rotary_dim). Gemma-4 global layers override all three.
7315    fn layer_geom(&self, li: usize) -> (usize, usize, usize) {
7316        if !self.layer_is_local(li) {
7317            if let Some((ghd, gkv)) = self.global_attn {
7318                return (gkv, ghd, ghd);
7319            }
7320        }
7321        (
7322            self.num_kv_heads,
7323            self.head_dim,
7324            if self.layer_is_local(li) {
7325                self.rotary_dim_local.unwrap_or(self.rotary_dim)
7326            } else {
7327                self.rotary_dim
7328            },
7329        )
7330    }
7331
7332    /// Forward one position through all layers (hybrid dispatch).
7333    fn forward_layers(
7334        &mut self,
7335        hidden: &[f32],
7336        position: usize,
7337        task_mask: Option<&TaskMask>,
7338    ) -> Vec<f32> {
7339        let out = self.forward_layers_upto(hidden, position, task_mask, None);
7340        self.o1_progress();
7341        out
7342    }
7343
7344    // ── Network pipeline-split building blocks (coordinator/worker) ──
7345    // A remote worker owns layers [from ..= upto] and their KV; the
7346    // coordinator owns the rest plus embed / final norm / head. Attention
7347    // causality is per-layer, so a whole prompt's boundary hiddens ship
7348    // as one batch and decode ships one vector per token.
7349
7350    /// Embed one token id (embed multiplier applied).
7351    pub fn embed_id(&self, id: u32) -> Vec<f32> {
7352        self.embed_single(id)
7353    }
7354
7355    /// Refuse the archs/modes whose forward cannot be cut at a layer
7356    /// boundary. Loud by design: a split that silently changed the math
7357    /// would be a chimera.
7358    pub fn split_supported(&self) -> Result<(), String> {
7359        if self.dsv4.is_some() {
7360            return Err(
7361                "network split: DeepSeek-V4 runs its own fused stack (not splittable yet)".into(),
7362            );
7363        }
7364        if self.dsv41.is_some() {
7365            return Err(
7366                "network split: DeepSeek-V4.1 owns the shared CED/CSA2 state (not splittable)"
7367                    .into(),
7368            );
7369        }
7370        if self.qwen4_exp.is_some() {
7371            return Err(
7372                "network split: Qwen3.8-Flash-Next hyper/QSA stack is not splittable yet".into(),
7373            );
7374        }
7375        if self.g3n.is_some() {
7376            return Err(
7377                "network split: Gemma-3n runs its own AltUp stack (not splittable yet)".into(),
7378            );
7379        }
7380        Ok(())
7381    }
7382
7383    /// Forward `hidden` through layers [from ..= upto] at `position`,
7384    /// appending those layers' KV/state. Both split sides call this
7385    /// over their own range; a task mask applies to the span's own
7386    /// layers (each side masks what it runs).
7387    pub fn forward_span(
7388        &mut self,
7389        hidden: &[f32],
7390        position: usize,
7391        from: usize,
7392        upto: usize,
7393        task_mask: Option<&TaskMask>,
7394    ) -> Result<Vec<f32>, String> {
7395        self.split_supported()?;
7396        if from > upto || upto >= self.num_layers {
7397            return Err(format!(
7398                "forward_span: layer range {from}..={upto} outside 0..{}",
7399                self.num_layers
7400            ));
7401        }
7402        if hidden.len() != self.hidden_size {
7403            return Err(format!(
7404                "forward_span: hidden len {} ≠ hidden_size {}",
7405                hidden.len(),
7406                self.hidden_size
7407            ));
7408        }
7409        let out = self.forward_layers_span(hidden, position, task_mask, from, Some(upto));
7410        self.o1_progress();
7411        if self
7412            .graph_failed
7413            .swap(false, std::sync::atomic::Ordering::Relaxed)
7414        {
7415            self.cancel
7416                .store(false, std::sync::atomic::Ordering::Relaxed);
7417            self.clear_sequence_state();
7418            return Err("forward_span: deferred O(1) transition failed".into());
7419        }
7420        Ok(out)
7421    }
7422
7423    /// Final norm + lm_head over a boundary hidden (the final-logit
7424    /// softcap is applied by lm_head_forward itself).
7425    pub fn logits_from_hidden(&mut self, hidden: &[f32]) -> Vec<f32> {
7426        let normed = inference::rms_norm(
7427            hidden,
7428            &self.weights.final_norm,
7429            self.rms_eps,
7430            self.norm_style,
7431        );
7432        self.lm_head_forward(&normed)
7433    }
7434
7435    /// Sample the next token with this pipeline's sampler state.
7436    pub fn sample_next(&mut self, logits: &[f32], past_tokens: &[u32]) -> u32 {
7437        sampler::sample_with_scratch(
7438            logits,
7439            &self.sampler_config,
7440            past_tokens,
7441            &mut self.rng,
7442            &mut self.sampler_scratch,
7443        )
7444    }
7445
7446    /// Fresh sequence: clear KV, reuse history and device mirrors.
7447    pub fn reset_session(&mut self) {
7448        self.clear_sequence_state();
7449    }
7450
7451    /// Batched span prefill from token ids (coordinator side): embed +
7452    /// layers [0 ..= upto]; returns the boundary hiddens of ALL positions
7453    /// (ids.len() × hidden). Rides the same layer-major machinery as the
7454    /// local prefill; falls back to the per-position walk under
7455    /// CMF_PREFILL=seq.
7456    pub fn prefill_span_ids(
7457        &mut self,
7458        ids: &[u32],
7459        start_pos: usize,
7460        upto: usize,
7461        task_mask: Option<&TaskMask>,
7462    ) -> Result<Vec<f32>, String> {
7463        self.split_supported()?;
7464        if upto >= self.num_layers {
7465            return Err(format!(
7466                "prefill_span_ids: upto {upto} outside 0..{}",
7467                self.num_layers
7468            ));
7469        }
7470        // Same predicate as the whole-stack prefill: a span whose GDN
7471        // state lives on the device must walk positions through the
7472        // graph, not through the batched CPU span.
7473        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
7474            let out =
7475                self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, upto + 1);
7476            self.check_o1_progress_failure("prefill_span_ids")?;
7477            Ok(out)
7478        } else {
7479            let hs = self.hidden_size;
7480            let mut out = Vec::with_capacity(ids.len() * hs);
7481            for (i, &id) in ids.iter().enumerate() {
7482                let emb = self.embed_id(id);
7483                out.extend_from_slice(&self.forward_span(
7484                    &emb,
7485                    start_pos + i,
7486                    0,
7487                    upto,
7488                    task_mask,
7489                )?);
7490            }
7491            Ok(out)
7492        }
7493    }
7494
7495    /// Batched span prefill from boundary hiddens (worker side): layers
7496    /// [from ..= upto] for every position in the batch; returns the batch.
7497    pub fn prefill_span_hidden(
7498        &mut self,
7499        hidden: &[f32],
7500        start_pos: usize,
7501        from: usize,
7502        upto: usize,
7503        task_mask: Option<&TaskMask>,
7504    ) -> Result<Vec<f32>, String> {
7505        self.split_supported()?;
7506        let hs = self.hidden_size;
7507        if hidden.is_empty() || hidden.len() % hs != 0 {
7508            return Err(format!(
7509                "prefill_span_hidden: {} floats is not a multiple of hidden {hs}",
7510                hidden.len()
7511            ));
7512        }
7513        if from > upto || upto >= self.num_layers {
7514            return Err(format!(
7515                "prefill_span_hidden: layer range {from}..={upto} outside 0..{}",
7516                self.num_layers
7517            ));
7518        }
7519        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
7520            let out = self.prefill_batch_span(
7521                PrefillIn::Hidden(hidden),
7522                start_pos,
7523                task_mask,
7524                from,
7525                upto + 1,
7526            );
7527            self.check_o1_progress_failure("prefill_span_hidden")?;
7528            Ok(out)
7529        } else {
7530            let b = hidden.len() / hs;
7531            let mut out = Vec::with_capacity(hidden.len());
7532            for i in 0..b {
7533                let h = self.forward_span(
7534                    &hidden[i * hs..(i + 1) * hs],
7535                    start_pos + i,
7536                    from,
7537                    upto,
7538                    task_mask,
7539                )?;
7540                out.extend_from_slice(&h);
7541            }
7542            Ok(out)
7543        }
7544    }
7545
7546    /// Build the whole-token wgpu graph for a pure-attention q1 model (every
7547    /// layer Full q1 + dense q1 FFN, no gate/bias). Returns the post-stack
7548    /// hidden (caller does final norm + lm_head), or None to fall back.
7549    fn try_token_graph_wgpu(
7550        &self,
7551        hidden: &[f32],
7552        position: usize,
7553        logits_out: &mut Vec<f32>,
7554        layers_run: &mut usize,
7555    ) -> Option<Result<Vec<f32>, ()>> {
7556        self.try_token_graph_wgpu_steps(
7557            hidden,
7558            position,
7559            logits_out,
7560            1,
7561            None,
7562            Some(layers_run),
7563            0,
7564            self.num_layers,
7565        )
7566    }
7567
7568    /// The span twin (network split): the graph covers [from..upto_excl)
7569    /// — one submit per SEGMENT per token. lm_head folds in only when
7570    /// the span reaches the last layer.
7571    fn try_token_graph_wgpu_span(
7572        &self,
7573        hidden: &[f32],
7574        position: usize,
7575        logits_out: &mut Vec<f32>,
7576        from: usize,
7577        upto_excl: usize,
7578        layers_run: &mut usize,
7579    ) -> Option<Result<Vec<f32>, ()>> {
7580        self.try_token_graph_wgpu_steps(
7581            hidden,
7582            position,
7583            logits_out,
7584            1,
7585            None,
7586            Some(layers_run),
7587            from,
7588            upto_excl,
7589        )
7590    }
7591
7592    /// Greedy burst: forward `t_next` and let the device pick + re-embed
7593    /// the next k−1 tokens — k frames, ONE submit, k ids back. The ZML
7594    /// trade, on wgpu. None ⇒ caller keeps the per-token path.
7595    fn try_multi_burst(&self, t_next: u32, position: usize, k: usize) -> Option<Vec<u32>> {
7596        if self.o1_active() || self.attn_softcap > 0.0 {
7597            return None;
7598        }
7599        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
7600        if !graph_on || crate::gpu::graph_unsupported() {
7601            // Same memo as the decode site: this path builds the very
7602            // same graph, so a model it cannot build for must not be
7603            // walked again here either. Missing this guard was worth
7604            // 2.5x on an Adreno — 0.361 tok/s against 0.905 — because
7605            // the burst retried per token what decode had already given
7606            // up on.
7607            return None;
7608        }
7609        let emb = self.embed_single(t_next);
7610        let mut lg = Vec::new();
7611        let mut ids = Vec::new();
7612        match self.try_token_graph_wgpu_steps(
7613            &emb,
7614            position,
7615            &mut lg,
7616            k,
7617            Some(&mut ids),
7618            None,
7619            0,
7620            self.num_layers,
7621        ) {
7622            Some(Ok(_)) => {}
7623            Some(Err(())) => {
7624                // Preserve the backend's post-admission failure through the
7625                // Option-based burst API.  The decode caller consumes this
7626                // flag and clears the sequence instead of falling through
7627                // to a stale CPU recurrent state.
7628                self.graph_failed
7629                    .store(true, std::sync::atomic::Ordering::Relaxed);
7630                return None;
7631            }
7632            None => return None,
7633        }
7634        (ids.len() == k).then_some(ids)
7635    }
7636
7637    /// Multi-step greedy: k whole frames in ONE submit, argmax and re-embed
7638    /// on the device. `ids_out` receives the k winner ids; the hidden/logits
7639    /// outputs are NOT produced in that mode.
7640    fn try_token_graph_wgpu_steps(
7641        &self,
7642        hidden: &[f32],
7643        position: usize,
7644        logits_out: &mut Vec<f32>,
7645        steps: usize,
7646        ids_out: Option<&mut Vec<u32>>,
7647        layers_run: Option<&mut usize>,
7648        from: usize,
7649        upto_excl: usize,
7650    ) -> Option<Result<Vec<f32>, ()>> {
7651        // O(1) Nyström decode runs off the sealed state, not the KV cache the
7652        // graph mirrors — never take the graph while o1 is active.
7653        let o1_gpu = std::env::var("CMF_O1_GPU").as_deref() == Ok("1");
7654        if (self.o1_active() && !o1_gpu) || self.attn_softcap > 0.0 {
7655            // Softcapped scores have no graph kernel yet — CPU owns them.
7656            // o1 rides the graph only behind CMF_O1_GPU=1 while the port
7657            // proves itself; without it the CPU path owns o1 as before.
7658            return None;
7659        }
7660        // Per-layer sealed o1 state for the graph. During prefill the
7661        // state is still Collecting -> views are None -> the graph
7662        // refuses below and the CPU prefill records the q trace and
7663        // seals, exactly as the o1 design requires.
7664        let o1_views: Vec<Option<Vec<crate::nystrom::O1DeviceView<'_>>>> = (from..upto_excl)
7665            .map(|li| {
7666                if !o1_gpu {
7667                    return None;
7668                }
7669                self.kv_cache.layers[self.phys_layer(li)].o1_views()
7670            })
7671            .collect();
7672        if self.o1_active() && o1_gpu {
7673            // Any o1 layer not sealed (or degenerate exact-only) keeps the
7674            // whole token on the CPU: half-graph forwards would desync.
7675            let want: usize = (from..upto_excl)
7676                .filter(|li| self.kv_cache.layers[self.phys_layer(*li)].o1.is_some())
7677                .count();
7678            let have = o1_views.iter().filter(|v| v.is_some()).count();
7679            if want == 0 || have != want {
7680                // The silent twin of the gpu-side o1 gates, found the
7681                // same way: a 15x decode drop with an empty log. Views
7682                // stay None until the layer's state SEALS, so `have`
7683                // lagging `want` early in a run is the o1 design working
7684                // — but it must say so, or the next reader spends a
7685                // night proving the kernels innocent.
7686                // On CHANGE, not once: the first decline is the legal
7687                // unsealed prefill, and a once-print buries the state
7688                // that matters — what the count reads AFTER the seal.
7689                use std::sync::atomic::{AtomicUsize, Ordering};
7690                static LAST: AtomicUsize = AtomicUsize::new(usize::MAX);
7691                let code = have * 1000 + want;
7692                if LAST.swap(code, Ordering::Relaxed) != code {
7693                    tracing::warn!(
7694                        "o1 graph: {have} of {want} layers sealed — per-op until all seal"
7695                    );
7696                }
7697                return None;
7698            }
7699        }
7700        let nh = self.num_heads;
7701        let (nkv, hd, rd) = self.layer_geom(0);
7702        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
7703        let mut layers = Vec::with_capacity(upto_excl - from);
7704        let mut model = None;
7705        let dbg = std::env::var("CMF_GRAPH_DEBUG").is_ok();
7706        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
7707            if let Some((_, i, kind, rs)) = t.graph_weight() {
7708                return Some(crate::gpu::GraphW {
7709                    idx: i,
7710                    kind,
7711                    row_scale: rs,
7712                    data: &[],
7713                });
7714            }
7715            // Small unquantized projections (GDN in_proj_a/b) stay f32.
7716            t.as_f32().map(|d| crate::gpu::GraphW {
7717                idx: 0,
7718                kind: 4,
7719                row_scale: &[],
7720                data: d,
7721            })
7722        }
7723        for li in from..upto_excl {
7724            let lw = &self.weights.layers[self.phys_layer(li)];
7725            if dbg {
7726                let ak = match &lw.attn {
7727                    AttnKind::Mla(_) => "Mla".into(),
7728                    AttnKind::Full {
7729                        output_gate, bias, ..
7730                    } => format!("Full gate={output_gate} bias={}", bias.is_some()),
7731                    AttnKind::LinearGdn(_) => "LinearGdn".into(),
7732                    AttnKind::Kda(_) => "Kda".into(),
7733                    AttnKind::Linear(_) => "Linear".into(),
7734                    AttnKind::ShortConv(_) => "ShortConv".into(),
7735                };
7736                let fk = match &lw.ffn {
7737                    FfnKind::Dense(_) => "Dense",
7738                    FfnKind::Moe(_) => "Moe",
7739                    FfnKind::DenseMoe(_) => "DenseMoe",
7740                };
7741                eprintln!("graph L{li}: attn={ak} ffn={fk}");
7742            }
7743            let gffn = match &lw.ffn {
7744                FfnKind::DenseMoe(_) => return None, // dual branch: CPU path
7745                // A tube layer is several matrices, not one — the
7746                // whole-layer graph has no shape for it yet.
7747                FfnKind::Dense(d) if !d.segs.is_empty() => return None,
7748                FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
7749                    gate: gw(&d.gate_proj)?,
7750                    up: gw(&d.up_proj)?,
7751                    down: gw(&d.down_proj)?,
7752                },
7753                FfnKind::Moe(m) => {
7754                    // Adaptive τ and expert masks keep the CPU path, where
7755                    // they are implemented; so does a routed scale ≠ 1 (rare,
7756                    // and folding it into the select kernel is not written).
7757                    // Sigmoid routing with a selection bias (LFM2-MoE /
7758                    // DeepSeek noaux_tc) IS graphed — before it was, every
7759                    // LFM2-MoE token fell to the per-op path whole.
7760                    if m.route_tau.is_some()
7761                        || m.mask.is_some()
7762                        || (m.routed_scaling - 1.0).abs() > 1e-9
7763                    {
7764                        return None;
7765                    }
7766                    let shared = m.shared.as_ref();
7767                    let has_shared = shared.is_some();
7768                    let sgate = match shared {
7769                        Some((_, sg)) => gw(sg.as_ref()?)?,
7770                        // Unused by the kernel when has_shared is false; the
7771                        // router weight stands in so the plumbing stays total.
7772                        None => gw(&m.router)?,
7773                    };
7774                    let router = gw(&m.router)?;
7775                    let inter = m.experts.first()?.gate_proj.rows();
7776                    let mut experts = Vec::with_capacity(m.experts.len() + 1);
7777                    // q4t or q4tp, but not both in one layer — the kernels
7778                    // are picked per layer, not per expert.
7779                    let mut q4tp: Option<bool> = None;
7780                    // The mixed 2-bit profile: q2tp gate/up over a q4tp
7781                    // down. Uniform across the layer, like `q4tp` itself.
7782                    let mut gu_q2: Option<bool> = None;
7783                    for e in m.experts.iter().chain(shared.map(|(se, _)| se)) {
7784                        if !matches!(e.act, Act::Silu)
7785                            || e.gate_proj.rows() != inter
7786                            || e.up_proj.rows() != inter
7787                        {
7788                            return None;
7789                        }
7790                        let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
7791                            Some((mm, gi)) => (
7792                                mm,
7793                                gi,
7794                                e.up_proj.mapped_q4t()?.1,
7795                                e.down_proj.mapped_q4t()?.1,
7796                                false,
7797                                false,
7798                            ),
7799                            None => match e.gate_proj.mapped_q2tp() {
7800                                Some((mm, gi)) => (
7801                                    mm,
7802                                    gi,
7803                                    e.up_proj.mapped_q2tp()?.1,
7804                                    e.down_proj.mapped_q4tp()?.1,
7805                                    true,
7806                                    true,
7807                                ),
7808                                None => {
7809                                    let (mm, gi) = e.gate_proj.mapped_q4tp()?;
7810                                    (
7811                                        mm,
7812                                        gi,
7813                                        e.up_proj.mapped_q4tp()?.1,
7814                                        e.down_proj.mapped_q4tp()?.1,
7815                                        true,
7816                                        false,
7817                                    )
7818                                }
7819                            },
7820                        };
7821                        if *q4tp.get_or_insert(is_p) != is_p || *gu_q2.get_or_insert(is_q2) != is_q2
7822                        {
7823                            // The shared expert rides in the same packed
7824                            // buffer as the routed ones, so a layer that
7825                            // mixes layouts cannot be indexed by one stride.
7826                            // Say so: the symptom is a whole model quietly
7827                            // running its MoE on the CPU.
7828                            tracing::warn!(
7829                                "MoE layer mixes expert layouts (q4tp={is_p}, q2tp gate/up={is_q2})                                  — every expert of a layer, INCLUDING the shared one, must share                                  a layout. The whole-token graph declines this layer."
7830                            );
7831                            return None;
7832                        }
7833                        model.get_or_insert_with(|| mm.clone());
7834                        experts.push((gi, ui, di));
7835                    }
7836                    crate::gpu::GraphFfn::Moe {
7837                        router,
7838                        shared_gate: sgate,
7839                        experts,
7840                        n_exp: m.experts.len(),
7841                        // CMF_TOPK_PROBE: timing probe only — output is WRONG.
7842                        // Fewer experts shrink the MoE arithmetic while the
7843                        // dispatch count stays identical, which is the only
7844                        // clean way to tell a launch-bound decode from a
7845                        // compute-bound one.
7846                        top_k: std::env::var("CMF_TOPK_PROBE")
7847                            .ok()
7848                            .and_then(|v| v.parse::<usize>().ok())
7849                            .filter(|k| *k > 0 && *k <= m.top_k)
7850                            .unwrap_or(m.top_k),
7851                        inter,
7852                        norm_topk: m.norm_topk_prob,
7853                        q4tp: q4tp?,
7854                        gu_q2: gu_q2.unwrap_or(false),
7855                        sigmoid: m.router_sigmoid,
7856                        bias: m.expert_bias.as_deref(),
7857                        has_shared,
7858                    }
7859                }
7860            };
7861            let attn = match &lw.attn {
7862                AttnKind::Full {
7863                    wq,
7864                    wk,
7865                    wv,
7866                    wo,
7867                    q_norm,
7868                    k_norm,
7869                    output_gate,
7870                    softplus_gate,
7871                    bias,
7872                } => {
7873                    if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
7874                        return None;
7875                    }
7876                    let (m, _, _, _) = wq.graph_weight()?;
7877                    model = Some(m.clone());
7878                    crate::gpu::GraphAttn::Full {
7879                        wq: gw(wq)?,
7880                        wk: gw(wk)?,
7881                        wv: gw(wv)?,
7882                        wo: gw(wo)?,
7883                        q_norm: q_norm.as_deref(),
7884                        k_norm: k_norm.as_deref(),
7885                        bias: bias
7886                            .as_ref()
7887                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
7888                        output_gate: *output_gate,
7889                        cpu_k: self.kv_cache.layers[li].k_heads(),
7890                        cpu_v: self.kv_cache.layers[li].v_heads(),
7891                    }
7892                }
7893                AttnKind::LinearGdn(w) => {
7894                    let cfg = self.gdn_cfg?;
7895                    let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
7896                    model = Some(m.clone());
7897                    crate::gpu::GraphAttn::Gdn {
7898                        qkv: gw(&w.in_proj_qkv)?,
7899                        z: gw(&w.in_proj_z)?,
7900                        a: gw(&w.in_proj_a)?,
7901                        b: gw(&w.in_proj_b)?,
7902                        out: gw(&w.out_proj)?,
7903                        conv1d: &w.conv1d,
7904                        a_log: &w.a_log,
7905                        dt_bias: &w.dt_bias,
7906                        norm: &w.norm,
7907                        nv: cfg.num_v_heads,
7908                        nk: cfg.num_k_heads,
7909                        dk: cfg.key_head_dim,
7910                        dv: cfg.value_head_dim,
7911                        kk: cfg.conv_kernel,
7912                        cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
7913                    }
7914                }
7915                AttnKind::ShortConv(w) => {
7916                    let cfg = self.short_conv_cfg?;
7917                    let (m, _, _, _) = w.in_proj.graph_weight()?;
7918                    model = Some(m.clone());
7919                    crate::gpu::GraphAttn::ShortConv {
7920                        inp: gw(&w.in_proj)?,
7921                        out: gw(&w.out_proj)?,
7922                        taps: &w.conv,
7923                        kernel: cfg.kernel,
7924                        cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
7925                    }
7926                }
7927                _ => return None,
7928            };
7929            layers.push(crate::gpu::GraphLayer {
7930                input_norm: &lw.input_norm,
7931                attn,
7932                post_norm: &lw.post_norm,
7933                ffn: gffn,
7934            });
7935        }
7936        let model = model?;
7937        // Fold final-norm + lm_head into the graph when this call wants logits
7938        // and the lm_head is a graphable (quantized) weight — the graph then
7939        // reads back logits (into logits_out) instead of the hidden, dropping
7940        // the separate CPU/GPU lm_head op + its sync. Never the f32 fallback:
7941        // an unquantized lm_head is vocab·hidden and must not be uploaded.
7942        let lm_gw = if upto_excl == self.num_layers
7943            && self.graph_want_logits
7944            && std::env::var("CMF_GPU_LMHEAD")
7945                .map(|v| v != "0")
7946                .unwrap_or(true)
7947        {
7948            self.weights.lm_head.graph_weight().map(|(_, i, kind, rs)| {
7949                (
7950                    crate::gpu::GraphW {
7951                        idx: i,
7952                        kind,
7953                        row_scale: rs,
7954                        data: &[],
7955                    },
7956                    self.weights.lm_head.rows(),
7957                )
7958            })
7959        } else {
7960            None
7961        };
7962        let lm = lm_gw.as_ref().map(|(gw, rows)| (gw, *rows));
7963        // Multi-step re-embeds the winner on the device.
7964        let emb_gw = if steps > 1 {
7965            self.weights
7966                .embed_tokens
7967                .graph_weight()
7968                .map(|(_, i, kind, rs)| {
7969                    (
7970                        crate::gpu::GraphW {
7971                            idx: i,
7972                            kind,
7973                            row_scale: rs,
7974                            data: &[],
7975                        },
7976                        self.weights.embed_tokens.rows(),
7977                        self.embed_multiplier,
7978                    )
7979                })
7980        } else {
7981            None
7982        };
7983
7984        // Loop boundaries: virtual layer indices after which final_norm is
7985        // applied (mid-stack only; the GLOBAL last layer's norm folds into
7986        // lm_head). Span-relative — the executor compares its enumerate
7987        // index. A span ending mid-stack keeps its boundary norm even when
7988        // it is the span's own last layer.
7989        let loop_norm_at: Vec<usize> = if self.loop_final_norm {
7990            (from..upto_excl.min(self.num_layers - 1))
7991                .filter(|&li| (li + 1) % self.physical_layers == 0)
7992                .map(|li| li - from)
7993                .collect()
7994        } else {
7995            Vec::new()
7996        };
7997        let mut h = hidden.to_vec();
7998        let outcome = crate::gpu::forward_token_graph(
7999            &model,
8000            self.graph_kv_id,
8001            &layers,
8002            &o1_views,
8003            self.o1_epoch,
8004            &self.inv_freq,
8005            &mut h,
8006            nh,
8007            nkv,
8008            hd,
8009            self.attn_scale,
8010            rd,
8011            self.hidden_size,
8012            self.intermediate_size,
8013            position,
8014            self.kv_cache.max_seq_len,
8015            gemma,
8016            self.rms_eps as f32,
8017            lm,
8018            &self.weights.final_norm,
8019            logits_out,
8020            &loop_norm_at,
8021            steps,
8022            emb_gw.as_ref().map(|(gw, rows, m)| (gw, *rows, *m)),
8023            ids_out,
8024            layers_run,
8025            from,
8026            false,
8027        );
8028        match outcome {
8029            crate::gpu::TokenGraphOutcome::Completed => Some(Ok(h)),
8030            crate::gpu::TokenGraphOutcome::Failed => Some(Err(())),
8031            crate::gpu::TokenGraphOutcome::Declined => None,
8032        }
8033    }
8034
8035    /// Batched prefill: k contiguous prompt positions through the whole wgpu
8036    /// graph in ONE submit (projections/FFN as GEMMs). `hiddens` is [k·hidden]
8037    /// in/out (embeddings in, layer output out); KV mirror / GDN state advance.
8038    /// false ⇒ unsupported → caller keeps the per-position graph.
8039    /// The b-row Metal graph plan for the whole model: every layer as a
8040    /// GDN run or a full-attention item, all-or-nothing (a layer outside the
8041    /// graph's contract → None, the caller runs plain). Shared by the
8042    /// speculative verify and the batched prefill.
8043    #[cfg(target_os = "macos")]
8044    #[allow(clippy::type_complexity)]
8045    fn metal_rows_plan(
8046        &self,
8047    ) -> Option<(
8048        Vec<MetalRowsItem<'_>>,
8049        std::sync::Arc<cortiq_core::CmfModel>,
8050        Option<crate::gpu_metal::GdnGpuCfg>,
8051    )> {
8052        use crate::gpu_metal::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, MetalFfn};
8053        if !crate::gpu::q1_force()
8054            || !crate::gpu::enabled_here()
8055            || std::env::var("CMF_GPU_BLOCK")
8056                .map(|v| v == "0")
8057                .unwrap_or(false)
8058            || self.attn_softcap > 0.0
8059            || self.o1_active()
8060            || self.swa.is_some()
8061            || self.global_attn.is_some()
8062            || self.attention_heads_per_layer.is_some()
8063            || self.attn_v_norm
8064            || self.loop_final_norm
8065        {
8066            return None;
8067        }
8068        let attend_contract = self.head_dim % 4 == 0
8069            && self.head_dim <= 256
8070            && self.rotary_dim >= 2
8071            && self.rotary_dim <= self.head_dim
8072            && (self.rotary_dim / 2) % 32 == 0
8073            && self.num_kv_heads > 0
8074            && self.num_heads % self.num_kv_heads == 0;
8075        if !attend_contract {
8076            return None;
8077        }
8078        let mut plan: Vec<MetalRowsItem> = Vec::new();
8079        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
8080        for li in 0..self.num_layers {
8081            let lw = &self.weights.layers[self.phys_layer(li)];
8082            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
8083                return None;
8084            }
8085            let ffn = match &lw.ffn {
8086                FfnKind::Dense(d) if d.act == Act::Silu && d.segs.is_empty() => {
8087                    let (Some(g), Some(u), Some(dn)) = (
8088                        d.gate_proj.q1_parts(),
8089                        d.up_proj.q1_parts(),
8090                        d.down_proj.q1_parts(),
8091                    ) else {
8092                        return None;
8093                    };
8094                    MetalFfn::Dense {
8095                        gate: g,
8096                        up: u,
8097                        down: dn,
8098                    }
8099                }
8100                _ => return None,
8101            };
8102            match &lw.attn {
8103                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
8104                    let (Some(qkv), Some(z), Some(a), Some(bb), Some(out)) = (
8105                        w.in_proj_qkv.q1_parts(),
8106                        w.in_proj_z.q1_parts(),
8107                        w.in_proj_a.f32_parts(),
8108                        w.in_proj_b.f32_parts(),
8109                        w.out_proj.q1_parts(),
8110                    ) else {
8111                        return None;
8112                    };
8113                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
8114                        model_ref.get_or_insert_with(|| model.clone());
8115                    }
8116                    let gl = GdnGpuLayer {
8117                        attn_norm: &lw.input_norm,
8118                        post_norm: &lw.post_norm,
8119                        qkv,
8120                        z,
8121                        a,
8122                        b: bb,
8123                        out,
8124                        ffn,
8125                        conv1d: &w.conv1d,
8126                        a_log: &w.a_log,
8127                        dt_bias: &w.dt_bias,
8128                        gnorm: &w.norm,
8129                    };
8130                    match plan.last_mut() {
8131                        Some(MetalRowsItem::Gdn { run, .. }) => run.push(gl),
8132                        _ => plan.push(MetalRowsItem::Gdn {
8133                            run: vec![gl],
8134                            first: li,
8135                        }),
8136                    }
8137                }
8138                AttnKind::Full {
8139                    wq,
8140                    wk,
8141                    wv,
8142                    wo,
8143                    q_norm,
8144                    k_norm,
8145                    output_gate,
8146                    softplus_gate: None,
8147                    bias: None,
8148                } => {
8149                    let (Some(pq), Some(pk), Some(pv), Some(po)) =
8150                        (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts())
8151                    else {
8152                        return None;
8153                    };
8154                    if let QTensor::Mapped { model, .. } = wq {
8155                        model_ref.get_or_insert_with(|| model.clone());
8156                    }
8157                    let cache = &self.kv_cache.layers[li];
8158                    if cache.mode != crate::kv_cache::KvMode::F32 || cache.o1.is_some() {
8159                        return None;
8160                    }
8161                    plan.push(MetalRowsItem::Attn {
8162                        l: AttnGpuLayer {
8163                            attn_norm: &lw.input_norm,
8164                            post_norm: &lw.post_norm,
8165                            wq: pq,
8166                            wk: pk,
8167                            wv: pv,
8168                            wo: po,
8169                            ffn,
8170                        },
8171                        li,
8172                        q_norm: q_norm.as_deref(),
8173                        k_norm: k_norm.as_deref(),
8174                        output_gate: *output_gate,
8175                    });
8176                }
8177                _ => return None,
8178            }
8179        }
8180        let model = model_ref?;
8181        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
8182            nv: cfg.num_v_heads,
8183            nk: cfg.num_k_heads,
8184            dk: cfg.key_head_dim,
8185            dv: cfg.value_head_dim,
8186            kk: cfg.conv_kernel,
8187            hidden: self.hidden_size,
8188            inter: self.intermediate_size,
8189            c_dim: cfg.conv_dim(),
8190            eps: cfg.rms_eps as f32,
8191            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8192        });
8193        Some((plan, model, gcfg))
8194    }
8195
8196    /// `AttnDeviceParams` for a plan item over the CPU cache as it stands.
8197    #[cfg(target_os = "macos")]
8198    #[allow(clippy::too_many_arguments)]
8199    fn metal_attn_params<'a>(
8200        li: usize,
8201        cache: &'a crate::kv_cache::LayerKvCache,
8202        q_norm: Option<&'a [f32]>,
8203        k_norm: Option<&'a [f32]>,
8204        output_gate: bool,
8205        inv_freq: &'a [f32],
8206        geom: (usize, usize, usize, usize),
8207        pos0: usize,
8208        kv_id: u64,
8209        scale: f32,
8210        eps: f32,
8211        gemma: bool,
8212    ) -> (crate::gpu_metal::AttnDeviceParams<'a>, usize) {
8213        let (nh, nkv, hd, rd) = geom;
8214        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
8215        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
8216        let cpu_stored = cpu_k[0].len() / hd;
8217        (
8218            crate::gpu_metal::AttnDeviceParams {
8219                kv_id,
8220                layer: li,
8221                nh,
8222                nkv,
8223                hd,
8224                rd,
8225                position: pos0,
8226                scale,
8227                eps,
8228                gemma,
8229                output_gate,
8230                q_norm,
8231                k_norm,
8232                inv_freq,
8233                cpu_k,
8234                cpu_v,
8235                cpu_stored,
8236                o1: None,
8237            },
8238            cpu_stored,
8239        )
8240    }
8241
8242    /// Run the rows plan over `hiddens` (b rows at `pos0..`): validate,
8243    /// encode every item, optionally the head, sync. Returns the graph
8244    /// (for the commit / state finish) plus the GDN layer indices and the
8245    /// attention layers with the row count they were encoded against.
8246    #[cfg(target_os = "macos")]
8247    #[allow(clippy::type_complexity)]
8248    fn metal_rows_run(
8249        &mut self,
8250        hiddens: &mut [f32],
8251        pos0: usize,
8252        b: usize,
8253        prefill: bool,
8254        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
8255    ) -> Option<MetalVerifyPending> {
8256        use crate::gpu_metal::{GraphDims, VerifyGraph};
8257        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
8258        for l in &mut self.kv_cache.layers {
8259            if l.linear_state.len() != want && want > 0 {
8260                l.linear_state = vec![0f32; want];
8261            }
8262        }
8263        let (plan, model, gcfg) = self.metal_rows_plan()?;
8264        let dims = GraphDims {
8265            hidden: self.hidden_size,
8266            eps: self.rms_eps as f32,
8267            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8268        };
8269        let mut graph = if prefill {
8270            VerifyGraph::new_prefill(&model, dims, hiddens, b)?
8271        } else {
8272            VerifyGraph::new(&model, dims, hiddens, b)?
8273        };
8274        let geom = (
8275            self.num_heads,
8276            self.num_kv_heads,
8277            self.head_dim,
8278            self.rotary_dim,
8279        );
8280        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
8281        let eps = self.rms_eps as f32;
8282        let kv_id = self.graph_kv_id;
8283        let inv_freq = self.inv_freq.clone();
8284        for item in &plan {
8285            let ok = match item {
8286                MetalRowsItem::Gdn { run, .. } => gcfg
8287                    .as_ref()
8288                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
8289                    .unwrap_or(false),
8290                MetalRowsItem::Attn {
8291                    l,
8292                    li,
8293                    q_norm,
8294                    k_norm,
8295                    output_gate,
8296                } => {
8297                    let (p, _) = Self::metal_attn_params(
8298                        *li,
8299                        &self.kv_cache.layers[*li],
8300                        *q_norm,
8301                        *k_norm,
8302                        *output_gate,
8303                        &inv_freq,
8304                        geom,
8305                        pos0,
8306                        kv_id,
8307                        self.attn_scale,
8308                        eps,
8309                        gemma,
8310                    );
8311                    graph.attn_ok(l, &p)
8312                }
8313            };
8314            if !ok {
8315                use std::sync::atomic::{AtomicBool, Ordering};
8316                static SAID: AtomicBool = AtomicBool::new(false);
8317                if !SAID.swap(true, Ordering::Relaxed) {
8318                    tracing::warn!("metal rows graph: a layer failed preflight — declining");
8319                }
8320                return None;
8321            }
8322        }
8323        let lm = match &spec {
8324            Some((lm, _, _)) => {
8325                if !graph.lm_head_ok(*lm) {
8326                    return None;
8327                }
8328                Some(*lm)
8329            }
8330            None => None,
8331        };
8332        let mut gdn_layers = Vec::new();
8333        let mut attn_layers = Vec::new();
8334        for item in &plan {
8335            match item {
8336                MetalRowsItem::Gdn { run, first } => {
8337                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
8338                        .iter()
8339                        .map(|l| l.linear_state.as_slice())
8340                        .collect();
8341                    if !graph.encode_gdn_run_b(run, &ro, gcfg.as_ref().unwrap()) {
8342                        return None;
8343                    }
8344                    gdn_layers.extend(*first..*first + run.len());
8345                }
8346                MetalRowsItem::Attn {
8347                    l,
8348                    li,
8349                    q_norm,
8350                    k_norm,
8351                    output_gate,
8352                } => {
8353                    let (p, cpu_stored) = Self::metal_attn_params(
8354                        *li,
8355                        &self.kv_cache.layers[*li],
8356                        *q_norm,
8357                        *k_norm,
8358                        *output_gate,
8359                        &inv_freq,
8360                        geom,
8361                        pos0,
8362                        kv_id,
8363                        self.attn_scale,
8364                        eps,
8365                        gemma,
8366                    );
8367                    if !graph.encode_attn_b(l, &p) {
8368                        return None;
8369                    }
8370                    attn_layers.push((*li, cpu_stored));
8371                }
8372            }
8373        }
8374        if let (Some(lm), Some((_, final_norm, _))) = (lm, spec.as_ref()) {
8375            if !graph.encode_lm_head_b(final_norm, lm) {
8376                return None;
8377            }
8378        }
8379        graph.sync();
8380        if let Some((lm, _, logits)) = spec {
8381            logits.resize(b * lm.1, 0.0);
8382            graph.read_logits(logits);
8383        }
8384        graph.read_hidden(hiddens);
8385        Some(MetalVerifyPending {
8386            graph,
8387            gdn_layers,
8388            attn_layers,
8389        })
8390    }
8391
8392    /// Native-Metal twin of `try_batch_graph_wgpu`: the b rows through the
8393    /// whole model on the `VerifyGraph` (one submit), the head folded in
8394    /// when `spec` asks; `hiddens` come back as the last layer's output
8395    /// rows, `spec.2` as `[b][lm_rows]` logits. The graph is parked in
8396    /// `metal_verify` for `metal_verify_commit`.
8397    #[cfg(target_os = "macos")]
8398    fn try_batch_graph_metal(
8399        &mut self,
8400        hiddens: &mut [f32],
8401        positions: &[usize],
8402        b: usize,
8403        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
8404    ) -> crate::gpu::BatchGraphOutcome {
8405        let _t0 = std::time::Instant::now();
8406        if positions.len() != b
8407            || positions.windows(2).any(|w| w[1] != w[0] + 1)
8408            || hiddens.len() != b * self.hidden_size
8409        {
8410            return crate::gpu::BatchGraphOutcome::Declined;
8411        }
8412        let Some(pending) = self.metal_rows_run(hiddens, positions[0], b, false, spec) else {
8413            return crate::gpu::BatchGraphOutcome::Declined;
8414        };
8415        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
8416            eprintln!(
8417                "metal-verify: {:.1} ms | b={b}",
8418                _t0.elapsed().as_secs_f64() * 1e3
8419            );
8420        }
8421        self.metal_verify = Some(pending);
8422        crate::gpu::BatchGraphOutcome::Completed
8423    }
8424
8425    /// Batched prefill on the Metal rows graph: `ids` (≤ 512) at
8426    /// `start_pos..`, states written in place, K/V rows appended to the
8427    /// CPU caches; returns every position's output hidden (`[b][hidden]`).
8428    /// None = the graph declined before touching anything.
8429    #[cfg(target_os = "macos")]
8430    fn prefill_batch_metal(&mut self, ids: &[u32], start_pos: usize) -> Option<Vec<f32>> {
8431        let b = ids.len();
8432        if b == 0 || b > 512 {
8433            return None;
8434        }
8435        let hs = self.hidden_size;
8436        let mut hiddens = vec![0f32; b * hs];
8437        for (j, &id) in ids.iter().enumerate() {
8438            let e = self.embed_single(id);
8439            hiddens[j * hs..(j + 1) * hs].copy_from_slice(&e);
8440        }
8441        let mut pending = self.metal_rows_run(&mut hiddens, start_pos, b, true, None)?;
8442        // states are final: copy them to the owners
8443        let idxs = pending.gdn_layers.clone();
8444        let mut outs: Vec<&mut [f32]> = self
8445            .kv_cache
8446            .layers
8447            .iter_mut()
8448            .enumerate()
8449            .filter(|(i, _)| idxs.binary_search(i).is_ok())
8450            .map(|(_, l)| l.linear_state.as_mut_slice())
8451            .collect();
8452        pending.graph.finish_states(&mut outs);
8453        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
8454        let mut kbuf = vec![0f32; b * nkv * hd];
8455        let mut vbuf = vec![0f32; b * nkv * hd];
8456        for (li, cpu_stored) in &pending.attn_layers {
8457            if crate::gpu_metal::kv_mirror_read_rows(
8458                self.graph_kv_id,
8459                *li,
8460                nkv,
8461                hd,
8462                *cpu_stored,
8463                b,
8464                &mut kbuf,
8465                &mut vbuf,
8466            ) {
8467                let cache = &mut self.kv_cache.layers[*li];
8468                for r in 0..b {
8469                    cache.append(
8470                        &kbuf[r * nkv * hd..(r + 1) * nkv * hd],
8471                        &vbuf[r * nkv * hd..(r + 1) * nkv * hd],
8472                        &[],
8473                    );
8474                }
8475                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + b);
8476            }
8477        }
8478        Some(hiddens)
8479    }
8480
8481    /// Commit a Metal verify round: replay the GDN recurrences over the
8482    /// `a + 1` accepted positions into the CPU states, append the accepted
8483    /// K/V rows from the mirrors to the CPU caches, re-point the mirrors.
8484    #[cfg(target_os = "macos")]
8485    fn metal_verify_commit(&mut self, a: usize) -> bool {
8486        let Some(mut pending) = self.metal_verify.take() else {
8487            return false;
8488        };
8489        let n = a + 1;
8490        // encode order == ascending layer order (the plan walks 0..layers)
8491        let idxs = pending.gdn_layers.clone();
8492        let mut outs: Vec<&mut [f32]> = self
8493            .kv_cache
8494            .layers
8495            .iter_mut()
8496            .enumerate()
8497            .filter(|(i, _)| idxs.binary_search(i).is_ok())
8498            .map(|(_, l)| l.linear_state.as_mut_slice())
8499            .collect();
8500        if !pending.graph.commit(n, &mut outs) {
8501            return false;
8502        }
8503        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
8504        let mut kbuf = vec![0f32; n * nkv * hd];
8505        let mut vbuf = vec![0f32; n * nkv * hd];
8506        for (li, cpu_stored) in &pending.attn_layers {
8507            if crate::gpu_metal::kv_mirror_read_rows(
8508                self.graph_kv_id,
8509                *li,
8510                nkv,
8511                hd,
8512                *cpu_stored,
8513                n,
8514                &mut kbuf,
8515                &mut vbuf,
8516            ) {
8517                let cache = &mut self.kv_cache.layers[*li];
8518                for r in 0..n {
8519                    cache.append(
8520                        &kbuf[r * nkv * hd..(r + 1) * nkv * hd],
8521                        &vbuf[r * nkv * hd..(r + 1) * nkv * hd],
8522                        &[],
8523                    );
8524                }
8525                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + n);
8526            }
8527        }
8528        true
8529    }
8530
8531    /// The round's warm-ups as ONE b-row graph run over the MTP block on
8532    /// Metal: `pairs` = (trunk hidden, next token) at consecutive positions
8533    /// from `first_pos`; the block's input projection is folded in, the
8534    /// appended K/V rows are pulled into the CPU MTP cache. False = the
8535    /// graph declined (nothing appended).
8536    #[cfg(target_os = "macos")]
8537    fn mtp_warm_batch_metal(
8538        &mut self,
8539        m: &mut MtpModule,
8540        pairs: &[(&[f32], u32)],
8541        first_pos: usize,
8542    ) -> bool {
8543        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, VerifyGraph};
8544        let b = pairs.len();
8545        if b == 0 || b > 512 || m.kv.mode != crate::kv_cache::KvMode::F32 || m.kv.o1.is_some() {
8546            return false;
8547        }
8548        let AttnKind::Full {
8549            wq,
8550            wk,
8551            wv,
8552            wo,
8553            q_norm,
8554            k_norm,
8555            output_gate,
8556            softplus_gate: None,
8557            bias: None,
8558        } = &m.layer.attn
8559        else {
8560            return false;
8561        };
8562        let FfnKind::Dense(d) = &m.layer.ffn else {
8563            return false;
8564        };
8565        if !d.segs.is_empty() {
8566            return false;
8567        }
8568        let (Some(pq), Some(pk), Some(pv), Some(po)) =
8569            (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts())
8570        else {
8571            return false;
8572        };
8573        let (Some(g), Some(u), Some(dn)) = (
8574            d.gate_proj.q1_parts(),
8575            d.up_proj.q1_parts(),
8576            d.down_proj.q1_parts(),
8577        ) else {
8578            return false;
8579        };
8580        let Some(eh) = m.eh_proj.q1_parts() else {
8581            return false;
8582        };
8583        let QTensor::Mapped { model, .. } = wq else {
8584            return false;
8585        };
8586        let model = model.clone();
8587        let hs = self.hidden_size;
8588        // [enorm(embed(tok)); hnorm(hidden)] rows
8589        let mut cat = vec![0f32; b * 2 * hs];
8590        for (j, (h, tok)) in pairs.iter().enumerate() {
8591            let e = self.embed_single(*tok);
8592            let (ce, ch) = cat[j * 2 * hs..(j + 1) * 2 * hs].split_at_mut(hs);
8593            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, ce);
8594            inference::rms_norm_into(h, &m.hnorm, self.rms_eps, self.norm_style, ch);
8595        }
8596        let dims = GraphDims {
8597            hidden: hs,
8598            eps: self.rms_eps as f32,
8599            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8600        };
8601        let Some(mut graph) = VerifyGraph::new_via_proj(&model, dims, eh, &cat, b) else {
8602            return false;
8603        };
8604        let l = AttnGpuLayer {
8605            attn_norm: &m.layer.input_norm,
8606            post_norm: &m.layer.post_norm,
8607            wq: pq,
8608            wk: pk,
8609            wv: pv,
8610            wo: po,
8611            ffn: MetalFfn::Dense {
8612                gate: g,
8613                up: u,
8614                down: dn,
8615            },
8616        };
8617        let (nh, nkv, hd, rd) = (
8618            self.num_heads,
8619            self.num_kv_heads,
8620            self.head_dim,
8621            self.rotary_dim,
8622        );
8623        let inv_freq = self.inv_freq.clone();
8624        let cpu_stored;
8625        {
8626            let cache = &m.kv;
8627            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
8628            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
8629            cpu_stored = cpu_k[0].len() / hd;
8630            if cpu_stored != first_pos {
8631                return false;
8632            }
8633            let p = AttnDeviceParams {
8634                kv_id: self.mtp_kv_id(),
8635                layer: Self::MTP_LAYER_BASE,
8636                nh,
8637                nkv,
8638                hd,
8639                rd,
8640                position: first_pos,
8641                scale: self.attn_scale,
8642                eps: self.rms_eps as f32,
8643                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8644                output_gate: *output_gate,
8645                q_norm: q_norm.as_deref(),
8646                k_norm: k_norm.as_deref(),
8647                inv_freq: &inv_freq,
8648                cpu_k,
8649                cpu_v,
8650                cpu_stored,
8651                o1: None,
8652            };
8653            if !graph.attn_ok(&l, &p) || !graph.encode_attn_b(&l, &p) {
8654                return false;
8655            }
8656        }
8657        graph.sync();
8658        let mut kbuf = vec![0f32; b * nkv * hd];
8659        let mut vbuf = vec![0f32; b * nkv * hd];
8660        if !crate::gpu_metal::kv_mirror_read_rows(
8661            self.mtp_kv_id(),
8662            Self::MTP_LAYER_BASE,
8663            nkv,
8664            hd,
8665            cpu_stored,
8666            b,
8667            &mut kbuf,
8668            &mut vbuf,
8669        ) {
8670            return false;
8671        }
8672        for r in 0..b {
8673            m.kv.append(
8674                &kbuf[r * nkv * hd..(r + 1) * nkv * hd],
8675                &vbuf[r * nkv * hd..(r + 1) * nkv * hd],
8676                &[],
8677            );
8678        }
8679        crate::gpu_metal::kv_mirror_set_stored(
8680            self.mtp_kv_id(),
8681            Self::MTP_LAYER_BASE,
8682            cpu_stored + b,
8683        );
8684        true
8685    }
8686
8687    /// Draft-head shortlist size: `CMF_DRAFT_VOCAB` rows (default 65536,
8688    /// capped at the head; 0 = full head).
8689    fn draft_vocab_rows(head_rows: usize) -> usize {
8690        static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
8691        let n = *N.get_or_init(|| {
8692            std::env::var("CMF_DRAFT_VOCAB")
8693                .ok()
8694                .and_then(|v| v.parse().ok())
8695                .unwrap_or(65536)
8696        });
8697        if n == 0 { head_rows } else { n.min(head_rows) }
8698    }
8699
8700    /// One MTP block step on the native Metal token graph: block input on
8701    /// the host, the attention layer + FFN device-resident over the MTP
8702    /// mirror, the head folded in when `want_logits`. The appended K/V row
8703    /// is pulled into the CPU MTP cache (owner of record) after the sync.
8704    #[cfg(target_os = "macos")]
8705    fn mtp_step_metal(
8706        &mut self,
8707        m: &mut MtpModule,
8708        hidden: &[f32],
8709        next_token: u32,
8710        position: usize,
8711        want_logits: bool,
8712    ) -> Option<(Vec<f32>, Vec<f32>)> {
8713        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, TokenGraph};
8714        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0")
8715            || !crate::gpu::q1_force()
8716            || !crate::gpu::enabled_here()
8717            || self.attn_softcap > 0.0
8718            || self.attention_heads_per_layer.is_some()
8719            || m.kv.mode != crate::kv_cache::KvMode::F32
8720            || m.kv.o1.is_some()
8721        {
8722            return None;
8723        }
8724        let AttnKind::Full {
8725            wq,
8726            wk,
8727            wv,
8728            wo,
8729            q_norm,
8730            k_norm,
8731            output_gate,
8732            softplus_gate: None,
8733            bias: None,
8734        } = &m.layer.attn
8735        else {
8736            return None;
8737        };
8738        let FfnKind::Dense(d) = &m.layer.ffn else {
8739            return None;
8740        };
8741        if d.act != Act::Silu || !d.segs.is_empty() {
8742            return None;
8743        }
8744        let (pq, pk, pv, po) = (
8745            wq.q1_parts()?,
8746            wk.q1_parts()?,
8747            wv.q1_parts()?,
8748            wo.q1_parts()?,
8749        );
8750        let (g, u, dn) = (
8751            d.gate_proj.q1_parts()?,
8752            d.up_proj.q1_parts()?,
8753            d.down_proj.q1_parts()?,
8754        );
8755        let QTensor::Mapped { model, .. } = wq else {
8756            return None;
8757        };
8758        let model = model.clone();
8759        let lm = if want_logits {
8760            Some(self.weights.lm_head.q1_parts()?)
8761        } else {
8762            None
8763        };
8764        let dims = GraphDims {
8765            hidden: self.hidden_size,
8766            eps: self.rms_eps as f32,
8767            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8768        };
8769        // The block input `eh_proj · [enorm(e); hnorm(h)]` rides in the
8770        // graph (one submit a step); the host per-op matvec if it cannot.
8771        let hs = self.hidden_size;
8772        let mut x = vec![0f32; hs];
8773        let mut graph = TokenGraph::new(&model, dims, &x)?;
8774        let mut folded = false;
8775        if let Some(eh) = m.eh_proj.q1_parts() {
8776            let e = self.embed_single(next_token);
8777            let mut cat = vec![0.0f32; 2 * hs];
8778            let (cat_e, cat_h) = cat.split_at_mut(hs);
8779            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
8780            inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
8781            folded = graph.encode_input_proj(eh, &cat);
8782        }
8783        if !folded {
8784            x = self.mtp_block_input(m, hidden, next_token);
8785            graph = TokenGraph::new(&model, dims, &x)?;
8786        }
8787        let l = AttnGpuLayer {
8788            attn_norm: &m.layer.input_norm,
8789            post_norm: &m.layer.post_norm,
8790            wq: pq,
8791            wk: pk,
8792            wv: pv,
8793            wo: po,
8794            ffn: MetalFfn::Dense {
8795                gate: g,
8796                up: u,
8797                down: dn,
8798            },
8799        };
8800        let (nh, nkv, hd, rd) = (
8801            self.num_heads,
8802            self.num_kv_heads,
8803            self.head_dim,
8804            self.rotary_dim,
8805        );
8806        let inv_freq = self.inv_freq.clone();
8807        {
8808            let cache = &m.kv;
8809            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
8810            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
8811            let cpu_stored = cpu_k[0].len() / hd;
8812            let p = AttnDeviceParams {
8813                kv_id: self.mtp_kv_id(),
8814                layer: Self::MTP_LAYER_BASE,
8815                nh,
8816                nkv,
8817                hd,
8818                rd,
8819                position,
8820                scale: self.attn_scale,
8821                eps: self.rms_eps as f32,
8822                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
8823                output_gate: *output_gate,
8824                q_norm: q_norm.as_deref(),
8825                k_norm: k_norm.as_deref(),
8826                inv_freq: &inv_freq,
8827                cpu_k,
8828                cpu_v,
8829                cpu_stored,
8830                o1: None,
8831            };
8832            if !graph.attn_device_ok(&l, &p) || !graph.encode_attn_device(&l, &p) {
8833                return None;
8834            }
8835        }
8836        // The draft's head over a vocabulary SHORTLIST (the first
8837        // CMF_DRAFT_VOCAB rows — BPE ids run roughly by merge rank, so the
8838        // low ids carry the mass): the verify keeps the full head, so a true
8839        // token past the cut is only a rejected draft, never a wrong token.
8840        // 662 MB a step on Qwen3.8 becomes 170 MB at 65536.
8841        let draft_rows = if let Some(lm) = lm {
8842            Self::draft_vocab_rows(lm.1)
8843        } else {
8844            0
8845        };
8846        if let Some(lm) = lm {
8847            if !graph.lm_head_ok(lm) {
8848                return None;
8849            }
8850            if draft_rows < lm.1 {
8851                if !graph.encode_lm_head_part(&m.final_norm, lm, draft_rows) {
8852                    return None;
8853                }
8854            } else {
8855                graph.encode_lm_head(&m.final_norm, lm);
8856            }
8857        }
8858        graph.sync();
8859        let mut logits = Vec::new();
8860        if let Some(lm) = lm {
8861            let n_read = draft_rows.min(lm.1).min(self.vocab_size);
8862            logits = attention::take_buf(n_read);
8863            graph.read_logits(&mut logits);
8864            // ids past the shortlist: never drafted (−∞ in every chain)
8865            logits.resize(self.vocab_size, f32::NEG_INFINITY);
8866        }
8867        graph.finish(&mut x);
8868        let mut krow = attention::take_buf(nkv * hd);
8869        let mut vrow = attention::take_buf(nkv * hd);
8870        if crate::gpu_metal::kv_mirror_read_last(
8871            self.mtp_kv_id(),
8872            Self::MTP_LAYER_BASE,
8873            nkv,
8874            hd,
8875            &mut krow,
8876            &mut vrow,
8877        ) {
8878            m.kv.append(&krow, &vrow, &[]);
8879        }
8880        attention::recycle_buf(&mut krow);
8881        attention::recycle_buf(&mut vrow);
8882        Some((logits, x))
8883    }
8884
8885    fn try_batch_graph_wgpu(
8886        &self,
8887        hiddens: &mut [f32],
8888        positions: &[usize],
8889        k: usize,
8890        spec: Option<crate::gpu::SpecTail<'_>>,
8891    ) -> crate::gpu::BatchGraphOutcome {
8892        let _tb = std::time::Instant::now();
8893        if self.attn_softcap > 0.0 {
8894            return crate::gpu::BatchGraphOutcome::Declined; // capped scores: no graph kernel — CPU path
8895        }
8896        let nh = self.num_heads;
8897        let (nkv, hd, rd) = self.layer_geom(0);
8898        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
8899        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
8900            if let Some((_, i, kind, rs)) = t.graph_weight() {
8901                return Some(crate::gpu::GraphW {
8902                    idx: i,
8903                    kind,
8904                    row_scale: rs,
8905                    data: &[],
8906                });
8907            }
8908            t.as_f32().map(|d| crate::gpu::GraphW {
8909                idx: 0,
8910                kind: 4,
8911                row_scale: &[],
8912                data: d,
8913            })
8914        }
8915        let built: Option<(
8916            Vec<crate::gpu::GraphLayer<'_>>,
8917            std::sync::Arc<cortiq_core::CmfModel>,
8918        )> = (|| {
8919            let mut layers = Vec::with_capacity(self.num_layers);
8920            let mut model = None;
8921            for li in 0..self.num_layers {
8922                let lw = &self.weights.layers[self.phys_layer(li)];
8923                // MoE routes per token, so its experts are encoded token by
8924                // token inside the batched submit while attention and the
8925                // projections stay GEMMs. Refusing MoE here is what left
8926                // prefill running one position at a time: 33 tok/s against
8927                // 54 on decode, i.e. reading the prompt was slower than
8928                // writing the answer.
8929                let gffn = match &lw.ffn {
8930                    FfnKind::Dense(d) if !d.segs.is_empty() => return None,
8931                    FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
8932                        gate: gw(&d.gate_proj)?,
8933                        up: gw(&d.up_proj)?,
8934                        down: gw(&d.down_proj)?,
8935                    },
8936                    FfnKind::Moe(m) => {
8937                        if m.router_sigmoid
8938                            || m.expert_bias.is_some()
8939                            || m.route_tau.is_some()
8940                            || m.mask.is_some()
8941                        {
8942                            return None;
8943                        }
8944                        let (se, sg) = m.shared.as_ref()?;
8945                        let sgate = gw(sg.as_ref()?)?;
8946                        let router = gw(&m.router)?;
8947                        let inter = m.experts.first()?.gate_proj.rows();
8948                        let mut experts = Vec::with_capacity(m.experts.len() + 1);
8949                        let mut q4tp: Option<bool> = None;
8950                        let mut gu_q2: Option<bool> = None;
8951                        for e in m.experts.iter().chain(std::iter::once(se)) {
8952                            if !matches!(e.act, Act::Silu)
8953                                || e.gate_proj.rows() != inter
8954                                || e.up_proj.rows() != inter
8955                            {
8956                                return None;
8957                            }
8958                            // Same ladder as the token graph: q4t → q2tp
8959                            // (mixed profile: 2-bit gate/up over a q4tp
8960                            // down) → q4tp. Uniform across the layer.
8961                            let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
8962                                Some((mm, gi)) => (
8963                                    mm,
8964                                    gi,
8965                                    e.up_proj.mapped_q4t()?.1,
8966                                    e.down_proj.mapped_q4t()?.1,
8967                                    false,
8968                                    false,
8969                                ),
8970                                None => match e.gate_proj.mapped_q2tp() {
8971                                    Some((mm, gi)) => (
8972                                        mm,
8973                                        gi,
8974                                        e.up_proj.mapped_q2tp()?.1,
8975                                        e.down_proj.mapped_q4tp()?.1,
8976                                        true,
8977                                        true,
8978                                    ),
8979                                    None => {
8980                                        let (mm, gi) = e.gate_proj.mapped_q4tp()?;
8981                                        (
8982                                            mm,
8983                                            gi,
8984                                            e.up_proj.mapped_q4tp()?.1,
8985                                            e.down_proj.mapped_q4tp()?.1,
8986                                            true,
8987                                            false,
8988                                        )
8989                                    }
8990                                },
8991                            };
8992                            if *q4tp.get_or_insert(is_p) != is_p
8993                                || *gu_q2.get_or_insert(is_q2) != is_q2
8994                            {
8995                                return None;
8996                            }
8997                            model.get_or_insert_with(|| mm.clone());
8998                            experts.push((gi, ui, di));
8999                        }
9000                        crate::gpu::GraphFfn::Moe {
9001                            router,
9002                            shared_gate: sgate,
9003                            experts,
9004                            n_exp: m.experts.len(),
9005                            top_k: m.top_k,
9006                            inter,
9007                            norm_topk: m.norm_topk_prob,
9008                            q4tp: q4tp?,
9009                            gu_q2: gu_q2.unwrap_or(false),
9010                            sigmoid: false,
9011                            bias: None,
9012                            has_shared: true,
9013                        }
9014                    }
9015                    _ => return None,
9016                };
9017                let attn = match &lw.attn {
9018                    AttnKind::Full {
9019                        wq,
9020                        wk,
9021                        wv,
9022                        wo,
9023                        q_norm,
9024                        k_norm,
9025                        output_gate,
9026                        softplus_gate,
9027                        bias,
9028                    } => {
9029                        if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
9030                            return None;
9031                        }
9032                        let (m, _, _, _) = wq.graph_weight()?;
9033                        model = Some(m.clone());
9034                        crate::gpu::GraphAttn::Full {
9035                            wq: gw(wq)?,
9036                            wk: gw(wk)?,
9037                            wv: gw(wv)?,
9038                            wo: gw(wo)?,
9039                            q_norm: q_norm.as_deref(),
9040                            k_norm: k_norm.as_deref(),
9041                            bias: bias
9042                                .as_ref()
9043                                .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
9044                            output_gate: *output_gate,
9045                            cpu_k: self.kv_cache.layers[li].k_heads(),
9046                            cpu_v: self.kv_cache.layers[li].v_heads(),
9047                        }
9048                    }
9049                    AttnKind::LinearGdn(w) => {
9050                        let cfg = self.gdn_cfg?;
9051                        let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
9052                        model = Some(m.clone());
9053                        crate::gpu::GraphAttn::Gdn {
9054                            qkv: gw(&w.in_proj_qkv)?,
9055                            z: gw(&w.in_proj_z)?,
9056                            a: gw(&w.in_proj_a)?,
9057                            b: gw(&w.in_proj_b)?,
9058                            out: gw(&w.out_proj)?,
9059                            conv1d: &w.conv1d,
9060                            a_log: &w.a_log,
9061                            dt_bias: &w.dt_bias,
9062                            norm: &w.norm,
9063                            nv: cfg.num_v_heads,
9064                            nk: cfg.num_k_heads,
9065                            dk: cfg.key_head_dim,
9066                            dv: cfg.value_head_dim,
9067                            kk: cfg.conv_kernel,
9068                            cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
9069                        }
9070                    }
9071                    _ => return None,
9072                };
9073                layers.push(crate::gpu::GraphLayer {
9074                    input_norm: &lw.input_norm,
9075                    attn,
9076                    post_norm: &lw.post_norm,
9077                    ffn: gffn,
9078                });
9079            }
9080            Some((layers, model?))
9081        })();
9082        let Some((layers, model)) = built else {
9083            {
9084                use std::sync::atomic::{AtomicBool, Ordering};
9085                static SAID: AtomicBool = AtomicBool::new(false);
9086                if !SAID.swap(true, Ordering::Relaxed) {
9087                    tracing::warn!("batch graph: BUILDER refused (layer weights/kinds)");
9088                }
9089            }
9090            return crate::gpu::BatchGraphOutcome::Declined;
9091        };
9092        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
9093            eprintln!("batch-build: {:.1} ms", _tb.elapsed().as_secs_f64() * 1e3);
9094        }
9095        crate::gpu::forward_batch_graph(
9096            &model,
9097            self.graph_kv_id,
9098            &layers,
9099            &self.inv_freq,
9100            hiddens,
9101            nh,
9102            nkv,
9103            hd,
9104            rd,
9105            self.hidden_size,
9106            self.intermediate_size,
9107            positions,
9108            self.kv_cache.max_seq_len,
9109            gemma,
9110            self.rms_eps as f32,
9111            self.attn_scale,
9112            k,
9113            &(0..self.num_layers)
9114                .map(|li| self.kv_cache.layers[self.phys_layer(li)].o1_views())
9115                .collect::<Vec<_>>(),
9116            self.o1_epoch,
9117            spec,
9118        )
9119    }
9120
9121    /// Same, stopping after layer `upto` inclusive (routing probe φ).
9122    /// `CMF_DSV4_DRAFT_PROBE=1` — grade the draft against what the trunk goes on
9123    /// to produce. Off by default; it runs a whole draft per decoded token.
9124    fn draft_probe() -> bool {
9125        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
9126        *ON.get_or_init(|| std::env::var("CMF_DSV4_DRAFT_PROBE").is_ok_and(|v| v != "0"))
9127    }
9128
9129    /// `CMF_DSV4_DRAFT_PROBE=1`: measure how much of the draft the trunk
9130    /// would have agreed with, WITHOUT verifying or rolling anything back.
9131    ///
9132    /// The number this produces decides the whole speculation design — at
9133    /// acceptance a, a block of B positions yields 1 + a + a² + ... tokens
9134    /// per trunk pass — so it is worth measuring before any of the machinery
9135    /// that would exploit it exists. Each draft is parked with the position
9136    /// it was made at, and graded as the real tokens arrive.
9137    /// `CMF_DSV4_SPEC=1` — the DeepSeek-V4 speculative decode: draft five
9138    /// on the card, verify them in one batched trunk pass, commit the
9139    /// accepted prefix, roll the rest back.
9140    #[cfg(feature = "gpu")]
9141    fn dsv4_spec_on() -> bool {
9142        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
9143        *ON.get_or_init(|| {
9144            // Test-only runtime gate: model loading still performs the same
9145            // reservation and trunk packing, which gives rollback parity a
9146            // topology-identical non-speculative control arm.
9147            if let Ok(v) = std::env::var("CMF_DSV4_SPEC_RUN") {
9148                return v != "0";
9149            }
9150            // An explicit value is a diagnostic force/escape hatch.  With no
9151            // knob, speculation is eligible only when model loading reserved
9152            // its bounded pack.  On small q4tp cards the geometric reserve
9153            // gate deliberately leaves this at zero: trying to build DSpark
9154            // after the exact trunk filled VRAM is both slower and a device
9155            // OOM (measured on A40).
9156            std::env::var("CMF_DSV4_SPEC")
9157                .map(|v| v != "0")
9158                .unwrap_or_else(|_| {
9159                    crate::gpu_wgpu::DRAFT_RESERVE.load(std::sync::atomic::Ordering::Relaxed) > 0
9160                })
9161        })
9162    }
9163
9164    /// One speculative round at the decode tip. `t_next` is the token the
9165    /// sampler just committed for `next_pos`. Returns the EXTRA accepted
9166    /// tokens (possibly none) and the new position, with `graph_logits`
9167    /// left holding the last accepted position's logits — exactly what the
9168    /// loop top expects. `None` means "speculate not this round": nothing
9169    /// was committed, the caller forwards normally.
9170    #[cfg(feature = "gpu")]
9171    fn dsv4_spec_step(
9172        &mut self,
9173        tip_token: u32,
9174        t_next: u32,
9175        next_pos: usize,
9176        max_extra: usize,
9177        drafted: &mut usize,
9178        accepted_ctr: &mut usize,
9179    ) -> Option<(Vec<u32>, usize)> {
9180        let t_all = std::time::Instant::now();
9181        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
9182            thread_local! {
9183                static LAST: std::cell::Cell<Option<std::time::Instant>> =
9184                    const { std::cell::Cell::new(None) };
9185            }
9186            LAST.with(|l| {
9187                if let Some(prev) = l.get() {
9188                    eprintln!(
9189                        "между раундами {:.1} мс",
9190                        prev.elapsed().as_secs_f64() * 1e3
9191                    );
9192                }
9193                l.set(Some(std::time::Instant::now()));
9194            });
9195        }
9196        if std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
9197            eprintln!("spec_step: вход pos={next_pos}");
9198        }
9199        let n_layers = self.dsv4.as_ref().map(|b| b.1.len())?;
9200        let cfg = self.dsv4.as_ref().map(|b| b.2)?;
9201        // The draft state and its capture, armed exactly as the probe does.
9202        if self.dspark.is_none() {
9203            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
9204            if t.is_empty() {
9205                return None;
9206            }
9207            crate::dsv4::dspark_arm(&t, cfg.dim);
9208            self.dspark = Some(crate::dsv4::DsparkState::new(
9209                self.dsv4_mtp.len(),
9210                &cfg,
9211                t.len(),
9212            ));
9213        }
9214        let targets = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
9215        let pack = crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg);
9216        if pack.is_none() && std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
9217            eprintln!("spec_step: пак не построился (targets {targets:?})");
9218        }
9219        let pack = pack?;
9220        let block = crate::dsv4::dspark_block();
9221        let b_box = self.dsv4.as_mut()?;
9222        let (g, layers, st) = (&b_box.0, &b_box.1, &mut b_box.3);
9223        let ds = self.dspark.as_mut()?;
9224        // The tip's captures: either this token ran on a normal path that
9225        // filled the thread-local, or the previous spec round left them.
9226        let dbg = std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok();
9227        if !crate::dsv4::dspark_take(&mut ds.main_hidden) && !ds.have_hidden {
9228            if dbg {
9229                eprintln!("spec_step: нет захвата");
9230            }
9231            return None;
9232        }
9233        ds.have_hidden = true;
9234        let tip_pos = next_pos.checked_sub(1)?;
9235        let draft_started = std::time::Instant::now();
9236        let mut conf = Vec::new();
9237        let props = crate::dsv4::dspark_draft_gpu(
9238            g,
9239            &self.dsv4_mtp,
9240            &cfg,
9241            ds,
9242            pack,
9243            st.kv_id,
9244            tip_token,
9245            tip_pos,
9246            self.pool.as_deref(),
9247            &mut conf,
9248        );
9249        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
9250        *drafted += block;
9251        if props.is_empty() || props[0] != t_next {
9252            if dbg {
9253                eprintln!(
9254                    "spec_step: черновик {} (props0={:?} t_next={t_next})",
9255                    if props.is_empty() {
9256                        "пуст"
9257                    } else {
9258                        "мимо"
9259                    },
9260                    props.first()
9261                );
9262            }
9263            return None;
9264        }
9265        // `fed[0]` is `t_next`, which the outer loop has already committed;
9266        // only `fed[1..]` become additional output tokens. Cap the verify
9267        // transaction itself to the caller's remaining output budget instead
9268        // of merely truncating the returned vector: otherwise the KV/state
9269        // would advance past `max_tokens` and a 64-token request could return
9270        // 66 tokens (and poison a reused session with two invisible steps).
9271        let mut k_verify = crate::dsv4::dspark_verify_k()
9272            .min(props.len())
9273            .min(max_extra.saturating_add(1));
9274        // Adaptive depth: positions the draft itself doubts are paid for on
9275        // every verify and delivered almost never (natural-text survival
9276        // [.67 .50 .29 .08 .04]). `CMF_DSPARK_CONF_MIN=p` trims the fed
9277        // prefix at the first proposal whose confidence drops below p; on
9278        // predictable text the confidences stay high and nothing changes.
9279        let conf_min = {
9280            static M: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
9281            *M.get_or_init(|| {
9282                std::env::var("CMF_DSPARK_CONF_MIN")
9283                    .ok()
9284                    .and_then(|v| v.parse().ok())
9285                    .unwrap_or(0.0)
9286            })
9287        };
9288        if conf_min > 0.0 && conf.len() >= props.len() {
9289            let mut keep = 1usize;
9290            while keep < k_verify && conf.get(keep).copied().unwrap_or(0.0) >= conf_min {
9291                keep += 1;
9292            }
9293            k_verify = k_verify.min(keep.max(2));
9294        }
9295        if k_verify < 2 {
9296            return None;
9297        }
9298        let mut fed = Vec::with_capacity(k_verify);
9299        fed.push(t_next);
9300        fed.extend_from_slice(&props[1..k_verify]);
9301        let mut argmax = Vec::new();
9302        let mut logits_all = Vec::new();
9303        let mut walked = Vec::new();
9304        let txn = crate::dsv4::dsv4_verify_chunk(
9305            g,
9306            layers,
9307            &cfg,
9308            st,
9309            &fed,
9310            next_pos,
9311            &self.inv_freq,
9312            self.pool.as_deref(),
9313            &targets,
9314            &mut argmax,
9315            &mut logits_all,
9316            &mut walked,
9317        );
9318        if txn.is_none() && dbg {
9319            eprintln!("spec_step: verify отказал");
9320        }
9321        let txn = txn?;
9322        let spec_gpu_end = txn.gpu_end;
9323        let b = fed.len();
9324        let mut accepted = 1usize;
9325        while accepted < b && fed[accepted] == argmax[accepted - 1] {
9326            accepted += 1;
9327        }
9328        // `CMF_DSV4_SPEC_FORCE_REJECT=1` — accept nothing beyond the known
9329        // token, every round: the pure rollback exerciser. The output must
9330        // stay byte-identical to the plain walk; anything else is a
9331        // transaction bug, isolated from the acceptance logic.
9332        if std::env::var("CMF_DSV4_SPEC_FORCE_REJECT").is_ok_and(|v| v != "0") {
9333            accepted = 1;
9334        }
9335        if std::env::var("CMF_DSV4_SPEC_TRACE").is_ok() {
9336            eprintln!("spec@{next_pos}: fed={fed:?} argmax={argmax:?} accepted={accepted}");
9337        }
9338        let t_fin = std::time::Instant::now();
9339        if !crate::dsv4::dsv4_spec_finish(
9340            g,
9341            layers,
9342            &cfg,
9343            st,
9344            txn,
9345            accepted,
9346            &fed,
9347            &self.inv_freq,
9348            self.pool.as_deref(),
9349        ) {
9350            tracing::warn!("dsv4: спекулятивный откат не удался — состояние подозрительно");
9351            return None;
9352        }
9353        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
9354            eprintln!(
9355                "finish(k={accepted}): {:.1} мс",
9356                t_fin.elapsed().as_secs_f64() * 1e3
9357            );
9358        }
9359        *accepted_ctr += accepted - 1;
9360        // Captures per accepted token: device targets photographed by the
9361        // batch, host targets from the verify's own walk. The last one
9362        // becomes the new tip's draft input; every one owes the ring an
9363        // entry for its position.
9364        let (hc, dim) = (cfg.hc_mult, cfg.dim);
9365        // Complete-chain layers are photographed by the fused submission;
9366        // partial device layers overwrite that slot after exact host cold-
9367        // expert correction.  Thus every target in the contiguous device
9368        // prefix has a valid per-token capture.
9369        let dev_caps: Vec<usize> = targets
9370            .iter()
9371            .copied()
9372            .filter(|&t| t < spec_gpu_end)
9373            .collect();
9374        let mut caps_all = vec![0.0f32; dev_caps.len() * b * hc * dim];
9375        if !crate::gpu_wgpu::dsv4_spec_cap_read_all(b, dev_caps.len(), hc * dim, &mut caps_all) {
9376            return None;
9377        }
9378        for t in 0..accepted {
9379            let tip = t + 1 == accepted;
9380            for (slot, &tl) in targets.iter().enumerate() {
9381                if let Some(di) = dev_caps.iter().position(|&d| d == tl) {
9382                    let lo = (di * b + t) * hc * dim;
9383                    crate::dsv4::dspark_capture(
9384                        &caps_all[lo..lo + hc * dim],
9385                        &cfg,
9386                        slot,
9387                        &mut ds.main_hidden,
9388                    );
9389                } else if tip
9390                    && crate::dsv4::dspark_peek_slot(slot, dim, {
9391                        let lo = slot * dim;
9392                        &mut ds.main_hidden[lo..lo + dim]
9393                    })
9394                {
9395                    // The tip's host-layer captures are the walk's own
9396                    // per-layer notes — exact. (The walk that ran last ended
9397                    // on exactly this token, on both the accept-all and the
9398                    // rollback path.)
9399                } else {
9400                    // Intermediate tokens: the post-tail state stands in for
9401                    // the per-layer capture on host targets below the last
9402                    // layer. Ring-entry quality only; the tip is exact.
9403                    crate::dsv4::dspark_capture(
9404                        &walked[t * hc * dim..(t + 1) * hc * dim],
9405                        &cfg,
9406                        slot,
9407                        &mut ds.main_hidden,
9408                    );
9409                }
9410            }
9411            crate::dsv4::dspark_ring_append(
9412                g,
9413                &self.dsv4_mtp,
9414                &cfg,
9415                ds,
9416                next_pos + t,
9417                self.pool.as_deref(),
9418            );
9419        }
9420        let row = logits_all[(accepted - 1) * cfg.vocab..accepted * cfg.vocab].to_vec();
9421        self.graph_logits = Some(row);
9422        // The speculative loop never runs the probe, so the trunk tally has
9423        // no other place to cycle. Armed only when someone asked for the
9424        // dump; the host tail is the only tallying path here, which is
9425        // precisely the population a partial pack would serve.
9426        if std::env::var("CMF_DSV4_TRUNK_PICK_DUMP").is_ok() {
9427            crate::dsv4::trunk_freq_note(&crate::dsv4::pick_tally_take());
9428            crate::dsv4::pick_tally_arm();
9429        }
9430        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
9431            eprintln!(
9432                "spec_step total {:.1} мс (k={accepted})",
9433                t_all.elapsed().as_secs_f64() * 1e3
9434            );
9435        }
9436        Some((fed[1..accepted].to_vec(), next_pos + accepted))
9437    }
9438
9439    fn dspark_probe(&mut self, position: usize, token_id: u32) {
9440        if self.dsv4_mtp.is_empty() || !Self::draft_probe() {
9441            return;
9442        }
9443        // What the trunk just routed to, for this token.
9444        let trunk_now = crate::dsv4::pick_tally_take();
9445        crate::dsv4::trunk_freq_note(&trunk_now);
9446        if !trunk_now.is_empty() {
9447            self.dspark_trunk_picks.push(trunk_now);
9448            let keep = crate::dsv4::dspark_block();
9449            if self.dspark_trunk_picks.len() > keep {
9450                self.dspark_trunk_picks.remove(0);
9451            }
9452        }
9453        // Grade whatever is waiting: the token just decoded sits at
9454        // `position`, so it answers the draft made at `position - 1 - i`.
9455        for p in std::mem::take(&mut self.dspark_pending) {
9456            let Some(i) = position.checked_sub(p.0 + 1) else {
9457                continue;
9458            };
9459            let mut p = p;
9460            if i < p.1.len() {
9461                if p.2 && p.1[i] == token_id {
9462                    p.3 = i + 1;
9463                } else {
9464                    p.2 = false;
9465                }
9466                if i + 1 < p.1.len() {
9467                    self.dspark_pending.push(p);
9468                    continue;
9469                }
9470            }
9471            self.dspark_hist.push(p.3);
9472            self.dspark_real.push(token_id);
9473        }
9474        let Some(b) = &mut self.dsv4 else { return };
9475        let (g, layers, cfg) = (&b.0, &b.1, b.2);
9476        let n_layers = layers.len();
9477        if self.dspark.is_none() {
9478            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
9479            if t.is_empty() {
9480                return;
9481            }
9482            eprintln!(
9483                "DSpark: захват со слоёв {t:?}, блок {}",
9484                crate::dsv4::dspark_block()
9485            );
9486            crate::dsv4::dspark_arm(&t, cfg.dim);
9487            self.dspark = Some(crate::dsv4::DsparkState::new(
9488                self.dsv4_mtp.len(),
9489                &cfg,
9490                t.len(),
9491            ));
9492        }
9493        let ds = self.dspark.as_mut().unwrap();
9494        if !crate::dsv4::dspark_take(&mut ds.main_hidden) {
9495            return; // this token ran on a path that captures nothing
9496        }
9497        let mut conf = Vec::new();
9498        crate::dsv4::pick_tally_arm();
9499        // The trunk has already consumed the adaptive VRAM budget. Until the
9500        // draft owns an explicit bounded device pack, its tensors are an
9501        // out-of-core CPU/disk tier by contract: never let per-op probes try
9502        // to squeeze another multi-gigabyte MTP expert cache onto the card.
9503        let draft_started = std::time::Instant::now();
9504        #[cfg(feature = "gpu")]
9505        let gpu_draft = crate::dsv4::dspark_gpu_on();
9506        #[cfg(not(feature = "gpu"))]
9507        let gpu_draft = false;
9508        let props = if gpu_draft {
9509            #[cfg(feature = "gpu")]
9510            {
9511                let kv_id = b.3.kv_id;
9512                match crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg) {
9513                    Some(pk) => crate::dsv4::dspark_draft_gpu(
9514                        g,
9515                        &self.dsv4_mtp,
9516                        &cfg,
9517                        ds,
9518                        pk,
9519                        kv_id,
9520                        token_id,
9521                        position,
9522                        self.pool.as_deref(),
9523                        &mut conf,
9524                    ),
9525                    None => Vec::new(),
9526                }
9527            }
9528            #[cfg(not(feature = "gpu"))]
9529            Vec::new()
9530        } else {
9531            crate::gpu::cpu_scope(|| {
9532                crate::dsv4::dspark_draft(
9533                    g,
9534                    &self.dsv4_mtp,
9535                    &cfg,
9536                    ds,
9537                    token_id,
9538                    position,
9539                    self.pool.as_deref(),
9540                    &mut conf,
9541                )
9542            })
9543        };
9544        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
9545        let draft_picks = crate::dsv4::pick_tally_take();
9546        crate::dsv4::dspark_freq_note(&draft_picks);
9547        // Re-arm for the NEXT trunk token; the probe runs after the forward,
9548        // so this is the only place that can.
9549        crate::dsv4::pick_tally_arm();
9550        if !props.is_empty() {
9551            // Two ratios, side by side: what a batched verify over the trunk
9552            // would read against what it asks for, and the same for the
9553            // draft's three stages. Near 1.0 means a batch amortises nothing.
9554            let (tu, tt) = {
9555                let flat: Vec<(usize, Vec<usize>)> = self
9556                    .dspark_trunk_picks
9557                    .iter()
9558                    .flat_map(|v| v.iter().cloned())
9559                    .collect();
9560                // Per layer, across the window of tokens.
9561                let mut per: std::collections::HashMap<usize, Vec<usize>> =
9562                    std::collections::HashMap::new();
9563                for (li, picks) in flat {
9564                    per.entry(li).or_default().extend(picks);
9565                }
9566                let n = per.len().max(1);
9567                let mut u = 0usize;
9568                let mut t = 0usize;
9569                for (_, v) in per {
9570                    t += v.len();
9571                    u += v.iter().collect::<std::collections::HashSet<_>>().len();
9572                }
9573                (u / n, t / n)
9574            };
9575            let (du, dt) = crate::dsv4::tally_unique(&draft_picks);
9576            self.dspark_exp.push((tu, tt, du, dt));
9577            self.dspark_pending.push((position, props, true, 0));
9578        }
9579        if self.dspark_hist.len() >= 8 && self.dspark_hist.len() % 8 == 0 {
9580            let n = self.dspark_hist.len() as f32;
9581            let mean: f32 = self.dspark_hist.iter().sum::<usize>() as f32 / n;
9582            let block = crate::dsv4::dspark_block();
9583            let mut at = vec![0usize; block + 1];
9584            for &k in &self.dspark_hist {
9585                at[k] += 1;
9586            }
9587            // Prefix survival: S_i = P(the first i positions all held).
9588            let mut surv = Vec::with_capacity(block);
9589            for i in 1..=block {
9590                let k = at[i..].iter().sum::<usize>() as f32 / n;
9591                surv.push(format!("{k:.2}"));
9592            }
9593            let distinct = self
9594                .dspark_real
9595                .iter()
9596                .collect::<std::collections::HashSet<_>>()
9597                .len();
9598            let (tu, tt, du, dt) = self.dspark_exp.iter().fold((0, 0, 0, 0), |a, b| {
9599                (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3)
9600            });
9601            let m = self.dspark_exp.len().max(1);
9602            eprintln!(
9603                "DSpark: черновиков {}, принято в среднем {mean:.2} из {block} \
9604                 (токенов за проход {:.2}), распределение {at:?}, выживание [{}]",
9605                self.dspark_hist.len(),
9606                mean + 1.0,
9607                surv.join(" ")
9608            );
9609            eprintln!(
9610                "DSpark: разных токенов {distinct} из {} (вырожденность), \
9611                 эксперты ствол {}/{} на слой за {block} токенов, \
9612                 черновик {}/{} за блок, draft {:.2} мс/блок",
9613                self.dspark_real.len(),
9614                tu / m,
9615                tt / m,
9616                du / m,
9617                dt / m,
9618                self.dspark_draft_ns as f64 / self.dspark_exp.len().max(1) as f64 / 1e6
9619            );
9620        }
9621    }
9622
9623    fn forward_layers_upto(
9624        &mut self,
9625        hidden: &[f32],
9626        position: usize,
9627        task_mask: Option<&TaskMask>,
9628        upto: Option<usize>,
9629    ) -> Vec<f32> {
9630        // In-process multi-GPU: each segment runs pinned to its card,
9631        // and the only thing crossing the boundary is one hidden vector
9632        // that never leaves this address space. Same layer split the
9633        // network mode does, minus the second process, the socket, the
9634        // serialization and the dir_hash handshake.
9635        if let Some(plan) = self.gpu_plan.clone() {
9636            if upto.is_none() && plan.len() > 1 {
9637                let mut h = hidden.to_vec();
9638                for &(dev, from, upto_incl) in plan.iter() {
9639                    h = crate::gpu::with_device(dev, || {
9640                        self.forward_layers_span(&h, position, task_mask, from, Some(upto_incl))
9641                    });
9642                }
9643                return h;
9644            }
9645        }
9646        self.forward_layers_span(hidden, position, task_mask, 0, upto)
9647    }
9648
9649    /// Split this pipeline's layer stack across local GPUs: segment i
9650    /// runs on `devices[i]`. Contiguous and even by layer count — the
9651    /// VRAM-weighted planner is the next step, and an uneven card pair
9652    /// is why it will be needed. `None` clears the plan.
9653    pub fn set_gpu_plan(&mut self, devices: Option<&[usize]>) -> Result<(), String> {
9654        self.set_gpu_plan_at(devices, None)
9655    }
9656
9657    /// The same, with an explicit first boundary (`--peer-split`): card
9658    /// 0 takes layers `[0..at)`, the rest split what remains. Uneven
9659    /// cards, or an attention-heavy head, are why this knob exists.
9660    pub fn set_gpu_plan_at(
9661        &mut self,
9662        devices: Option<&[usize]>,
9663        at: Option<usize>,
9664    ) -> Result<(), String> {
9665        let Some(devs) = devices.filter(|d| d.len() > 1) else {
9666            self.gpu_plan = None;
9667            return Ok(());
9668        };
9669        self.split_supported()?;
9670        let n = self.num_layers;
9671        if devs.len() > n {
9672            return Err(format!("{} devices for {n} layers", devs.len()));
9673        }
9674        if let Some(k) = at {
9675            if k == 0 || k >= n {
9676                return Err(format!("split at {k}: the model has {n} layers"));
9677            }
9678            if devs.len() == 2 {
9679                self.gpu_plan = Some(std::sync::Arc::new(vec![
9680                    (devs[0], 0, k - 1),
9681                    (devs[1], k, n - 1),
9682                ]));
9683                return Ok(());
9684            }
9685            return Err(format!(
9686                "an explicit split point takes exactly 2 devices, got {}",
9687                devs.len()
9688            ));
9689        }
9690        let per = n.div_ceil(devs.len());
9691        let mut plan = Vec::with_capacity(devs.len());
9692        let mut from = 0usize;
9693        for &d in devs {
9694            if from >= n {
9695                break;
9696            }
9697            let upto = (from + per - 1).min(n - 1);
9698            plan.push((d, from, upto));
9699            from = upto + 1;
9700        }
9701        self.gpu_plan = Some(std::sync::Arc::new(plan));
9702        Ok(())
9703    }
9704
9705    /// The active in-process split, if any: (device, first layer, last).
9706    pub fn gpu_plan(&self) -> Option<Vec<(usize, usize, usize)>> {
9707        self.gpu_plan.as_ref().map(|p| p.as_ref().clone())
9708    }
9709
9710    /// Layer span [from ..= upto] (upto None = last layer): the building
9711    /// block the network pipeline-split rides on. `from > 0` skips the
9712    /// arch escape hatches (the pub `forward_span` refuses those archs
9713    /// first) and the whole-token graph — the plain per-layer loop is
9714    /// the canonical executor for a partial stack.
9715    fn forward_layers_span(
9716        &mut self,
9717        hidden: &[f32],
9718        position: usize,
9719        task_mask: Option<&TaskMask>,
9720        from: usize,
9721        upto: Option<usize>,
9722    ) -> Vec<f32> {
9723        debug_assert!(
9724            from == 0
9725                || (self.dsv4.is_none()
9726                    && self.dsv41.is_none()
9727                    && self.qwen4_exp.is_none()
9728                    && self.g3n.is_none())
9729        );
9730        if let Some(b) = &mut self.qwen4_exp {
9731            let _ = (task_mask, upto);
9732            let token_id = hidden.first().copied().unwrap_or(0.0) as u32;
9733            let mut logits = Vec::new();
9734            crate::qwen4_exp::forward_token(
9735                &b.0,
9736                &b.1,
9737                &b.2,
9738                &mut b.3,
9739                token_id,
9740                position,
9741                &self.inv_freq,
9742                self.pool.as_deref(),
9743                &mut logits,
9744                true,
9745            );
9746            self.graph_logits = Some(logits);
9747            return vec![0.0; self.hidden_size];
9748        }
9749        // DeepSeek-V4 runs its own stack: the state is hc_mult copies, and
9750        // the forward returns LOGITS, not a hidden — the head is inside it
9751        // (the final fold sits between the last layer and the norm). The
9752        // token id rides in `hidden[0]`, written by embed_single, because
9753        // the hash layers route by id rather than by content.
9754        if let Some(b) = &mut self.dsv4 {
9755            let _ = (task_mask, upto);
9756            let token_id = hidden.first().copied().unwrap_or(0.0) as u32;
9757            let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
9758            st.pos = position;
9759            let mut logits = Vec::new();
9760            crate::dsv4::forward_token(
9761                g,
9762                layers,
9763                &cfg,
9764                st,
9765                token_id,
9766                &self.inv_freq,
9767                self.pool.as_deref(),
9768                &mut logits,
9769            );
9770            self.graph_logits = Some(logits);
9771            self.dspark_probe(position, token_id);
9772            // The caller expects a hidden; the logits went out of band, as
9773            // with the fused lm_head path.
9774            return vec![0.0; self.hidden_size];
9775        }
9776        // DeepSeek-V4.1 owns its complete stack and emits logits out of band.
9777        if let Some(b) = &mut self.dsv41 {
9778            let _ = (task_mask, upto);
9779            let token_id = hidden.first().copied().unwrap_or(0.0) as u32;
9780            let mut logits = Vec::new();
9781            crate::dsv41::forward_token(
9782                &b.0,
9783                &b.1,
9784                &b.2,
9785                &mut b.3,
9786                token_id,
9787                position,
9788                self.pool.as_deref(),
9789                &mut logits,
9790            );
9791            self.graph_logits = Some(logits);
9792            return vec![0.0; self.hidden_size];
9793        }
9794        // Gemma-3n runs its own stack (4 AltUp replicas don't fit this
9795        // loop); `hidden` is the extended embedding from embed_single.
9796        if let Some(b) = &self.g3n {
9797            let _ = (task_mask, upto);
9798            return crate::g3n::g3n_forward(
9799                &b.0,
9800                &b.1,
9801                hidden,
9802                position,
9803                &mut self.kv_cache.layers,
9804                self.num_heads,
9805                self.num_kv_heads,
9806                self.head_dim,
9807                self.pool.as_deref(),
9808            );
9809        }
9810        let mut h = hidden.to_vec();
9811        // Split borrows: copy scalars / clone handles so the per-layer
9812        // cfg does not hold `&self` while the KV cache is `&mut`.
9813        let (nh, _nkv, _hd, hs, _rd, eps) = (
9814            self.num_heads,
9815            self.num_kv_heads,
9816            self.head_dim,
9817            self.hidden_size,
9818            self.rotary_dim,
9819            self.rms_eps,
9820        );
9821        let pool = self.pool.clone();
9822        // Opt-in wgpu token-graph attention (discrete Vulkan/DX12): the whole
9823        // attention sub-block runs resident in one submit. Off by default.
9824        // Whole-token wgpu graph: eligibility + arbitration.
9825        //  - explicit CMF_GPU_WGPU_GRAPH forces it on/off;
9826        //  - discrete adapters (4090: decode 76 -> 137 tok/s) and GDN
9827        //    hybrids (recurrent state device-resident, no CPU twin to
9828        //    race) TRUST it;
9829        //  - integrated/mobile adapters RACE it against the normal path
9830        //    at generation granularity (gpu::graph_race_*) — tiled
9831        //    mobile GPUs can turn the ~300-dispatch graph into seconds
9832        //    per token, while a fast phone GPU keeps its win.
9833        let graph_env = std::env::var("CMF_GPU_WGPU_GRAPH").ok();
9834        let graph_on = match graph_env.as_deref() {
9835            Some("0") => false,
9836            Some("prefill") => false, // decode keeps the per-op path
9837            Some(_) => true,
9838            // Unset: same discrete-only default as every other graph
9839            // site. "Is the GPU on" used to stand in here — which made
9840            // the 0.2 tok/s whole-token graph race-eligible on mobile
9841            // adapters and cost 12-14× on first tokens (cmfmobile
9842            // TUNING.md); integrated GPUs keep the per-op probe path.
9843            None => crate::gpu::wgpu_graph_default(),
9844        };
9845        let graph_trusted =
9846            graph_env.is_some() || crate::gpu::wgpu_graph_default() || self.gdn_cfg.is_some();
9847        let race_eligible = graph_on
9848            && upto.is_none()
9849            && task_mask.is_none()
9850            && from == 0
9851            && !crate::gpu::graph_unsupported();
9852        let mut tail_start = 0usize;
9853        if race_eligible && crate::gpu::graph_race_use_graph(graph_trusted) {
9854            let t_graph = std::time::Instant::now();
9855            let mut lg = Vec::new();
9856            let mut gl = 0usize;
9857            let built = self.try_token_graph_wgpu(hidden, position, &mut lg, &mut gl);
9858            let declined = built.is_none();
9859            let built = match built {
9860                Some(Ok(hh)) => Some(hh),
9861                Some(Err(())) => {
9862                    // O(1) state was admitted before the device failure; the
9863                    // CPU mirrors are stale by construction.  Clear the whole
9864                    // sequence and stop rather than walking that stale state.
9865                    self.clear_sequence_state();
9866                    self.graph_failed
9867                        .store(true, std::sync::atomic::Ordering::Relaxed);
9868                    self.cancel
9869                        .store(true, std::sync::atomic::Ordering::Relaxed);
9870                    tracing::error!("token graph failed after admission; sequence state cleared");
9871                    return vec![0.0; self.hidden_size];
9872                }
9873                None => None,
9874            };
9875            // Past the transient guards (o1 still collecting, a softcap)
9876            // a refusal is about the weights and will never change —
9877            // remember it instead of walking every layer again next
9878            // token.
9879            if declined && !self.o1_active() && self.attn_softcap == 0.0 {
9880                crate::gpu::graph_mark_unsupported();
9881            }
9882            graph_note(built.is_some(), gl, self.num_layers);
9883            if let Some(hh) = built {
9884                let dur = t_graph.elapsed();
9885                if std::env::var("CMF_GRAPH_PROF").is_ok() {
9886                    eprintln!("graph-call: {:.2} ms total", dur.as_secs_f64() * 1000.0);
9887                }
9888                if gl > 0 && gl < self.num_layers {
9889                    // Device prefix: the graph ran layers 0..gl and handed
9890                    // back the boundary hidden — the loop below owns the
9891                    // tail. The prefix layers' KV/state advanced on the
9892                    // device; the tail's advances on the host below. One
9893                    // boundary crossing per token.
9894                    h = hh;
9895                    tail_start = gl;
9896                } else if graph_trusted || !crate::gpu::graph_race_first_token_hopeless(dur) {
9897                    if !graph_trusted {
9898                        crate::gpu::graph_race_record(true, dur);
9899                    }
9900                    if !lg.is_empty() {
9901                        // Graph produced logits (final-norm + lm_head folded in) —
9902                        // pad/cap to vocab and hand them to the sampler directly.
9903                        lg.resize(self.vocab_size, 0.0);
9904                        if let Some(c) = self.final_softcap {
9905                            for l in lg.iter_mut() {
9906                                *l = c * (*l / c).tanh();
9907                            }
9908                        }
9909                        self.graph_logits = Some(lg);
9910                    }
9911                    return hh;
9912                }
9913                // Hopeless first graph token: discard it and fall through
9914                // to the normal path. Safe exactly here — the prompt KV is
9915                // still CPU-owned (chunked prefill), so recomputing this
9916                // position is exact; the mirror's extra row is never read
9917                // (the race just settled on the normal path).
9918            }
9919        }
9920        // KIMI-LINEAR HAS NO SPLIT BUG. The 2.6× reported from the
9921        // model rotation (12.2 tok/s on one card against 4.6 on two)
9922        // was a single measurement of a model whose arm arbitration is
9923        // borderline, and it did not survive repetition. Three runs an
9924        // arm, same binary, back to back:
9925        //   probe on : 1 GPU 9.5 / 5.7 / 5.9   2 GPU 7.8 / 13.0 / 13.3
9926        //   pinned   : 1 GPU 5.6 / 5.3 / 5.2   2 GPU 3.5 / 4.2 / 3.4
9927        // With the arms pinned the split costs about 1.45×, which is
9928        // what a layer split costs. With the probe free, TWO CARDS RUN
9929        // FASTER — because for this model the CPU arm wins some op
9930        // classes and the probe finds that.
9931        //
9932        // Two things do stand, and both are measured. The token graph
9933        // builds NOTHING here (`covered 0 of 14 layers [0..14)`), so
9934        // every layer walks per-op on either arm — that is where the
9935        // headroom is, not in the split. And this model's benchmark is
9936        // unusable without `CMF_GPU_PROBE=0`: the arbitration alone
9937        // moves it by more than 2×.
9938        //
9939        // Span runs (network split): the graph covers exactly [from..=upto]
9940        // — one submit per SEGMENT per token. No race: its state is global
9941        // and calibrated on full stacks, so spans take the graph only where
9942        // it is trusted by default (discrete adapters / CMF_GPU_WGPU_GRAPH).
9943        let span = from > 0 || upto.is_some();
9944        if span && graph_on && task_mask.is_none() && graph_trusted {
9945            let upto_excl = upto.map_or(self.num_layers, |u| u + 1);
9946            let mut lg = Vec::new();
9947            let mut gl = 0usize;
9948            let span_res =
9949                self.try_token_graph_wgpu_span(hidden, position, &mut lg, from, upto_excl, &mut gl);
9950            let span_res = match span_res {
9951                Some(Ok(hh)) => Some(hh),
9952                Some(Err(())) => {
9953                    self.clear_sequence_state();
9954                    self.graph_failed
9955                        .store(true, std::sync::atomic::Ordering::Relaxed);
9956                    self.cancel
9957                        .store(true, std::sync::atomic::Ordering::Relaxed);
9958                    tracing::error!(
9959                        "span token graph failed after admission; sequence state cleared"
9960                    );
9961                    return vec![0.0; self.hidden_size];
9962                }
9963                None => None,
9964            };
9965            graph_note(span_res.is_some(), gl, upto_excl - from);
9966            if std::env::var("CMF_GPU_DEBUG").is_ok() {
9967                // How much of the span the graph actually covered. A
9968                // prefix of nothing means every layer walks per-op and
9969                // the split's extra cost is elsewhere.
9970                static SEEN: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
9971                if SEEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 4 {
9972                    eprintln!(
9973                        "span graph: covered {gl} of {} layers [{from}..{upto_excl}) res={}",
9974                        upto_excl - from,
9975                        span_res.is_some()
9976                    );
9977                }
9978            }
9979            if let Some(hh) = span_res {
9980                if gl == upto_excl - from {
9981                    if !lg.is_empty() {
9982                        lg.resize(self.vocab_size, 0.0);
9983                        if let Some(c) = self.final_softcap {
9984                            for l in lg.iter_mut() {
9985                                *l = c * (*l / c).tanh();
9986                            }
9987                        }
9988                        self.graph_logits = Some(lg);
9989                    }
9990                    crate::gpu::set_layer(-1);
9991                    return hh;
9992                }
9993                // Partial device prefix of the span: CPU owns the tail.
9994                h = hh;
9995                tail_start = from + gl;
9996            }
9997        }
9998        let t_race_cpu = (race_eligible && !graph_trusted).then(std::time::Instant::now);
9999
10000        // A partial graph is an explicit GPU-prefix / CPU-tail split. Keep
10001        // the tail PURE host-side: letting its QTensor hooks re-enter the
10002        // residency arena streams every omitted layer through Vulkan and the
10003        // driver's freed-allocation cache can grow to the full model size
10004        // (25.4 GiB observed with a 14 GiB budget on Granite 30B Q8_2F).
10005        let _host_tail = (tail_start > from).then(crate::gpu::enter_cpu_scope);
10006        let automatic_gpu_prefix = self.automatic_gpu_prefix();
10007
10008        #[cfg(target_os = "macos")]
10009        let mut gpu_skip_until = 0usize;
10010        for li in tail_start.max(from)..self.num_layers {
10011            let _capacity_tail = automatic_gpu_prefix
10012                .filter(|&prefix| li >= prefix)
10013                .map(|_| crate::gpu::enter_cpu_scope());
10014            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU (CMF_GPU_LAYERS)
10015            if let Some(u) = upto {
10016                if li > u {
10017                    break;
10018                }
10019            }
10020            if let Some(mask) = task_mask {
10021                if !mask.layer_alive(li) {
10022                    continue; // dead layer: residual pass-through
10023                }
10024            }
10025            // Whole-block q1 token graph: a run of consecutive q1
10026            // layers — GDN and full attention — executes with one sync
10027            // per CPU attend instead of per op (macOS/Metal).
10028            #[cfg(target_os = "macos")]
10029            {
10030                if li < gpu_skip_until {
10031                    continue;
10032                }
10033                if task_mask.is_none() {
10034                    let end = self.q1_graph_gpu(li, upto, position, &mut h);
10035                    if end > li {
10036                        gpu_skip_until = end;
10037                        // Looped Transformer: the graph stopped at a loop
10038                        // boundary — apply final norm before the next iteration.
10039                        if self.is_loop_end(end - 1) && end < self.num_layers {
10040                            h = inference::rms_norm(
10041                                &h,
10042                                &self.weights.final_norm,
10043                                self.rms_eps,
10044                                self.norm_style,
10045                            );
10046                        }
10047                        continue;
10048                    }
10049                }
10050            }
10051
10052            let lw = &self.weights.layers[self.phys_layer(li)];
10053            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
10054                if tp.parse::<usize>().ok() == Some(position) {
10055                    let n: f32 = h.iter().map(|x| x * x).sum::<f32>().sqrt();
10056                    eprintln!(
10057                        "TRACE pos {position} layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
10058                        h[0], h[1]
10059                    );
10060                }
10061            }
10062            // Norm into the pipeline scratch — the returning rms_norm
10063            // allocated twice per layer per token (roadmap §3 P0).
10064            inference::rms_norm_into(
10065                &h,
10066                &lw.input_norm,
10067                self.rms_eps,
10068                self.norm_style,
10069                &mut self.ws.n1,
10070            );
10071
10072            let attn_out = match &lw.attn {
10073                AttnKind::Mla(w) => {
10074                    let inv_freq_l = self.layer_inv_freq(li);
10075                    let rs = self.layer_rope_scale(li);
10076                    let eps = self.rms_eps;
10077                    let pool = self.pool.clone();
10078                    mla_attention(
10079                        w,
10080                        &self.ws.n1,
10081                        &mut self.kv_cache.layers[li],
10082                        position,
10083                        &inv_freq_l,
10084                        rs,
10085                        eps,
10086                        pool.as_deref(),
10087                    )
10088                }
10089                AttnKind::Linear(w) => {
10090                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
10091                    vmf_phase_forward(
10092                        &self.ws.n1,
10093                        w,
10094                        &cfg,
10095                        &mut self.kv_cache.layers[li].linear_state,
10096                        self.pool.as_deref(),
10097                    )
10098                }
10099                AttnKind::Kda(w) => {
10100                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
10101                    crate::linear_core::kda_forward(
10102                        &self.ws.n1,
10103                        w,
10104                        &cfg,
10105                        &mut self.kv_cache.layers[li].linear_state,
10106                        self.pool.as_deref(),
10107                    )
10108                }
10109                AttnKind::LinearGdn(w) => {
10110                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
10111                    gdn_forward(
10112                        &self.ws.n1,
10113                        w,
10114                        &cfg,
10115                        &mut self.kv_cache.layers[li].linear_state,
10116                        self.pool.as_deref(),
10117                    )
10118                }
10119                AttnKind::ShortConv(w) => {
10120                    let cfg = self
10121                        .short_conv_cfg
10122                        .expect("short-conv layer without short_conv_cfg");
10123                    short_conv_forward(
10124                        &self.ws.n1,
10125                        w,
10126                        &cfg,
10127                        &mut self.kv_cache.layers[li].linear_state,
10128                        self.pool.as_deref(),
10129                    )
10130                }
10131                AttnKind::Full {
10132                    wq,
10133                    wk,
10134                    wv,
10135                    wo,
10136                    q_norm,
10137                    k_norm,
10138                    output_gate,
10139                    softplus_gate,
10140                    bias,
10141                } if self.kv_cache.layers[li].o1_sealed() => {
10142                    // O(1) override: decode on the sealed Nyström state
10143                    // instead of the growing KV cache.
10144                    let inv_freq_l = self.layer_inv_freq(li);
10145                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
10146                    let cfg = QwenAttnCfg {
10147                        num_heads: self.layer_num_heads(li),
10148                        num_kv_heads: nkv_l,
10149                        head_dim: hd_l,
10150                        hidden_size: hs,
10151                        position,
10152                        inv_freq: &inv_freq_l,
10153                        rotary_dim: rd_l,
10154                        scale: self.attn_scale,
10155                        softcap: self.attn_softcap,
10156                        window: None,
10157                        v_norm: self.attn_v_norm,
10158                        q_norm: q_norm.as_deref(),
10159                        k_norm: k_norm.as_deref(),
10160                        output_gate: *output_gate,
10161                        softplus_gate: softplus_gate
10162                            .as_ref()
10163                            .map(|(gate, per_head)| (gate, *per_head)),
10164                        rope_scale: self.layer_rope_scale(li),
10165                        bias: bias
10166                            .as_ref()
10167                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
10168                        rms_eps: eps,
10169                        norm_style: self.norm_style,
10170                        pool: pool.as_deref(),
10171                    };
10172                    attention::qwen_attention_nystrom(
10173                        &self.ws.n1,
10174                        wq,
10175                        wk,
10176                        wv,
10177                        wo,
10178                        &mut self.kv_cache.layers[li],
10179                        &cfg,
10180                    )
10181                }
10182                AttnKind::Full {
10183                    wq,
10184                    wk,
10185                    wv,
10186                    wo,
10187                    q_norm,
10188                    k_norm,
10189                    output_gate,
10190                    softplus_gate,
10191                    bias,
10192                } => 'attn: {
10193                    // wgpu token-graph attention (opt-in): whole sub-block in
10194                    // one submit, device K/V mirror. q1 only, no gate/bias/mask.
10195                    if graph_on
10196                        && !*output_gate
10197                        && softplus_gate.is_none()
10198                        && self.attention_heads_per_layer.is_none()
10199                        && bias.is_none()
10200                        && task_mask.is_none()
10201                    {
10202                        let inv_freq_l = self.layer_inv_freq(li);
10203                        let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
10204                        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
10205                        if let (Some((gm, qi)), Some((_, ki)), Some((_, vi)), Some((_, oi))) = (
10206                            wq.mapped_q1(),
10207                            wk.mapped_q1(),
10208                            wv.mapped_q1(),
10209                            wo.mapped_q1(),
10210                        ) {
10211                            let gm = gm.clone();
10212                            let mut out = vec![0f32; hs];
10213                            let cache = &self.kv_cache.layers[li];
10214                            if crate::gpu::attn_dropin(
10215                                &gm,
10216                                self.graph_kv_id,
10217                                li,
10218                                &self.ws.n1,
10219                                qi,
10220                                ki,
10221                                vi,
10222                                oi,
10223                                q_norm.as_deref(),
10224                                k_norm.as_deref(),
10225                                &inv_freq_l,
10226                                nh,
10227                                nkv_l,
10228                                hd_l,
10229                                rd_l,
10230                                hs,
10231                                position,
10232                                self.kv_cache.max_seq_len,
10233                                gemma,
10234                                eps as f32,
10235                                cache.k_heads(),
10236                                cache.v_heads(),
10237                                &mut out,
10238                            ) {
10239                                break 'attn out;
10240                            }
10241                        }
10242                    }
10243                    let masked = task_mask
10244                        .map(|m| m.head_flags(li, self.num_heads).iter().any(|&a| !a))
10245                        .unwrap_or(false);
10246                    let f32_view = (wq.as_f32(), wk.as_f32(), wv.as_f32(), wo.as_f32());
10247                    match (masked, f32_view) {
10248                        // Historical masked path (f32 slices; the loader
10249                        // keeps masked models in f32).
10250                        (true, (Some(q), Some(k), Some(v), Some(o))) => {
10251                            let active_heads = task_mask.unwrap().head_flags(li, self.num_heads);
10252                            attention::multi_head_attention(
10253                                &self.ws.n1,
10254                                q,
10255                                k,
10256                                v,
10257                                o,
10258                                &mut self.kv_cache.layers[li],
10259                                self.num_heads,
10260                                self.num_kv_heads,
10261                                self.head_dim,
10262                                self.hidden_size,
10263                                position,
10264                                &active_heads,
10265                                &self.inv_freq,
10266                            )
10267                        }
10268                        (masked, _) => {
10269                            if masked {
10270                                tracing::warn!(
10271                                    "layer {li}: head mask on quantized weights not \
10272                                     supported yet — executing dense"
10273                                );
10274                            }
10275                            let inv_freq_l = self.layer_inv_freq(li);
10276                            let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
10277                            let cfg = QwenAttnCfg {
10278                                num_heads: self.layer_num_heads(li),
10279                                num_kv_heads: nkv_l,
10280                                head_dim: hd_l,
10281                                hidden_size: hs,
10282                                position,
10283                                inv_freq: &inv_freq_l,
10284                                rotary_dim: rd_l,
10285                                scale: self.attn_scale,
10286                                softcap: self.attn_softcap,
10287                                window: self.layer_window(li),
10288                                v_norm: self.attn_v_norm,
10289                                q_norm: q_norm.as_deref(),
10290                                k_norm: k_norm.as_deref(),
10291                                output_gate: *output_gate,
10292                                softplus_gate: softplus_gate
10293                                    .as_ref()
10294                                    .map(|(gate, per_head)| (gate, *per_head)),
10295                                rope_scale: self.layer_rope_scale(li),
10296                                bias: bias
10297                                    .as_ref()
10298                                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
10299                                rms_eps: eps,
10300                                norm_style: self.norm_style,
10301                                pool: pool.as_deref(),
10302                            };
10303                            attention::qwen_attention(
10304                                &self.ws.n1,
10305                                wq,
10306                                wk,
10307                                wv,
10308                                wo,
10309                                &mut self.kv_cache.layers[li],
10310                                &cfg,
10311                            )
10312                        }
10313                    }
10314                }
10315            };
10316            // Gemma sandwich norm: normalize the attention branch before
10317            // it joins the residual stream.
10318            let attn_out = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
10319                Some(w) => inference::rms_norm(&attn_out, w, self.rms_eps, self.norm_style),
10320                None => attn_out,
10321            };
10322            let lw = &self.weights.layers[self.phys_layer(li)];
10323            inference::add_rmsnorm_fused_into(
10324                &mut h,
10325                &attn_out,
10326                &lw.post_norm,
10327                self.rms_eps,
10328                self.norm_style,
10329                &mut self.ws.p1,
10330            );
10331            let mut attn_out = attn_out;
10332            attention::recycle_buf(&mut attn_out);
10333            let post_normed = &self.ws.p1;
10334
10335            let ffn_masked = task_mask
10336                .map(|m| m.ffn_active_count(li) < self.intermediate_size)
10337                .unwrap_or(false);
10338            // One masked dense CONTRACT, dispatched by cost. The
10339            // activation-zeroing arm (the batched sweep's, validated
10340            // against the replica to 0.8%) computes the FULL fused FFN
10341            // and zeroes the dead — right whenever most neurons live.
10342            // The sparse arm reads ONLY active rows and down columns —
10343            // per-row dots are slower per element than the fused kernel,
10344            // so it pays only once the mask is deep enough. The 0.5
10345            // crossover is first-principles (fused kernels run ~2x the
10346            // per-row dot throughput); a shallow specialist (95% alive)
10347            // stays fused, a --target-sparsity bake flips arms on its
10348            // own weight.
10349            let ffn_out = match (ffn_masked, &lw.ffn) {
10350                // A defragged tube layer answers its own mask: the core
10351                // always runs, each tube runs when its bit is on, and
10352                // the tubes that are off are never read from the mmap.
10353                (_, FfnKind::Dense(d)) if !d.segs.is_empty() => {
10354                    let row = task_mask
10355                        .and_then(|tm| tm.ffn_masks.get(li))
10356                        .map(|v| v.as_slice());
10357                    tube_ffn(d, post_normed, 1, self.pool.as_deref(), row)
10358                }
10359                (true, FfnKind::Dense(d)) => {
10360                    let tm = task_mask.unwrap();
10361                    let alive = tm.ffn_active_count(li);
10362                    let deep = alive * 2 <= self.intermediate_size;
10363                    if deep && d.down_proj.sparse_col_ok() {
10364                        let active = tm.ffn_active_indices(li);
10365                        sparse_ffn_quant(
10366                            d,
10367                            post_normed,
10368                            &active,
10369                            self.hidden_size,
10370                            self.pool.as_deref(),
10371                        )
10372                    } else if deep
10373                        && let (Some(g), Some(u), Some(dn)) = (
10374                            d.gate_proj.as_f32(),
10375                            d.up_proj.as_f32(),
10376                            d.down_proj.as_f32(),
10377                        )
10378                    {
10379                        let active = tm.ffn_active_indices(li);
10380                        inference::sparse_ffn_forward(
10381                            post_normed,
10382                            g,
10383                            u,
10384                            dn,
10385                            self.hidden_size,
10386                            self.intermediate_size,
10387                            &active,
10388                            self.pool.as_deref(),
10389                        )
10390                    } else {
10391                        let row = tm.ffn_masks.get(li).map(|v| v.as_slice());
10392                        dense_ffn_batch(d, post_normed, 1, self.pool.as_deref(), row)
10393                    }
10394                }
10395                (true, FfnKind::Moe(m)) => {
10396                    // MoE is sparse by expert selection; a task mask
10397                    // narrows the ROUTABLE set via its expert fields
10398                    // (spec §5) when it carries them.
10399                    let allowed = task_mask.and_then(|tm| tm.expert_flags(li, m.experts.len()));
10400                    ffn_forward(
10401                        &lw.ffn,
10402                        post_normed,
10403                        self.pool.as_deref(),
10404                        allowed.as_deref(),
10405                    )
10406                }
10407                (true, FfnKind::DenseMoe(dm)) => dense_moe_ffn(
10408                    dm,
10409                    post_normed,
10410                    &h,
10411                    self.rms_eps,
10412                    self.norm_style,
10413                    self.pool.as_deref(),
10414                ),
10415                (false, _) => match &lw.ffn {
10416                    FfnKind::DenseMoe(dm) => dense_moe_ffn(
10417                        dm,
10418                        post_normed,
10419                        &h,
10420                        self.rms_eps,
10421                        self.norm_style,
10422                        self.pool.as_deref(),
10423                    ),
10424                    _ => {
10425                        let allowed = match (&lw.ffn, task_mask) {
10426                            (FfnKind::Moe(m), Some(tm)) => tm.expert_flags(li, m.experts.len()),
10427                            _ => None,
10428                        };
10429                        ffn_forward(
10430                            &lw.ffn,
10431                            post_normed,
10432                            self.pool.as_deref(),
10433                            allowed.as_deref(),
10434                        )
10435                    }
10436                },
10437            };
10438            let ffn_out = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
10439                Some(w) => inference::rms_norm(&ffn_out, w, self.rms_eps, self.norm_style),
10440                None => ffn_out,
10441            };
10442            for (i, &f) in ffn_out.iter().enumerate() {
10443                h[i] += f;
10444            }
10445            let mut ffn_out = ffn_out;
10446            attention::recycle_buf(&mut ffn_out);
10447
10448            // Gemma-4: the layer output is scaled by a learned scalar.
10449            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
10450                for v in h.iter_mut() {
10451                    *v *= sc;
10452                }
10453            }
10454
10455            // Looped Transformer: apply final norm at the end of each loop iteration.
10456            // Nanbeige 4.2: after layer 21 (virtual), apply norm before looping back to layer 0.
10457            if self.is_loop_end(li) && li + 1 < self.num_layers {
10458                h = inference::rms_norm(
10459                    &h,
10460                    &self.weights.final_norm,
10461                    self.rms_eps,
10462                    self.norm_style,
10463                );
10464            }
10465
10466            // Dynamic routing φ capture (on-policy): the
10467            // EMA of the post-residual hidden at the router's phi_layer,
10468            // updated as the context evolves during decode.
10469            if self.dyn_phi_layer == Some(li) {
10470                self.update_dyn_phi(&h);
10471            }
10472        }
10473        crate::gpu::set_layer(-1); // layers done — lm_head outside layer-split
10474        if let Some(t) = t_race_cpu {
10475            crate::gpu::graph_race_record(false, t.elapsed());
10476        }
10477
10478        h
10479    }
10480
10481    /// EMA of φ at the router layer (rolling, weight 0.2 = ~5-token
10482    /// horizon). First observation seeds it exactly.
10483    fn update_dyn_phi(&mut self, h: &[f32]) {
10484        const A: f32 = 0.2;
10485        if self.dyn_phi_ema.len() != h.len() {
10486            self.dyn_phi_ema = vec![0.0; h.len()];
10487            self.dyn_phi_seen = 0;
10488        }
10489        if self.dyn_phi_seen == 0 {
10490            self.dyn_phi_ema.copy_from_slice(h);
10491        } else {
10492            for (e, &v) in self.dyn_phi_ema.iter_mut().zip(h) {
10493                *e = (1.0 - A) * *e + A * v;
10494            }
10495        }
10496        self.dyn_phi_seen += 1;
10497    }
10498
10499    /// Current router φ (EMA at phi_layer); empty until first capture.
10500    pub fn dyn_phi(&self) -> &[f32] {
10501        &self.dyn_phi_ema
10502    }
10503
10504    /// Enable/disable φ capture at the router layer, reset the EMA.
10505    pub fn set_dyn_phi_layer(&mut self, layer: Option<usize>) {
10506        self.dyn_phi_layer = layer;
10507        self.dyn_phi_ema.clear();
10508        self.dyn_phi_seen = 0;
10509    }
10510
10511    /// Skills eligible for dynamic switching: (index, id, phi_layer).
10512    pub fn dynamic_skills(&self) -> Vec<(usize, String, usize)> {
10513        let Some(model) = &self.model else {
10514            return Vec::new();
10515        };
10516        model
10517            .header
10518            .skills
10519            .iter()
10520            .enumerate()
10521            .filter_map(|(i, sk)| {
10522                let ok = matches!(self.dyn_skill_layers.get(i), Some(Some(_)));
10523                let sel = sk.selection.as_ref()?;
10524                (ok).then(|| (i, sk.id.clone(), sel.phi_layer))
10525            })
10526            .collect()
10527    }
10528
10529    /// Index of the currently overlaid skill (None = backbone).
10530    pub fn active_skill(&self) -> Option<usize> {
10531        self.dyn_active
10532    }
10533
10534    /// Enable dynamic per-token skill routing: build the hysteresis
10535    /// router from the container's routable skills, start φ capture at
10536    /// their (shared) phi_layer. Returns the number of routable skills
10537    /// (0 = nothing to route; router stays off). Idempotent.
10538    pub fn enable_dynamic_routing(&mut self) -> usize {
10539        use crate::swarm::{DynRouter, RoutableSkill};
10540        let Some(model) = self.model.clone() else {
10541            return 0;
10542        };
10543        // A blend materialized f32 working tensors into the layers; there
10544        // is no single skill index to revert from → refuse (honest).
10545        if self.dyn_blend_loaded {
10546            tracing::warn!("dynamic routing unavailable on a blend-loaded pipeline");
10547            return 0;
10548        }
10549        // A statically-overlaid skill that is NOT FFN-eligible can't be
10550        // cheaply reverted at generation start → refuse rather than
10551        // silently keep it overlaid.
10552        if let Some(a) = self.dyn_active {
10553            if !matches!(self.dyn_skill_layers.get(a), Some(Some(_))) {
10554                tracing::warn!("loaded skill is not FFN-eligible — dynamic routing unavailable");
10555                return 0;
10556            }
10557        }
10558        let hidden = self.hidden_size;
10559        let mut skills = Vec::new();
10560        for (idx, id, _phi) in self.dynamic_skills() {
10561            if let Some(sel) = model.header.skills[idx].selection.as_ref() {
10562                if let Some(rs) = RoutableSkill::from_descriptor(idx, id, sel, hidden) {
10563                    skills.push(rs);
10564                }
10565            }
10566        }
10567        if skills.is_empty() {
10568            return 0;
10569        }
10570        // Skills should share a phi_layer; warn (not fail) if they don't.
10571        let phi = skills[0].phi_layer;
10572        if skills.iter().any(|s| s.phi_layer != phi) {
10573            tracing::warn!("routable skills disagree on phi_layer; using {phi}");
10574        }
10575        let n = skills.len();
10576        self.set_dyn_phi_layer(Some(phi));
10577        self.dyn_router = Some(DynRouter::new(skills));
10578        n
10579    }
10580
10581    /// Human-readable switch log from the last dynamic-routed generation.
10582    pub fn route_switches(&self) -> Vec<(usize, Option<String>, Option<String>)> {
10583        self.dyn_router
10584            .as_ref()
10585            .map(|r| r.switches.clone())
10586            .unwrap_or_default()
10587    }
10588
10589    /// LM head: hidden → logits [vocab_size]. The dominant matvec of
10590    /// every decode step — row-parallel on the worker pool.
10591    fn lm_head_forward(&self, hidden: &[f32]) -> Vec<f32> {
10592        let rows = self.weights.lm_head.rows();
10593        let mut logits = attention::take_buf(rows.min(self.vocab_size));
10594        self.weights
10595            .lm_head
10596            .matvec(hidden, &mut logits, self.pool.as_deref());
10597        logits.resize(self.vocab_size, 0.0);
10598        if let Some(m) = self.logit_multiplier {
10599            for l in logits.iter_mut() {
10600                *l *= m;
10601            }
10602        }
10603        if let Some(c) = self.final_softcap {
10604            for l in logits.iter_mut() {
10605                *l = c * (*l / c).tanh();
10606            }
10607        }
10608        if let Some(cm) = self.head_clusters.as_ref() {
10609            self.hierarchical_head_logprobs(hidden, cm, &mut logits);
10610        }
10611        logits
10612    }
10613
10614    /// Two-level head (Cortiq Embryo): in place, logits[v] ← log p(v) =
10615    /// (lc[c] − lse(lc)) + (logit[v] − lse over v's cluster block), c = v / S.
10616    fn hierarchical_head_logprobs(&self, hidden: &[f32], cm: &[f32], logits: &mut [f32]) {
10617        let h = hidden.len();
10618        let ncl = cm.len() / h.max(1);
10619        if ncl == 0 || logits.len() % ncl != 0 {
10620            return;
10621        }
10622        let cs = logits.len() / ncl;
10623        // cluster logits + log-softmax
10624        let mut lc = vec![0.0f32; ncl];
10625        for c in 0..ncl {
10626            let row = &cm[c * h..(c + 1) * h];
10627            let mut s = 0.0f32;
10628            for j in 0..h {
10629                s += row[j] * hidden[j];
10630            }
10631            lc[c] = s;
10632        }
10633        let mx = lc.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
10634        let lse: f32 = mx + lc.iter().map(|v| (v - mx).exp()).sum::<f32>().ln();
10635        for c in 0..ncl {
10636            let blk = &mut logits[c * cs..(c + 1) * cs];
10637            let bm = blk.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
10638            let bl: f32 = bm + blk.iter().map(|v| (v - bm).exp()).sum::<f32>().ln();
10639            let add = lc[c] - lse - bl;
10640            for v in blk.iter_mut() {
10641                *v += add;
10642            }
10643        }
10644    }
10645
10646    /// Prefill `ids` and return the next-token logits — what the model
10647    /// would predict next, WITHOUT committing to generation (introspection
10648    /// for `cortiq explain`). Clears and repopulates the KV cache; leaves
10649    /// the active overlay untouched.
10650    pub fn prefill_next_logits(&mut self, ids: &[u32], task_mask: Option<&TaskMask>) -> Vec<f32> {
10651        self.clear_sequence_state();
10652        // This helper is used by the pooled classification endpoint, where
10653        // every request is a fresh sequence. The shared reset also clears the
10654        // wgpu token graph's device-side recurrent state.
10655        crate::gpu::graph_race_begin_generation();
10656        if task_mask.is_none() {
10657            self.o1_begin();
10658        }
10659        let mut hidden = vec![0.0f32; self.hidden_size];
10660        for (pos, &id) in ids.iter().enumerate() {
10661            let emb = self.embed_single(id);
10662            hidden = self.forward_layers(&emb, pos, task_mask);
10663        }
10664        if let Err(err) = self.o1_seal_checked() {
10665            self.o1_fail(err);
10666        }
10667        inference::rms_norm_into(
10668            &hidden,
10669            &self.weights.final_norm,
10670            self.rms_eps,
10671            self.norm_style,
10672            &mut self.ws.n1,
10673        );
10674        self.lm_head_forward(&self.ws.n1)
10675    }
10676}
10677
10678/// Convenience: deterministic tiny pipeline for tests.
10679pub fn create_test_pipeline(
10680    hidden_size: usize,
10681    intermediate_size: usize,
10682    num_heads: usize,
10683    num_kv_heads: usize,
10684    head_dim: usize,
10685    num_layers: usize,
10686    vocab_size: usize,
10687) -> Pipeline {
10688    // Small pseudo-random weights: constant weights make attention
10689    // degenerate and hide indexing bugs.
10690    let synth = |n: usize, salt: usize| -> Vec<f32> {
10691        (0..n)
10692            .map(|i| (((i * 31 + salt * 17 + 7) % 97) as f32 / 97.0 - 0.5) * 0.2)
10693            .collect()
10694    };
10695    let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
10696        QTensor::from_f32(synth(rows * cols, salt), rows, cols)
10697    };
10698    let layer_weights: Vec<LayerWeights> = (0..num_layers)
10699        .map(|li| LayerWeights {
10700            input_norm: vec![1.0; hidden_size],
10701            post_norm: vec![1.0; hidden_size],
10702            attn_out_norm: None,
10703            ffn_out_norm: None,
10704            layer_scale: None,
10705            ffn: FfnKind::Dense(DenseFfn {
10706                gate_proj: qt(intermediate_size, hidden_size, li * 10 + 5),
10707                up_proj: qt(intermediate_size, hidden_size, li * 10 + 6),
10708                down_proj: qt(hidden_size, intermediate_size, li * 10 + 7),
10709                act: Act::Silu,
10710                down_t: None,
10711                segs: Vec::new(),
10712            }),
10713            attn: AttnKind::Full {
10714                bias: None,
10715                wq: qt(num_heads * head_dim, hidden_size, li * 10 + 1),
10716                wk: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 2),
10717                wv: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 3),
10718                wo: qt(hidden_size, num_heads * head_dim, li * 10 + 4),
10719                q_norm: None,
10720                k_norm: None,
10721                output_gate: false,
10722                softplus_gate: None,
10723            },
10724        })
10725        .collect();
10726
10727    Pipeline::new(
10728        Tokenizer::byte_level(),
10729        PipelineWeights {
10730            embed_tokens: qt(vocab_size, hidden_size, 100),
10731            layers: layer_weights,
10732            lm_head: qt(vocab_size, hidden_size, 200),
10733            final_norm: vec![1.0; hidden_size],
10734        },
10735        hidden_size,
10736        intermediate_size,
10737        num_heads,
10738        num_kv_heads,
10739        head_dim,
10740        num_layers,
10741        num_layers, // physical_layers = num_layers (non-looped)
10742        false,      // loop_final_norm
10743        vocab_size,
10744        1e-6,
10745        10_000.0,
10746        NormStyle::Qwen,
10747        4096,
10748        SamplerConfig {
10749            seed: Some(42),
10750            ..Default::default()
10751        },
10752    )
10753}
10754
10755/// Batched dense-FFN: gate/up/down via matmat (element-wise the same
10756/// math as b × dense_ffn — the same dot kernels).
10757/// One mask bit, LSB-first per byte — `TaskMask::ffn_active_indices`'s
10758/// convention.
10759#[inline]
10760fn mask_bit(row: &[u8], j: usize) -> bool {
10761    (row.get(j >> 3).copied().unwrap_or(0) >> (j & 7)) & 1 != 0
10762}
10763
10764/// Zero the CLOSED neurons' activations in a [rows × inter] panel — the
10765/// masked-inference fast path's whole trick: full fused quant compute,
10766/// then the mask lands on the ACTIVATIONS, which is arithmetically the
10767/// pruned network without touching a quantized weight byte. Whole open
10768/// bytes (0xFF = 8 open neurons) skip in one test.
10769/// `CMF_FFN_MASK_GAIN` — Patent 12 FIG. 4, variance-preserving
10770/// rescaling: truncation removes a share of the layer's output energy,
10771/// so the survivors are scaled up to put the variance back where the
10772/// downstream norm expects it. A scalar here; per layer it is
10773/// `sqrt(total energy / kept energy)`.
10774fn mask_gain() -> f32 {
10775    static G: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
10776    *G.get_or_init(|| {
10777        std::env::var("CMF_FFN_MASK_GAIN")
10778            .ok()
10779            .and_then(|v| v.parse().ok())
10780            .unwrap_or(1.0)
10781    })
10782}
10783
10784fn zero_masked_cols(g: &mut [f32], rows: usize, inter: usize, row: &[u8]) {
10785    // With CMF_FFN_MEANFILL a closed neuron contributes its average
10786    // instead of nothing — same bytes read, one constant restored.
10787    let fill = meanfill().and_then(|(i, v)| {
10788        let li = crate::gpu::cur_layer();
10789        (*i == inter && li >= 0).then(|| &v[li as usize * inter..(li as usize + 1) * inter])
10790    });
10791    for r in 0..rows {
10792        let base = r * inter;
10793        for (bi, &byte) in row.iter().enumerate() {
10794            if byte == 0xFF {
10795                continue;
10796            }
10797            let j0 = bi * 8;
10798            for bit in 0..8 {
10799                let j = j0 + bit;
10800                if j < inter && byte & (1 << bit) == 0 {
10801                    g[base + j] = fill.map_or(0.0, |f| f[j]);
10802                }
10803            }
10804        }
10805    }
10806    let gain = mask_gain();
10807    if gain != 1.0 {
10808        for v in g[..rows * inter].iter_mut() {
10809            *v *= gain;
10810        }
10811    }
10812}
10813
10814/// True when neuron `i`'s bit is set (no mask = everything runs).
10815#[inline]
10816fn tube_bit(row: Option<&[u8]>, i: usize) -> bool {
10817    row.is_none_or(|r| mask_bit(r, i))
10818}
10819
10820/// Every bit below `n` set — the common case for a tube file's CORE,
10821/// where only the tube bits vary per task.
10822fn all_bits_on(row: &[u8], n: usize) -> bool {
10823    (0..n).all(|i| mask_bit(row, i))
10824}
10825
10826/// `CMF_TUBE_TOPK` — how many tubes a TOKEN may open (0 = the task mask
10827/// decides alone). This is the dense FFN read as a mixture: the tubes
10828/// are the experts a k-means over `gate_proj` rows found, and the token
10829/// picks among them. `CMF_TUBE_SCORE=gate` scores a tube by its own
10830/// gate (realizable: only `up`/`down` of the losers go unread),
10831/// `=oracle` scores by the true `silu(gate)·up` mass (the ceiling —
10832/// only `down` is saved, and the selection has read what it predicts).
10833fn tube_topk() -> usize {
10834    static K: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
10835    *K.get_or_init(|| {
10836        std::env::var("CMF_TUBE_TOPK")
10837            .ok()
10838            .and_then(|v| v.parse().ok())
10839            .unwrap_or(0)
10840    })
10841}
10842
10843fn tube_score_oracle() -> bool {
10844    static O: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
10845    *O.get_or_init(|| std::env::var("CMF_TUBE_SCORE").is_ok_and(|v| v == "oracle"))
10846}
10847
10848/// The routed arm of `tube_ffn`: a token opens only its best `k` tubes.
10849/// At `b == 1` (decode) the losers are genuinely never read — that is
10850/// the speed. At `b > 1` (the scoring sweep) every tube is computed and
10851/// the losers' activations are zeroed instead: same arithmetic, so the
10852/// perplexity is the routed model's, measured without a per-token
10853/// gather in the middle of a GEMM.
10854fn tube_ffn_routed(
10855    d: &DenseFfn,
10856    xs: &[f32],
10857    b: usize,
10858    pool: Option<&Pool>,
10859    mask_row: Option<&[u8]>,
10860    k: usize,
10861) -> Vec<f32> {
10862    let hidden = d.down_proj.rows();
10863    let core = d.gate_proj.rows();
10864    let core_full = mask_row.is_none_or(|r| all_bits_on(r, core));
10865    let mut out = match (b, core_full, mask_row) {
10866        (1, true, _) => dense_ffn(d, xs, pool),
10867        (1, false, Some(row)) => dense_ffn_masked(d, xs, pool, row),
10868        (_, true, _) => dense_ffn_batch(d, xs, b, pool, None),
10869        (_, false, row) => dense_ffn_batch(d, xs, b, pool, row),
10870    };
10871    let cand: Vec<usize> = (0..d.segs.len())
10872        .filter(|&i| tube_bit(mask_row, d.segs[i].start))
10873        .collect();
10874    if cand.is_empty() {
10875        return out;
10876    }
10877    // gate (and, where the score or the batch needs it, up) per tube.
10878    // The SCORE is taken at the point the serving path could take it:
10879    // off the gate alone, or off the finished activation for the oracle.
10880    let oracle = tube_score_oracle();
10881    let mut acts: Vec<Vec<f32>> = Vec::with_capacity(cand.len());
10882    let mut scores = vec![0f32; b * cand.len()];
10883    for (ci, &i) in cand.iter().enumerate() {
10884        let seg = &d.segs[i];
10885        let w = seg.width;
10886        let mut g = vec![0.0f32; b * w];
10887        if b == 1 {
10888            seg.gate.matvec(xs, &mut g, pool);
10889        } else {
10890            seg.gate.matmat(xs, b, &mut g, pool);
10891        }
10892        for v in g.iter_mut() {
10893            *v = Act::Silu.combine(*v, 1.0);
10894        }
10895        if !oracle {
10896            for t in 0..b {
10897                scores[t * cand.len() + ci] =
10898                    g[t * w..(t + 1) * w].iter().map(|v| v * v).sum::<f32>();
10899            }
10900        }
10901        if oracle || b > 1 {
10902            let mut u = vec![0.0f32; b * w];
10903            if b == 1 {
10904                seg.up.matvec(xs, &mut u, pool);
10905            } else {
10906                seg.up.matmat(xs, b, &mut u, pool);
10907            }
10908            for (a, &v) in g.iter_mut().zip(u.iter()) {
10909                *a *= v;
10910            }
10911            if oracle {
10912                for t in 0..b {
10913                    scores[t * cand.len() + ci] =
10914                        g[t * w..(t + 1) * w].iter().map(|v| v * v).sum::<f32>();
10915                }
10916            }
10917        }
10918        acts.push(g);
10919    }
10920    // per-token scores and the winners
10921    let keep = k.min(cand.len());
10922    let mut scratch: Vec<f32> = Vec::new();
10923    for t in 0..b {
10924        let mut sc: Vec<(f32, usize)> = (0..cand.len())
10925            .map(|ci| (scores[t * cand.len() + ci], ci))
10926            .collect();
10927        sc.sort_unstable_by(|x, y| y.0.total_cmp(&x.0));
10928        let mut alive = vec![false; cand.len()];
10929        for &(_, ci) in sc.iter().take(keep) {
10930            alive[ci] = true;
10931        }
10932        if b > 1 {
10933            for (ci, a) in acts.iter_mut().enumerate() {
10934                if !alive[ci] {
10935                    let w = d.segs[cand[ci]].width;
10936                    a[t * w..(t + 1) * w].fill(0.0);
10937                }
10938            }
10939        } else {
10940            // decode: finish only the winners — the losers' up/down
10941            // (and, with the gate score, everything but their gate)
10942            // are never touched.
10943            for (ci, &i) in cand.iter().enumerate() {
10944                if !alive[ci] {
10945                    continue;
10946                }
10947                let seg = &d.segs[i];
10948                let w = seg.width;
10949                let g = &mut acts[ci];
10950                if !tube_score_oracle() {
10951                    scratch.clear();
10952                    scratch.resize(w, 0.0);
10953                    seg.up.matvec(xs, &mut scratch, pool);
10954                    for (a, &v) in g.iter_mut().zip(scratch.iter()) {
10955                        *a *= v;
10956                    }
10957                }
10958                let mut acc = vec![0.0f32; hidden];
10959                seg.down.matvec(g, &mut acc, pool);
10960                for (o, a) in out.iter_mut().zip(&acc) {
10961                    *o += *a;
10962                }
10963            }
10964        }
10965    }
10966    if b > 1 {
10967        for (ci, &i) in cand.iter().enumerate() {
10968            let seg = &d.segs[i];
10969            let mut acc = vec![0.0f32; b * hidden];
10970            seg.down.matmat(&acts[ci], b, &mut acc, pool);
10971            for (o, a) in out.iter_mut().zip(&acc) {
10972                *o += *a;
10973            }
10974        }
10975    }
10976    out
10977}
10978
10979/// FFN of a defragged tube layer: the always-on core plus the tubes the
10980/// task mask switches on. Each tube is a normal tensor triple, so the
10981/// same kernels run it and an inactive tube's bytes are never read —
10982/// that is the whole point of the defrag (a scattered mask cannot skip
10983/// bytes; a contiguous one is just a smaller matrix).
10984fn tube_ffn(
10985    d: &DenseFfn,
10986    xs: &[f32],
10987    b: usize,
10988    pool: Option<&Pool>,
10989    mask_row: Option<&[u8]>,
10990) -> Vec<f32> {
10991    if tube_topk() > 0 {
10992        return tube_ffn_routed(d, xs, b, pool, mask_row, tube_topk());
10993    }
10994    let hidden = d.down_proj.rows();
10995    let core = d.gate_proj.rows();
10996    let core_full = mask_row.is_none_or(|r| all_bits_on(r, core));
10997    let mut out = match (b, core_full, mask_row) {
10998        (1, true, _) => dense_ffn(d, xs, pool),
10999        (1, false, Some(row)) => dense_ffn_masked(d, xs, pool, row),
11000        (_, true, _) => dense_ffn_batch(d, xs, b, pool, None),
11001        (_, false, row) => dense_ffn_batch(d, xs, b, pool, row),
11002    };
11003    TUBE_SCRATCH.with(|sc| {
11004        let mut sc = sc.borrow_mut();
11005        let [g, u, acc] = &mut *sc;
11006        for seg in &d.segs {
11007            if !tube_bit(mask_row, seg.start) {
11008                continue;
11009            }
11010            let w = seg.width;
11011            g.resize(b * w, 0.0);
11012            if b == 1
11013                && d.act == Act::Silu
11014                && QTensor::matvec_silu_mul(&seg.gate, &seg.up, xs, g, pool)
11015            {
11016                // g holds silu(gate)·up.
11017            } else {
11018                u.resize(b * w, 0.0);
11019                if b == 1 {
11020                    QTensor::matvec_many([&seg.gate, &seg.up], xs, [g, u], pool);
11021                } else {
11022                    seg.gate.matmat(xs, b, g, pool);
11023                    seg.up.matmat(xs, b, u, pool);
11024                }
11025                for i in 0..b * w {
11026                    g[i] = d.act.combine(g[i], u[i]);
11027                }
11028            }
11029            acc.resize(b * hidden, 0.0);
11030            acc.fill(0.0);
11031            if b == 1 {
11032                seg.down.matvec(g, acc, pool);
11033            } else {
11034                seg.down.matmat(g, b, acc, pool);
11035            }
11036            for (o, a) in out.iter_mut().zip(acc.iter()) {
11037                *o += *a;
11038            }
11039        }
11040        out
11041    })
11042}
11043
11044thread_local! {
11045    /// gate / up / down-accumulator scratch for the tube loop — a tube
11046    /// runs once per layer per token, and a fresh Vec each time is a
11047    /// malloc per tube per layer per token.
11048    static TUBE_SCRATCH: std::cell::RefCell<[Vec<f32>; 3]> =
11049        const { std::cell::RefCell::new([Vec::new(), Vec::new(), Vec::new()]) };
11050}
11051
11052fn dense_ffn_batch(
11053    d: &DenseFfn,
11054    xs: &[f32],
11055    b: usize,
11056    pool: Option<&Pool>,
11057    mask_row: Option<&[u8]>,
11058) -> Vec<f32> {
11059    let inter = d.gate_proj.rows();
11060    let hidden = d.down_proj.rows();
11061    // Fused on-device SwiGLU when the device is in play: three separate
11062    // `matmat` calls are three round trips per layer, and the gate/up
11063    // panels (b × inter — 22 MB each at a 512-token chunk) cross the bus
11064    // twice for nothing. The kernel already existed for the image DiT;
11065    // the LLM prefill was simply never wired to it. A task mask needs the
11066    // activations on the host between the halves, so it keeps the CPU
11067    // arm below.
11068    if mask_row.is_none()
11069        && d.act == Act::Silu
11070        && b >= 32
11071        && crate::gpu::enabled_here()
11072        && !crate::gpu::mm_killed()
11073        // The refit pass needs this layer's activations on the host; the
11074        // fused chain keeps them on the device. Refusing it here costs
11075        // one round trip and keeps every GEMM on the card — the
11076        // alternative was running the whole calibration on the CPU.
11077        && refit_dir().is_none()
11078        // Same for the mass/hit probes. The accumulator at the bottom of
11079        // this function only sees `g` when `g` came back to the host, so
11080        // a fused batch would leave it summing nothing — a probe that
11081        // reports zeros rather than failing, which is worse.
11082        && !ffn_probe_active()
11083    {
11084        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
11085            d.gate_proj.mapped_q4t(),
11086            d.up_proj.mapped_q4t(),
11087            d.down_proj.mapped_q4t(),
11088        ) {
11089            let mut out = vec![0.0f32; b * hidden];
11090            if crate::gpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
11091                return out;
11092            }
11093        }
11094        // The q4tp twin (same kernel family, scale from the row ladder) —
11095        // the DiT has run it in production since the pipeline containers;
11096        // the LLM prefill was simply never wired to it, so a q4tp model's
11097        // prefill panels stayed on the CPU.
11098        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
11099            d.gate_proj.mapped_q4tp(),
11100            d.up_proj.mapped_q4tp(),
11101            d.down_proj.mapped_q4tp(),
11102        ) {
11103            let mut out = vec![0.0f32; b * hidden];
11104            if crate::gpu::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
11105                return out;
11106            }
11107        }
11108    }
11109    let mut g = vec![0.0f32; b * inter];
11110    d.gate_proj.matmat(xs, b, &mut g, pool);
11111    let mut u = vec![0.0f32; b * inter];
11112    d.up_proj.matmat(xs, b, &mut u, pool);
11113    if gate_topk() > 0 && d.act == Act::Silu {
11114        for t in 0..b {
11115            let row = &mut g[t * inter..(t + 1) * inter];
11116            for v in row.iter_mut() {
11117                *v = Act::Silu.combine(*v, 1.0);
11118            }
11119            keep_top_k(row, gate_topk());
11120        }
11121        for i in 0..b * inter {
11122            g[i] *= u[i];
11123        }
11124    } else {
11125        for i in 0..b * inter {
11126            g[i] = d.act.combine(g[i], u[i]);
11127        }
11128    }
11129    if let Some(row) = mask_row {
11130        zero_masked_cols(&mut g, b, inter, row);
11131    }
11132    if oracle_topk() > 0 {
11133        for t in 0..b {
11134            keep_top_k(&mut g[t * inter..(t + 1) * inter], oracle_topk());
11135        }
11136    }
11137    let mut out = vec![0.0f32; b * hidden];
11138    d.down_proj.matmat(&g, b, &mut out, pool);
11139    if refit_dir().is_some() {
11140        let li = crate::gpu::cur_layer();
11141        if li >= 0 {
11142            refit_accumulate(li as usize, &g, b, inter, &out, hidden, pool);
11143        }
11144    }
11145    // The DTG-MA probe, on the batched path: one prefill sweep gives the
11146    // same per-neuron statistic the per-position probe does, and on a 27B
11147    // that is minutes instead of hours.
11148    FFN_PROBE.with(|pr| {
11149        if let Some(acc) = pr.borrow_mut().as_mut() {
11150            let li = crate::gpu::cur_layer();
11151            if li < 0 {
11152                return;
11153            }
11154            let Some(row) = acc.get_mut(li as usize) else {
11155                return;
11156            };
11157            let sq = probe_sq();
11158            for t in 0..b {
11159                for (a, &v) in row.iter_mut().zip(&g[t * inter..(t + 1) * inter]) {
11160                    *a += if sq {
11161                        (v as f64) * (v as f64)
11162                    } else {
11163                        (v as f64).abs()
11164                    };
11165                }
11166            }
11167        }
11168    });
11169    out
11170}
11171
11172/// Batched MoE-FFN: router batched, positions are GROUPED by expert —
11173/// an expert's weights are read once for all its positions in the chunk
11174/// (the main prefill-GEMM win on MoE: 960MB/token of 35B experts).
11175/// Accumulate per-channel activation energy for `CMF_RMS_TRACE`.
11176fn accumulate_act(m: &MoeFfn, xs: &[f32], b: usize) {
11177    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
11178    static DUMP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
11179    let on = *ON.get_or_init(|| std::env::var("CMF_RMS_TRACE").is_ok());
11180    let dump = *DUMP.get_or_init(|| std::env::var("CMF_ACT_DUMP").is_ok());
11181    if (!on && !dump) || b == 0 {
11182        return;
11183    }
11184    let hidden = xs.len() / b;
11185    if on {
11186        let mut acc = m.act_sq.borrow_mut();
11187        if acc.len() < hidden {
11188            acc.resize(hidden, 0.0);
11189        }
11190        for t in 0..b {
11191            let row = &xs[t * hidden..(t + 1) * hidden];
11192            for (a, &v) in acc.iter_mut().zip(row) {
11193                *a += (v as f64) * (v as f64);
11194            }
11195        }
11196    }
11197    if dump {
11198        // Cap the capture: the covariance needs a few thousand rows, and a
11199        // whole prefill of every layer would be gigabytes for no extra rank.
11200        let cap: usize = std::env::var("CMF_ACT_DUMP_ROWS")
11201            .ok()
11202            .and_then(|v| v.parse().ok())
11203            .unwrap_or(4096);
11204        let mut rows = m.act_rows.borrow_mut();
11205        if rows.len() < cap * hidden {
11206            let take = b.min((cap * hidden - rows.len()) / hidden.max(1));
11207            rows.extend_from_slice(&xs[..take * hidden]);
11208        }
11209    }
11210}
11211
11212/// Send-able cursor over a Vec-of-Vecs: each pool worker writes only its
11213/// own slots (disjoint by construction in the caller).
11214#[derive(Clone, Copy)]
11215struct SendVecs(*mut Vec<f32>);
11216unsafe impl Send for SendVecs {}
11217unsafe impl Sync for SendVecs {}
11218impl SendVecs {
11219    #[inline]
11220    fn at(self, i: usize) -> *mut Vec<f32> {
11221        unsafe { self.0.add(i) }
11222    }
11223}
11224
11225fn moe_ffn_batch(
11226    m: &MoeFfn,
11227    xs: &[f32],
11228    b: usize,
11229    hidden: usize,
11230    pool: Option<&Pool>,
11231    allowed: Option<&[bool]>,
11232) -> Vec<f32> {
11233    accumulate_act(m, xs, b);
11234    let ne = m.experts.len();
11235    let mut logits = vec![0.0f32; b * ne];
11236    match &m.resonance {
11237        Some(r) => {
11238            let hdim = xs.len() / b.max(1);
11239            for bi in 0..b {
11240                r.scores(
11241                    &xs[bi * hdim..(bi + 1) * hdim],
11242                    &mut logits[bi * ne..(bi + 1) * ne],
11243                );
11244            }
11245        }
11246        None => m.router.matmat(xs, b, &mut logits, pool),
11247    }
11248
11249    // Assignments: expert → [(position, weight)] — same routing as
11250    // moe_ffn, per position (see `moe_route`).
11251    let mut assign: Vec<Vec<(usize, f32)>> = vec![Vec::new(); ne];
11252    {
11253        let mut st = m.stats.borrow_mut();
11254        if st.len() < ne {
11255            st.resize(ne, 0);
11256        }
11257        for bi in 0..b {
11258            let (idx, p, wsum) = moe_route(&logits[bi * ne..(bi + 1) * ne], m, allowed);
11259            for &e in &idx {
11260                st[e] += 1;
11261                assign[e].push((bi, p[e] / wsum));
11262            }
11263        }
11264    }
11265
11266    let mut out = vec![0.0f32; b * hidden];
11267    let cols = m.experts[0].gate_proj.cols();
11268    let run_expert = |d: &DenseFfn, list: &[(usize, f32)], out: &mut [f32]| {
11269        let sb = list.len();
11270        let mut sub = vec![0.0f32; sb * cols];
11271        for (k, &(bi, _)) in list.iter().enumerate() {
11272            sub[k * cols..(k + 1) * cols].copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
11273        }
11274        let eo = dense_ffn_batch(d, &sub, sb, pool, None);
11275        for (k, &(bi, w)) in list.iter().enumerate() {
11276            for i in 0..hidden {
11277                out[bi * hidden + i] += w * eo[k * hidden + i];
11278            }
11279        }
11280    };
11281    // Routed experts: the panels are TINY (b·top_k spread over every
11282    // expert — a few positions each), so a pool dispatch per expert is
11283    // pure barrier cost. Invert the parallelism: workers take WHOLE
11284    // experts (serial math inside), then one deterministic scatter in
11285    // expert order — the exact accumulation order the serial loop had.
11286    let active: Vec<usize> = (0..ne).filter(|&e| !assign[e].is_empty()).collect();
11287    if pool.is_some() && active.len() >= 8 {
11288        let mut panels: Vec<Vec<f32>> = vec![Vec::new(); active.len()];
11289        {
11290            let panel_ptr = SendVecs(panels.as_mut_ptr());
11291            // Capture only the expert table: `m` itself carries RefCell
11292            // stats and must not cross the pool boundary.
11293            let experts = &m.experts;
11294            let (active_r, assign_r) = (&active, &assign);
11295            let run = |start: usize, end: usize| {
11296                for ai in start..end {
11297                    let e = active_r[ai];
11298                    let list = &assign_r[e];
11299                    let sb = list.len();
11300                    let mut sub = vec![0.0f32; sb * cols];
11301                    for (k, &(bi, _)) in list.iter().enumerate() {
11302                        sub[k * cols..(k + 1) * cols]
11303                            .copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
11304                    }
11305                    // SAFETY: each worker owns a disjoint panels[ai].
11306                    unsafe {
11307                        *panel_ptr.at(ai) = dense_ffn_batch(&experts[e], &sub, sb, None, None);
11308                    }
11309                }
11310            };
11311            match pool {
11312                Some(p) => p.run_rows(active.len(), &run),
11313                None => run(0, active.len()),
11314            }
11315        }
11316        for (ai, &e) in active.iter().enumerate() {
11317            for (k, &(bi, w)) in assign[e].iter().enumerate() {
11318                let eo = &panels[ai][k * hidden..(k + 1) * hidden];
11319                for i in 0..hidden {
11320                    out[bi * hidden + i] += w * eo[i];
11321                }
11322            }
11323        }
11324    } else {
11325        for &e in &active {
11326            run_expert(&m.experts[e], &assign[e], &mut out);
11327        }
11328    }
11329    if let Some((se, gate)) = &m.shared {
11330        let all: Vec<(usize, f32)> = if let Some(gate) = gate {
11331            let mut gl = vec![0.0f32; b];
11332            gate.matmat(xs, b, &mut gl, pool);
11333            (0..b)
11334                .map(|bi| (bi, 1.0 / (1.0 + (-gl[bi]).exp())))
11335                .collect()
11336        } else {
11337            (0..b).map(|bi| (bi, 1.0)).collect()
11338        };
11339        run_expert(se, &all, &mut out);
11340    }
11341    out
11342}
11343
11344thread_local! {
11345    /// gate/up activation scratch for the dense FFN paths (single uses
11346    /// two slots, the fused pair all four) — these were fresh
11347    /// intermediate-size Vecs on every layer of every token.
11348    static FFN_SCRATCH: std::cell::RefCell<[Vec<f32>; 4]> =
11349        const { std::cell::RefCell::new([Vec::new(), Vec::new(), Vec::new(), Vec::new()]) };
11350}
11351
11352/// Dense SwiGLU FFN through QTensor matvecs (any storage).
11353fn dense_ffn(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
11354    // Per-token sparsity, when the file was built for it: gate first,
11355    // then only the chosen neurons' up/down rows leave the mmap.
11356    if gate_topk() > 0
11357        && let Some(out) = dense_ffn_dynamic(d, x, pool, gate_topk())
11358    {
11359        return out;
11360    }
11361    // Whole-FFN GPU submit (этап 4.2 increment): gate → silu·up → down
11362    // chained in ONE command buffer with the intermediate activations
11363    // resident on the device — 3 per-op polls become 1 per layer. The
11364    // moe_block backend already implements exactly this chain; a dense
11365    // FFN is one expert with weight 1. Runtime probe: the chain still
11366    // pays one submit+poll per layer — alternate it against the pure-CPU
11367    // FFN and keep whichever is faster on this machine.
11368    // q1 FFNs offload at any practical size: the q1 CPU kernel is
11369    // compute-bound, so the UMA threshold logic does not apply — the
11370    // probe measures and decides either way.
11371    if crate::gpu::enabled_here()
11372        && (d.gate_proj.rows() >= crate::gpu::min_rows() || d.gate_proj.is_q1())
11373    {
11374        let arm = if d.gate_proj.is_q1() && crate::gpu::q1_force() {
11375            crate::gpu::ProbeArm::Gpu
11376        } else {
11377            crate::gpu::probe_arm(crate::gpu::OpClass::Ffn)
11378        };
11379        match arm {
11380            crate::gpu::ProbeArm::Gpu => {
11381                let t0 = std::time::Instant::now();
11382                if let Some(out) = dense_ffn_gpu(d, x, pool) {
11383                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
11384                    return out;
11385                }
11386                // Declined: no timing exists, so say so. Silence here is
11387                // what left `ffn` undecided for 9000 calls and cost a
11388                // failed device attempt on half of them.
11389                crate::gpu::probe_note_decline(crate::gpu::OpClass::Ffn);
11390            }
11391            crate::gpu::ProbeArm::CpuTimed => {
11392                let t0 = std::time::Instant::now();
11393                let out = crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
11394                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
11395                return out;
11396            }
11397            crate::gpu::ProbeArm::Cpu => {
11398                return crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
11399            }
11400        }
11401    }
11402    dense_ffn_cpu(d, x, pool)
11403}
11404
11405/// The pure-CPU dense-FFN body (also the fallback of every GPU refusal).
11406fn dense_ffn_cpu(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
11407    let inter = d.gate_proj.rows();
11408    FFN_SCRATCH.with(|s| {
11409        let mut s = s.borrow_mut();
11410        let [g, u, ..] = &mut *s;
11411        g.resize(inter, 0.0);
11412        // Fused gate+up+silu: one dispatch, no separate silu pass.
11413        // Falls back to matvec_many + silu loop for unsupported dtypes.
11414        if gate_topk() > 0 {
11415            // Gate first, select, and only then pay for `up`: the
11416            // measurement arm computes both and zeroes the losers, which
11417            // is the same arithmetic.
11418            u.resize(inter, 0.0);
11419            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
11420            for i in 0..inter {
11421                g[i] = Act::Silu.combine(g[i], 1.0);
11422            }
11423            keep_top_k(g, gate_topk());
11424            for i in 0..inter {
11425                g[i] *= u[i];
11426            }
11427        } else if d.act == Act::Silu
11428            && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool)
11429        {
11430            // g now holds silu(gate)·up directly.
11431        } else {
11432            u.resize(inter, 0.0);
11433            // Multi-matrix job: gate+up under one pool dispatch.
11434            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
11435            for i in 0..inter {
11436                g[i] = d.act.combine(g[i], u[i]);
11437            }
11438        }
11439        // DTG-MA bake probe (Patent 2): accumulate this layer's
11440        // per-neuron activation mass while a probe pass is active.
11441        // `CMF_FFN_PROBE_TOPK=k` switches the statistic from mass to a
11442        // HIT COUNT — how many tokens rank the neuron in their own top
11443        // k. Mass asks "how loud is this neuron overall", the count
11444        // asks "how often does this task actually need it", and the two
11445        // rank neurons differently whenever a few tokens are loud.
11446        FFN_PROBE.with(|pr| {
11447            if let Some(acc) = pr.borrow_mut().as_mut() {
11448                let li = crate::gpu::cur_layer();
11449                if li >= 0 {
11450                    if let Some(row) = acc.get_mut(li as usize) {
11451                        match probe_topk() {
11452                            0 if probe_sq() => {
11453                                for (a, &v) in row.iter_mut().zip(g.iter()) {
11454                                    *a += (v as f64) * (v as f64);
11455                                }
11456                            }
11457                            0 if probe_signed() => {
11458                                for (a, &v) in row.iter_mut().zip(g.iter()) {
11459                                    *a += v as f64;
11460                                }
11461                            }
11462                            0 => {
11463                                for (a, &v) in row.iter_mut().zip(g.iter()) {
11464                                    *a += (v as f64).abs();
11465                                }
11466                            }
11467                            k => {
11468                                let n = g.len();
11469                                let k = k.min(n);
11470                                let mut mag: Vec<f32> = g.iter().map(|v| v.abs()).collect();
11471                                let (_, kth, _) = mag.select_nth_unstable_by(k - 1, |a, b| {
11472                                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
11473                                });
11474                                let thr = *kth;
11475                                for (a, &v) in row.iter_mut().zip(g.iter()) {
11476                                    if v.abs() >= thr {
11477                                        *a += 1.0;
11478                                    }
11479                                }
11480                            }
11481                        }
11482                    }
11483                }
11484            }
11485        });
11486        if oracle_topk() > 0 {
11487            keep_top_k(g, oracle_topk());
11488        }
11489        {
11490            let li = crate::gpu::cur_layer();
11491            if li >= 0 {
11492                adump_row(li as usize, g);
11493            }
11494        }
11495        let mut out = attention::take_buf(d.down_proj.rows());
11496        d.down_proj.matvec(g, &mut out, pool);
11497        out
11498    })
11499}
11500
11501/// Online accumulators for the AWNP refit of a narrowed FFN.
11502///
11503/// The refit needs `Gss = A_SᵀA_S` and `YA = YᵀA_S` per layer, where `A_S`
11504/// are the calibration activations of the KEPT neurons and `Y` the full
11505/// FFN output. Both are small enough to hold; the thing that is not is
11506/// the activations they are built from — a 27B layer would dump a
11507/// gigabyte per thousand tokens. So they are accumulated as the
11508/// calibration runs and written once at the end.
11509///
11510/// `CMF_FFN_REFIT=<dir>` holds `support.<L>.u32` (a u32 count then the
11511/// kept indices) for every layer to accumulate; `CMF_FFN_REFIT_FROM/TO`
11512/// bound the layer span so the accumulators fit in RAM.
11513pub struct RefitAcc {
11514    pub support: Vec<u32>,
11515    pub gss: Vec<f32>,
11516    pub ya: Vec<f32>,
11517    pub hidden: usize,
11518    pub tokens: u64,
11519    /// Activations staged transposed ([ns, t] and [hidden, t]) until the
11520    /// batch is worth a GEMM. The product costs `ns²` to move and add
11521    /// REGARDLESS of how many tokens went into it, so folding 16 chunks
11522    /// into one call cuts that cost 16× — it was 15 TB of traffic per
11523    /// calibration pass at one call per 256 tokens.
11524    pub buf_g: Vec<f32>,
11525    pub buf_o: Vec<f32>,
11526    pub buf_t: usize,
11527}
11528
11529/// The product buffer is SHARED across layers — one 473 MB allocation,
11530/// not one per layer (that was 30 GB of nothing on a 64-layer model).
11531/// It lives under the same lock as the accumulators.
11532type RefitState = (std::collections::HashMap<usize, RefitAcc>, Vec<f32>);
11533
11534static REFIT: std::sync::OnceLock<Option<(String, std::sync::Mutex<RefitState>)>> =
11535    std::sync::OnceLock::new();
11536
11537/// Is an FFN probe accumulator installed on this thread? The fused GPU
11538/// FFN must decline while one is, or the probe silently measures zero.
11539fn ffn_probe_active() -> bool {
11540    FFN_PROBE.with(|p| p.borrow().is_some())
11541}
11542
11543fn refit_dir() -> Option<&'static (String, std::sync::Mutex<RefitState>)> {
11544    REFIT
11545        .get_or_init(|| {
11546            std::env::var("CMF_FFN_REFIT").ok().map(|d| {
11547                (
11548                    d,
11549                    std::sync::Mutex::new((std::collections::HashMap::new(), Vec::new())),
11550                )
11551            })
11552        })
11553        .as_ref()
11554}
11555
11556/// Accumulate one prefill panel into the layer's refit statistics.
11557fn refit_accumulate(
11558    li: usize,
11559    g: &[f32],
11560    b: usize,
11561    inter: usize,
11562    out: &[f32],
11563    hidden: usize,
11564    pool: Option<&Pool>,
11565) {
11566    let Some((dir, map)) = refit_dir() else {
11567        return;
11568    };
11569    static SPAN: std::sync::OnceLock<(usize, usize)> = std::sync::OnceLock::new();
11570    let (from, to) = *SPAN.get_or_init(|| {
11571        let g = |k: &str, d: usize| {
11572            std::env::var(k)
11573                .ok()
11574                .and_then(|v| v.parse().ok())
11575                .unwrap_or(d)
11576        };
11577        (
11578            g("CMF_FFN_REFIT_FROM", 0),
11579            g("CMF_FFN_REFIT_TO", usize::MAX),
11580        )
11581    });
11582    if li < from || li > to {
11583        return;
11584    }
11585    let mut guard = map.lock().unwrap();
11586    let (map, shared) = &mut *guard;
11587    let acc = match map.entry(li) {
11588        std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
11589        std::collections::hash_map::Entry::Vacant(e) => {
11590            let path = format!("{dir}/support.{li}.u32");
11591            let Ok(bytes) = std::fs::read(&path) else {
11592                eprintln!("refit: no {path} — layer {li} skipped");
11593                return;
11594            };
11595            let n = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
11596            let support: Vec<u32> = bytes[4..4 + n * 4]
11597                .chunks_exact(4)
11598                .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
11599                .collect();
11600            eprintln!(
11601                "refit: layer {li} support {n} ({:.0} MB of accumulator)",
11602                (n * n + hidden * n) as f64 * 4.0 / 1e6
11603            );
11604            e.insert(RefitAcc {
11605                gss: vec![0.0; n * n],
11606                ya: vec![0.0; hidden * n],
11607                buf_g: Vec::new(),
11608                buf_o: Vec::new(),
11609                buf_t: 0,
11610                support,
11611                hidden,
11612                tokens: 0,
11613            })
11614        }
11615    };
11616    let ns = acc.support.len();
11617    // Stage this chunk transposed; the GEMM fires once the batch is full.
11618    let cap = refit_batch();
11619    if acc.buf_g.is_empty() {
11620        acc.buf_g = vec![0.0; ns * cap];
11621        acc.buf_o = vec![0.0; hidden * cap];
11622    }
11623    let take = b.min(cap - acc.buf_t);
11624    for t in 0..take {
11625        let col = acc.buf_t + t;
11626        for (j, &n) in acc.support.iter().enumerate() {
11627            acc.buf_g[j * cap + col] = g[t * inter + n as usize];
11628        }
11629        for h in 0..hidden {
11630            acc.buf_o[h * cap + col] = out[t * hidden + h];
11631        }
11632    }
11633    acc.buf_t += take;
11634    acc.tokens += take as u64;
11635    if acc.buf_t < cap {
11636        return;
11637    }
11638    let bt = acc.buf_t;
11639    acc.buf_t = 0;
11640    // The GEMM WRITES its C (it zeroes the accumulators it uses), so the
11641    // chunk product lands in scratch and is added on — the one thing that
11642    // silently turns a Gram over 13 000 tokens into a Gram over 256.
11643    // Both products are `C[n, m] += X[n, b] · Yᵀ[b, m]` with X and Y
11644    // stored row-major [·, b] — exactly `gemm_nt_f32`'s shape, so the
11645    // card does them when it is up (this is the whole calibration's
11646    // cost: O(|S|²) per token, 2.9 PFLOP for a 27B pass). The tiled CPU
11647    // loop stays as the fallback. Neither accumulates, so the product
11648    // lands in scratch and is added on.
11649    let RefitAcc {
11650        gss,
11651        ya,
11652        buf_g,
11653        buf_o,
11654        ..
11655    } = acc;
11656    let need = (ns * ns).max(hidden * ns);
11657    if shared.len() < need {
11658        shared.resize(need, 0.0);
11659    }
11660    let scratch = &mut shared[..];
11661    let _ = bt;
11662    if crate::gpu::gemm_nt_f32_transient(buf_g, buf_g, &mut scratch[..ns * ns], ns, cap, ns) {
11663        add_into(gss, &scratch[..ns * ns], pool);
11664        if crate::gpu::gemm_nt_f32_transient(
11665            buf_o,
11666            buf_g,
11667            &mut scratch[..hidden * ns],
11668            hidden,
11669            cap,
11670            ns,
11671        ) {
11672            add_into(ya, &scratch[..hidden * ns], pool);
11673        } else {
11674            accum_outer_t(ya, hidden, ns, cap, buf_o, buf_g, pool);
11675        }
11676    } else {
11677        accum_outer_t(gss, ns, ns, cap, buf_g, buf_g, pool);
11678        accum_outer_t(ya, hidden, ns, cap, buf_o, buf_g, pool);
11679    }
11680    // No zeroing: the batch is always filled exactly (cap is a multiple
11681    // of the prefill chunk), and a memset of 178 MB a layer would cost
11682    // more than the GEMM.
11683}
11684
11685/// `CMF_FFN_REFIT_BATCH` — tokens staged before each GEMM (default 4096).
11686fn refit_batch() -> usize {
11687    static B: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
11688    *B.get_or_init(|| {
11689        std::env::var("CMF_FFN_REFIT_BATCH")
11690            .ok()
11691            .and_then(|v| v.parse().ok())
11692            .unwrap_or(4096)
11693    })
11694}
11695
11696/// `c[m, n] += Σ_t left[m, t]·right[n, t]` — both operands transposed,
11697/// the CPU fallback for the staged batch.
11698fn accum_outer_t(
11699    c: &mut [f32],
11700    m: usize,
11701    n: usize,
11702    b: usize,
11703    left: &[f32],
11704    right: &[f32],
11705    pool: Option<&Pool>,
11706) {
11707    let ptr = SendMut(c.as_mut_ptr());
11708    let body = |i: usize| {
11709        let ptr = &ptr;
11710        let row = unsafe { std::slice::from_raw_parts_mut(ptr.0.add(i * n), n) };
11711        for t in 0..b {
11712            let a = left[i * b + t];
11713            if a == 0.0 {
11714                continue;
11715            }
11716            for (j, o) in row.iter_mut().enumerate() {
11717                *o += a * right[j * b + t];
11718            }
11719        }
11720    };
11721    match pool {
11722        Some(p) if m > 1 => p.run_rows(m, &|s, e| {
11723            for i in s..e {
11724                body(i);
11725            }
11726        }),
11727        _ => {
11728            for i in 0..m {
11729                body(i);
11730            }
11731        }
11732    }
11733}
11734
11735/// `dst += src`, spread over the pool — at 118 M floats a layer this is
11736/// not a loop to leave on one core.
11737fn add_into(dst: &mut [f32], src: &[f32], pool: Option<&Pool>) {
11738    let n = dst.len().min(src.len());
11739    match pool {
11740        Some(p) if n >= 1 << 16 => {
11741            let ptr = SendMut(dst.as_mut_ptr());
11742            let f = |s: usize, e: usize| {
11743                let ptr = &ptr;
11744                for blk in s..e {
11745                    let (a, b) = (blk * 4096, ((blk + 1) * 4096).min(n));
11746                    for i in a..b {
11747                        unsafe { *ptr.0.add(i) += src[i] };
11748                    }
11749                }
11750            };
11751            p.run_rows(n.div_ceil(4096), &f);
11752        }
11753        _ => {
11754            for (d, v) in dst.iter_mut().zip(&src[..n]) {
11755                *d += *v;
11756            }
11757        }
11758    }
11759}
11760
11761/// `c[m, n] += Σ_t left[t, m]·right[t, n]`, with `left` stored [m, t] and
11762/// `right` [t, n]. Tiled over the rows of `c` so a tile stays in cache
11763/// while each token's `right` row streams past it once, and parallel
11764/// over tiles.
11765fn accum_outer(
11766    c: &mut [f32],
11767    m: usize,
11768    n: usize,
11769    b: usize,
11770    left: &[f32],
11771    right: &[f32],
11772    pool: Option<&Pool>,
11773) {
11774    const TILE: usize = 32;
11775    let tiles = m.div_ceil(TILE);
11776    let cp = SendMut(c.as_mut_ptr());
11777    let body = |ti: usize| {
11778        let cp = &cp;
11779        let i0 = ti * TILE;
11780        let i1 = (i0 + TILE).min(m);
11781        for t in 0..b {
11782            let r = &right[t * n..t * n + n];
11783            for i in i0..i1 {
11784                let a = left[i * b + t];
11785                if a == 0.0 {
11786                    continue;
11787                }
11788                // SAFETY: tiles partition c's rows; workers never overlap.
11789                let row = unsafe { std::slice::from_raw_parts_mut(cp.0.add(i * n), n) };
11790                for (o, v) in row.iter_mut().zip(r) {
11791                    *o += a * *v;
11792                }
11793            }
11794        }
11795    };
11796    match pool {
11797        Some(p) if tiles > 1 => p.run_rows(tiles, &|s, e| {
11798            for ti in s..e {
11799                body(ti);
11800            }
11801        }),
11802        _ => {
11803            for ti in 0..tiles {
11804                body(ti);
11805            }
11806        }
11807    }
11808}
11809
11810/// Write what the calibration accumulated: `gss.<L>.f32` and `ya.<L>.f32`.
11811pub fn refit_flush() -> usize {
11812    let Some((dir, map)) = refit_dir() else {
11813        return 0;
11814    };
11815    let guard = map.lock().unwrap();
11816    let mut n = 0;
11817    for (li, acc) in guard.0.iter() {
11818        // A silently truncated write here is a Gram that reshapes to
11819        // nothing an hour later — say it out loud instead.
11820        let w = |name: &str, v: &[f32]| {
11821            let path = format!("{dir}/{name}.{li}.f32");
11822            let bytes: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
11823            match std::fs::write(&path, &bytes) {
11824                Ok(()) => {}
11825                Err(e) => eprintln!(
11826                    "refit: FAILED to write {path} ({} MB): {e}",
11827                    bytes.len() / 1_000_000
11828                ),
11829            }
11830        };
11831        w("gss", &acc.gss);
11832        w("ya", &acc.ya);
11833        println!(
11834            "refit L{li}: {} support, {} tokens, hidden {}",
11835            acc.support.len(),
11836            acc.tokens,
11837            acc.hidden
11838        );
11839        n += 1;
11840    }
11841    n
11842}
11843
11844/// `CMF_FFN_ADUMP=<prefix>` — append every probed token's FFN activation
11845/// row to `<prefix>.<layer>.f16`. The co-activation record: which
11846/// neurons fire together, which is what a tube has to group if a token
11847/// is ever going to open one tube instead of sixteen.
11848fn adump_row(li: usize, g: &[f32]) {
11849    use std::io::Write as _;
11850    static FILES: std::sync::OnceLock<
11851        Option<(
11852            String,
11853            std::sync::Mutex<std::collections::HashMap<usize, std::fs::File>>,
11854        )>,
11855    > = std::sync::OnceLock::new();
11856    let Some((prefix, map)) = FILES
11857        .get_or_init(|| {
11858            std::env::var("CMF_FFN_ADUMP")
11859                .ok()
11860                .map(|p| (p, std::sync::Mutex::new(std::collections::HashMap::new())))
11861        })
11862        .as_ref()
11863    else {
11864        return;
11865    };
11866    // `CMF_FFN_ADUMP_FROM/_TO` narrow the dump to a layer span, so a big
11867    // calibration run fits on disk in a few passes instead of one.
11868    static SPAN: std::sync::OnceLock<(usize, usize)> = std::sync::OnceLock::new();
11869    let (from, to) = *SPAN.get_or_init(|| {
11870        let g = |k: &str, d: usize| {
11871            std::env::var(k)
11872                .ok()
11873                .and_then(|v| v.parse().ok())
11874                .unwrap_or(d)
11875        };
11876        (
11877            g("CMF_FFN_ADUMP_FROM", 0),
11878            g("CMF_FFN_ADUMP_TO", usize::MAX),
11879        )
11880    });
11881    if li < from || li > to {
11882        return;
11883    }
11884    let mut map = map.lock().unwrap();
11885    let f = map.entry(li).or_insert_with(|| {
11886        std::fs::File::create(format!("{prefix}.{li}.f16")).expect("adump file")
11887    });
11888    let mut bytes = Vec::with_capacity(g.len() * 2);
11889    for v in g {
11890        bytes.extend_from_slice(&cortiq_core::quant::f32_to_f16(*v).to_le_bytes());
11891    }
11892    let _ = f.write_all(&bytes);
11893}
11894
11895/// `CMF_FFN_ORACLE_TOPK` — keep only the k largest |silu(g)·u| of each
11896/// token and zero the rest. Not a serving mode: it is the CEILING of
11897/// contextual sparsity — what a per-token router would be chasing —
11898/// measured by cheating, since the selection reads the very activations
11899/// it would have to predict.
11900fn oracle_topk() -> usize {
11901    static K: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
11902    *K.get_or_init(|| {
11903        std::env::var("CMF_FFN_ORACLE_TOPK")
11904            .ok()
11905            .and_then(|v| v.parse().ok())
11906            .unwrap_or(0)
11907    })
11908}
11909
11910/// `CMF_FFN_GATE_TOPK` — the REALIZABLE cousin of the oracle: rank the
11911/// neurons by their gate alone (which the kernel has computed anyway
11912/// before it reads `up`), keep the k best, and drop the rest. Every
11913/// dropped neuron's `up` row and `down` column stay unread, so this is
11914/// the sparsity a serving path can actually take without a router.
11915fn gate_topk() -> usize {
11916    static K: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
11917    *K.get_or_init(|| {
11918        std::env::var("CMF_FFN_GATE_TOPK")
11919            .ok()
11920            .and_then(|v| v.parse().ok())
11921            .unwrap_or(0)
11922    })
11923}
11924
11925/// `CMF_FFN_GATE_BLOCK` — select in blocks of B neurons instead of one
11926/// by one. A scattered per-neuron choice cannot be read efficiently (a
11927/// row at a time, no prefetch runway); a block of 32 is a contiguous
11928/// 32-row slab of `up` and of the transposed `down`, which the ordinary
11929/// kernels stream. The question the measurement answers is what the
11930/// block costs in quality.
11931fn gate_block() -> usize {
11932    static B: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
11933    *B.get_or_init(|| {
11934        std::env::var("CMF_FFN_GATE_BLOCK")
11935            .ok()
11936            .and_then(|v| v.parse().ok())
11937            .unwrap_or(1)
11938    })
11939}
11940
11941/// Zero all but the `k` largest BLOCKS (by summed square) of a row.
11942fn keep_top_blocks(g: &mut [f32], keep_n: usize, block: usize) {
11943    let n = g.len();
11944    let nb = n.div_ceil(block);
11945    let kb = (keep_n.div_ceil(block)).clamp(1, nb);
11946    if kb >= nb {
11947        return;
11948    }
11949    let mut score: Vec<f32> = (0..nb)
11950        .map(|b| {
11951            g[b * block..((b + 1) * block).min(n)]
11952                .iter()
11953                .map(|v| v * v)
11954                .sum::<f32>()
11955        })
11956        .collect();
11957    let mut ord = score.clone();
11958    let (_, kth, _) = ord.select_nth_unstable_by(kb - 1, |a, b| {
11959        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
11960    });
11961    let thr = *kth;
11962    for b in 0..nb {
11963        if score[b] < thr {
11964            g[b * block..((b + 1) * block).min(n)].fill(0.0);
11965        }
11966    }
11967    score.clear();
11968}
11969
11970/// Zero all but the `k` largest magnitudes of one token's activation row.
11971fn keep_top_k(g: &mut [f32], k: usize) {
11972    if gate_block() > 1 {
11973        return keep_top_blocks(g, k, gate_block());
11974    }
11975    let n = g.len();
11976    if k == 0 || k >= n {
11977        return;
11978    }
11979    let mut mag: Vec<f32> = g.iter().map(|v| v.abs()).collect();
11980    let (_, kth, _) = mag.select_nth_unstable_by(k - 1, |a, b| {
11981        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
11982    });
11983    let thr = *kth;
11984    for v in g.iter_mut() {
11985        if v.abs() < thr {
11986            *v = 0.0;
11987        }
11988    }
11989}
11990
11991/// `CMF_FFN_PROBE_SQ` — accumulate Σa², so the dump divided by the token
11992/// count and square-rooted is the RMS activation trace Patent 12 weights
11993/// its matrices by.
11994fn probe_sq() -> bool {
11995    static S: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
11996    *S.get_or_init(|| std::env::var("CMF_FFN_PROBE_SQ").is_ok())
11997}
11998
11999/// `CMF_FFN_PROBE_SIGNED` — accumulate the SIGNED activation sum
12000/// instead of its magnitude: what a dropped neuron contributes ON
12001/// AVERAGE, which is the bias a narrowed FFN can add back for free.
12002fn probe_signed() -> bool {
12003    static S: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
12004    *S.get_or_init(|| std::env::var("CMF_FFN_PROBE_SIGNED").is_ok())
12005}
12006
12007/// `CMF_FFN_MEANFILL=<file>` — a masked-out neuron contributes its MEAN
12008/// activation instead of zero (`u32 layers, u32 inter, f32[…]`, the mass
12009/// dump layout, holding per-neuron means). Dropping a neuron outright
12010/// also drops its average contribution, which shifts the layer output by
12011/// a constant; filling the mean back is one add per layer and costs no
12012/// bytes off the bus. This is the measurement arm — in a tube file the
12013/// same correction ships as a per-task bias vector.
12014fn meanfill() -> Option<&'static (usize, Vec<f32>)> {
12015    static M: std::sync::OnceLock<Option<(usize, Vec<f32>)>> = std::sync::OnceLock::new();
12016    M.get_or_init(|| {
12017        let p = std::env::var("CMF_FFN_MEANFILL").ok()?;
12018        let b = std::fs::read(&p).ok()?;
12019        let inter = u32::from_le_bytes(b[4..8].try_into().ok()?) as usize;
12020        let vals: Vec<f32> = b[8..]
12021            .chunks_exact(4)
12022            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
12023            .collect();
12024        eprintln!("meanfill: {} value(s), inter {inter}", vals.len());
12025        Some((inter, vals))
12026    })
12027    .as_ref()
12028}
12029
12030/// `CMF_FFN_PROBE_TOPK` — 0 (default) = accumulate mass, k>0 = count
12031/// how often a neuron lands in a token's top k.
12032fn probe_topk() -> usize {
12033    static K: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
12034    *K.get_or_init(|| {
12035        std::env::var("CMF_FFN_PROBE_TOPK")
12036            .ok()
12037            .and_then(|v| v.parse().ok())
12038            .unwrap_or(0)
12039    })
12040}
12041
12042thread_local! {
12043    /// DTG-MA activation probe: per-layer per-neuron Σ|silu(g)·u|
12044    /// accumulator, alive only during `Pipeline::probe_ffn_mass`.
12045    static FFN_PROBE: std::cell::RefCell<Option<Vec<Vec<f64>>>> =
12046        const { std::cell::RefCell::new(None) };
12047}
12048
12049/// Per-token structured sparsity, paid for in bytes.
12050///
12051/// The gate is the cheapest third of an FFN and it already says which
12052/// neurons matter: `silu(gate)` near zero means the neuron contributes
12053/// nothing whatever `up` says. So compute every gate, keep the `k`
12054/// loudest, and read ONLY those neurons' `up` rows and `down` rows —
12055/// the latter needs `down_proj` stored transposed, otherwise a neuron's
12056/// down weights are a strided column and "reading only those" costs a
12057/// full cache line each.
12058///
12059/// Returns `None` when the file has no transposed `down` (the caller
12060/// then runs the ordinary dense path).
12061fn dense_ffn_dynamic(d: &DenseFfn, x: &[f32], pool: Option<&Pool>, k: usize) -> Option<Vec<f32>> {
12062    let dt = d.down_t.as_ref()?;
12063    let inter = d.gate_proj.rows();
12064    let hidden = dt.cols();
12065    if k == 0 || k >= inter || d.act != Act::Silu {
12066        return None;
12067    }
12068    DYN_SCRATCH.with(|sc| {
12069        let mut sc = sc.borrow_mut();
12070        let DynScratch {
12071            g,
12072            mag,
12073            live,
12074            parts,
12075        } = &mut *sc;
12076        g.resize(inter, 0.0);
12077        d.gate_proj.matvec(x, g, pool);
12078        for v in g.iter_mut() {
12079            *v = inference::silu(*v);
12080        }
12081        // The k-th largest |silu(gate)| is the threshold; ties keep more,
12082        // which is the safe side.
12083        mag.clear();
12084        mag.extend(g.iter().map(|v| v.abs()));
12085        let (_, kth, _) = mag.select_nth_unstable_by(k - 1, |a, b| {
12086            b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
12087        });
12088        let thr = *kth;
12089        live.clear();
12090        live.extend((0..inter as u32).filter(|&n| g[n as usize].abs() >= thr));
12091        let mut out = vec![0.0f32; hidden];
12092        match pool {
12093            Some(p) if live.len() >= 64 => {
12094                let nw = p.n_workers() + 1;
12095                parts.clear();
12096                parts.resize(nw * hidden, 0.0);
12097                let ptr = SendMut(parts.as_mut_ptr());
12098                let n = live.len();
12099                let live_ref: &[u32] = live;
12100                let g_ref: &[f32] = g;
12101                p.run(&|w, workers| {
12102                    let chunk = n.div_ceil(workers);
12103                    let (s, e) = (w * chunk, ((w + 1) * chunk).min(n));
12104                    if s >= e {
12105                        return;
12106                    }
12107                    WORKER_SCRATCH.with(|ws| {
12108                        let mut ws = ws.borrow_mut();
12109                        let [scratch, acc] = &mut *ws;
12110                        scratch.resize(hidden.max(x.len()), 0.0);
12111                        acc.clear();
12112                        acc.resize(hidden, 0.0);
12113                        for (o, &nrm) in live_ref[s..e].iter().enumerate() {
12114                            // One neuron of runway: the next row's lines
12115                            // start moving while this one is multiplied.
12116                            if let Some(&nx) = live_ref[s..e].get(o + 1) {
12117                                d.up_proj.prefetch_row(nx as usize);
12118                                dt.prefetch_row(nx as usize);
12119                            }
12120                            let idx = nrm as usize;
12121                            let up = d.up_proj.row_dot(idx, x, scratch);
12122                            let a = g_ref[idx] * up;
12123                            if a != 0.0 {
12124                                dt.add_row_scaled(idx, a, acc, scratch);
12125                            }
12126                        }
12127                        for (j, v) in acc.iter().enumerate() {
12128                            unsafe { *ptr.at(w * hidden + j) = *v };
12129                        }
12130                    });
12131                });
12132                for w in 0..nw {
12133                    for (j, o) in out.iter_mut().enumerate() {
12134                        *o += parts[w * hidden + j];
12135                    }
12136                }
12137            }
12138            _ => {
12139                WORKER_SCRATCH.with(|ws| {
12140                    let mut ws = ws.borrow_mut();
12141                    let [scratch, _acc] = &mut *ws;
12142                    scratch.resize(hidden.max(x.len()), 0.0);
12143                    for &nrm in live.iter() {
12144                        let idx = nrm as usize;
12145                        let up = d.up_proj.row_dot(idx, x, scratch);
12146                        let a = g[idx] * up;
12147                        if a != 0.0 {
12148                            dt.add_row_scaled(idx, a, &mut out, scratch);
12149                        }
12150                    }
12151                });
12152            }
12153        }
12154        Some(out)
12155    })
12156}
12157
12158/// Caller-side scratch of the dynamic path — one allocation per thread,
12159/// not one per layer per token (that alone cost a third of the decode).
12160struct DynScratch {
12161    g: Vec<f32>,
12162    mag: Vec<f32>,
12163    live: Vec<u32>,
12164    parts: Vec<f32>,
12165}
12166
12167thread_local! {
12168    static DYN_SCRATCH: std::cell::RefCell<DynScratch> = const {
12169        std::cell::RefCell::new(DynScratch {
12170            g: Vec::new(),
12171            mag: Vec::new(),
12172            live: Vec::new(),
12173            parts: Vec::new(),
12174        })
12175    };
12176    /// Pool-worker scratch: the row buffer and this worker's partial sum.
12177    static WORKER_SCRATCH: std::cell::RefCell<[Vec<f32>; 2]> =
12178        const { std::cell::RefCell::new([Vec::new(), Vec::new()]) };
12179}
12180
12181/// `dense_ffn_cpu` with a per-visit mask landing on the activations —
12182/// the masked-inference fast path's decode arm. Full fused quant
12183/// compute, closed neurons zeroed before down: arithmetically the
12184/// pruned network, no dequant, no weight bytes touched.
12185fn dense_ffn_masked(d: &DenseFfn, x: &[f32], pool: Option<&Pool>, mask_row: &[u8]) -> Vec<f32> {
12186    let inter = d.gate_proj.rows();
12187    FFN_SCRATCH.with(|s| {
12188        let mut s = s.borrow_mut();
12189        let [g, u, ..] = &mut *s;
12190        g.resize(inter, 0.0);
12191        if d.act == Act::Silu && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool) {
12192            // g holds silu(gate)·up.
12193        } else {
12194            u.resize(inter, 0.0);
12195            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
12196            for i in 0..inter {
12197                g[i] = d.act.combine(g[i], u[i]);
12198            }
12199        }
12200        zero_masked_cols(g, 1, inter, mask_row);
12201        let mut out = attention::take_buf(d.down_proj.rows());
12202        d.down_proj.matvec(g, &mut out, pool);
12203        out
12204    })
12205}
12206
12207/// Dense FFN as one GPU submission via the MoE block path (single
12208/// expert, weight 1.0): gate → silu·up → down chained in one command
12209/// buffer, intermediate activations device-resident. None → weights
12210/// not q8-mapped in the primary shard / over the VRAM budget / backend
12211/// refusal → honest CPU path.
12212fn dense_ffn_gpu(d: &DenseFfn, x: &[f32], _pool: Option<&Pool>) -> Option<Vec<f32>> {
12213    // The GPU block hardcodes SiLU; GeLU FFNs (Gemma) stay on CPU.
12214    if d.act != Act::Silu {
12215        return None;
12216    }
12217    // Threshold: tiny FFNs are not worth a submission (q1 excepted —
12218    // see the caller's gate).
12219    if d.gate_proj.rows() < crate::gpu::min_rows() && !d.gate_proj.is_q1() {
12220        return None;
12221    }
12222    let mut jobs: Vec<crate::gpu::MoeJob> = Vec::with_capacity(1);
12223    let mut model_ref = None;
12224    moe_push_job(d, x, 1.0, &mut jobs, &mut model_ref)?;
12225    let model = model_ref?;
12226    let hidden = jobs[0].down.1;
12227    let mut out = attention::take_buf(hidden);
12228    if crate::gpu::moe_block(&model, &jobs, &mut out) {
12229        Some(out)
12230    } else {
12231        let mut out = out;
12232        attention::recycle_buf(&mut out);
12233        None
12234    }
12235}
12236
12237/// q8-mapped primary-shard tensor parts for a GPU job: q8_2f carries
12238/// its column field, q8_row runs with empty col slices (the backend
12239/// skips the multiply). Shared by the MoE block and the dense-FFN
12240/// single-job path.
12241#[allow(clippy::type_complexity)]
12242#[allow(clippy::type_complexity)]
12243pub(crate) fn moe_parts(
12244    t: &QTensor,
12245) -> Option<(
12246    &std::sync::Arc<cortiq_core::CmfModel>,
12247    usize,
12248    usize,
12249    usize,
12250    &[f32],
12251    &[f32],
12252    bool,
12253    bool,
12254    bool,
12255)> {
12256    match t {
12257        QTensor::Mapped {
12258            model,
12259            idx,
12260            dtype: dt @ (cortiq_core::TensorDtype::Q8_2f | cortiq_core::TensorDtype::Q8Row),
12261            rows,
12262            cols,
12263            row_scale,
12264            col_field,
12265            ..
12266        } if (*dt == cortiq_core::TensorDtype::Q8Row) || !col_field.is_empty() => Some((
12267            model, *idx, *rows, *cols, row_scale, col_field, false, false, false,
12268        )),
12269        // q1: tile-embedded scales — empty rs/col slices, raw xs.
12270        QTensor::Mapped {
12271            model,
12272            idx,
12273            dtype: cortiq_core::TensorDtype::Q1,
12274            rows,
12275            cols,
12276            ..
12277        } => Some((
12278            model,
12279            *idx,
12280            *rows,
12281            *cols,
12282            &[][..],
12283            &[][..],
12284            true,
12285            false,
12286            false,
12287        )),
12288        // q4_tiled: 18-byte tiles with embedded f16 scales — raw xs.
12289        QTensor::Mapped {
12290            model,
12291            idx,
12292            dtype: cortiq_core::TensorDtype::Q4Tiled,
12293            rows,
12294            cols,
12295            ..
12296        } => Some((
12297            model,
12298            *idx,
12299            *rows,
12300            *cols,
12301            &[][..],
12302            &[][..],
12303            false,
12304            true,
12305            false,
12306        )),
12307        // q4tp: same raw-xs contract, different stride and scale plane.
12308        QTensor::Mapped {
12309            model,
12310            idx,
12311            dtype: cortiq_core::TensorDtype::Q4TiledP,
12312            rows,
12313            cols,
12314            ..
12315        } => Some((
12316            model,
12317            *idx,
12318            *rows,
12319            *cols,
12320            &[][..],
12321            &[][..],
12322            false,
12323            true,
12324            false,
12325        )),
12326        // q2tp: the 2-bit expert plane of the mixed profile — q4 family
12327        // for stride bookkeeping, flagged q2 so the trio validation can
12328        // demand a q4tp down.
12329        QTensor::Mapped {
12330            model,
12331            idx,
12332            dtype: cortiq_core::TensorDtype::Q2TiledP,
12333            rows,
12334            cols,
12335            ..
12336        } => Some((
12337            model,
12338            *idx,
12339            *rows,
12340            *cols,
12341            &[][..],
12342            &[][..],
12343            false,
12344            true,
12345            true,
12346        )),
12347        _ => None,
12348    }
12349}
12350
12351/// Map a softmax-router MoE onto the Metal token graph's contract:
12352/// f32 router, gated shared expert, experts uniformly q4tp (or the
12353/// mixed profile: q2tp gate/up over a q4tp down). Sigmoid/bias/τ
12354/// routers, masks, per-expert scales and Gemma's router-input norm
12355/// refuse here — those semantics stay on the CPU path.
12356#[cfg(target_os = "macos")]
12357fn metal_moe_graph_parts(m: &MoeFfn, hidden: usize) -> Option<crate::gpu::GpuMoe<'_>> {
12358    if m.router_sigmoid
12359        || m.router_input_norm
12360        || m.expert_bias.is_some()
12361        || m.route_tau.is_some()
12362        || m.mask.is_some()
12363        || m.per_expert_scale.is_some()
12364        || m.experts.is_empty()
12365        || m.top_k == 0
12366        || m.resonance.is_some()
12367    {
12368        return None;
12369    }
12370    // The select kernel hard-codes the gated shared expert; an
12371    // ungated one would need its own weight-1 slot.
12372    let (sh, sg) = match &m.shared {
12373        Some((sh, Some(sg))) => (sh, sg),
12374        _ => return None,
12375    };
12376    let (rf, rr, rc) = m.router.f32_parts()?;
12377    if rr != m.experts.len() || rc != hidden {
12378        return None;
12379    }
12380    let (sf, sr, sc) = sg.f32_parts()?;
12381    if sr * sc != hidden {
12382        return None;
12383    }
12384    let inter = m.experts[0].gate_proj.rows();
12385    // The first expert's gate decides the profile; every trio (shared
12386    // included) must agree — the jobs ladder flips ONE kernel for all.
12387    let gu_q2 = m.experts[0].gate_proj.mapped_q2tp().is_some();
12388    let trio = |e: &DenseFfn| -> Option<(usize, usize, usize)> {
12389        if e.act != Act::Silu
12390            || e.gate_proj.rows() != inter
12391            || e.gate_proj.cols() != hidden
12392            || e.up_proj.rows() != inter
12393            || e.up_proj.cols() != hidden
12394            || e.down_proj.rows() != hidden
12395            || e.down_proj.cols() != inter
12396        {
12397            return None;
12398        }
12399        let pick = |t: &QTensor| -> Option<usize> {
12400            if gu_q2 {
12401                t.mapped_q2tp().map(|(_, i)| i)
12402            } else {
12403                t.mapped_q4tp().map(|(_, i)| i)
12404            }
12405        };
12406        Some((
12407            pick(&e.gate_proj)?,
12408            pick(&e.up_proj)?,
12409            e.down_proj.mapped_q4tp().map(|(_, i)| i)?,
12410        ))
12411    };
12412    let experts = m.experts.iter().map(trio).collect::<Option<Vec<_>>>()?;
12413    let shared = trio(sh)?;
12414    Some(crate::gpu::GpuMoe {
12415        router: rf,
12416        sgate: sf,
12417        experts,
12418        shared,
12419        n_exp: m.experts.len(),
12420        top_k: m.top_k,
12421        inter,
12422        norm_topk: m.norm_topk_prob,
12423        route_scale: m.routed_scaling,
12424        gu_q2,
12425    })
12426}
12427
12428/// Build one gate/up/down GPU job from three tensors. `moe_push_job` is the
12429/// DenseFfn-shaped caller; architectures that keep their experts in their own
12430/// structs (DeepSeek-V4) come here directly.
12431pub(crate) fn moe_push_job_parts<'a>(
12432    gate: &'a QTensor,
12433    up: &'a QTensor,
12434    down: &'a QTensor,
12435    x: &[f32],
12436    w: f32,
12437    swiglu_limit: f32,
12438    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
12439    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
12440) -> Option<()> {
12441    use crate::qtensor::prescale;
12442    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(gate)?;
12443    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(up)?;
12444    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(down)?;
12445    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
12446        return None; // mixed-dtype trio — honest CPU path
12447    }
12448    // The 2-bit profile is gate/up q2tp over a PLAIN q4tp down; any other
12449    // 2-bit arrangement stays on the CPU.
12450    if gq2 && (dq2 || !dq4 || down.mapped_q4tp().is_none()) {
12451        return None;
12452    }
12453    if !gq2 && dq2 {
12454        return None;
12455    }
12456    model_ref.get_or_insert_with(|| gm.clone());
12457    let dt = |cf: &[f32]| {
12458        if cf.is_empty() {
12459            cortiq_core::TensorDtype::Q8Row
12460        } else {
12461            cortiq_core::TensorDtype::Q8_2f
12462        }
12463    };
12464    jobs.push(crate::gpu::MoeJob {
12465        gate: (gi, gr, gc, grs),
12466        up: (ui, ur, uc, urs),
12467        down: (di, dr, dc, drs),
12468        xs_gate: prescale(x, gcf, dt(gcf)).into_owned(),
12469        xs_up: prescale(x, ucf, dt(ucf)).into_owned(),
12470        down_col: dcf,
12471        w,
12472        q1: gq1,
12473        q4t: gq4 && !gq2 && gate.mapped_q4tp().is_none(),
12474        q4tp: gq4 && (gq2 || gate.mapped_q4tp().is_some()),
12475        gu_q2: gq2,
12476        swiglu_limit,
12477    });
12478    Some(())
12479}
12480
12481/// Build one gate/up/down GPU job (see `moe_parts`).
12482fn moe_push_job<'a>(
12483    d: &'a DenseFfn,
12484    x: &[f32],
12485    w: f32,
12486    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
12487    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
12488) -> Option<()> {
12489    use crate::qtensor::prescale;
12490    if d.act != Act::Silu {
12491        return None; // GPU block hardcodes SiLU
12492    }
12493    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(&d.gate_proj)?;
12494    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(&d.up_proj)?;
12495    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(&d.down_proj)?;
12496    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
12497        return None; // mixed-dtype trio — honest CPU path
12498    }
12499    if gq2 && (dq2 || !dq4 || d.down_proj.mapped_q4tp().is_none()) {
12500        return None;
12501    }
12502    if !gq2 && dq2 {
12503        return None;
12504    }
12505    model_ref.get_or_insert_with(|| gm.clone());
12506    let gdt = if gcf.is_empty() {
12507        cortiq_core::TensorDtype::Q8Row
12508    } else {
12509        cortiq_core::TensorDtype::Q8_2f
12510    };
12511    let udt = if ucf.is_empty() {
12512        cortiq_core::TensorDtype::Q8Row
12513    } else {
12514        cortiq_core::TensorDtype::Q8_2f
12515    };
12516    jobs.push(crate::gpu::MoeJob {
12517        gate: (gi, gr, gc, grs),
12518        up: (ui, ur, uc, urs),
12519        down: (di, dr, dc, drs),
12520        xs_gate: prescale(x, gcf, gdt).into_owned(),
12521        xs_up: prescale(x, ucf, udt).into_owned(),
12522        down_col: dcf,
12523        w,
12524        q1: gq1,
12525        q4t: gq4 && !gq2 && d.gate_proj.mapped_q4tp().is_none(),
12526        q4tp: gq4 && (gq2 || d.gate_proj.mapped_q4tp().is_some()),
12527        gu_q2: gq2,
12528        swiglu_limit: 0.0,
12529    });
12530    Some(())
12531}
12532
12533/// Sparse dense-FFN directly on QUANTIZED weights (mask × mmap): reads
12534/// ONLY the active neurons' gate/up rows and down columns from the mmap
12535/// — no full-matrix dequant, no f32 model copy. This is what lets a
12536/// masked big model run at quantized RSS (the historical mask path
12537/// forced the whole model to f32). Semantics identical to the f32
12538/// sparse path within quant tolerance.
12539fn sparse_ffn_quant(
12540    d: &DenseFfn,
12541    x: &[f32],
12542    active: &[u16],
12543    hidden: usize,
12544    pool: Option<&Pool>,
12545) -> Vec<f32> {
12546    let n = active.len();
12547    let inter = d.gate_proj.rows();
12548    let mut act = vec![0.0f32; n];
12549    // Scratch is needed if EITHER projection is group-packed (q4/vbit);
12550    // gate/up normally share a dtype but sizing on both is robust.
12551    let need_scratch = !(d.gate_proj.sparse_col_ok() && d.up_proj.sparse_col_ok());
12552    let compute = |ai: usize| -> f32 {
12553        let idx = active[ai] as usize;
12554        if idx >= inter {
12555            return 0.0; // defensive parity with the f32 sparse path
12556        }
12557        let mut s = if need_scratch {
12558            vec![0.0f32; hidden]
12559        } else {
12560            Vec::new()
12561        };
12562        let gate = d.gate_proj.row_dot(idx, x, &mut s);
12563        let up = d.up_proj.row_dot(idx, x, &mut s);
12564        d.act.combine(gate, up)
12565    };
12566    match pool {
12567        Some(p) if n >= 256 => {
12568            let ptr = SendMut(act.as_mut_ptr());
12569            p.run(&|widx, nw| {
12570                let chunk = n.div_ceil(nw);
12571                let (s, e) = (widx * chunk, ((widx + 1) * chunk).min(n));
12572                for ai in s..e {
12573                    unsafe { *ptr.at(ai) = compute(ai) };
12574                }
12575            });
12576        }
12577        _ => {
12578            for (ai, a) in act.iter_mut().enumerate() {
12579                *a = compute(ai);
12580            }
12581        }
12582    }
12583    // Scatter through active down columns (reads only those columns).
12584    let mut out = vec![0.0f32; hidden];
12585    for (ai, &idx) in active.iter().enumerate() {
12586        let w = act[ai];
12587        if w.abs() >= 1e-12 && (idx as usize) < inter {
12588            d.down_proj.add_col_scaled(idx as usize, w, &mut out);
12589        }
12590    }
12591    out
12592}
12593
12594/// Test-only re-export of the private sparse-quant FFN (mask × mmap gate).
12595#[doc(hidden)]
12596pub fn sparse_ffn_quant_for_test(
12597    d: &DenseFfn,
12598    x: &[f32],
12599    active: &[u16],
12600    hidden: usize,
12601) -> Vec<f32> {
12602    sparse_ffn_quant(d, x, active, hidden, None)
12603}
12604
12605/// Dequantize a DenseFfn's three matrices to f32 (transient; only the
12606/// q4/vbit-masked fallback uses it — the memory-lean path is
12607/// sparse_ffn_quant). Reuses row_f32 row-by-row.
12608fn dequant_dense_f32(d: &DenseFfn) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
12609    let deq = |t: &QTensor| -> Vec<f32> {
12610        let (rows, cols) = (t.rows(), t.cols());
12611        let mut out = vec![0.0f32; rows * cols];
12612        for r in 0..rows {
12613            t.row_f32(r, &mut out[r * cols..(r + 1) * cols]);
12614        }
12615        out
12616    };
12617    (deq(&d.gate_proj), deq(&d.up_proj), deq(&d.down_proj))
12618}
12619
12620/// Pointer wrapper for the worker-pool scatter (same pattern as qtensor).
12621struct SendMut(*mut f32);
12622unsafe impl Send for SendMut {}
12623unsafe impl Sync for SendMut {}
12624impl SendMut {
12625    #[inline]
12626    // Deliberate unsynchronized scatter: pool workers write disjoint indices
12627    // in parallel, so returning `&mut` from `&self` is intentional here.
12628    #[allow(clippy::mut_from_ref)]
12629    unsafe fn at(&self, i: usize) -> &mut f32 {
12630        unsafe { &mut *self.0.add(i) }
12631    }
12632}
12633
12634/// Router → (selected experts in torch.topk order, per-expert score
12635/// vector, normalizer). The final weight of expert `e` is `p[e] / wsum`.
12636///
12637/// Two regimes share this. Qwen: softmax over ALL experts, top-k of the
12638/// probabilities, optional renorm — `router_sigmoid=false`, no bias,
12639/// scale 1 → bit-identical to the historical path. LFM2-MoE /
12640/// DeepSeek-V3 `noaux_tc`: per-expert sigmoid scores, an optional
12641/// selection bias (top-k CHOICE only; weights stay unbiased), a 1e-6 renorm
12642/// floor and a routed scale.
12643pub(crate) fn moe_route(
12644    logits: &[f32],
12645    m: &MoeFfn,
12646    allowed: Option<&[bool]>,
12647) -> (Vec<usize>, Vec<f32>, f32) {
12648    let ne = logits.len();
12649    let p: Vec<f32> = if m.router_sigmoid {
12650        logits.iter().map(|&l| 1.0 / (1.0 + (-l).exp())).collect()
12651    } else {
12652        let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
12653        let mut e: Vec<f32> = logits.iter().map(|&l| (l - mx).exp()).collect();
12654        let s: f32 = e.iter().sum();
12655        for v in &mut e {
12656            *v /= s;
12657        }
12658        e
12659    };
12660    // Expert restriction: the static env mask (CMF_MOE_MASK) AND the
12661    // active task mask's expert fields (spec §5) both narrow the
12662    // candidate set; selection happens over the admitted experts only.
12663    // With norm_topk the kept weights renormalize below; without it
12664    // the excluded mass is honestly dropped.
12665    let admit = |e: usize| {
12666        m.mask.as_ref().is_none_or(|mk| mk[e])
12667            && allowed.is_none_or(|a| a.get(e).copied().unwrap_or(false))
12668    };
12669    let mut idx: Vec<usize> = (0..ne).filter(|&e| admit(e)).collect();
12670    // Descending by selection score, lower index wins ties (torch.topk).
12671    match &m.expert_bias {
12672        Some(b) => idx.sort_unstable_by(|&x, &y| {
12673            (p[y] + b[y])
12674                .partial_cmp(&(p[x] + b[x]))
12675                .unwrap()
12676                .then(x.cmp(&y))
12677        }),
12678        None => idx.sort_unstable_by(|&x, &y| p[y].partial_cmp(&p[x]).unwrap().then(x.cmp(&y))),
12679    }
12680    idx.truncate(m.top_k);
12681    // Adaptive τ-routing: trim the tail experts once the kept mass is
12682    // enough. wsum below renormalizes over the KEPT set, so the output
12683    // stays a proper weighted average.
12684    if let Some(tau) = m.route_tau {
12685        let total: f32 = idx.iter().map(|&e| p[e]).sum();
12686        if total > 0.0 {
12687            let mut acc = 0.0f32;
12688            let mut keep = idx.len();
12689            for (i, &e) in idx.iter().enumerate() {
12690                acc += p[e];
12691                if acc >= tau * total {
12692                    keep = i + 1;
12693                    break;
12694                }
12695            }
12696            idx.truncate(keep);
12697        }
12698    }
12699    let wsum: f32 = if m.norm_topk_prob {
12700        let s: f32 = idx.iter().map(|&e| p[e]).sum();
12701        // LFM2 floors the denom (matches HF `+ 1e-6`); the softmax path's
12702        // probs already sum near 1, so it stays exactly as before.
12703        (if m.router_sigmoid { s + 1e-6 } else { s }) / m.routed_scaling
12704    } else {
12705        1.0 / m.routed_scaling
12706    };
12707    (idx, p, wsum)
12708}
12709
12710/// See the call site: one `layer:e1,e2,…` line per routed token.
12711fn moe_trace(idx: &[usize]) {
12712    moe_trace_at(crate::gpu::cur_layer() as i32, idx)
12713}
12714
12715/// The same, for callers that know their layer (DSV4 owns its layers and
12716/// never sets the pipeline's current-layer marker).
12717pub(crate) fn moe_trace_at(li: i32, idx: &[usize]) {
12718    use std::io::Write;
12719    static F: std::sync::OnceLock<Option<std::sync::Mutex<std::fs::File>>> =
12720        std::sync::OnceLock::new();
12721    let Some(f) = F.get_or_init(|| {
12722        let p = std::env::var("CMF_MOE_TRACE").ok()?;
12723        Some(std::sync::Mutex::new(
12724            std::fs::OpenOptions::new()
12725                .create(true)
12726                .append(true)
12727                .open(p)
12728                .ok()?,
12729        ))
12730    }) else {
12731        return;
12732    };
12733    let ids: Vec<String> = idx.iter().map(|e| e.to_string()).collect();
12734    let _ = writeln!(f.lock().unwrap(), "{li}:{}", ids.join(","));
12735}
12736
12737/// MoE FFN: router → top-k experts (see `moe_route`). Only selected
12738/// experts' pages are touched in mmap.
12739pub(crate) fn moe_ffn(
12740    m: &MoeFfn,
12741    x: &[f32],
12742    pool: Option<&Pool>,
12743    allowed: Option<&[bool]>,
12744) -> Vec<f32> {
12745    accumulate_act(m, x, 1);
12746    let ne = m.experts.len();
12747    let mut logits = vec![0.0f32; ne];
12748    match &m.resonance {
12749        Some(r) => r.scores(x, &mut logits),
12750        None => m.router.matvec(x, &mut logits, pool),
12751    }
12752    let (idx, p, wsum) = moe_route(&logits, m, allowed);
12753    {
12754        let mut st = m.stats.borrow_mut();
12755        if st.len() < ne {
12756            st.resize(ne, 0);
12757        }
12758        for &e in &idx {
12759            st[e] += 1;
12760        }
12761    }
12762    // `CMF_MOE_TRACE=<file>`: append one line per (layer, token) with the
12763    // selected expert ids. The cumulative `stats` above answer "which
12764    // experts are popular"; a residency design needs the question they
12765    // cannot answer — whether CONSECUTIVE tokens reuse experts (the
12766    // temporal locality an LRU cache lives on, FreeToken §4).
12767    moe_trace(&idx);
12768    // D5: the whole layer MoE block in one GPU command buffer (experts — the
12769    // same mmap via a no-copy buffer; intermediate activations on the GPU).
12770    // Same Ffn probe class as the dense chain: one submit per layer
12771    // either wins on this driver stack or it doesn't.
12772    if crate::gpu::enabled_here() {
12773        match crate::gpu::probe_arm(crate::gpu::OpClass::Ffn) {
12774            crate::gpu::ProbeArm::Gpu => {
12775                let t0 = std::time::Instant::now();
12776                if let Some(out) = moe_ffn_gpu(m, x, &idx, &p, wsum, pool) {
12777                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
12778                    return out;
12779                }
12780            }
12781            crate::gpu::ProbeArm::CpuTimed => {
12782                let t0 = std::time::Instant::now();
12783                let out = crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
12784                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
12785                return out;
12786            }
12787            crate::gpu::ProbeArm::Cpu => {
12788                return crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
12789            }
12790        }
12791    }
12792    moe_ffn_cpu(m, x, &idx, &p, wsum, pool)
12793}
12794
12795/// One-shot report of whether the whole-token wgpu graph actually formed.
12796/// A refusal silently reverts to the per-op path, which is how a model can
12797/// look "GPU-accelerated" while every layer walks the host.  A device prefix
12798/// is tracked separately because it still pays a host boundary for the tail.
12799fn graph_note(built: bool, layers_run: usize, total_layers: usize) {
12800    use std::sync::atomic::{AtomicBool, Ordering};
12801    if built {
12802        GRAPH_TOK_OK.fetch_add(1, Ordering::Relaxed);
12803        if total_layers > 0 && layers_run < total_layers {
12804            GRAPH_TOK_PREFIX.fetch_add(1, Ordering::Relaxed);
12805        } else {
12806            GRAPH_TOK_FULL.fetch_add(1, Ordering::Relaxed);
12807        }
12808    } else {
12809        GRAPH_TOK_MISS.fetch_add(1, Ordering::Relaxed);
12810    }
12811    static SAID: AtomicBool = AtomicBool::new(false);
12812    if !SAID.swap(true, Ordering::Relaxed) {
12813        if built {
12814            tracing::info!("wgpu whole-token graph: ACTIVE");
12815        } else {
12816            tracing::warn!("wgpu whole-token graph refused — per-op path");
12817        }
12818    }
12819}
12820
12821/// Whole-token graph outcomes, process-wide: a benchmark that claims a
12822/// GPU number while MISS climbs is measuring the CPU — the honest-bench
12823/// contract makes that an error, not a footnote.
12824pub static GRAPH_TOK_OK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
12825pub static GRAPH_TOK_MISS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
12826/// Graph calls that returned a hidden after running only a leading device
12827/// prefix.  These are valid hybrid executions but must not be reported as a
12828/// full GPU graph in benchmark evidence.
12829pub static GRAPH_TOK_PREFIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
12830/// Graph calls that covered the complete requested layer span.
12831pub static GRAPH_TOK_FULL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
12832
12833/// `CMF_MOE_BATCH=0` restores the per-expert serial loop — the A/B lever
12834/// for the batched kernel, and how its bit-identity is checked.
12835fn moe_batch_enabled() -> bool {
12836    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
12837    *ON.get_or_init(|| std::env::var("CMF_MOE_BATCH").as_deref() != Ok("0"))
12838}
12839
12840/// Two-dispatch CPU MoE: every routed expert (and the shared one) fused
12841/// into one gate/up/SiLU dispatch and one down dispatch, instead of two
12842/// pool barriers per expert. Bit-identical to the serial loop below —
12843/// see `moe_gate_up_many` / `moe_down_many`. `None` = the batched kernel
12844/// does not cover this layer, walk the serial path.
12845fn moe_ffn_cpu_batched(
12846    m: &MoeFfn,
12847    x: &[f32],
12848    idx: &[usize],
12849    p: &[f32],
12850    wsum: f32,
12851    pool: Option<&Pool>,
12852) -> Option<Vec<f32>> {
12853    if idx.is_empty() || !moe_batch_enabled() {
12854        return None;
12855    }
12856    // The bake probe reads per-neuron activation mass out of the
12857    // single-expert path; batching would skip it. Rare and offline —
12858    // hand those runs to the serial loop.
12859    if FFN_PROBE.with(|pr| pr.borrow().is_some()) {
12860        return None;
12861    }
12862    let n = idx.len() + usize::from(m.shared.is_some());
12863    let mut pairs = Vec::with_capacity(n);
12864    let mut downs = Vec::with_capacity(n);
12865    let mut ws = Vec::with_capacity(n);
12866    for &e in idx {
12867        let d = &m.experts[e];
12868        if d.act != Act::Silu {
12869            return None;
12870        }
12871        pairs.push((&d.gate_proj, &d.up_proj));
12872        downs.push(&d.down_proj);
12873        ws.push(p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]));
12874    }
12875    // The shared expert goes last, matching the serial loop's order —
12876    // the f32 accumulation order is part of the bit-identity claim.
12877    if let Some((se, gate)) = &m.shared {
12878        if se.act != Act::Silu {
12879            return None;
12880        }
12881        let g = gate.as_ref().map_or(1.0, |gate| {
12882            let mut gl = [0.0f32; 1];
12883            gate.matvec(x, &mut gl, pool);
12884            1.0 / (1.0 + (-gl[0]).exp())
12885        });
12886        pairs.push((&se.gate_proj, &se.up_proj));
12887        downs.push(&se.down_proj);
12888        ws.push(g);
12889    }
12890    let inter = pairs[0].0.rows();
12891    let mut gs: Vec<Vec<f32>> = (0..pairs.len()).map(|_| vec![0f32; inter]).collect();
12892    if !QTensor::moe_gate_up_many(&pairs, x, &mut gs, pool) {
12893        return None;
12894    }
12895    let mut out = attention::take_buf(x.len());
12896    if !QTensor::moe_down_many(&downs, &gs, &ws, &mut out, pool) {
12897        attention::recycle_buf(&mut out);
12898        return None;
12899    }
12900    Some(out)
12901}
12902
12903/// Exact CPU completion for the routed experts a dynamic device cache did
12904/// not contain. The weights are already the router's final normalized mix.
12905/// Keeping this independent of `MoeFfn` makes the job `Sync`: its routing
12906/// statistics live in a `RefCell`, while the immutable expert tensors can be
12907/// evaluated safely in parallel with the GPU's resident subset.
12908pub(crate) fn moe_cold_experts_cpu(
12909    experts: &[(&DenseFfn, f32)],
12910    x: &[f32],
12911    pool: Option<&Pool>,
12912) -> Vec<f32> {
12913    let mut out = attention::take_buf(x.len());
12914    if experts.is_empty() {
12915        return out;
12916    }
12917    let pairs: Vec<_> = experts
12918        .iter()
12919        .map(|(e, _)| (&e.gate_proj, &e.up_proj))
12920        .collect();
12921    let downs: Vec<_> = experts.iter().map(|(e, _)| &e.down_proj).collect();
12922    let weights: Vec<_> = experts.iter().map(|(_, w)| *w).collect();
12923    let inter = experts[0].0.gate_proj.rows();
12924    let mut activations: Vec<Vec<f32>> = (0..experts.len()).map(|_| vec![0.0; inter]).collect();
12925    if QTensor::moe_gate_up_many(&pairs, x, &mut activations, pool)
12926        && QTensor::moe_down_many(&downs, &activations, &weights, &mut out, pool)
12927    {
12928        return out;
12929    }
12930    out.fill(0.0);
12931    for &(expert, weight) in experts {
12932        let mut one = dense_ffn(expert, x, pool);
12933        for (o, v) in out.iter_mut().zip(&one) {
12934            *o += weight * v;
12935        }
12936        attention::recycle_buf(&mut one);
12937    }
12938    out
12939}
12940
12941/// The pure-CPU MoE expert loop (also the fallback of every GPU refusal).
12942fn moe_ffn_cpu(
12943    m: &MoeFfn,
12944    x: &[f32],
12945    idx: &[usize],
12946    p: &[f32],
12947    wsum: f32,
12948    pool: Option<&Pool>,
12949) -> Vec<f32> {
12950    if let Some(out) = moe_ffn_cpu_batched(m, x, idx, p, wsum, pool) {
12951        return out;
12952    }
12953    let mut out = attention::take_buf(x.len());
12954    for &e in idx {
12955        let mut eo = dense_ffn(&m.experts[e], x, pool);
12956        let w = p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]);
12957        for i in 0..out.len() {
12958            out[i] += w * eo[i];
12959        }
12960        attention::recycle_buf(&mut eo);
12961    }
12962    if let Some((se, gate)) = &m.shared {
12963        let mut so = dense_ffn(se, x, pool);
12964        let g = gate.as_ref().map_or(1.0, |gate| {
12965            let mut gl = [0.0f32; 1];
12966            gate.matvec(x, &mut gl, pool);
12967            1.0 / (1.0 + (-gl[0]).exp())
12968        });
12969        for i in 0..out.len() {
12970            out[i] += g * so[i];
12971        }
12972        attention::recycle_buf(&mut so);
12973    }
12974    out
12975}
12976
12977/// DeepSeek-V2 MLA forward, expand-to-MHA form (see `AttnKind::Mla`):
12978/// per token the latent expands to every head's K/V and the ordinary
12979/// cache + grouped attend do the rest. K head layout is [rope | nope]
12980/// (rotary_dim = qk_rope rotates the shared rope key and each q head's
12981/// prefix); V rows are zero-padded to the K head_dim inside the cache
12982/// and the pad is sliced off before O. Attention importance is not
12983/// accumulated for MLA yet (no eviction interplay).
12984#[allow(clippy::too_many_arguments)]
12985fn mla_attention(
12986    w: &MlaWeights,
12987    normed: &[f32],
12988    cache: &mut crate::kv_cache::LayerKvCache,
12989    position: usize,
12990    inv_freq: &[f32],
12991    rope_scale: f32,
12992    eps: f64,
12993    pool: Option<&Pool>,
12994) -> Vec<f32> {
12995    let (nh, dr, dn, dv, lora) = (w.nh, w.qk_rope, w.qk_nope, w.v_dim, w.lora);
12996    let hd = dr + dn;
12997    let mut q = vec![0.0f32; nh * hd];
12998    match (&w.q_a, &w.q_a_norm) {
12999        (Some(qa), Some(qn)) => {
13000            let mut t = vec![0.0f32; qa.rows()];
13001            qa.matvec(normed, &mut t, pool);
13002            let tn = inference::rms_norm(&t, qn, eps, NormStyle::Qwen);
13003            w.q_proj.matvec(&tn, &mut q, pool);
13004        }
13005        _ => w.q_proj.matvec(normed, &mut q, pool),
13006    }
13007    let mut ca = vec![0.0f32; lora + dr];
13008    w.kv_a.matvec(normed, &mut ca, pool);
13009    let (c_lat, k_rope) = ca.split_at_mut(lora);
13010    let latn = inference::rms_norm(c_lat, &w.kv_a_norm, eps, NormStyle::Qwen);
13011    let mut kvb = vec![0.0f32; nh * (dn + dv)];
13012    w.kv_b.matvec(&latn, &mut kvb, pool);
13013    if !w.nope {
13014        attention::rope_rotate_scaled(k_rope, position, inv_freq, rope_scale);
13015    }
13016    for h in 0..nh {
13017        if !w.nope {
13018            attention::rope_rotate_scaled(
13019                &mut q[h * hd..h * hd + dr],
13020                position,
13021                inv_freq,
13022                rope_scale,
13023            );
13024        }
13025    }
13026    let mut k = vec![0.0f32; nh * hd];
13027    let mut v = vec![0.0f32; nh * hd];
13028    for h in 0..nh {
13029        k[h * hd..h * hd + dr].copy_from_slice(k_rope);
13030        k[h * hd + dr..(h + 1) * hd].copy_from_slice(&kvb[h * (dn + dv)..h * (dn + dv) + dn]);
13031        v[h * hd..h * hd + dv].copy_from_slice(&kvb[h * (dn + dv) + dn..(h + 1) * (dn + dv)]);
13032    }
13033    cache.append(&k, &v, &vec![true; nh]);
13034    let (ao, mut imp) = attention::attend_all_heads(&q, cache, nh, 1, hd, w.scale, None, 0.0);
13035    attention::recycle_buf(&mut imp);
13036    let mut ov = vec![0.0f32; nh * dv];
13037    for h in 0..nh {
13038        ov[h * dv..(h + 1) * dv].copy_from_slice(&ao[h * hd..h * hd + dv]);
13039    }
13040    let mut out = vec![0.0f32; w.o_proj.rows()];
13041    w.o_proj.matvec(&ov, &mut out, pool);
13042    out
13043}
13044
13045/// Gemma-4 dual-branch FFN (spec: see `FfnKind::DenseMoe`). The dense
13046/// branch reads the pre-FFN-normed activation; the router and the
13047/// expert branch read the RAW residual — the router through a
13048/// scale-less rms norm (its constant gain is folded into the weights),
13049/// the experts through `pre_norm_2`. CPU path; GPU graphs refuse the
13050/// layer kind honestly.
13051fn dense_moe_ffn(
13052    dm: &DenseMoeFfn,
13053    x_normed: &[f32],
13054    h_raw: &[f32],
13055    eps: f64,
13056    norm_style: NormStyle,
13057    pool: Option<&Pool>,
13058) -> Vec<f32> {
13059    let mut d = dense_ffn(&dm.dense, x_normed, pool);
13060    d = inference::rms_norm(&d, &dm.post_norm_1, eps, norm_style);
13061    let m = &dm.moe;
13062    let ne = m.experts.len();
13063    let mut logits = vec![0.0f32; ne];
13064    if m.router_input_norm {
13065        let ss: f32 = h_raw.iter().map(|v| v * v).sum::<f32>() / h_raw.len() as f32;
13066        let inv = 1.0 / (ss + eps as f32).sqrt();
13067        let xr: Vec<f32> = h_raw.iter().map(|v| v * inv).collect();
13068        m.router.matvec(&xr, &mut logits, pool);
13069    } else {
13070        m.router.matvec(h_raw, &mut logits, pool);
13071    }
13072    let (idx, p, wsum) = moe_route(&logits, m, None);
13073    {
13074        let mut st = m.stats.borrow_mut();
13075        if st.len() < ne {
13076            st.resize(ne, 0);
13077        }
13078        for &e in &idx {
13079            st[e] += 1;
13080        }
13081    }
13082    let x2 = inference::rms_norm(h_raw, &dm.pre_norm_2, eps, norm_style);
13083    let mo = moe_ffn_cpu(m, &x2, &idx, &p, wsum, pool);
13084    let mo = inference::rms_norm(&mo, &dm.post_norm_2, eps, norm_style);
13085    for (di, mi) in d.iter_mut().zip(&mo) {
13086        *di += mi;
13087    }
13088    d
13089}
13090
13091/// Building the MoE-layer GPU jobs: all selected experts (+shared) must
13092/// be q8_2f-Mapped from the primary mapping; otherwise None → CPU path.
13093/// One-shot report of why the MoE GPU block refused. A silent `?` here
13094/// sends every expert to the CPU with nothing in the logs to say so —
13095/// which is exactly how a q4tp MoE model looked "GPU-accelerated" while
13096/// running entirely on the host.
13097fn moe_gpu_refused(why: &'static str) {
13098    use std::sync::atomic::{AtomicBool, Ordering};
13099    static SAID: AtomicBool = AtomicBool::new(false);
13100    if !SAID.swap(true, Ordering::Relaxed) {
13101        tracing::warn!("MoE GPU block refused ({why}) — experts run on the CPU");
13102    }
13103}
13104
13105fn moe_ffn_gpu(
13106    m: &MoeFfn,
13107    x: &[f32],
13108    idx: &[usize],
13109    p: &[f32],
13110    wsum: f32,
13111    pool: Option<&Pool>,
13112) -> Option<Vec<f32>> {
13113    use crate::gpu::MoeJob;
13114
13115    let mut jobs: Vec<MoeJob> = Vec::with_capacity(idx.len() + 1);
13116    let mut model_ref = None;
13117    for &e in idx {
13118        if moe_push_job(&m.experts[e], x, p[e] / wsum, &mut jobs, &mut model_ref).is_none() {
13119            moe_gpu_refused("push_job(expert)");
13120            return None;
13121        }
13122    }
13123    if let Some((se, gate)) = &m.shared {
13124        let g = gate.as_ref().map_or(1.0, |gate| {
13125            let mut gl = [0.0f32; 1];
13126            gate.matvec(x, &mut gl, pool);
13127            1.0 / (1.0 + (-gl[0]).exp())
13128        });
13129        if moe_push_job(se, x, g, &mut jobs, &mut model_ref).is_none() {
13130            moe_gpu_refused("push_job(shared)");
13131            return None;
13132        }
13133    }
13134    let Some(model) = model_ref else {
13135        moe_gpu_refused("no model_ref");
13136        return None;
13137    };
13138    let hidden = jobs[0].down.1;
13139    let mut out = vec![0.0f32; hidden];
13140    if crate::gpu::moe_block(&model, &jobs, &mut out) {
13141        Some(out)
13142    } else {
13143        moe_gpu_refused("gpu::moe_block");
13144        None
13145    }
13146}
13147
13148/// Single-position FFN dispatch.
13149fn ffn_forward(
13150    ffn: &FfnKind,
13151    x: &[f32],
13152    pool: Option<&Pool>,
13153    experts_allowed: Option<&[bool]>,
13154) -> Vec<f32> {
13155    match ffn {
13156        FfnKind::Dense(d) if !d.segs.is_empty() => tube_ffn(d, x, 1, pool, None),
13157        FfnKind::Dense(d) => dense_ffn(d, x, pool),
13158        FfnKind::Moe(m) => moe_ffn(m, x, pool, experts_allowed),
13159        // Dual-branch layers need the raw residual — their callers
13160        // dispatch dense_moe_ffn directly; the auxiliary paths that land
13161        // here (MTP draft, o1 replay) do not co-occur with gemma-4 MoE.
13162        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
13163    }
13164}
13165
13166/// Fused two-position FFN: gate/up/down streamed once (dense). MoE
13167/// falls back to two singles — expert sets differ per position, there
13168/// is nothing to fuse.
13169fn ffn_forward_pair(
13170    ffn: &FfnKind,
13171    x1: &[f32],
13172    x2: &[f32],
13173    pool: Option<&Pool>,
13174    experts_allowed: Option<&[bool]>,
13175) -> (Vec<f32>, Vec<f32>) {
13176    let d = match ffn {
13177        // A tube layer has nothing to fuse across the pair — the tubes
13178        // are separate matrices; two singles are the honest path.
13179        FfnKind::Dense(d) if !d.segs.is_empty() => {
13180            return (
13181                tube_ffn(d, x1, 1, pool, None),
13182                tube_ffn(d, x2, 1, pool, None),
13183            );
13184        }
13185        FfnKind::Dense(d) => d,
13186        FfnKind::Moe(m) => {
13187            return (
13188                moe_ffn(m, x1, pool, experts_allowed),
13189                moe_ffn(m, x2, pool, experts_allowed),
13190            );
13191        }
13192        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
13193    };
13194    let inter = d.gate_proj.rows();
13195    FFN_SCRATCH.with(|s| {
13196        let mut s = s.borrow_mut();
13197        let [g1, g2, u1, u2] = &mut *s;
13198        g1.resize(inter, 0.0);
13199        g2.resize(inter, 0.0);
13200        u1.resize(inter, 0.0);
13201        u2.resize(inter, 0.0);
13202        // Multi-matrix pair job: gate+up under one pool dispatch
13203        // (o1s = lane-1 outputs across tensors, o2s = lane-2).
13204        QTensor::matvec2_many(
13205            [&d.gate_proj, &d.up_proj],
13206            x1,
13207            x2,
13208            [g1.as_mut_slice(), u1.as_mut_slice()],
13209            [g2.as_mut_slice(), u2.as_mut_slice()],
13210            pool,
13211        );
13212        for i in 0..inter {
13213            g1[i] = d.act.combine(g1[i], u1[i]);
13214            g2[i] = d.act.combine(g2[i], u2[i]);
13215        }
13216        let mut o1 = attention::take_buf(d.down_proj.rows());
13217        let mut o2 = attention::take_buf(d.down_proj.rows());
13218        d.down_proj.matvec2(g1, g2, &mut o1, &mut o2, pool);
13219        (o1, o2)
13220    })
13221}
13222
13223#[cfg(test)]
13224mod tests {
13225
13226    #[test]
13227    fn mtp_prefill_pair_boundaries_skip_only_final_prompt_row() {
13228        assert_eq!(mtp_prefill_pair_count(0, 128, 256), 128);
13229        assert_eq!(mtp_prefill_pair_count(128, 256, 256), 127);
13230        assert_eq!(mtp_prefill_pair_count(0, 256, 256), 255);
13231        assert_eq!(mtp_prefill_pair_count(256, 256, 256), 0);
13232        assert_eq!(mtp_prefill_pair_count(300, 320, 256), 0);
13233    }
13234
13235    #[test]
13236    fn cancel_flag_stops_generation() {
13237        let mut p = create_test_pipeline(16, 32, 2, 2, 8, 2, 32);
13238        // Set before the call: the prefill loops honour it, the run
13239        // returns immediately with the cancelled reason and no tokens.
13240        p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
13241        let r = p.generate_from_ids(&[1, 2, 3], 8, None, None).unwrap();
13242        assert_eq!(r.finish_reason, "cancelled");
13243        assert!(
13244            r.token_ids.is_empty(),
13245            "no tokens after cancel: {:?}",
13246            r.token_ids
13247        );
13248        assert_eq!(p.kv_cache.seq_len(), 0);
13249        assert!(p.kv_history.is_empty());
13250        assert!(!p.graph_want_logits);
13251        assert!(p.graph_logits.is_none());
13252        // Flag auto-cleared: the next call generates normally.
13253        let r2 = p.generate_from_ids(&[1, 2, 3], 4, None, None).unwrap();
13254        assert_ne!(r2.finish_reason, "cancelled");
13255    }
13256    use super::*;
13257
13258    /// sparse_ffn_quant must equal a dense FFN where inactive neurons are
13259    /// zeroed (mask × mmap correctness). On F32 tensors this is EXACT —
13260    /// it validates the row_dot / add_col_scaled / scatter indexing, the
13261    /// bug-prone part. The q8 branches reuse the golden-tested linear
13262    /// The per-token sparse path reads a transposed `down`; it must
13263    /// agree with the arm that computes everything and zeroes the
13264    /// losers, or the speed measurement is measuring a different model.
13265    #[test]
13266    fn dynamic_ffn_equals_the_zeroing_arm() {
13267        let (hidden, inter) = (8usize, 32usize);
13268        let synth = |n: usize, salt: usize| -> Vec<f32> {
13269            (0..n)
13270                .map(|i| (((i * 29 + salt * 13 + 7) % 89) as f32 / 89.0 - 0.5) * 0.6)
13271                .collect()
13272        };
13273        let down = synth(hidden * inter, 3);
13274        let mut down_t = vec![0.0f32; inter * hidden];
13275        for r in 0..hidden {
13276            for c in 0..inter {
13277                down_t[c * hidden + r] = down[r * inter + c];
13278            }
13279        }
13280        let d = DenseFfn {
13281            gate_proj: QTensor::from_f32(synth(inter * hidden, 1), inter, hidden),
13282            up_proj: QTensor::from_f32(synth(inter * hidden, 2), inter, hidden),
13283            down_proj: QTensor::from_f32(down.clone(), hidden, inter),
13284            act: Act::Silu,
13285            down_t: Some(QTensor::from_f32(down_t, inter, hidden)),
13286            segs: Vec::new(),
13287        };
13288        let x = synth(hidden, 11);
13289        let k = 12usize;
13290        let got = dense_ffn_dynamic(&d, &x, None, k).expect("down_t present");
13291        // Reference: full compute, keep the k loudest |silu(gate)|.
13292        let mut g = vec![0.0f32; inter];
13293        d.gate_proj.matvec(&x, &mut g, None);
13294        let mut u = vec![0.0f32; inter];
13295        d.up_proj.matvec(&x, &mut u, None);
13296        for v in g.iter_mut() {
13297            *v = inference::silu(*v);
13298        }
13299        keep_top_k(&mut g, k);
13300        for i in 0..inter {
13301            g[i] *= u[i];
13302        }
13303        let mut want = vec![0.0f32; hidden];
13304        d.down_proj.matvec(&g, &mut want, None);
13305        for (a, b) in want.iter().zip(&got) {
13306            assert!((a - b).abs() < 1e-5, "dynamic {b} vs reference {a}");
13307        }
13308    }
13309
13310    /// A tube layer is the same layer, re-cut. With every tube open the
13311    /// answer must equal the dense FFN over the concatenated neurons
13312    /// (the permutation is an identity on the layer's function); with a
13313    /// tube closed it must equal the dense FFN with those neurons
13314    /// zeroed — the mask semantics, now paid for in bytes not read.
13315    #[test]
13316    fn tube_ffn_open_equals_dense_and_closed_equals_masked() {
13317        let (hidden, core, tube) = (8usize, 12usize, 8usize);
13318        let inter = core + tube;
13319        let synth = |n: usize, salt: usize| -> Vec<f32> {
13320            (0..n)
13321                .map(|i| (((i * 41 + salt * 17 + 5) % 97) as f32 / 97.0 - 0.5) * 0.5)
13322                .collect()
13323        };
13324        let (g_all, u_all) = (synth(inter * hidden, 1), synth(inter * hidden, 2));
13325        let d_all = synth(hidden * inter, 3);
13326        // The dense layer, and the same weights cut into core + tube.
13327        let dense = DenseFfn {
13328            gate_proj: QTensor::from_f32(g_all.clone(), inter, hidden),
13329            up_proj: QTensor::from_f32(u_all.clone(), inter, hidden),
13330            down_proj: QTensor::from_f32(d_all.clone(), hidden, inter),
13331            act: Act::Silu,
13332            down_t: None,
13333            segs: Vec::new(),
13334        };
13335        let rows =
13336            |v: &[f32], a: usize, b: usize| -> Vec<f32> { v[a * hidden..b * hidden].to_vec() };
13337        let cols = |v: &[f32], a: usize, b: usize| -> Vec<f32> {
13338            let mut o = Vec::with_capacity(hidden * (b - a));
13339            for r in 0..hidden {
13340                o.extend_from_slice(&v[r * inter + a..r * inter + b]);
13341            }
13342            o
13343        };
13344        let tubed = DenseFfn {
13345            down_t: None,
13346            gate_proj: QTensor::from_f32(rows(&g_all, 0, core), core, hidden),
13347            up_proj: QTensor::from_f32(rows(&u_all, 0, core), core, hidden),
13348            down_proj: QTensor::from_f32(cols(&d_all, 0, core), hidden, core),
13349            act: Act::Silu,
13350            segs: vec![FfnSeg {
13351                gate: QTensor::from_f32(rows(&g_all, core, inter), tube, hidden),
13352                up: QTensor::from_f32(rows(&u_all, core, inter), tube, hidden),
13353                down: QTensor::from_f32(cols(&d_all, core, inter), hidden, tube),
13354                start: core,
13355                width: tube,
13356            }],
13357        };
13358        let x = synth(hidden, 7);
13359        let want = dense_ffn(&dense, &x, None);
13360        let got = tube_ffn(&tubed, &x, 1, None, None);
13361        for (a, b) in want.iter().zip(&got) {
13362            assert!((a - b).abs() < 1e-5, "open tube: {a} vs {b}");
13363        }
13364        // Closed tube: bits on for the core, off for the tube.
13365        let mut bits = vec![0u8; inter.div_ceil(8)];
13366        for n in 0..core {
13367            bits[n / 8] |= 1 << (n % 8);
13368        }
13369        let closed = tube_ffn(&tubed, &x, 1, None, Some(&bits));
13370        let masked = dense_ffn_masked(&dense, &x, None, &bits);
13371        for (a, b) in masked.iter().zip(&closed) {
13372            assert!((a - b).abs() < 1e-5, "closed tube: {a} vs {b}");
13373        }
13374        // The batched arm must agree with the single-position one.
13375        let batch = tube_ffn(&tubed, &x, 1, None, Some(&bits));
13376        for (a, b) in closed.iter().zip(&batch) {
13377            assert_eq!(a, b, "batch arm disagrees with decode arm");
13378        }
13379    }
13380
13381    /// scale, structurally identical to the matvec kernels.
13382    #[test]
13383    fn sparse_ffn_quant_equals_dense_with_inactive_zeroed() {
13384        let (hidden, inter) = (16usize, 40usize);
13385        let synth = |n: usize, salt: usize| -> Vec<f32> {
13386            (0..n)
13387                .map(|i| (((i * 37 + salt * 11 + 3) % 101) as f32 / 101.0 - 0.5) * 0.4)
13388                .collect()
13389        };
13390        let d = DenseFfn {
13391            gate_proj: QTensor::from_f32(synth(inter * hidden, 1), inter, hidden),
13392            up_proj: QTensor::from_f32(synth(inter * hidden, 2), inter, hidden),
13393            down_proj: QTensor::from_f32(synth(hidden * inter, 3), hidden, inter),
13394            act: Act::Silu,
13395            down_t: None,
13396            segs: Vec::new(),
13397        };
13398        let x = synth(hidden, 9);
13399        // Active = every 3rd neuron.
13400        let active: Vec<u16> = (0..inter as u16).filter(|i| i % 3 == 0).collect();
13401
13402        let sparse = sparse_ffn_quant(&d, &x, &active, hidden, None);
13403
13404        // Reference: full dense FFN but g[i]=0 for inactive neurons.
13405        let mut g = vec![0.0f32; inter];
13406        d.gate_proj.matvec(&x, &mut g, None);
13407        let mut u = vec![0.0f32; inter];
13408        d.up_proj.matvec(&x, &mut u, None);
13409        let act_set: std::collections::HashSet<u16> = active.iter().copied().collect();
13410        for i in 0..inter {
13411            g[i] = if act_set.contains(&(i as u16)) {
13412                inference::silu(g[i]) * u[i]
13413            } else {
13414                0.0
13415            };
13416        }
13417        let mut reference = vec![0.0f32; hidden];
13418        d.down_proj.matvec(&g, &mut reference, None);
13419
13420        let max_d = sparse
13421            .iter()
13422            .zip(&reference)
13423            .map(|(a, b)| (a - b).abs())
13424            .fold(0.0f32, f32::max);
13425        assert!(max_d < 1e-5, "sparse != dense-zeroed: max|Δ| = {max_d}");
13426    }
13427
13428    /// Attach a synthetic MTP head (same structure as a main layer).
13429    fn attach_test_mtp(p: &mut Pipeline) {
13430        let (h, inter, heads, kv, hd) = (
13431            p.hidden_size,
13432            p.intermediate_size,
13433            p.num_heads,
13434            p.num_kv_heads,
13435            p.head_dim,
13436        );
13437        let synth = |n: usize, salt: usize| -> Vec<f32> {
13438            (0..n)
13439                .map(|i| (((i * 29 + salt * 23 + 5) % 101) as f32 / 101.0 - 0.5) * 0.2)
13440                .collect()
13441        };
13442        let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
13443            QTensor::from_f32(synth(rows * cols, salt), rows, cols)
13444        };
13445        p.mtp = Some(MtpModule {
13446            enorm: vec![1.0; h],
13447            hnorm: vec![1.0; h],
13448            eh_proj: qt(h, 2 * h, 301),
13449            layer: LayerWeights {
13450                input_norm: vec![1.0; h],
13451                post_norm: vec![1.0; h],
13452                attn_out_norm: None,
13453                ffn_out_norm: None,
13454                layer_scale: None,
13455                ffn: FfnKind::Dense(DenseFfn {
13456                    gate_proj: qt(inter, h, 315),
13457                    up_proj: qt(inter, h, 316),
13458                    down_proj: qt(h, inter, 317),
13459                    act: Act::Silu,
13460                    down_t: None,
13461                    segs: Vec::new(),
13462                }),
13463                attn: AttnKind::Full {
13464                    bias: None,
13465                    wq: qt(heads * hd, h, 311),
13466                    wk: qt(kv * hd, h, 312),
13467                    wv: qt(kv * hd, h, 313),
13468                    wo: qt(h, heads * hd, 314),
13469                    q_norm: None,
13470                    k_norm: None,
13471                    output_gate: false,
13472                    softplus_gate: None,
13473                },
13474            },
13475            final_norm: vec![1.0; h],
13476            kv: crate::kv_cache::LayerKvCache::new(kv, hd),
13477        });
13478    }
13479
13480    #[test]
13481    fn speculative_equals_vanilla_greedy() {
13482        // Speculative decode and the wgpu token graph are mutually
13483        // exclusive; a leaked CMF_GPU=wgpu from a parallel gpu test
13484        // would silently disable drafting. Pin the graph off.
13485        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
13486        let run = |spec: bool| {
13487            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13488            p.sampler_config.temperature = 0.0;
13489            attach_test_mtp(&mut p);
13490            p.speculative = spec;
13491            let r = p.generate("abcdef", 12, None, None).unwrap();
13492            (r.token_ids, r.mtp_drafted, r.mtp_accepted)
13493        };
13494        let (vanilla, d0, _) = run(false);
13495        let (spec, d1, a1) = run(true);
13496        assert_eq!(d0, 0, "vanilla path must not draft");
13497        assert!(d1 > 0, "speculative path must draft");
13498        assert_eq!(
13499            vanilla, spec,
13500            "speculative must reproduce the exact greedy sequence (accepted {a1}/{d1})"
13501        );
13502    }
13503
13504    #[test]
13505    fn speculative_accepts_constant_oracle() {
13506        // See speculative_equals_vanilla_greedy: pin the wgpu graph off.
13507        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
13508        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13509        p.sampler_config.temperature = 0.0;
13510        p.sampler_config.repetition_penalty = 1.0;
13511        // Constant lm_head → every logit equal → both the main model and
13512        // the draft head argmax to token 0: acceptance must be 100%.
13513        p.weights.lm_head = QTensor::from_f32(vec![0.01; 64 * 8], 64, 8);
13514        attach_test_mtp(&mut p);
13515        p.speculative = true;
13516        let r = p.generate("abcd", 10, None, None).unwrap();
13517        assert!(r.mtp_drafted > 0);
13518        assert_eq!(
13519            r.mtp_accepted, r.mtp_drafted,
13520            "constant logits → every draft accepted"
13521        );
13522        // Ties resolve to the same token in both the main and draft
13523        // heads — the sequence is one repeated token.
13524        assert!(r.token_ids.windows(2).all(|w| w[0] == w[1]));
13525    }
13526
13527    #[test]
13528    fn empty_prompt_is_an_error_not_a_panic() {
13529        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
13530        let r = p.generate("", 4, None, None);
13531        assert!(r.is_err(), "empty prompt must be a clean error");
13532    }
13533
13534    #[test]
13535    fn every_token_enters_kv_exactly_once() {
13536        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13537        // Greedy so no RNG variance; byte tokenizer → 3 prompt tokens.
13538        p.sampler_config.temperature = 0.0;
13539        let r = p.generate("abc", 2, None, None).unwrap();
13540        assert_eq!(r.prompt_tokens, 3);
13541        // prompt(3) + first sampled token forwarded before second logits:
13542        // step0 samples from prefill hidden (no extra forward), then
13543        // forwards t1 → cache 4; step1 samples, loop ends (max_tokens).
13544        assert_eq!(
13545            p.kv_cache.seq_len(),
13546            3 + r.tokens_generated - 1,
13547            "each token must be cached exactly once (v1 cached the last prompt token twice)"
13548        );
13549    }
13550
13551    #[test]
13552    fn generation_is_reproducible_with_seed() {
13553        let run = || {
13554            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13555            p.generate("hello", 8, None, None).unwrap().token_ids
13556        };
13557        assert_eq!(run(), run());
13558    }
13559
13560    #[test]
13561    fn resetting_sampler_restarts_the_seeded_stream() {
13562        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13563        let config = SamplerConfig {
13564            seed: Some(1234),
13565            ..SamplerConfig::default()
13566        };
13567        p.set_sampler_config(config.clone());
13568        let first = p.generate("hello", 8, None, None).unwrap().token_ids;
13569        p.set_sampler_config(config);
13570        let second = p.generate("hello", 8, None, None).unwrap().token_ids;
13571        assert_eq!(first, second);
13572    }
13573
13574    #[test]
13575    fn eviction_bounds_the_cache() {
13576        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
13577        p.kv_cache.max_seq_len = 6;
13578        p.sampler_config.temperature = 0.0;
13579        let _ = p.generate("abcd", 12, None, None).unwrap();
13580        assert!(
13581            p.kv_cache.seq_len() <= 6 + 1,
13582            "cache must stay bounded by max_seq_len (got {})",
13583            p.kv_cache.seq_len()
13584        );
13585    }
13586
13587    #[test]
13588    fn confidence_matches_tokens_and_is_a_probability() {
13589        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13590        p.sampler_config.temperature = 0.0;
13591        p.sampler_config.repetition_penalty = 1.0;
13592        let r = p.generate("abcd", 10, None, None).unwrap();
13593        assert_eq!(
13594            r.token_confidence.len(),
13595            r.token_ids.len(),
13596            "one confidence per emitted token"
13597        );
13598        for &c in &r.token_confidence {
13599            assert!((0.0..=1.0).contains(&c), "confidence out of [0,1]: {c}");
13600        }
13601        // top1_prob is a valid softmax probability.
13602        let logits = [1.0f32, 3.0, 0.5, 3.0];
13603        let p0 = top1_prob_t(&logits, 1, 1.0);
13604        let p1 = top1_prob_t(&logits, 3, 1.0);
13605        assert!((p0 - p1).abs() < 1e-6, "equal logits → equal prob");
13606        assert!(p0 > 0.0 && p0 < 1.0);
13607        // Calibration temperature > 1 softens an over-confident peak.
13608        let sharp = top1_prob_t(&logits, 1, 1.0);
13609        let soft = top1_prob_t(&logits, 1, 2.0);
13610        assert!(soft < sharp, "higher temperature lowers peak confidence");
13611    }
13612
13613    #[test]
13614    fn trace_is_opt_in_and_parallels_the_output() {
13615        // Off by default: the runtime is silent unless observation asked.
13616        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13617        p.sampler_config.temperature = 0.0;
13618        p.sampler_config.repetition_penalty = 1.0;
13619        let r = p.generate("abcd", 10, None, None).unwrap();
13620        assert!(r.traces.is_empty(), "trace must be empty unless enabled");
13621
13622        // On: exactly one row per emitted token, aligned with the output.
13623        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13624        p.sampler_config.temperature = 0.0;
13625        p.sampler_config.repetition_penalty = 1.0;
13626        p.set_trace(true);
13627        let r = p.generate("abcd", 10, None, None).unwrap();
13628        assert_eq!(r.traces.len(), r.token_ids.len(), "one trace row per token");
13629        for (i, tr) in r.traces.iter().enumerate() {
13630            assert_eq!(tr.t, i, "trace index is sequential");
13631            assert_eq!(tr.token_id, r.token_ids[i], "trace token_id matches output");
13632            assert_eq!(
13633                tr.confidence, r.token_confidence[i],
13634                "trace confidence matches the confidence channel"
13635            );
13636            // No dynamic router in this pipeline → no skill, no coherence.
13637            assert!(tr.active_skill.is_none() && tr.recon.is_none() && !tr.switched);
13638        }
13639    }
13640
13641    #[test]
13642    fn explain_prefill_logits_match_greedy_first_token() {
13643        // `cortiq explain` shows the next-token distribution from
13644        // prefill_next_logits; its argmax must equal what greedy generate
13645        // actually emits first — otherwise explain would lie.
13646        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13647        p.sampler_config.temperature = 0.0;
13648        p.sampler_config.repetition_penalty = 1.0;
13649        let ids = p.tokenizer.encode("abcd");
13650        let logits = p.prefill_next_logits(&ids, None);
13651        let argmax = logits
13652            .iter()
13653            .enumerate()
13654            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
13655            .unwrap()
13656            .0 as u32;
13657        let r = p.generate("abcd", 1, None, None).unwrap();
13658        assert_eq!(
13659            argmax, r.token_ids[0],
13660            "explain preview must match greedy emit"
13661        );
13662    }
13663
13664    #[test]
13665    fn laguna_shared_expert_is_unconditionally_added() {
13666        let matrix = |values: Vec<f32>| QTensor::from_f32(values, 2, 2);
13667        let identity = || matrix(vec![1.0, 0.0, 0.0, 1.0]);
13668        let zero_dense = || DenseFfn {
13669            gate_proj: matrix(vec![0.0; 4]),
13670            up_proj: matrix(vec![0.0; 4]),
13671            down_proj: matrix(vec![0.0; 4]),
13672            act: Act::Silu,
13673            down_t: None,
13674            segs: Vec::new(),
13675        };
13676        let shared = DenseFfn {
13677            gate_proj: identity(),
13678            up_proj: identity(),
13679            down_proj: identity(),
13680            act: Act::Silu,
13681            down_t: None,
13682            segs: Vec::new(),
13683        };
13684        let x = [1.0, 2.0];
13685        let expected = dense_ffn(&shared, &x, None);
13686        let moe = MoeFfn {
13687            router: QTensor::from_f32(vec![0.0, 0.0], 1, 2),
13688            experts: vec![zero_dense()],
13689            top_k: 1,
13690            norm_topk_prob: true,
13691            router_sigmoid: true,
13692            expert_bias: None,
13693            routed_scaling: 1.0,
13694            route_tau: None,
13695            shared: Some((shared, None)),
13696            stats: std::cell::RefCell::new(Vec::new()),
13697            act_sq: std::cell::RefCell::new(Vec::new()),
13698            act_rows: std::cell::RefCell::new(Vec::new()),
13699            mask: None,
13700            per_expert_scale: None,
13701            router_input_norm: false,
13702            resonance: None,
13703        };
13704        let actual = moe_ffn_cpu(&moe, &x, &[0], &[0.0], 1.0, None);
13705        for (actual, expected) in actual.iter().zip(expected) {
13706            assert!((actual - expected).abs() < 1e-6);
13707        }
13708    }
13709
13710    #[test]
13711    fn o1_batch_transition_publishes_one_epoch_before_serial_handoff() {
13712        const B: usize = 19;
13713        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13714        p.set_o1(Some(crate::nystrom::O1Cfg {
13715            layers: crate::nystrom::O1Layers::All,
13716            m: 4,
13717            w: 8,
13718            sink: 2,
13719            rect: crate::nystrom::O1Rect::Aggregate,
13720        }));
13721        p.o1_begin_with_prefix(Some(B));
13722        let ids: Vec<u32> = (0..B as u32).collect();
13723        let _ = p.prefill_batch_span(PrefillIn::Ids(&ids), 0, None, 0, p.num_layers);
13724
13725        assert_eq!(p.o1_epoch, 1, "all layers publish one completed transition");
13726        assert!(p.kv_cache.layers.iter().all(|l| l.o1_sealed()));
13727        let next = p.embed_single(B as u32);
13728        let _ = p.forward_layers(&next, B, None);
13729        assert_eq!(p.o1_epoch, 1, "sealed handoff must not republish the epoch");
13730    }
13731
13732    #[test]
13733    fn o1_pair_transition_commits_scratch_before_epoch_publication() {
13734        const B: usize = 19;
13735        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
13736        // Keep a real recurrent layer ahead of the Full O(1) layer so the
13737        // pair test observes the GDN lane-2 scratch swap at the same
13738        // boundary, rather than only exercising an artificial scratch vec.
13739        let gdn_cfg = crate::linear_core::GdnCfg {
13740            num_v_heads: 2,
13741            num_k_heads: 1,
13742            key_head_dim: 2,
13743            value_head_dim: 4,
13744            conv_kernel: 3,
13745            hidden_size: 8,
13746            rms_eps: 1e-6,
13747            output_gate_sigmoid: false,
13748        };
13749        let synth = |n: usize, salt: usize| -> Vec<f32> {
13750            (0..n)
13751                .map(|i| (((i * 13 + salt * 7) % 97) as f32 / 97.0 - 0.5) * 0.4)
13752                .collect()
13753        };
13754        let qt = |rows: usize, cols: usize, salt: usize| {
13755            crate::qtensor::QTensor::from_f32(synth(rows * cols, salt), rows, cols)
13756        };
13757        let c_dim = gdn_cfg.conv_dim();
13758        let vd = gdn_cfg.num_v_heads * gdn_cfg.value_head_dim;
13759        p.weights.layers[0].attn = AttnKind::LinearGdn(crate::linear_core::GdnWeights {
13760            in_proj_qkv: qt(c_dim, 8, 1),
13761            in_proj_z: qt(vd, 8, 2),
13762            in_proj_a: qt(gdn_cfg.num_v_heads, 8, 3),
13763            in_proj_b: qt(gdn_cfg.num_v_heads, 8, 4),
13764            conv1d: synth(c_dim * gdn_cfg.conv_kernel, 5),
13765            a_log: vec![0.2, 0.5],
13766            dt_bias: synth(gdn_cfg.num_v_heads, 6),
13767            norm: vec![1.0; gdn_cfg.value_head_dim],
13768            out_proj: qt(8, vd, 7),
13769        });
13770        p.gdn_cfg = Some(gdn_cfg);
13771        p.set_o1(Some(crate::nystrom::O1Cfg {
13772            layers: crate::nystrom::O1Layers::All,
13773            m: 4,
13774            w: 8,
13775            sink: 2,
13776            rect: crate::nystrom::O1Rect::Aggregate,
13777        }));
13778        p.o1_begin_with_prefix(Some(B));
13779        for pos in 0..B - 2 {
13780            let emb = p.embed_single(pos as u32);
13781            let _ = p.forward_layers(&emb, pos, None);
13782        }
13783        let lane1_state = p.kv_cache.layers[0].linear_state.clone();
13784
13785        let e1 = p.embed_single((B - 2) as u32);
13786        let e2 = p.embed_single((B - 1) as u32);
13787        let _ = p.forward_pair(&e1, &e2, B - 2);
13788
13789        assert_eq!(p.o1_epoch, 1, "pair crossing B publishes one epoch");
13790        assert!(
13791            p.kv_cache
13792                .layers
13793                .iter()
13794                .enumerate()
13795                .all(|(li, l)| !p.o1_flags[li] || l.o1_sealed())
13796        );
13797        assert!(!p.kv_cache.layers[0].linear_state.is_empty());
13798        assert_ne!(
13799            p.kv_cache.layers[0].linear_state, lane1_state,
13800            "real pair must commit GDN lane 2 before returning"
13801        );
13802        assert!(p.kv_cache.layers[0].linear_scratch.is_empty());
13803        let next = p.embed_single(B as u32);
13804        let _ = p.forward_layers(&next, B, None);
13805        assert_eq!(p.o1_epoch, 1, "serial continuation must reuse the epoch");
13806    }
13807
13808    #[test]
13809    fn o1_error_observation_stays_terminal_until_reset() {
13810        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13811        p.set_o1(Some(crate::nystrom::O1Cfg {
13812            layers: crate::nystrom::O1Layers::All,
13813            m: 4,
13814            w: 8,
13815            sink: 2,
13816            rect: crate::nystrom::O1Rect::Aggregate,
13817        }));
13818        p.o1_begin();
13819        p.kv_cache.layers[0].o1_abort("synthetic transition failure".into());
13820
13821        assert!(p.o1_seal_checked().is_err());
13822        assert!(
13823            p.o1_seal_checked().is_err(),
13824            "retry must see the sticky error"
13825        );
13826        let k = vec![0.2f32; 4];
13827        let v = vec![0.3f32; 4];
13828        p.kv_cache.layers[0].append(&k, &v, &[]);
13829        assert_eq!(p.kv_cache.layers[0].seq_len, 0);
13830
13831        p.reset_session();
13832        p.o1_begin();
13833        p.kv_cache.layers[0].append(&k, &v, &[]);
13834        assert_eq!(p.kv_cache.layers[0].seq_len, 1);
13835    }
13836
13837    #[test]
13838    fn nll_graph_failure_is_terminal_and_request_is_reusable() {
13839        let ids = vec![1u32, 2, 3, 4, 5, 6];
13840        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13841        p.graph_logits = Some(vec![123.0]);
13842        p.graph_want_logits = true;
13843        p.graph_failed
13844            .store(true, std::sync::atomic::Ordering::Relaxed);
13845        p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
13846        let err = p.nll_ids_from(&ids, 0).expect_err("prior graph failure");
13847        assert!(err.contains("before NLL"));
13848        assert!(p.graph_logits.is_none());
13849        assert!(!p.graph_want_logits);
13850        assert!(!p.graph_failed.load(std::sync::atomic::Ordering::Relaxed));
13851        assert!(!p.cancel.load(std::sync::atomic::Ordering::Relaxed));
13852
13853        let mut fresh = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13854        let expected = fresh.nll_ids_from(&ids, 0).expect("fresh NLL");
13855        let actual = p.nll_ids_from(&ids, 0).expect("reused NLL");
13856        assert_eq!(actual.1, expected.1);
13857        assert!((actual.0 - expected.0).abs() < 1e-9);
13858    }
13859
13860    #[test]
13861    fn nll_forward_failure_discards_partial_score_and_clears_sidechannels() {
13862        let ids = vec![1u32, 2, 3, 4, 5, 6];
13863        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13864        p.nll_test_fail_at = Some(1);
13865        let err = p
13866            .nll_ids_from(&ids, 0)
13867            .expect_err("one-shot forward failure");
13868        assert!(err.contains("forward") || err.contains("score row"));
13869        assert!(!p.graph_failed.load(std::sync::atomic::Ordering::Relaxed));
13870        assert!(!p.graph_want_logits);
13871        assert!(p.graph_logits.is_none());
13872        assert!(p.kv_history.is_empty());
13873
13874        let mut fresh = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13875        let expected = fresh.nll_ids_from(&ids, 0).expect("fresh NLL");
13876        let actual = p.nll_ids_from(&ids, 0).expect("reused NLL");
13877        assert_eq!(actual.1, expected.1);
13878        assert!((actual.0 - expected.0).abs() < 1e-9);
13879    }
13880
13881    #[test]
13882    fn nll_serial_failure_before_first_row_is_reported() {
13883        let ids = vec![1u32, 2, 3, 4];
13884        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13885        p.nll_test_force_serial = true;
13886        p.nll_test_fail_at = Some(0);
13887        let err = p.nll_ids_from(&ids, 0).expect_err("serial forward failure");
13888        assert!(err.contains("serial forward"));
13889        assert!(p.kv_history.is_empty());
13890        assert!(!p.graph_failed.load(std::sync::atomic::Ordering::Relaxed));
13891        assert!(!p.cancel.load(std::sync::atomic::Ordering::Relaxed));
13892    }
13893
13894    #[test]
13895    fn ffn_probe_failure_discards_recorder_and_state() {
13896        let ids = vec![1u32, 2, 3, 4];
13897        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13898        p.nll_test_fail_at = Some(0);
13899        let err = p
13900            .probe_ffn_mass_batch(&ids)
13901            .expect_err("probe forward failure");
13902        assert!(err.contains("NLL"));
13903        assert!(FFN_PROBE.with(|probe| probe.borrow().is_none()));
13904        assert!(p.kv_history.is_empty());
13905        assert!(!p.graph_failed.load(std::sync::atomic::Ordering::Relaxed));
13906    }
13907
13908    #[test]
13909    fn nll_test_controls_are_pipeline_scoped() {
13910        let ids = vec![1u32, 2, 3, 4];
13911        let mut failing = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13912        let mut unaffected = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13913        failing.nll_test_force_serial = true;
13914        failing.nll_test_fail_at = Some(0);
13915
13916        assert!(!failing.can_prefill_batched());
13917        assert!(unaffected.can_prefill_batched());
13918        let expected = unaffected
13919            .nll_ids_from(&ids, 0)
13920            .expect("unaffected pipeline remains usable");
13921        let err = failing
13922            .nll_ids_from(&ids, 0)
13923            .expect_err("failure injection belongs to failing pipeline");
13924        assert!(err.contains("serial forward"));
13925        assert!(failing.nll_test_fail_at.is_none());
13926        assert!(unaffected.can_prefill_batched());
13927        let actual = unaffected
13928            .nll_ids_from(&ids, 0)
13929            .expect("unaffected pipeline remains reusable");
13930        assert_eq!(actual.1, expected.1);
13931        assert!((actual.0 - expected.0).abs() < 1e-9);
13932    }
13933
13934    #[test]
13935    fn forward_ids_failure_channel_is_terminal_and_reusable() {
13936        let ids = vec![1u32, 2, 3, 4, 5, 6];
13937        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
13938        p.graph_logits = Some(vec![123.0]);
13939        p.graph_want_logits = true;
13940        p.graph_failed
13941            .store(true, std::sync::atomic::Ordering::Relaxed);
13942        p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
13943
13944        let err = p
13945            .forward_ids(&ids, None)
13946            .expect_err("a failed forward must not become a valid head result");
13947        assert!(err.contains("forward_ids setup"));
13948        assert!(p.graph_logits.is_none());
13949        assert!(!p.graph_want_logits);
13950        assert!(!p.graph_failed.load(std::sync::atomic::Ordering::Relaxed));
13951        assert!(!p.cancel.load(std::sync::atomic::Ordering::Relaxed));
13952        assert_eq!(p.kv_cache.seq_len(), 0);
13953
13954        let expected = create_test_pipeline(8, 16, 2, 1, 4, 1, 64)
13955            .forward_ids(&ids, None)
13956            .expect("fresh forward_ids");
13957        let actual = p
13958            .forward_ids(&ids, None)
13959            .expect("pipeline remains reusable after a failed forward");
13960        assert_eq!(actual.len(), expected.len());
13961        assert!(
13962            actual
13963                .iter()
13964                .zip(expected)
13965                .all(|(a, b)| (a - b).abs() < 1e-9)
13966        );
13967        assert_eq!(p.kv_cache.seq_len(), ids.len());
13968    }
13969}