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    /// Token ids currently materialized in the KV cache (the forwarded
92    /// prompt + all generated tokens except the last, which is sampled
93    /// but not yet forwarded). Lets the next generate call prefill only
94    /// the suffix when a chat app resends the whole history.
95    pub kv_history: Vec<u32>,
96    /// KDA geometry (Kimi Linear / Kimi-K3) — shared by every Kda layer.
97    pub kda_cfg: Option<crate::linear_core::KdaCfg>,
98    /// Gemma-3n stack (AltUp/LAuReL/PLE/KV-sharing): its own forward —
99    /// weights.layers stays empty, the KV caches are the shared ones.
100    pub g3n: Option<Box<(crate::g3n::G3nGlobals, Vec<crate::g3n::G3nLayer>)>>,
101    /// DeepSeek-V4 runs its own stack too: its hidden state is `hc_mult`
102    /// copies of a vector, so no loop written for a single residual
103    /// stream can carry it.
104    pub dsv4: Option<
105        Box<(
106            crate::dsv4::Dsv4Globals,
107            Vec<crate::dsv4::Dsv4Layer>,
108            crate::dsv4::Dsv4Cfg,
109            crate::dsv4::Dsv4State,
110        )>,
111    >,
112    /// DeepSeek-V4's own speculation stack: three draft modules, each a full
113    /// layer, plus a confidence head on the last. Empty when the file has
114    /// none, which is the only signal the decode path needs.
115    pub dsv4_mtp: Vec<crate::dsv4::Dsv4Mtp>,
116    /// The draft's per-sequence state (KV rings, captured trunk hidden).
117    pub dspark: Option<crate::dsv4::DsparkState>,
118    /// Drafts awaiting their verdict: (position, proposals, still matching,
119    /// accepted so far).
120    pub dspark_pending: Vec<(usize, Vec<u32>, bool, usize)>,
121    /// Accepted prefix length of every graded draft.
122    pub dspark_hist: Vec<usize>,
123    /// The real tokens the drafts were graded against — a degenerate,
124    /// repeating output would make any acceptance number meaningless, and
125    /// the cheapest guard against believing one is to count them.
126    pub dspark_real: Vec<u32>,
127    /// The trunk's expert picks for the last few tokens, per layer. The
128    /// union over a window of them is what a batched verify would have to
129    /// read, and the ratio to the pick count is all it could save.
130    pub dspark_trunk_picks: Vec<Vec<(usize, Vec<usize>)>>,
131    /// (unique, total) expert picks per draft, trunk side and draft side.
132    pub dspark_exp: Vec<(usize, usize, usize, usize)>,
133    /// Wall time spent in the deliberately out-of-core draft. Kept separate
134    /// from trunk decode so block batching can be judged without conflating
135    /// it with GPU chain variance.
136    pub dspark_draft_ns: u128,
137    /// LFM2 short-convolution geometry (present when the model has
138    /// `ShortConv` mixer layers).
139    pub short_conv_cfg: Option<ShortConvCfg>,
140    /// Multi-token-prediction head (None = absent).
141    pub mtp: Option<MtpModule>,
142    /// Speculative decode via MTP (greedy only; `CMF_MTP=0` disables).
143    pub speculative: bool,
144    rng: SplitMix64,
145    sampler_scratch: SamplerScratch,
146    /// Speculative SAMPLING state (graph_spec_step, temperature > 0): the
147    /// correction token a rejected draft produced — committed by the loop
148    /// top in place of a fresh draw — and the per-round draft
149    /// distributions / target scratch, reused so a round allocates
150    /// nothing at the vocab size.
151    spec_forced: Option<u32>,
152    spec_q: Vec<Vec<f32>>,
153    spec_p: Vec<f32>,
154    spec_res: Vec<f32>,
155    /// The same three for the sparse chain (top-k configs).
156    spec_qs: Vec<sampler::Sparse>,
157    spec_ps: sampler::Sparse,
158    spec_ress: sampler::Sparse,
159    /// Which arm the MTP draft block runs on this generation: Some(true)
160    /// = the whole-token graph (device attention, one submit a step),
161    /// Some(false) = the per-op path; None = not decided yet. Decided
162    /// on the first draft and held, because the two arms keep the MTP
163    /// KV in different places (device mirror vs the CPU cache) and a
164    /// mid-run switch would read the wrong one.
165    mtp_graph_mode: Option<bool>,
166    /// The Metal verify graph of the round in flight, between its sync
167    /// (logits read) and the commit that replays the accepted prefix.
168    #[cfg(target_os = "macos")]
169    metal_verify: Option<MetalVerifyPending>,
170    /// Precomputed RoPE inverse frequencies [head_dim/2]. Arc: the
171    /// forward path clones a handle to escape the &mut self borrow —
172    /// cloning the table itself was a per-forward allocation.
173    pub(crate) inv_freq: std::sync::Arc<Vec<f32>>,
174    /// Reusable norm buffers for the decode hot path (roadmap §3 P0:
175    /// steady-state forward should not heap-allocate). Disjoint field
176    /// from `weights`/`kv_cache`, so split borrows keep working.
177    ws: ForwardScratch,
178    /// Persistent worker pool (None = serial; see CMF_THREADS).
179    pool: Option<std::sync::Arc<Pool>>,
180    // ── Dynamic per-token skill routing (spec §9, claim 14/16) ──
181    /// Source model, retained so a skill switch can re-resolve the
182    /// touched layers' FFN tensors (Mapped = mmap pointers, cheap).
183    pub(crate) model: Option<std::sync::Arc<cortiq_core::CmfModel>>,
184    /// Masks present → weights are dequantized f32 (rebuild path).
185    pub(crate) dyn_force_f32: bool,
186    /// Per-skill FFN layers actually replaced (derived from tensors, not
187    /// the meta `layers` field — ru2 replaces down_proj in 0..23 while
188    /// its meta says [20..23]). None = skill touches non-FFN tensors →
189    /// ineligible for cheap dynamic switching (honest refusal).
190    pub(crate) dyn_skill_layers: Vec<Option<Vec<usize>>>,
191    /// Currently overlaid skill (index into model.header.skills); None =
192    /// backbone. Set at load time to the statically-overlaid skill so
193    /// `set_active_skill(None)` correctly reverts it (else a static
194    /// skill would silently persist — the union-diff assumes dyn_active
195    /// always mirrors the live overlay). Switched by `set_active_skill`.
196    pub(crate) dyn_active: Option<usize>,
197    /// Pipeline was loaded with a soft blend (materialized working
198    /// tensors, not a single skill index) → dynamic routing refuses:
199    /// there is no single index to revert the blend from.
200    pub(crate) dyn_blend_loaded: bool,
201    /// Layer whose post-residual hidden feeds the router φ (shared by
202    /// swarm skills). None = φ capture off.
203    pub(crate) dyn_phi_layer: Option<usize>,
204    /// EMA of φ at `dyn_phi_layer` over the decode window (on-policy).
205    dyn_phi_ema: Vec<f32>,
206    dyn_phi_seen: usize,
207    /// Hysteresis router driving per-token skill switches during decode
208    /// (None = static/no dynamic routing). Taken out during generation.
209    pub dyn_router: Option<crate::swarm::DynRouter>,
210    /// O(1) Nyström attention setting (CLI/env/header-hint resolved by
211    /// the caller; None = plain cache attention everywhere).
212    o1_cfg: Option<crate::nystrom::O1Cfg>,
213    /// Bumped at every o1 seal — the GPU state mirror re-uploads when it
214    /// sees a new epoch (each generate seals fresh CPU state).
215    o1_epoch: u64,
216    /// Per-layer o1 flags derived from `o1_cfg` (Full layers only).
217    o1_flags: Vec<bool>,
218    /// Emit a structured per-token trace (B4 telemetry channel). Off by
219    /// default — the runtime is silent unless observation is requested.
220    trace: bool,
221    /// Confidence-calibration temperature (B1): reported Born mass is
222    /// softmax(logits / calib_temp). 1.0 = raw. Set from header.calibration.
223    calib_temp: f32,
224    /// Process-unique id keying this pipeline's device KV mirrors.
225    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
226    graph_kv_id: u64,
227    /// Decode asks the token graph to also run final-norm + lm_head on
228    /// the device (drops the separate per-op lm_head round trip).
229    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
230    graph_want_logits: bool,
231    /// Logits the graph produced for the token just forwarded (taken by
232    /// the decode loop; None = compute on the CPU path).
233    graph_logits: Option<Vec<f32>>,
234    /// Token embeddings are multiplied by this at input (Gemma: √hidden).
235    pub embed_multiplier: f32,
236    /// Attention score scale (1/√head_dim unless the arch overrides —
237    /// Gemma's query_pre_attn_scalar).
238    pub attn_scale: f32,
239    /// Sliding-window attention: (window, every-Nth-layer-is-global
240    /// pattern) — Gemma-3.
241    pub swa: Option<(usize, usize)>,
242    /// Explicit local/global schedule for architectures that cannot be
243    /// represented by Gemma's every-Nth-global convention.
244    pub sliding_layers: Option<Vec<bool>>,
245    /// RoPE table of the sliding (local) layers, when they use their
246    /// own base frequency (Gemma-3: 10k local vs 1M global).
247    pub inv_freq_local: Option<std::sync::Arc<Vec<f32>>>,
248    pub rotary_dim_local: Option<usize>,
249    pub rope_scale: f32,
250    pub rope_scale_local: f32,
251    /// Gemma-4: global layers run their own geometry — (head_dim,
252    /// num_kv_heads); sliding layers keep the base fields.
253    pub global_attn: Option<(usize, usize)>,
254    /// Gemma-4: the global layers' proportional RoPE table (len
255    /// global_head_dim/2, zero-padded tail = identity rotation).
256    pub inv_freq_global: Option<std::sync::Arc<Vec<f32>>>,
257    /// Scale-less RMS normalization of V heads before caching (Gemma-4).
258    pub attn_v_norm: bool,
259    /// Final-logit soft-capping C: logits = C·tanh(logits/C) (Gemma-4).
260    pub final_softcap: Option<f32>,
261    /// Gemma-2 attention-logit soft-capping (0.0 = off).
262    pub attn_softcap: f32,
263    /// Compute per-token Born confidence (a full-vocab softmax each
264    /// token). On by default; `bench --core` turns it off to match
265    /// llama-bench's core timing.
266    confidence_on: bool,
267}
268
269#[cfg(target_os = "macos")]
270impl Drop for Pipeline {
271    fn drop(&mut self) {
272        crate::gpu::kv_mirror_drop(self.graph_kv_id);
273    }
274}
275
276/// Model weights. Matrices are `QTensor` (owned f32 for small models
277/// and tests — bit-identical to the historical paths — or quantized
278/// bytes zero-copy from the CMF mmap for big models). 1-D norms are
279/// always small and stay f32.
280pub struct PipelineWeights {
281    /// Embedding table: [vocab_size, hidden_size]
282    pub embed_tokens: QTensor,
283    /// Per-layer weights
284    pub layers: Vec<LayerWeights>,
285    /// LM head: [vocab_size, hidden_size]
286    pub lm_head: QTensor,
287    /// Final norm: [hidden_size]
288    pub final_norm: Vec<f32>,
289}
290
291/// One transformer layer: shared norms + MLP, attention by kind.
292pub struct LayerWeights {
293    pub input_norm: Vec<f32>,
294    /// The pre-FFN norm (`post_attention_layernorm` classically;
295    /// `pre_feedforward_layernorm` on Gemma-2/3 sandwich layers).
296    pub post_norm: Vec<f32>,
297    /// Gemma-2/3 sandwich: norm applied to the ATTENTION OUTPUT before
298    /// its residual add (`post_attention_layernorm` there).
299    pub attn_out_norm: Option<Vec<f32>>,
300    /// Gemma-4: the whole layer output is multiplied by this scalar.
301    pub layer_scale: Option<f32>,
302    /// Gemma-2/3 sandwich: norm applied to the FFN OUTPUT before its
303    /// residual add (`post_feedforward_layernorm`).
304    pub ffn_out_norm: Option<Vec<f32>>,
305    pub ffn: FfnKind,
306    pub attn: AttnKind,
307}
308
309/// FFN gate activation: SiLU (SwiGLU family) or tanh-GELU (Gemma's
310/// GeGLU). A property of the model, carried on every FFN triple.
311#[derive(Clone, Copy, PartialEq, Debug, Default)]
312pub enum Act {
313    #[default]
314    Silu,
315    GeluTanh,
316    /// Kimi-K3 SituAndMul: BOTH halves transform —
317    /// a = β·tanh(g/β)·σ(g), up' = linβ·tanh(u/linβ) (linβ>0), out = a·up'.
318    Situ {
319        beta: f32,
320        linear_beta: f32,
321    },
322}
323
324impl Act {
325    pub fn from_arch(name: &str) -> Self {
326        if name == "gelu_tanh" {
327            Self::GeluTanh
328        } else {
329            Self::Silu
330        }
331    }
332
333    /// Arch-driven constructor (activation name + situ betas).
334    pub fn from_arch_full(arch: &cortiq_core::ModelArch) -> Self {
335        match arch.hidden_act.as_str() {
336            "situ" => Self::Situ {
337                beta: arch.activation_situ_beta.unwrap_or(1.0) as f32,
338                linear_beta: arch.activation_situ_linear_beta.unwrap_or(0.0) as f32,
339            },
340            other => Self::from_arch(other),
341        }
342    }
343
344    #[inline]
345    pub fn apply(self, x: f32) -> f32 {
346        match self {
347            Self::Silu => inference::silu(x),
348            Self::GeluTanh => inference::gelu_tanh(x),
349            Self::Situ { beta, .. } => beta * (x / beta).tanh() * (1.0 / (1.0 + (-x).exp())),
350        }
351    }
352
353    /// Gated combine — the FFN contract. Situ transforms the UP half
354    /// too, so callers must use this instead of apply(g)·u.
355    #[inline]
356    pub fn combine(self, g: f32, u: f32) -> f32 {
357        match self {
358            Self::Situ { linear_beta, .. } if linear_beta > 0.0 => {
359                self.apply(g) * (linear_beta * (u / linear_beta).tanh())
360            }
361            _ => self.apply(g) * u,
362        }
363    }
364}
365
366/// Dense gated triple — the FFN of a dense layer or of one expert.
367pub struct DenseFfn {
368    pub gate_proj: QTensor,
369    pub up_proj: QTensor,
370    pub down_proj: QTensor,
371    /// Gate activation (SiLU default; Gemma: tanh-GELU).
372    pub act: Act,
373}
374
375/// FFN operator of a layer, decided by tensor presence at load time
376/// (router `mlp.gate.weight` in the directory = MoE layer).
377pub enum FfnKind {
378    Dense(DenseFfn),
379    /// Mixture-of-Experts (Qwen2-MoE / Qwen3-MoE): softmax over ALL
380    /// expert logits → top-k, optional renorm; experts stay quantized
381    /// in mmap — only the selected ones are touched per token.
382    Moe(MoeFfn),
383    /// Gemma-4 MoE: a dense MLP branch AND a routed-expert branch in
384    /// the SAME layer, each with its own norm sandwich. The dense
385    /// branch reads the pre-FFN-normed input; the expert branch (and
386    /// the router) read the RAW residual through `pre_norm_2`:
387    ///   d = post_norm_1(dense(x̂));  m = post_norm_2(Σwₑ·FFNₑ(pre_norm_2(h)))
388    ///   ffn_out = d + m   (the caller's ffn_out_norm + residual follow)
389    DenseMoe(Box<DenseMoeFfn>),
390}
391
392/// Gemma-4 dual-branch FFN (see `FfnKind::DenseMoe`).
393pub struct DenseMoeFfn {
394    pub dense: DenseFfn,
395    pub moe: MoeFfn,
396    /// post_feedforward_layernorm_1 — dense-branch output norm.
397    pub post_norm_1: Vec<f32>,
398    /// pre_feedforward_layernorm_2 — expert-branch input norm (applied
399    /// to the RAW residual, not the pre-FFN-normed activation).
400    pub pre_norm_2: Vec<f32>,
401    /// post_feedforward_layernorm_2 — expert-branch output norm.
402    pub post_norm_2: Vec<f32>,
403}
404
405pub struct MoeFfn {
406    /// Router `mlp.gate.weight` [num_experts, hidden].
407    pub router: QTensor,
408    pub experts: Vec<DenseFfn>,
409    pub top_k: usize,
410    pub norm_topk_prob: bool,
411    /// Router scores per-expert with a sigmoid (LFM2-MoE / DeepSeek-V3
412    /// `noaux_tc`) instead of a softmax over all experts (Qwen).
413    pub router_sigmoid: bool,
414    /// Per-expert selection bias `mlp.expert_bias` [num_experts]
415    /// (LFM2-MoE): added to the sigmoid scores for the top-k CHOICE only;
416    /// the gathered weights use the unbiased scores. None = no bias.
417    pub expert_bias: Option<Vec<f32>>,
418    /// Top-k weights are multiplied by this after the optional renorm
419    /// (LFM2-MoE `routed_scaling_factor`; 1.0 = off).
420    pub routed_scaling: f32,
421    /// Adaptive routing (CMF_MOE_TAU, opt-in): keep the smallest
422    /// prefix of the top-k whose renormalized mass reaches τ —
423    /// confident tokens touch 1–2 experts, flat ones keep all k.
424    /// MoE decode is memory-bound, so skipped experts are skipped
425    /// weight traffic. None = classic fixed top-k (bit-identical).
426    pub route_tau: Option<f32>,
427    /// Always-on shared expert. Qwen2-MoE carries an additional sigmoid
428    /// gate; Laguna adds the shared expert unconditionally (`None`).
429    pub shared: Option<(DenseFfn, Option<QTensor>)>,
430    /// Expert-selection counters (truncated Fisher B-field of claim 12:
431    /// routing frequency during calibration). Filled by every forward,
432    /// read by the CLI via CMF_MOE_STATS. RefCell: decode is single-threaded.
433    pub stats: std::cell::RefCell<Vec<u64>>,
434    /// Per-CHANNEL sum of squares of this FFN's input, accumulated over a
435    /// calibration run (`CMF_RMS_TRACE`). These are the RMS activation
436    /// traces AWNP needs: raw weight magnitude says every channel matters
437    /// equally, and the question AWNP asks is whether the ACTIVATIONS
438    /// disagree. Off unless the env var is set — an f64 add per channel
439    /// per token is cheap, but not free.
440    pub act_sq: std::cell::RefCell<Vec<f64>>,
441    /// Raw FFN-input rows captured for the layers named by `CMF_ACT_DUMP`
442    /// (`"9,19"`). AWNP is nullspace PROJECTION: after dropping channels the
443    /// survivors are refitted to absorb what was removed, and how much they
444    /// can absorb depends on the activation COVARIANCE, not on per-channel
445    /// RMS. Per-channel numbers can only bound the cost from above.
446    pub act_rows: std::cell::RefCell<Vec<f32>>,
447    /// Task mask over routed experts (DTG-MA over MoE, claim-12 B-field
448    /// applied): `false` experts are excluded from selection, the
449    /// softmax renormalizes over the allowed set. Built by the loader
450    /// from CMF_MOE_MASK=<stats.json> + CMF_MOE_MASK_COVER. None = all.
451    pub mask: Option<Vec<bool>>,
452    /// Gemma-4: per-expert weight scale applied AFTER the top-k renorm
453    /// (`router.per_expert_scale`). None = 1.0 everywhere.
454    pub per_expert_scale: Option<Vec<f32>>,
455    /// Gemma-4: the router reads a SCALE-LESS rms-norm of its input
456    /// (the constant gain router.scale·√hidden is folded into the
457    /// router weights at convert time).
458    pub router_input_norm: bool,
459}
460
461/// Attention operator of a layer. Extension point: new operators are
462/// new variants here + a forward in their own module.
463pub enum AttnKind {
464    /// GQA softmax attention (+ optional Qwen3.5 qk-norm / output gate).
465    Full {
466        wq: QTensor,
467        wk: QTensor,
468        wv: QTensor,
469        wo: QTensor,
470        q_norm: Option<Vec<f32>>,
471        k_norm: Option<Vec<f32>>,
472        output_gate: bool,
473        /// Laguna: a separate softplus projection applied to the attention
474        /// output before O. The bool means one scalar per head (broadcast
475        /// across head_dim); false means one scalar per element.
476        softplus_gate: Option<(QTensor, bool)>,
477        /// Qwen2-family projection biases (q, k, v).
478        bias: Option<(Vec<f32>, Vec<f32>, Vec<f32>)>,
479    },
480    /// Canonical linear core (VMF phase attention).
481    Linear(VmfPhaseWeights),
482    /// Faithful vendor linear operator (Qwen3.5 GatedDeltaNet).
483    LinearGdn(GdnWeights),
484    /// LFM2 gated short-convolution mixer (no KV cache; conv ring state
485    /// lives in the layer's `linear_state`).
486    ShortConv(ShortConvWeights),
487    /// DeepSeek-V2 Multi-head Latent Attention. v1 executes it as
488    /// expand-to-MHA: the latent is projected per token, K/V expand to
489    /// every head and live in the ordinary cache (K head layout
490    /// [rope | nope] so the standard partial rotary covers the shared
491    /// rope key; V rows are zero-padded to the K head_dim and the pad
492    /// is sliced off before O). Latent-resident cache is a later
493    /// optimization, not a semantic change.
494    Mla(Box<MlaWeights>),
495    /// Kimi Delta Attention (Kimi Linear / Kimi-K3): per-channel decayed
496    /// delta rule, separate q/k/v short convs, sigmoid-gated output norm.
497    /// State lives in the layer's `linear_state` (no KV cache).
498    Kda(Box<crate::linear_core::KdaWeights>),
499}
500
501/// DeepSeek-V2 MLA projections (see `AttnKind::Mla`).
502pub struct MlaWeights {
503    /// `[nh·(rope+nope), hidden]` (or `[…, q_lora]` when compressed) —
504    /// the converter permutes each head rope-first so rotary_dim =
505    /// qk_rope works unchanged.
506    pub q_proj: QTensor,
507    /// Compressed q (K3/V3 class): x → q_a `[q_lora, hidden]` →
508    /// rms(q_a_norm) → q_proj (= q_b). None = direct q (V2-Lite).
509    pub q_a: Option<QTensor>,
510    pub q_a_norm: Option<Vec<f32>>,
511    /// `kv_a_proj_with_mqa` `[lora + rope, hidden]` (latent first).
512    pub kv_a: QTensor,
513    /// RMS-norm weights over the latent (`kv_a_layernorm`, [lora]).
514    pub kv_a_norm: Vec<f32>,
515    /// `[nh·(nope+v), lora]` — per head [k_nope | v].
516    pub kv_b: QTensor,
517    /// `[hidden, nh·v]`.
518    pub o_proj: QTensor,
519    pub nh: usize,
520    pub qk_rope: usize,
521    pub qk_nope: usize,
522    pub v_dim: usize,
523    pub lora: usize,
524    /// Softmax scale (1/√(rope+nope), YaRN-mscale-corrected at load).
525    pub scale: f32,
526    /// Kimi Linear NoPE: skip the rotary entirely (layout unchanged).
527    pub nope: bool,
528}
529
530/// Multi-token-prediction head (DeepSeek/Qwen style, spec §2.1):
531/// `x = eh_proj·[enorm(embed(next)); hnorm(hidden)]` → one transformer
532/// block over its own KV → shared lm_head. Drafts the token after next;
533/// the main model verifies, so output is exact — MTP only buys speed.
534pub struct MtpModule {
535    pub enorm: Vec<f32>,
536    pub hnorm: Vec<f32>,
537    /// [hidden, 2·hidden]
538    pub eh_proj: QTensor,
539    pub layer: LayerWeights,
540    pub final_norm: Vec<f32>,
541    pub kv: crate::kv_cache::LayerKvCache,
542}
543
544/// A Metal verify graph after its sync: what the commit needs — the
545/// graph (per-layer replay scratch), the GDN layers in encode order (their
546/// CPU states receive the replay), and the attention layers with the CPU
547/// row count they were encoded against (the accepted rows are pulled from
548/// the mirror from there).
549/// One item of the Metal rows-graph plan.
550#[cfg(target_os = "macos")]
551enum MetalRowsItem<'a> {
552    Gdn {
553        run: Vec<crate::gpu_metal::GdnGpuLayer<'a>>,
554        first: usize,
555    },
556    Attn {
557        l: crate::gpu_metal::AttnGpuLayer<'a>,
558        li: usize,
559        q_norm: Option<&'a [f32]>,
560        k_norm: Option<&'a [f32]>,
561        output_gate: bool,
562    },
563}
564
565#[cfg(target_os = "macos")]
566struct MetalVerifyPending {
567    graph: crate::gpu_metal::VerifyGraph,
568    gdn_layers: Vec<usize>,
569    attn_layers: Vec<(usize, usize)>,
570}
571
572/// The speculation trial's phases (see the decode loop): four timed
573/// speculative rounds, eight timed plain tokens, then the faster arm
574/// until a re-check.
575#[derive(Clone, Copy)]
576enum SpecTrial {
577    Spec {
578        t0: std::time::Instant,
579        gen0: usize,
580        rounds: usize,
581    },
582    Plain {
583        t0: std::time::Instant,
584        gen0: usize,
585    },
586    Decided {
587        spec: bool,
588        recheck_at: usize,
589    },
590}
591
592/// The speculation monitor: exponential averages of a round's wall time
593/// and of the tokens it produced, and the plain token's wall time — the
594/// three numbers the keep/stop rule needs. A round pays when
595/// `tokens_per_round · plain_ms > round_ms · 1.03`. The one-shot trial
596/// (four rounds against eight tokens) mis-called prose: the first rounds
597/// after a prompt are formulaic and accept well, the body does not (an
598/// essay measured 39 against a plain 44.8 with the trial saying
599/// "speculate"), so the rule now runs on EVERY round and stops after four
600/// consecutive losing rounds; a stopped speculation is retried 128 tokens
601/// later.
602#[derive(Default, Clone, Copy)]
603struct SpecMon {
604    round_ms: f64,
605    tokens: f64,
606    plain_ms: f64,
607    n: u32,
608    fails: u32,
609}
610
611impl SpecMon {
612    fn round(&mut self, dt_ms: f64, produced: usize) {
613        self.n += 1;
614        if self.n == 1 {
615            return; // round 1 pays the batch scratch and the draft mirror
616        }
617        let a = if self.n == 2 { 1.0 } else { 0.3 };
618        self.round_ms += a * (dt_ms - self.round_ms);
619        self.tokens += a * (produced as f64 - self.tokens);
620    }
621    fn pays(&self) -> bool {
622        self.plain_ms > 0.0 && self.tokens * self.plain_ms > self.round_ms * 1.03
623    }
624}
625
626/// Result of a generation call.
627pub struct GenerateResult {
628    pub text: String,
629    pub token_ids: Vec<u32>,
630    pub prompt_tokens: usize,
631    pub tokens_generated: usize,
632    pub finish_reason: String,
633    /// Speculative-decode stats (0/0 when MTP is absent or inactive).
634    pub mtp_drafted: usize,
635    pub mtp_accepted: usize,
636    /// Per-generated-token confidence = softmax probability of the token
637    /// that was actually emitted (Born mass on the chosen state). High =
638    /// the model was sure; low = it was guessing. Same length as the
639    /// generated slice of `token_ids`.
640    pub token_confidence: Vec<f32>,
641    /// Structured per-token telemetry (B4 channel). Empty unless
642    /// `set_trace(true)`; otherwise same length as the generated slice.
643    pub traces: Vec<TokenTrace>,
644}
645
646/// One row of the structured telemetry trace (B4): the model's internal
647/// routing state at the moment a token was emitted. Every field is a
648/// quantity the runtime already computes — nothing is inferred or
649/// estimated (anti-principle: only measured bytes).
650#[derive(Clone, Debug)]
651pub struct TokenTrace {
652    /// 0-based index within the generated slice.
653    pub t: usize,
654    /// The emitted token id.
655    pub token_id: u32,
656    /// Born mass on the emitted token (softmax prob) — how sure the model was.
657    pub confidence: f32,
658    /// Skill in force while this token was generated (None = backbone).
659    pub active_skill: Option<String>,
660    /// Recon error E = ‖r−BBᵀr‖²/‖φ‖² at the last routing eval — coherence
661    /// with the active skill's subspace (low = coherent). None = no router
662    /// or not yet evaluated.
663    pub recon: Option<f32>,
664    /// The router changed the active skill right after this token (a
665    /// domain boundary crossed under the hysteresis barrier).
666    pub switched: bool,
667}
668
669/// Calibrated softmax probability of `id` under `logits` (the Born mass on
670/// the emitted token) — the confidence signal, cheap from logits already
671/// computed for sampling. `temp` is the calibration temperature (B1):
672/// softmax(logits / temp); 1.0 = raw.
673#[cfg_attr(not(test), allow(dead_code))]
674fn top1_prob_t(logits: &[f32], id: u32, temp: f32) -> f32 {
675    let t = if temp > 1e-3 { temp } else { 1.0 };
676    let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
677    let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
678    if sum > 0.0 {
679        (((logits[id as usize] - max) / t).exp()) / sum
680    } else {
681        0.0
682    }
683}
684
685/// prefill-GEMM enabled? (CMF_PREFILL=seq — emergency fallback to the
686/// sequential path.)
687fn prefill_batched() -> bool {
688    std::env::var("CMF_PREFILL")
689        .map(|v| v != "seq")
690        .unwrap_or(true)
691}
692
693/// Input to the layer-major batched span walk: token ids (embeds itself,
694/// full-stack and coordinator prefill) or ready boundary hiddens (the
695/// network worker's side of a split).
696#[derive(Clone, Copy)]
697enum PrefillIn<'a> {
698    Ids(&'a [u32]),
699    Hidden(&'a [f32]),
700}
701
702/// The batched prefill walks `weights.layers`. Architectures that load
703/// their own stack (gemma-3n's AltUp replicas, DeepSeek-V4's hyper-
704/// connections) leave that empty and must go position by position — asking
705/// otherwise indexes an empty vector, which is a panic rather than a
706/// fallback. Every call site goes through here so the next such
707/// architecture is one line, not four.
708impl Pipeline {
709    fn can_prefill_batched(&self) -> bool {
710        prefill_batched() && !self.weights.layers.is_empty()
711    }
712}
713
714/// Prefill chunk (positions per batched pass). On macOS the AMX GEMM
715/// path wants tall panels — M=48 starves the matrix units (ggml uses
716/// ubatch 512); elsewhere the historical 48 stays. CMF_PREFILL_CHUNK
717/// overrides. Pub: the network split MUST chunk identically to the
718/// local path — panel width reorders float accumulation, so a different
719/// chunk is a different (equally valid) generation.
720pub fn prefill_chunk() -> usize {
721    if let Some(n) = std::env::var("CMF_PREFILL_CHUNK")
722        .ok()
723        .and_then(|v| v.parse::<usize>().ok())
724    {
725        return n.max(1);
726    }
727    if cfg!(target_os = "macos") {
728        512
729    } else if cfg!(target_arch = "aarch64") {
730        // Mobile: big enough to feed the batched attend (gate b ≥ 32)
731        // and the blocked SDOT GEMM without the memory of 512.
732        256
733    } else {
734        48
735    }
736}
737
738/// Callback for streaming tokens. Return `false` to cancel.
739pub type TokenCallback = Box<dyn FnMut(&str) -> bool + Send>;
740
741impl Pipeline {
742    /// Map a virtual layer index to its physical weight index.
743    /// Looped Transformer (Nanbeige 4.2): 22 physical layers × 2 loops = 44 virtual;
744    /// virtual layer 23 maps back to physical layer 1 (23 % 22 = 1).
745    #[inline]
746    pub fn phys_layer(&self, virtual_idx: usize) -> usize {
747        virtual_idx % self.physical_layers
748    }
749
750    /// True when `virtual_idx` is the last layer of a loop iteration
751    /// (used for loop_final_norm insertion).
752    #[inline]
753    pub fn is_loop_end(&self, virtual_idx: usize) -> bool {
754        self.loop_final_norm && (virtual_idx + 1) % self.physical_layers == 0
755    }
756
757    /// Build a pipeline from parts (used by the loader and tests).
758    #[allow(clippy::too_many_arguments)]
759
760    /// Whole-block q1 token graph on the GPU (macOS/Metal): the run of
761    /// consecutive q1 layers — GDN *and* full attention — starting at
762    /// `start` executes as few command buffers as the CPU truly needs.
763    /// Hidden stays device-resident across every layer; the only syncs
764    /// are before each CPU attend (it needs q/k/v and owns the KV
765    /// cache) and the final hidden readback. Recurrent states
766    /// round-trip through shared memory (the CPU stays their owner, so
767    /// every other path remains coherent). Returns the first layer
768    /// index NOT covered (== `start` → refused, caller falls through
769    /// to the per-layer CPU path).
770    /// Should prefill run position-by-position through the GPU token
771    /// graph instead of the batched CPU chunk-GEMM? True for q1 GDN
772    /// hybrids on native Metal: their chunk prefill is walled by the
773    /// sequential scalar recurrence, so the graph's decode rate wins.
774    /// NOT for Looped Transformers, despite the per-chunk loop_final_norm
775    /// sync: the chunk-GEMM amortizes each weight over the whole chunk,
776    /// which the per-position graph cannot (Nanbeige 4.2 on M4, 512-token
777    /// prompt: 85 tok/s chunked vs 14 through the graph).
778    #[cfg(target_os = "macos")]
779    fn graph_prefill_preferred(&self) -> bool {
780        if !crate::gpu::enabled_here()
781            || !crate::gpu::q1_force()
782            || std::env::var("CMF_GPU_BLOCK")
783                .map(|v| v == "0")
784                .unwrap_or(false)
785            // CMF_PREFILL_GRAPH=0: the chunked prefill (GEMM projections,
786            // CPU recurrence) instead of the per-position token graph.
787            || std::env::var("CMF_PREFILL_GRAPH").as_deref() == Ok("0")
788        {
789            return false;
790        }
791        self.weights
792            .layers
793            .iter()
794            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(w) if w.in_proj_qkv.is_q1()))
795    }
796
797    #[cfg(not(target_os = "macos"))]
798    fn graph_prefill_preferred(&self) -> bool {
799        // Discrete-GPU wgpu whole-token graph: GDN layers carry recurrent state
800        // (conv ring + delta-rule S) resident on the GPU. A batched CPU prefill
801        // builds that state on the CPU only, leaving the GPU buffers zeroed at
802        // decode → garbage. Route GDN-hybrid prefill through the graph one
803        // position at a time so the resident state is seeded exactly as decode
804        // will read it. Pure-attention models keep the batched CPU prefill (its
805        // KV mirror re-syncs from the CPU cache, so no seeding gap).
806        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Prefill);
807        if !graph_on || !crate::gpu::enabled_here() {
808            return false;
809        }
810        // O(1) needs the CPU prefill: the q-trace that seals the Nyström
811        // skeleton is recorded there and nowhere else. The GDN half of
812        // the hybrid loses nothing — the graph's first decode creates
813        // its (ring, S) entries seeded from `cpu_state`, the same
814        // handoff every graph run relies on when the entry is fresh.
815        // Without this line the two designs collide on hybrids and o1
816        // never becomes graph-portable: prefill through the graph
817        // records no trace, so views stay None forever.
818        if self.o1_active() {
819            return false;
820        }
821        self.weights
822            .layers
823            .iter()
824            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(_)))
825    }
826
827    #[cfg(target_os = "macos")]
828    fn q1_graph_gpu(
829        &mut self,
830        start: usize,
831        upto: Option<usize>,
832        position: usize,
833        h: &mut [f32],
834    ) -> usize {
835        let _mt0 = std::time::Instant::now(); // CMF_METAL_HOSTPROF
836        use crate::gpu::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GraphDims, MetalFfn, TokenGraph};
837        if self.attn_softcap > 0.0 // capped scores: no graph kernel — CPU path
838            || !crate::gpu::enabled_here()
839            || !crate::gpu::q1_force()
840            || std::env::var("CMF_GPU_BLOCK")
841                .map(|v| v == "0")
842                .unwrap_or(false)
843        {
844            if std::env::var("CMF_GRAPH_DBG").is_ok() {
845                eprintln!(
846                    "block-graph: front gate (softcap={} enabled_here={} q1_force={})",
847                    self.attn_softcap > 0.0,
848                    crate::gpu::enabled_here(),
849                    crate::gpu::q1_force(),
850                );
851            }
852            return start;
853        }
854        // The graph encodes SiLU FFN, 1/√hd attention scores and
855        // full-context attend with no branch norms — Gemma-style archs
856        // (sliding window, scale override, sandwich norms, GeLU) fall
857        // back to the CPU path.
858        if self.swa.is_some()
859            || self.global_attn.is_some()
860            || self.attention_heads_per_layer.is_some()
861            || self.attn_v_norm
862            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
863            || self.weights.layers.iter().any(|lw| {
864                lw.attn_out_norm.is_some()
865                    || lw.ffn_out_norm.is_some()
866                    || lw.layer_scale.is_some()
867                    || matches!(&lw.ffn, FfnKind::Dense(d) if d.act != Act::Silu)
868            })
869        {
870            if std::env::var("CMF_GRAPH_DBG").is_ok() {
871                eprintln!(
872                    "block-graph: arch ineligible (swa={} gattn={} hpl={} vnorm={} scale_delta={:.2e})",
873                    self.swa.is_some(),
874                    self.global_attn.is_some(),
875                    self.attention_heads_per_layer.is_some(),
876                    self.attn_v_norm,
877                    (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs(),
878                );
879            }
880            return start;
881        }
882        // Looped Transformer: the graph covers ALL loop iterations;
883        // encode_loop_norm is inserted on-device at each boundary.
884        let limit = upto
885            .map(|u| u + 1)
886            .unwrap_or(self.num_layers)
887            .min(self.num_layers);
888
889        enum Item<'a> {
890            Gdn {
891                run: Vec<GdnGpuLayer<'a>>,
892                first: usize,
893            },
894            Attn {
895                l: AttnGpuLayer<'a>,
896                li: usize,
897                q_norm: Option<&'a [f32]>,
898                k_norm: Option<&'a [f32]>,
899                output_gate: bool,
900                bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
901                /// Attend on the device too (no sync): F32 KV, no
902                /// o1/bias, dims inside the kernels' contract.
903                full_gpu: bool,
904            },
905        }
906
907        // Device-attend KERNEL contract, shared by every Full layer. The
908        // hd>128 default-off POLICY is applied after the scan: it was
909        // measured on dense models, and a MoE plan inverts it — with the
910        // experts on device each CPU-attend sandwich costs a
911        // commit+wait, ~30 submits/token (W2 on M4: 14.7 tok/s
912        // sandwiched vs 27.1 device-attend vs 18.8 pure CPU).
913        let attend_mode = std::env::var("CMF_GPU_ATTEND").unwrap_or_else(|_| "auto".into());
914        let attend_contract = attend_mode != "0"
915            && attend_mode != "off"
916            && self.head_dim % 4 == 0
917            && self.head_dim <= 256
918            && self.rotary_dim >= 2
919            && self.rotary_dim <= self.head_dim
920            && (self.rotary_dim / 2) % 32 == 0
921            && self.num_kv_heads > 0
922            && self.num_heads % self.num_kv_heads == 0;
923
924        let mut plan: Vec<Item> = Vec::new();
925        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
926        // Break-reason diagnostics ride the same env as the plan summary.
927        let block_diag = std::env::var("CMF_GRAPH_DBG").is_ok();
928        let mut scan = start;
929        while scan < limit {
930            let lw = &self.weights.layers[self.phys_layer(scan)];
931            let ffn = match &lw.ffn {
932                FfnKind::Dense(d) => {
933                    let (Some(g), Some(u), Some(dn)) = (
934                        d.gate_proj.q1_parts(),
935                        d.up_proj.q1_parts(),
936                        d.down_proj.q1_parts(),
937                    ) else {
938                        if block_diag {
939                            eprintln!(
940                                "block-graph: L{scan} FFN trio not graph-mappable — run ends"
941                            );
942                        }
943                        break;
944                    };
945                    MetalFfn::Dense {
946                        gate: g,
947                        up: u,
948                        down: dn,
949                    }
950                }
951                FfnKind::Moe(m) => {
952                    let Some(moe) = metal_moe_graph_parts(m, self.hidden_size) else {
953                        if block_diag {
954                            eprintln!(
955                                "block-graph: L{scan} MoE outside the graph contract — run ends"
956                            );
957                        }
958                        break;
959                    };
960                    if let QTensor::Mapped { model, .. } = &m.experts[0].gate_proj {
961                        model_ref.get_or_insert_with(|| model.clone());
962                    }
963                    MetalFfn::Moe(moe)
964                }
965                _ => {
966                    if block_diag {
967                        eprintln!("block-graph: L{scan} non-graph FFN — run ends");
968                    }
969                    break;
970                }
971            };
972            match &lw.attn {
973                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
974                    let parts = (
975                        w.in_proj_qkv.q1_parts(),
976                        w.in_proj_z.q1_parts(),
977                        w.in_proj_a.f32_parts(),
978                        w.in_proj_b.f32_parts(),
979                        w.out_proj.q1_parts(),
980                    );
981                    let (Some(qkv), Some(z), Some(a), Some(b), Some(out)) = parts else {
982                        if block_diag {
983                            eprintln!(
984                                "block-graph: L{scan} GDN parts refused (qkv={} z={} a_f32={} b_f32={} out={})",
985                                w.in_proj_qkv.q1_parts().is_some(),
986                                w.in_proj_z.q1_parts().is_some(),
987                                w.in_proj_a.f32_parts().is_some(),
988                                w.in_proj_b.f32_parts().is_some(),
989                                w.out_proj.q1_parts().is_some(),
990                            );
991                        }
992                        break;
993                    };
994                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
995                        model_ref.get_or_insert_with(|| model.clone());
996                    }
997                    let gl = GdnGpuLayer {
998                        attn_norm: &lw.input_norm,
999                        post_norm: &lw.post_norm,
1000                        qkv,
1001                        z,
1002                        a,
1003                        b,
1004                        out,
1005                        ffn,
1006                        conv1d: &w.conv1d,
1007                        a_log: &w.a_log,
1008                        dt_bias: &w.dt_bias,
1009                        gnorm: &w.norm,
1010                    };
1011                    match plan.last_mut() {
1012                        Some(Item::Gdn { run, .. }) => run.push(gl),
1013                        _ => plan.push(Item::Gdn {
1014                            run: vec![gl],
1015                            first: scan,
1016                        }),
1017                    }
1018                }
1019                AttnKind::Full {
1020                    wq,
1021                    wk,
1022                    wv,
1023                    wo,
1024                    q_norm,
1025                    k_norm,
1026                    output_gate,
1027                    softplus_gate: None,
1028                    bias,
1029                } if !self.kv_cache.layers[scan].o1_sealed()
1030                    // Sealed o1 stays plannable when the Metal o1 port
1031                    // is on: full_gpu attends through the device state,
1032                    // and any refusal falls to the sandwich, whose CPU
1033                    // core routes sealed layers through the nystrom step.
1034                    || std::env::var("CMF_O1_METAL").as_deref() == Ok("1") =>
1035                {
1036                    let parts = (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts());
1037                    let (Some(pq), Some(pk), Some(pv), Some(po)) = parts else {
1038                        break;
1039                    };
1040                    if let QTensor::Mapped { model, .. } = wq {
1041                        model_ref.get_or_insert_with(|| model.clone());
1042                    }
1043                    let cache = &self.kv_cache.layers[scan];
1044                    // O(1) layer on Metal: the device attends through the
1045                    // sealed Nystrom state (opt-in while the port proves
1046                    // itself). Unsealed -> sandwich path = the CPU o1 step.
1047                    let o1_metal = cache.o1.is_some()
1048                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1")
1049                        && cache.o1_views().is_some();
1050                    let full_gpu = attend_contract
1051                        && cache.mode == crate::kv_cache::KvMode::F32
1052                        && (cache.o1.is_none() || o1_metal)
1053                        && bias.is_none()
1054                        && pq.1 == self.num_heads * self.head_dim * (1 + *output_gate as usize)
1055                        && pk.1 == self.num_kv_heads * self.head_dim
1056                        && pv.1 == self.num_kv_heads * self.head_dim
1057                        && po.2 == self.num_heads * self.head_dim;
1058                    plan.push(Item::Attn {
1059                        l: AttnGpuLayer {
1060                            attn_norm: &lw.input_norm,
1061                            post_norm: &lw.post_norm,
1062                            wq: pq,
1063                            wk: pk,
1064                            wv: pv,
1065                            wo: po,
1066                            ffn,
1067                        },
1068                        li: scan,
1069                        q_norm: q_norm.as_deref(),
1070                        k_norm: k_norm.as_deref(),
1071                        output_gate: *output_gate,
1072                        bias: bias
1073                            .as_ref()
1074                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
1075                        full_gpu,
1076                    });
1077                }
1078                _ => break,
1079            }
1080            scan += 1;
1081        }
1082        let Some(model) = model_ref else {
1083            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1084                eprintln!("q1-graph: no model ref (start {start}, scanned to {scan})");
1085            }
1086            return start;
1087        };
1088        if plan.is_empty() {
1089            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1090                eprintln!("q1-graph: empty plan at layer {start}");
1091            }
1092            return start;
1093        }
1094        let has_moe = plan.iter().any(|it| match it {
1095            Item::Gdn { run, .. } => run.iter().any(|l| matches!(l.ffn, MetalFfn::Moe(_))),
1096            Item::Attn { l, .. } => matches!(l.ffn, MetalFfn::Moe(_)),
1097        });
1098        let has_gdn = plan.iter().any(|it| matches!(it, Item::Gdn { .. }));
1099        let dev_attend = attend_contract
1100            && (self.head_dim <= 128
1101                || has_moe
1102                // A GDN hybrid attends on a quarter of its layers: the
1103                // hd>128 caution was measured on pure-dense models where
1104                // gqa_attend dominates, and on Qwen3.8-27B (hd 256, 48
1105                // GDN + 16 attn) the sandwich costs 2x the whole decode
1106                // (1.2 vs 2.21 tok/s measured before the arena fix).
1107                || (self.head_dim <= 256 && has_gdn)
1108                || attend_mode == "force"
1109                || attend_mode == "256");
1110        if !dev_attend {
1111            for it in &mut plan {
1112                if let Item::Attn { li, full_gpu, .. } = it {
1113                    // The hd>128 policy is about gqa_attend; an o1 layer
1114                    // attends through its own kernel set.
1115                    let keep_o1 = self.kv_cache.layers[*li].o1.is_some()
1116                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1");
1117                    if !keep_o1 {
1118                        *full_gpu = false;
1119                    }
1120                }
1121            }
1122        }
1123        if std::env::var("CMF_GRAPH_DBG").is_ok() {
1124            use std::sync::atomic::{AtomicBool, Ordering};
1125            static SAID: AtomicBool = AtomicBool::new(false);
1126            if !SAID.swap(true, Ordering::Relaxed) {
1127                let fg = plan
1128                    .iter()
1129                    .filter(|it| matches!(it, Item::Attn { full_gpu: true, .. }))
1130                    .count();
1131                let att = plan
1132                    .iter()
1133                    .filter(|it| matches!(it, Item::Attn { .. }))
1134                    .count();
1135                eprintln!(
1136                    "q1-graph: plan of {} items from layer {start} to {scan} | dev_attend={dev_attend} full_gpu {fg}/{att} | hd={} rd={} nkv={} nh={}",
1137                    plan.len(),
1138                    self.head_dim,
1139                    self.rotary_dim,
1140                    self.num_kv_heads,
1141                    self.num_heads,
1142                );
1143            }
1144        }
1145        let dims = GraphDims {
1146            hidden: self.hidden_size,
1147            eps: self.rms_eps as f32,
1148            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1149        };
1150        let Some(mut graph) = TokenGraph::new(&model, dims, h) else {
1151            return start;
1152        };
1153        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
1154            nv: cfg.num_v_heads,
1155            nk: cfg.num_k_heads,
1156            dk: cfg.key_head_dim,
1157            dv: cfg.value_head_dim,
1158            kk: cfg.conv_kernel,
1159            hidden: self.hidden_size,
1160            inter: self.intermediate_size,
1161            c_dim: cfg.conv_dim(),
1162            eps: cfg.rms_eps as f32,
1163            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1164        });
1165        // Validate the whole plan BEFORE encoding anything: after the
1166        // first sync a refused layer would leave the token
1167        // half-executed, so truncate to the provably encodable prefix.
1168        let mut valid = 0usize;
1169        let mut end = start;
1170        crate::gpu::stageprof(1, _mt0.elapsed()); // конец планирования
1171        if std::env::var("CMF_PLAN_DUMP").is_ok() {
1172            static ONCE: std::sync::Once = std::sync::Once::new();
1173            ONCE.call_once(|| {
1174                for it in &plan {
1175                    match it {
1176                        Item::Gdn { first, run } => {
1177                            eprintln!("plan: Gdn first={first} len={}", run.len())
1178                        }
1179                        Item::Attn { li, full_gpu, .. } => {
1180                            eprintln!("plan: Attn li={li} full_gpu={full_gpu}")
1181                        }
1182                    }
1183                }
1184            });
1185        }
1186        for item in &plan {
1187            let ok = match item {
1188                Item::Gdn { run, .. } => gcfg
1189                    .as_ref()
1190                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
1191                    .unwrap_or(false),
1192                Item::Attn { l, .. } => graph.attn_ok(l),
1193            };
1194            if !ok {
1195                if block_diag {
1196                    eprintln!(
1197                        "block-graph: plan item {} ({}) failed graph preflight",
1198                        valid,
1199                        match item {
1200                            Item::Gdn { run, first } => format!("GDN run L{first}+{}", run.len()),
1201                            Item::Attn { li, .. } => format!("Attn L{li}"),
1202                        }
1203                    );
1204                }
1205                break;
1206            }
1207            valid += 1;
1208            end += match item {
1209                Item::Gdn { run, .. } => run.len(),
1210                Item::Attn { .. } => 1,
1211            };
1212        }
1213        plan.truncate(valid);
1214        if plan.is_empty() {
1215            return start;
1216        }
1217
1218        let inv_freq = self.inv_freq.clone();
1219        let pool = self.pool.clone();
1220        let (nh, nkv, hd, hs, rd, eps) = (
1221            self.num_heads,
1222            self.num_kv_heads,
1223            self.head_dim,
1224            self.hidden_size,
1225            self.rotary_dim,
1226            self.rms_eps,
1227        );
1228        let norm_style = self.norm_style;
1229        let gemma = norm_style == cortiq_core::NormStyle::Gemma;
1230        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
1231        let kv_id = self.graph_kv_id;
1232        // GDN runs whose states await readback after the next sync
1233        // (device-attended layers add no sync, so several may stack).
1234        let mut pending: Vec<(usize, usize)> = Vec::new();
1235        // Device-attended layers: their K/V/imp are pulled from the
1236        // mirror after the final sync.
1237        let mut dev_attn: Vec<usize> = Vec::new();
1238        for item in &plan {
1239            let _xt0 = std::time::Instant::now();
1240            let _xkind: u32 = match item {
1241                Item::Gdn { .. } => 2,
1242                Item::Attn { .. } => 3,
1243            };
1244            // Looped Transformer: insert on-device norm at loop boundaries.
1245            if self.loop_final_norm {
1246                let item_start = match item {
1247                    Item::Gdn { first, .. } => *first,
1248                    Item::Attn { li, .. } => *li,
1249                };
1250                if item_start > start && self.is_loop_end(item_start - 1) {
1251                    graph.encode_loop_norm(&self.weights.final_norm);
1252                }
1253            }
1254            match item {
1255                Item::Gdn { run, first } => {
1256                    for l in &mut self.kv_cache.layers[*first..*first + run.len()] {
1257                        if l.linear_state.len() != want {
1258                            l.linear_state = vec![0f32; want];
1259                        }
1260                    }
1261                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
1262                        .iter()
1263                        .map(|l| l.linear_state.as_slice())
1264                        .collect();
1265                    let _ig = std::time::Instant::now();
1266                    if !graph.encode_gdn_run(run, &ro, gcfg.as_ref().unwrap()) {
1267                        // Unreachable: the plan was validated above.
1268                        tracing::error!("q1 graph: GDN run refused after validation");
1269                        return start;
1270                    }
1271                    // Early commit: the GPU starts the run while the
1272                    // CPU encodes the next layer (nothing to wait on).
1273                    graph.commit_kind = 2;
1274                    graph.commit();
1275                    crate::gpu::stageprof(0, _ig.elapsed());
1276                    pending.push((*first, run.len()));
1277                }
1278                Item::Attn {
1279                    l,
1280                    li,
1281                    q_norm,
1282                    k_norm,
1283                    output_gate,
1284                    bias,
1285                    full_gpu,
1286                } => {
1287                    let _ia = std::time::Instant::now();
1288                    // ── Fully device-resident attention: no sync at all.
1289                    if *full_gpu {
1290                        let cache = &self.kv_cache.layers[*li];
1291                        let o1p = if cache.o1.is_some() {
1292                            match cache.o1_views() {
1293                                Some(views) => Some(crate::gpu::O1AttnParams {
1294                                    views,
1295                                    epoch: self.o1_epoch,
1296                                }),
1297                                // Sealed state gone mid-run: sandwich.
1298                                None => None,
1299                            }
1300                        } else {
1301                            None
1302                        };
1303                        let o1_layer = cache.o1.is_some();
1304                        if o1_layer && o1p.is_none() {
1305                            // fall to the sandwich (CPU o1 step)
1306                        }
1307                        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
1308                        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
1309                        let cpu_stored = if o1_layer { 0 } else { cpu_k[0].len() / hd };
1310                        let p = crate::gpu::AttnDeviceParams {
1311                            kv_id,
1312                            layer: *li,
1313                            nh,
1314                            nkv,
1315                            hd,
1316                            rd,
1317                            position,
1318                            eps: eps as f32,
1319                            gemma,
1320                            output_gate: *output_gate,
1321                            q_norm: *q_norm,
1322                            k_norm: *k_norm,
1323                            inv_freq: &inv_freq,
1324                            cpu_k,
1325                            cpu_v,
1326                            cpu_stored,
1327                            o1: o1p,
1328                        };
1329                        let o1_bad = o1_layer && p.o1.is_none();
1330                        if !o1_bad && graph.attn_device_ok(l, &p) && graph.encode_attn_device(l, &p)
1331                        {
1332                            // o1 layers leave no mirror row to pull.
1333                            if p.o1.is_none() {
1334                                dev_attn.push(*li);
1335                            }
1336                            graph.commit_kind = 3;
1337                            graph.commit();
1338                            // The footer below is skipped by `continue`:
1339                            // account the device-attn item here or its
1340                            // cost hides from the stage profile entirely.
1341                            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1342                            continue;
1343                        }
1344                        // Mirror refused (nothing encoded) → sandwich.
1345                    }
1346                    graph.encode_attn_prefix(l);
1347                    graph.sync();
1348                    if !pending.is_empty() {
1349                        let idxs: Vec<usize> =
1350                            pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1351                        let mut outs: Vec<&mut [f32]> = self
1352                            .kv_cache
1353                            .layers
1354                            .iter_mut()
1355                            .enumerate()
1356                            .filter(|(i, _)| idxs.binary_search(i).is_ok())
1357                            .map(|(_, s)| s.linear_state.as_mut_slice())
1358                            .collect();
1359                        graph.read_states(&mut outs);
1360                    }
1361                    let mut q_raw = attention::take_buf(l.wq.1);
1362                    let mut k = attention::take_buf(l.wk.1);
1363                    let mut v = attention::take_buf(l.wv.1);
1364                    graph.read_qkv(&mut q_raw, &mut k, &mut v);
1365                    let cfg = QwenAttnCfg {
1366                        num_heads: nh,
1367                        num_kv_heads: nkv,
1368                        head_dim: hd,
1369                        hidden_size: hs,
1370                        position,
1371                        inv_freq: &inv_freq,
1372                        rotary_dim: rd,
1373                        scale: self.attn_scale,
1374                        softcap: self.attn_softcap,
1375                        window: None,
1376                        v_norm: false,
1377                        q_norm: *q_norm,
1378                        k_norm: *k_norm,
1379                        output_gate: *output_gate,
1380                        softplus_gate: None,
1381                        rope_scale: 1.0,
1382                        bias: *bias,
1383                        rms_eps: eps,
1384                        norm_style,
1385                        pool: pool.as_deref(),
1386                    };
1387                    // CMF_ATTN_ORACLE=1: diff the device attend against
1388                    // this CPU attend on identical inputs (bring-up).
1389                    let oracle = std::env::var("CMF_ATTN_ORACLE").as_deref() == Ok("1");
1390                    let _ = full_gpu;
1391                    let oracle_in = oracle.then(|| (q_raw.clone(), k.clone(), v.clone()));
1392                    let mut ao = attention::qwen_attention_core(
1393                        q_raw,
1394                        k,
1395                        v,
1396                        &mut self.kv_cache.layers[*li],
1397                        &cfg,
1398                    );
1399                    if let Some((qr0, k0, v0)) = oracle_in {
1400                        let (cq, _cg, ck, cv) = attention::finish_projection_debug(qr0, k0, v0, &cfg, position);
1401                        let mut h_now = vec![0f32; hs];
1402                        graph.read_h(&mut h_now);
1403                        let cache = &self.kv_cache.layers[*li];
1404                        let n_after = cache.head_keys(0).len() / hd;
1405                        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| &cache.head_keys(g)[..(n_after - 1) * hd]).collect();
1406                        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| &cache.head_values(g)[..(n_after - 1) * hd]).collect();
1407                        let p = crate::gpu::AttnDeviceParams {
1408                            kv_id,
1409                            layer: *li,
1410                            nh,
1411                            nkv,
1412                            hd,
1413                            rd,
1414                            position,
1415                            eps: eps as f32,
1416                            gemma,
1417                            output_gate: *output_gate,
1418                            q_norm: *q_norm,
1419                            k_norm: *k_norm,
1420                            inv_freq: &inv_freq,
1421                            cpu_k,
1422                            cpu_v,
1423                            cpu_stored: n_after - 1,
1424                            o1: None,
1425                        };
1426                        if let Some((dq, dk, dv, dao)) = graph.debug_attn_device(l, &p, &h_now) {
1427                            let md = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
1428                            let nn = |a: &[f32]| a.iter().map(|x| x * x).sum::<f32>().sqrt();
1429                            eprintln!(
1430                                "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}",
1431                                nn(&cq), md(&cq, &dq), nn(&ck), md(&ck, &dk), nn(&cv), md(&cv, &dv), nn(&ao), md(&ao, &dao)
1432                            );
1433                        } else {
1434                            eprintln!("attn-oracle L{li}: device probe declined");
1435                        }
1436                    }
1437                    graph.encode_attn_suffix(l, &ao);
1438                    // Early commit: the GPU starts O+FFN while the CPU
1439                    // encodes the following GDN run / attention prefix.
1440                    graph.commit();
1441                    attention::recycle_buf(&mut ao);
1442                }
1443            }
1444
1445            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1446        }
1447        // Ride the final norm + lm_head in the same command buffer when
1448        // this run reaches the model's end and the caller wants logits:
1449        // the separate per-op lm_head submit (a full round trip) folds
1450        // into the sync that already happens here.
1451        let mut lm_rows = None;
1452        if self.graph_want_logits
1453            && upto.is_none()
1454            && end == self.num_layers
1455            && std::env::var("CMF_GPU_LMHEAD")
1456                .map(|v| v != "0")
1457                .unwrap_or(true)
1458        {
1459            if let Some(lm) = self.weights.lm_head.q1_parts() {
1460                if graph.lm_head_ok(lm) {
1461                    graph.encode_lm_head(&self.weights.final_norm, lm);
1462                    lm_rows = Some(lm.1);
1463                }
1464            }
1465        }
1466        let _sy0 = std::time::Instant::now();
1467        graph.sync();
1468        let _rs0 = std::time::Instant::now();
1469        if !pending.is_empty() {
1470            let idxs: Vec<usize> = pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1471            let mut outs: Vec<&mut [f32]> = self
1472                .kv_cache
1473                .layers
1474                .iter_mut()
1475                .enumerate()
1476                .filter(|(i, _)| idxs.binary_search(i).is_ok())
1477                .map(|(_, s)| s.linear_state.as_mut_slice())
1478                .collect();
1479            graph.read_states(&mut outs);
1480        }
1481        if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() == Ok("1") {
1482            use std::sync::atomic::{AtomicU64, Ordering};
1483            static SY: AtomicU64 = AtomicU64::new(0);
1484            static RS: AtomicU64 = AtomicU64::new(0);
1485            static N: AtomicU64 = AtomicU64::new(0);
1486            SY.fetch_add((_rs0 - _sy0).as_nanos() as u64, Ordering::Relaxed);
1487            RS.fetch_add(_rs0.elapsed().as_nanos() as u64, Ordering::Relaxed);
1488            let n = N.fetch_add(1, Ordering::Relaxed) + 1;
1489            if n % 100 == 0 {
1490                eprintln!(
1491                    "postprof: sync-wait {:.1} ms/ток | read_states {:.1} ms/ток ({n})",
1492                    SY.load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
1493                    RS.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
1494                );
1495            }
1496        }
1497        if let Some(rows) = lm_rows {
1498            crate::gpu::hostprof_encode_done(_mt0);
1499            let mut lg = attention::take_buf(rows.min(self.vocab_size));
1500            graph.read_logits(&mut lg);
1501            crate::gpu::hostprof_total(_mt0);
1502            lg.resize(self.vocab_size, 0.0);
1503            if let Some(c) = self.final_softcap {
1504                for l in lg.iter_mut() {
1505                    *l = c * (*l / c).tanh();
1506                }
1507            }
1508            self.graph_logits = Some(lg);
1509        }
1510        graph.finish(h);
1511        // Device-attended layers: replay the CPU bookkeeping — append
1512        // the mirror's new K/V row (rope'd on the GPU) into the owner
1513        // cache, then bank this token's Born-importance mass.
1514        for li in dev_attn {
1515            let mut krow = attention::take_buf(nkv * hd);
1516            let mut vrow = attention::take_buf(nkv * hd);
1517            if crate::gpu::kv_mirror_read_last(kv_id, li, nkv, hd, &mut krow, &mut vrow) {
1518                let cache = &mut self.kv_cache.layers[li];
1519                cache.append(&krow, &vrow, &[]);
1520                let n = cache.seq_len;
1521                let mut imp = attention::take_buf(n);
1522                crate::gpu::kv_mirror_take_imp(kv_id, li, &mut imp);
1523                cache.accumulate_imp(&imp);
1524                attention::recycle_buf(&mut imp);
1525            }
1526            attention::recycle_buf(&mut krow);
1527            attention::recycle_buf(&mut vrow);
1528        }
1529        end
1530    }
1531
1532    pub fn new(
1533        tokenizer: Tokenizer,
1534        weights: PipelineWeights,
1535        hidden_size: usize,
1536        intermediate_size: usize,
1537        num_heads: usize,
1538        num_kv_heads: usize,
1539        head_dim: usize,
1540        num_layers: usize,
1541        physical_layers: usize,
1542        loop_final_norm: bool,
1543        vocab_size: usize,
1544        rms_eps: f64,
1545        rope_base: f32,
1546        norm_style: NormStyle,
1547        max_seq_len: usize,
1548        sampler_config: SamplerConfig,
1549    ) -> Self {
1550        let rng = match sampler_config.seed {
1551            Some(s) => SplitMix64::new(s),
1552            None => SplitMix64::from_entropy(),
1553        };
1554        let inv_freq = std::sync::Arc::new(attention::rope_inv_freq(head_dim, rope_base));
1555        let pool = Pool::from_env();
1556        if let Some(p) = &pool {
1557            tracing::info!("worker pool: {} threads", p.n_workers());
1558        }
1559        Self {
1560            gpu_plan: None,
1561            tokenizer: std::sync::Arc::new(tokenizer),
1562            kv_cache: KvCache::new(num_layers, num_kv_heads, head_dim, max_seq_len),
1563            sampler_config,
1564            weights,
1565            hidden_size,
1566            intermediate_size,
1567            num_heads,
1568            num_kv_heads,
1569            head_dim,
1570            num_layers,
1571            physical_layers,
1572            loop_final_norm,
1573            vocab_size,
1574            rms_eps,
1575            rope_base,
1576            norm_style,
1577            rotary_dim: head_dim,
1578            attention_heads_per_layer: None,
1579            vmf_cfg: None,
1580            gdn_cfg: None,
1581            kda_cfg: None,
1582            g3n: None,
1583            dsv4: None,
1584            dsv4_mtp: Vec::new(),
1585            dspark: None,
1586            dspark_pending: Vec::new(),
1587            dspark_hist: Vec::new(),
1588            dspark_real: Vec::new(),
1589            dspark_trunk_picks: Vec::new(),
1590            dspark_exp: Vec::new(),
1591            dspark_draft_ns: 0,
1592            logit_multiplier: None,
1593            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1594            kv_history: Vec::new(),
1595            short_conv_cfg: None,
1596            mtp: None,
1597            speculative: std::env::var("CMF_MTP").map(|v| v != "0").unwrap_or(true),
1598            rng,
1599            sampler_scratch: SamplerScratch::default(),
1600            spec_forced: None,
1601            spec_q: Vec::new(),
1602            spec_p: Vec::new(),
1603            spec_res: Vec::new(),
1604            spec_qs: Vec::new(),
1605            spec_ps: Vec::new(),
1606            spec_ress: Vec::new(),
1607            mtp_graph_mode: None,
1608            #[cfg(target_os = "macos")]
1609            metal_verify: None,
1610            inv_freq,
1611            ws: ForwardScratch::new(hidden_size),
1612            pool,
1613            model: None,
1614            dyn_force_f32: false,
1615            dyn_skill_layers: Vec::new(),
1616            dyn_active: None,
1617            dyn_blend_loaded: false,
1618            dyn_phi_layer: None,
1619            dyn_phi_ema: Vec::new(),
1620            dyn_phi_seen: 0,
1621            dyn_router: None,
1622            o1_cfg: None,
1623            o1_epoch: 0,
1624            o1_flags: Vec::new(),
1625            trace: false,
1626            calib_temp: 1.0,
1627            confidence_on: true,
1628            embed_multiplier: 1.0,
1629            attn_scale: 1.0 / (head_dim as f32).sqrt(),
1630            swa: None,
1631            sliding_layers: None,
1632            inv_freq_local: None,
1633            rotary_dim_local: None,
1634            rope_scale: 1.0,
1635            rope_scale_local: 1.0,
1636            global_attn: None,
1637            inv_freq_global: None,
1638            attn_v_norm: false,
1639            final_softcap: None,
1640            attn_softcap: 0.0,
1641            graph_want_logits: false,
1642            graph_logits: None,
1643            graph_kv_id: {
1644                static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1645                NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1646            },
1647        }
1648    }
1649
1650    /// Enable/disable per-layer O(1) Nyström attention. Only Full
1651    /// layers are eligible (a linear layer keeps its own operator).
1652    /// Applies to generation (`generate*`/`forward_ids`): the prompt
1653    /// pass stays exact, the seal happens once after prefill, decode
1654    /// runs on the O(1) state. Teacher-forced scoring (`ppl_ids`)
1655    /// intentionally stays exact.
1656    pub fn set_o1(&mut self, cfg: Option<crate::nystrom::O1Cfg>) {
1657        self.o1_flags = match &cfg {
1658            Some(c) => {
1659                let mut flags = c.layer_flags(self.num_layers);
1660                for (li, f) in flags.iter_mut().enumerate() {
1661                    if *f
1662                        && !matches!(
1663                            self.weights.layers[self.phys_layer(li)].attn,
1664                            AttnKind::Full { .. }
1665                        )
1666                    {
1667                        *f = false;
1668                    }
1669                }
1670                flags
1671            }
1672            None => Vec::new(),
1673        };
1674        if let Some(c) = &cfg {
1675            let n = self.o1_flags.iter().filter(|&&f| f).count();
1676            tracing::info!(
1677                "o1 nystrom attention: {n}/{} layer(s), m={} w={} sink={} rect={:?}",
1678                self.num_layers,
1679                c.m,
1680                c.w,
1681                c.sink,
1682                c.rect
1683            );
1684        }
1685        self.o1_cfg = cfg;
1686    }
1687
1688    /// True when at least one layer runs the O(1) kernel.
1689    pub fn o1_active(&self) -> bool {
1690        self.o1_cfg.is_some() && self.o1_flags.iter().any(|&f| f)
1691    }
1692
1693    /// Arm query collection on the o1 layers (fresh prompt pass).
1694    /// Reset the o1 layers to Collecting for a fresh sequence. Pub for the
1695    /// network split: each side runs the o1 lifecycle over ITS OWN layers
1696    /// (begin before prefill, seal at the prefill barrier).
1697    pub fn o1_begin(&mut self) {
1698        if let Some(c) = &self.o1_cfg {
1699            let (m, w, sink, rect) = (c.m, c.w, c.sink, c.rect);
1700            for (li, &f) in self.o1_flags.iter().enumerate() {
1701                if f {
1702                    self.kv_cache.layers[li].o1_begin(m, w, sink, rect);
1703                }
1704            }
1705        }
1706    }
1707
1708    /// Freeze landmarks + skeleton state after the prompt pass and drop
1709    /// the o1 layers' full KV; decode then runs `step()` per token.
1710    /// Pub for the network split (see `o1_begin`).
1711    pub fn o1_seal(&mut self) {
1712        self.o1_epoch = self.o1_epoch.wrapping_add(1);
1713        if self.o1_cfg.is_none() {
1714            return;
1715        }
1716        for li in 0..self.num_layers {
1717            if self.o1_flags.get(li).copied().unwrap_or(false) {
1718                self.kv_cache.layers[li].o1_seal(self.num_heads);
1719            }
1720        }
1721    }
1722
1723    /// Enable/disable the structured per-token telemetry trace (B4).
1724    pub fn set_trace(&mut self, on: bool) {
1725        self.trace = on;
1726    }
1727
1728    /// Replace all request-scoped sampler options and reset the random stream.
1729    /// This is required for deterministic `seed` semantics in pooled servers.
1730    pub fn set_sampler_config(&mut self, config: SamplerConfig) {
1731        self.rng = match config.seed {
1732            Some(seed) => SplitMix64::new(seed),
1733            None => SplitMix64::from_entropy(),
1734        };
1735        self.sampler_config = config;
1736    }
1737
1738    /// Toggle the per-token Born-confidence reduction (a full-vocab
1739    /// softmax each token). `bench --core` turns it off so the timed
1740    /// loop matches llama-bench's core contract; the result's
1741    /// `confidence` vec is empty while off.
1742    pub fn set_confidence(&mut self, on: bool) {
1743        self.confidence_on = on;
1744    }
1745
1746    /// Set the confidence-calibration temperature (B1). Values ≤0 are
1747    /// clamped to raw (1.0).
1748    pub fn set_calib_temp(&mut self, t: f32) {
1749        self.calib_temp = if t > 1e-3 { t } else { 1.0 };
1750    }
1751
1752    /// The active calibration temperature (1.0 = raw Born mass).
1753    pub fn calib_temp(&self) -> f32 {
1754        self.calib_temp
1755    }
1756
1757    /// Partial rotary (Qwen3.5): rotate only the first `rotary_dim` dims;
1758    /// the frequency table is rebuilt over the rotary dims.
1759    pub fn set_rotary(&mut self, rotary_dim: usize, base: f32) {
1760        self.rotary_dim = rotary_dim.min(self.head_dim);
1761        self.inv_freq = std::sync::Arc::new(attention::rope_inv_freq(self.rotary_dim, base));
1762    }
1763
1764    fn attn_cfg(&self, position: usize) -> QwenAttnCfg<'_> {
1765        QwenAttnCfg {
1766            num_heads: self.num_heads,
1767            num_kv_heads: self.num_kv_heads,
1768            head_dim: self.head_dim,
1769            hidden_size: self.hidden_size,
1770            position,
1771            inv_freq: &self.inv_freq,
1772            rotary_dim: self.rotary_dim,
1773            scale: self.attn_scale,
1774            softcap: self.attn_softcap,
1775            window: None,
1776            v_norm: false,
1777            q_norm: None,
1778            k_norm: None,
1779            output_gate: false,
1780            softplus_gate: None,
1781            rope_scale: self.rope_scale,
1782            bias: None,
1783            rms_eps: self.rms_eps,
1784            norm_style: self.norm_style,
1785            pool: self.pool.as_deref(),
1786        }
1787    }
1788
1789    /// Generate text from a plain-text prompt. Streams tokens via `on_token`.
1790    pub fn generate(
1791        &mut self,
1792        prompt: &str,
1793        max_tokens: usize,
1794        task_mask: Option<&TaskMask>,
1795        on_token: Option<TokenCallback>,
1796    ) -> Result<GenerateResult, String> {
1797        let input_ids = self.tokenizer.with_bos(self.tokenizer.encode(prompt));
1798        self.generate_from_ids(&input_ids, max_tokens, task_mask, on_token)
1799    }
1800
1801    /// Generate from prepared token ids (e.g. a chat template).
1802    ///
1803    /// With an MTP head, greedy generation without a task mask takes the
1804    /// speculative path: the MTP module drafts the token after next and
1805    /// the main model verifies both in one fused two-position forward
1806    /// (weights streamed once). The output is EXACTLY the vanilla greedy
1807    /// sequence — a rejected draft is rolled back — MTP only buys speed.
1808    pub fn generate_from_ids(
1809        &mut self,
1810        input_ids: &[u32],
1811        max_tokens: usize,
1812        task_mask: Option<&TaskMask>,
1813        mut on_token: Option<TokenCallback>,
1814    ) -> Result<GenerateResult, String> {
1815        if std::env::var("CMF_TRACE_H").is_ok() {
1816            eprintln!("input_ids: {input_ids:?}");
1817        }
1818        if input_ids.is_empty() {
1819            return Err("empty prompt: nothing to generate from".to_string());
1820        }
1821
1822        // Cross-turn KV reuse: a chat app resends the whole history
1823        // every turn; when the new ids strictly EXTEND what the cache
1824        // already holds, prefill only the tail — turn latency stays
1825        // proportional to the new text instead of the whole session.
1826        // Extension-only (no rollback), so it is exact for every layer
1827        // kind including recurrent state; MTP/o1/task-mask runs keep
1828        // the fresh-sequence path. CMF_KV_REUSE=0 disables.
1829        let reuse_from = {
1830            let on = !std::env::var("CMF_KV_REUSE").is_ok_and(|v| v == "0");
1831            let h = &self.kv_history;
1832            if on
1833                && task_mask.is_none()
1834                && self.mtp.is_none()
1835                && self.o1_cfg.is_none()
1836                && !h.is_empty()
1837                && h.len() < input_ids.len()
1838                && input_ids[..h.len()] == h[..]
1839            {
1840                h.len()
1841            } else {
1842                0
1843            }
1844        };
1845        if reuse_from == 0 {
1846            // Fresh sequence — the cache holds absolute positions.
1847            self.kv_cache.clear();
1848            self.kv_history.clear();
1849            crate::gpu::graph_kv_reset(self.graph_kv_id);
1850        } else if std::env::var("CMF_PREFILL_PROF").is_ok() {
1851            eprintln!(
1852                "kv-reuse: {} of {} prompt positions already cached",
1853                reuse_from,
1854                input_ids.len()
1855            );
1856        }
1857        crate::gpu::graph_race_begin_generation();
1858        self.o1_begin();
1859
1860        // Speculative decode is off under o1: a rejected draft can't be
1861        // rolled back out of the far accumulators / ring window (the
1862        // Nyström insertion is irreversible by design).
1863        // The wgpu token graph owns a device K/V mirror that speculative
1864        // rollback would desync — the two are mutually exclusive.
1865        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
1866        // Graph speculative decode (`CMF_GRAPH_SPEC=1`): the MTP head
1867        // drafts, ONE batched graph submit verifies the whole chain.
1868        //
1869        // It now PAYS on Qwen3.6-27B / RTX 5090 — 51.1 tok/s against a
1870        // plain 49.4 at k=3, medians of three, 89% of drafts accepted,
1871        // and the greedy continuation is byte-identical to the plain
1872        // path. That took the batch matvec sharing its nibble unpack
1873        // across the batch (`CMF_MV_BK=2`); before it, the same round
1874        // measured 43.6, an 11% LOSS, which is what the earlier note
1875        // here described.
1876        //
1877        // Still opt-in. One model's win is not a default: the verify
1878        // rides `gdn_spec_restore` and a batched frame whose numerics
1879        // are the batch kernels', and that has to be shown on more than
1880        // one architecture before every greedy decode takes it.
1881        // Greedy (with or without penalties) verifies by argmax equality.
1882        // Sampling (temperature > 0) can go through speculative SAMPLING —
1883        // draft from the MTP head's own post-chain distribution, accept
1884        // with min(1, p/q), correct from max(0, p − q); the emitted stream
1885        // is distributed exactly as the plain sampler's — but it is
1886        // OPT-IN (`CMF_GRAPH_SPEC_SAMPLE=1`): measured on Qwen3.8-27B /
1887        // RTX 5090 at the instruct row (0.7 / 0.80 / 20 / presence 1.5)
1888        // it decoded 19-22 tok/s against a plain 40 — nine post-chain
1889        // distributions a round plus a lower acceptance than greedy's,
1890        // against a verify that costs 2.7 single tokens. The greedy arms
1891        // pay +10%; the sampling arm needs a cheaper verify first.
1892        let spec_sampling_ok = self.sampler_config.temperature < 1e-6
1893            || std::env::var("CMF_GRAPH_SPEC_SAMPLE").as_deref() == Ok("1");
1894        // ON by default for greedy on the wgpu graph: with the draft on
1895        // the graph and the verify bit-exact, it measured 58.7 tok/s
1896        // against a plain 48.1 on Qwen3.8-27B q4tp / RTX 5090 (k=4) and
1897        // 51.1 against 49.4 on Qwen3.6-27B, and a round that stops
1898        // paying turns itself off below (acceptance watchdog).
1899        // `CMF_GRAPH_SPEC=0` disables; `=1` was the old opt-in spelling.
1900        // …but only where the batched verify has its register-blocked
1901        // kernel: q4tp dense FFNs (graph kind 6). q4t and q8_2f verify
1902        // through tile GEMMs today and measured a LOSS (q8_2f 22 against
1903        // 29 tok/s), the 2-bit plane the same; those stay opt-in
1904        // (`CMF_GRAPH_SPEC=1`).
1905        // …at least in nine dense FFNs of ten: a healed file carries its
1906        // last two layers at q8_2f, and two tile-GEMM verifies among 64 do
1907        // not change the arithmetic (measured: the healed q4tp file
1908        // decodes at the plain file's rate and would otherwise sit out).
1909        let (mut dense_n, mut dense_q4tp) = (0usize, 0usize);
1910        for lw in &self.weights.layers {
1911            if let FfnKind::Dense(d) = &lw.ffn {
1912                dense_n += 1;
1913                if matches!(d.gate_proj.graph_weight(), Some((_, _, 6, _)))
1914                    && matches!(d.up_proj.graph_weight(), Some((_, _, 6, _)))
1915                    && matches!(d.down_proj.graph_weight(), Some((_, _, 6, _)))
1916                {
1917                    dense_q4tp += 1;
1918                }
1919            }
1920        }
1921        let spec_default_ok = dense_n == 0 || dense_q4tp * 10 >= dense_n * 9;
1922        // Penalties break the draft head's agreement with the trunk (a
1923        // 1.1 repetition penalty measured 2 of 16 accepted): not by
1924        // default there either.
1925        let penalized = self.sampler_config.repetition_penalty != 1.0
1926            || self.sampler_config.presence_penalty != 0.0
1927            || !self.sampler_config.suppress_tokens.is_empty();
1928        // …and not on wgpu-over-Metal: the batched verify graph there
1929        // returned 0 accepted drafts and garbage text on a GDN hybrid
1930        // (16.08, Qwen3.5-0.8B) while Vulkan is bit-exact; the Mac's
1931        // default backend is native Metal without a batch graph anyway.
1932        #[cfg(feature = "gpu")]
1933        let metal_wgpu = graph_on && crate::gpu_wgpu::wgpu_backend_is_metal();
1934        #[cfg(not(feature = "gpu"))]
1935        let metal_wgpu = false;
1936        let spec_env = std::env::var("CMF_GRAPH_SPEC").ok();
1937        let spec_wanted = match spec_env.as_deref() {
1938            Some("0") => false,
1939            Some(_) => {
1940                if metal_wgpu {
1941                    tracing::warn!(
1942                        "CMF_GRAPH_SPEC forced on wgpu/Metal: the batched verify graph is not \
1943                         verified on this backend (garbage measured on Qwen3.5-0.8B)"
1944                    );
1945                }
1946                true
1947            }
1948            None => spec_default_ok && !penalized && !metal_wgpu,
1949        };
1950        // Native Metal: the b-row verify graph (`try_batch_graph_metal`)
1951        // stands where the wgpu batch graph stands on discrete cards.
1952        #[cfg(target_os = "macos")]
1953        let metal_graph = crate::gpu::q1_force()
1954            && crate::gpu::enabled_here()
1955            && std::env::var("CMF_GPU_BLOCK").map(|v| v != "0").unwrap_or(true);
1956        #[cfg(not(target_os = "macos"))]
1957        let metal_graph = false;
1958        let graph_spec = self.speculative
1959            && (graph_on || metal_graph)
1960            && self.mtp.is_some()
1961            && task_mask.is_none()
1962            && !self.o1_active()
1963            && spec_sampling_ok
1964            && spec_wanted;
1965        // GDN hybrids sit the fused-pair speculation out by default: the
1966        // recurrence is sequential, so the pair lane cannot parallelize
1967        // (the bench's own Pair line reads fused 1.28x TWO singles on the
1968        // 35B) and the draft's full-vocab head rides on top — measured 2x
1969        // SLOWER end to end (16.1 vs 32.4 tok/s on the 48-core stand).
1970        // CMF_MTP=1 forces it back for study.
1971        let pair_pays = self.gdn_cfg.is_none() || std::env::var("CMF_MTP").as_deref() == Ok("1");
1972        let spec_active = self.speculative
1973            && self.mtp.is_some()
1974            && task_mask.is_none()
1975            && !self.o1_active()
1976            && ((!graph_on && pair_pays && self.sampler_config.temperature < 1e-6) || graph_spec);
1977        // The MTP module is detached during generation so its mutable
1978        // state does not fight the borrow on `self`.
1979        let mut mtp = if spec_active { self.mtp.take() } else { None };
1980        if std::env::var("CMF_MTP_CHAIN_PROBE").is_ok() {
1981            eprintln!(
1982                "mtp-probe gate: spec_active={spec_active} mtp={} speculative={} graph_on={graph_on} temp_ok={}",
1983                mtp.is_some(),
1984                self.speculative,
1985                self.sampler_config.temperature < 1e-6,
1986            );
1987        }
1988        if let Some(m) = &mut mtp {
1989            m.kv.clear();
1990            // The MTP block's own device mirror starts over with its cache.
1991            crate::gpu::graph_kv_reset(self.mtp_kv_id());
1992            self.mtp_graph_mode = None;
1993        }
1994        // Dynamic router detached during decode (same borrow trick as MTP).
1995        // Speculative decode and dynamic routing are mutually exclusive
1996        // for now — the fused-pair path doesn't carry per-token φ.
1997        let mut router = if mtp.is_none() {
1998            self.dyn_router.take()
1999        } else {
2000            None
2001        };
2002        if let Some(r) = &mut router {
2003            r.reset(); // active=backbone, matching a fresh overlay
2004            self.dyn_phi_seen = 0; // fresh φ EMA per generation
2005            let _ = self.set_active_skill(None);
2006        }
2007
2008        let mut all_ids = input_ids.to_vec();
2009        let mut generated = 0usize;
2010        let mut finish_reason = "max_tokens".to_string();
2011        let mut drafted = 0usize;
2012        let mut accepted = 0usize;
2013        let mut confidence: Vec<f32> = Vec::new();
2014        let trace_on = self.trace;
2015        let calib_temp = self.calib_temp;
2016        let mut traces: Vec<TokenTrace> = Vec::new();
2017
2018        // ── Prefill: forward each prompt token once, KEEP the last hidden.
2019        //    Dense prefill runs in fused pairs (weights streamed once per
2020        //    two positions — bit-identical to sequential, proven by the
2021        //    pair tests). With MTP: warm the draft head on
2022        //    (hidden_p, token_{p+1}) pairs.
2023        let mut hidden = vec![0.0f32; self.hidden_size];
2024        let mut pos = reuse_from;
2025        // lm_head-in-graph is only sound when the very next logits
2026        // consumer is this loop's own (MTP and skill routing interleave
2027        // other forwards / can swap lm_head between forward and sample).
2028        // CMF_GPU_LMHEAD=0 keeps lm_head off the graph: the token reads back
2029        // the 8 KB hidden instead of ~1 MB of logits, and the head runs on
2030        // the host. A probe for how much of the graph's fixed per-token cost
2031        // is the logits readback (the layer sweep puts that fixed part at
2032        // 3.88 ms of an 18.5 ms frame).
2033        let fuse_lm = mtp.is_none()
2034            && router.is_none()
2035            && std::env::var("CMF_GPU_LMHEAD").as_deref() != Ok("0");
2036        self.graph_logits = None;
2037        self.graph_want_logits = false;
2038        let _tpf = std::time::Instant::now();
2039        let batch_k = std::env::var("CMF_BATCH_K")
2040            .ok()
2041            .and_then(|v| v.parse::<usize>().ok())
2042            .unwrap_or(0);
2043        // DeepSeek-V4 owns a separate hyper-connection stack. Route it
2044        // before the generic prefill choices: those correctly reject an
2045        // empty `weights.layers`, but their final per-position fallback used
2046        // to consume the whole prompt before `dsv4::forward_chunk` could see
2047        // it. The batch implementation therefore existed without a live
2048        // production entry point.
2049        //
2050        // Bounded chunks preserve cancellation responsiveness. Only the
2051        // prompt's final chunk asks for logits; every earlier head projection
2052        // would produce 129 280 values that no caller reads.
2053        while self.dsv4.is_some()
2054            && mtp.is_none()
2055            && pos < input_ids.len()
2056            && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2057        {
2058            let end = (pos + prefill_chunk()).min(input_ids.len());
2059            let ids: Vec<u32> = input_ids[pos..end].to_vec();
2060            let mut lg = Vec::new();
2061            if let Some(b) = &mut self.dsv4 {
2062                let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
2063                crate::dsv4::forward_chunk(
2064                    g,
2065                    layers,
2066                    &cfg,
2067                    st,
2068                    &ids,
2069                    pos,
2070                    &self.inv_freq,
2071                    self.pool.as_deref(),
2072                    &mut lg,
2073                    end == input_ids.len(),
2074                );
2075            }
2076            if end == input_ids.len() {
2077                self.graph_logits = Some(lg);
2078            }
2079            pos = end;
2080            hidden = vec![0.0; self.hidden_size];
2081        }
2082        // With dynamic routing, prefill sequentially so the φ hook fires
2083        // over the PROMPT — the router enters decode with a warm φ (the
2084        // fused-pair path skips the per-layer φ capture). o1 layers
2085        // collect their query trace in both the single and pair paths.
2086        let dyn_prefill = router.is_some();
2087        // q1 hybrids on Metal: the per-position GPU token graph beats
2088        // the CPU chunk-GEMM (whose wall is the sequential scalar GDN
2089        // recurrence), so prefill goes position-by-position through the
2090        // same graph as decode. Pure-attention models keep the batched
2091        // path — there the chunk-GEMM amortization wins.
2092        let graph_prefill = self.graph_prefill_preferred();
2093        // Native Metal, q4tp GDN hybrids: the prompt through the b-row
2094        // rows graph — projections as GEMMs over up to 512 positions, the
2095        // GDN recurrence in registers on the device, K/V rows appended by
2096        // the chunk — instead of one token-graph submit per position (the
2097        // 27B: 8 tok/s → GEMM-bound). The MTP warm-up rows come out of one
2098        // batched run of the block per chunk. Any refusal leaves the rest
2099        // of the prompt to the sequential paths below.
2100        #[cfg(target_os = "macos")]
2101        if task_mask.is_none()
2102            && !dyn_prefill
2103            && crate::gpu::q1_force()
2104            && crate::gpu::enabled_here()
2105            && self.gdn_cfg.is_some()
2106            && self.g3n.is_none()
2107            && input_ids.len() > 8
2108            && std::env::var("CMF_MTP_CHAIN_PROBE").is_err()
2109            && std::env::var("CMF_METAL_PREFILL").as_deref() != Ok("0")
2110        {
2111            let chunk: usize = std::env::var("CMF_METAL_PREFILL_CHUNK")
2112                .ok()
2113                .and_then(|v| v.parse().ok())
2114                .filter(|&v| (16..=512).contains(&v))
2115                .unwrap_or(256);
2116            let hs = self.hidden_size;
2117            let _tp = std::time::Instant::now();
2118            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2119                let end = (pos + chunk).min(input_ids.len());
2120                let Some(hb) = self.prefill_batch_metal(&input_ids[pos..end], pos) else {
2121                    break;
2122                };
2123                if let Some(m) = &mut mtp {
2124                    let n_pairs = if end < input_ids.len() { end - pos } else { end - pos - 1 };
2125                    if n_pairs > 0 {
2126                        let pairs: Vec<(&[f32], u32)> = (0..n_pairs)
2127                            .map(|j| (&hb[j * hs..(j + 1) * hs], input_ids[pos + j + 1]))
2128                            .collect();
2129                        if !self.mtp_warm_batch_metal(m, &pairs, pos) {
2130                            for (j, (h, t)) in pairs.iter().enumerate() {
2131                                let h = h.to_vec();
2132                                let _ = self.mtp_step(m, &h, *t, pos + j);
2133                            }
2134                        }
2135                    }
2136                }
2137                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2138                pos = end;
2139            }
2140            if std::env::var("CMF_PREFILL_PROF").is_ok() {
2141                eprintln!(
2142                    "metal-prefill: {} of {} tokens in {:.1} ms",
2143                    pos,
2144                    input_ids.len(),
2145                    _tp.elapsed().as_secs_f64() * 1e3
2146                );
2147            }
2148        }
2149        if task_mask.is_none()
2150            && !dyn_prefill
2151            && !graph_prefill
2152            && self.can_prefill_batched()
2153            && self.g3n.is_none()
2154            && input_ids.len() > 2
2155        {
2156            // Production prefill = the same chunked prefill-GEMM that
2157            // bench/PPL measure (roadmap §3 P0: generation used to warm
2158            // the prompt with the slower pair path — the published
2159            // prefill number didn't match real TTFT). MTP warm-up reads
2160            // each position's hidden straight from the chunk result.
2161            let chunk = prefill_chunk();
2162            let hs = self.hidden_size;
2163            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2164                let end = (pos + chunk).min(input_ids.len());
2165                let hb = self.prefill_batch(&input_ids[pos..end], pos);
2166                if let Some(m) = &mut mtp {
2167                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2168                        .ok()
2169                        .and_then(|v| v.parse().ok())
2170                        .unwrap_or(0);
2171                    for p in pos..end {
2172                        if p + 1 < input_ids.len() {
2173                            if probe >= 1 && p + 2 < input_ids.len() {
2174                                // Teacher-forced chain acceptance (see the
2175                                // tail loop's twin): the warm-up row stays,
2176                                // the chain's rows roll back.
2177                                let (d1, mut hx) = self.mtp_step_h(
2178                                    m,
2179                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2180                                    input_ids[p + 1],
2181                                    p,
2182                                );
2183                                let mut ok = d1 == input_ids[p + 2];
2184                                Self::chain_probe_note(0, ok);
2185                                let mut d_prev = d1;
2186                                let mut extra = 0usize;
2187                                for j in 1..probe {
2188                                    if p + 2 + j >= input_ids.len() {
2189                                        break;
2190                                    }
2191                                    let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, p + 1 + j);
2192                                    extra += 1;
2193                                    ok = ok && dj == input_ids[p + 2 + j];
2194                                    Self::chain_probe_note(j, ok);
2195                                    d_prev = dj;
2196                                    hx = hj;
2197                                }
2198                                m.kv.truncate_last(extra);
2199                            } else {
2200                                let _ = self.mtp_step(
2201                                    m,
2202                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2203                                    input_ids[p + 1],
2204                                    p,
2205                                );
2206                            }
2207                        }
2208                    }
2209                }
2210                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2211                pos = end;
2212            }
2213        }
2214        let pair_off = std::env::var("CMF_PAIR").is_ok_and(|v| v == "0");
2215        if task_mask.is_none()
2216            && !dyn_prefill
2217            && !graph_prefill
2218            && !pair_off
2219            && self.pair_supported()
2220        {
2221            while pos + 1 < input_ids.len()
2222                && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2223            {
2224                let e1 = self.embed_single(input_ids[pos]);
2225                let e2 = self.embed_single(input_ids[pos + 1]);
2226                let (h1, h2) = self.forward_pair(&e1, &e2, pos);
2227                // Both prefill tokens are real → commit lane-2 states.
2228                self.commit_linear_scratch();
2229                if let Some(m) = &mut mtp {
2230                    let _ = self.mtp_step(m, &h1, input_ids[pos + 1], pos);
2231                    if pos + 2 < input_ids.len() {
2232                        let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2233                            .ok()
2234                            .and_then(|v| v.parse().ok())
2235                            .unwrap_or(0);
2236                        if probe >= 1 && pos + 3 < input_ids.len() {
2237                            // Same teacher-forced chain table as the tail
2238                            // loop below, fed from the pair path that owns
2239                            // most prefill positions.
2240                            let (d1, mut hx) = self.mtp_step_h(m, &h2, input_ids[pos + 2], pos + 1);
2241                            let mut ok = d1 == input_ids[pos + 3];
2242                            Self::chain_probe_note(0, ok);
2243                            let mut d_prev = d1;
2244                            let mut extra = 0usize;
2245                            for j in 1..probe {
2246                                if pos + 3 + j >= input_ids.len() {
2247                                    break;
2248                                }
2249                                let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 2 + j);
2250                                extra += 1;
2251                                ok = ok && dj == input_ids[pos + 3 + j];
2252                                Self::chain_probe_note(j, ok);
2253                                d_prev = dj;
2254                                hx = hj;
2255                            }
2256                            m.kv.truncate_last(extra);
2257                        } else {
2258                            let _ = self.mtp_step(m, &h2, input_ids[pos + 2], pos + 1);
2259                        }
2260                    }
2261                }
2262                hidden = h2;
2263                pos += 2;
2264            }
2265        }
2266        // Batched GPU prefill for the wgpu decode graph (GDN hybrids): K prompt
2267        // positions per submit — projections/FFN as GEMMs (weight once per K),
2268        // attention/GDN looped inside — instead of one whole-graph submit per
2269        // position. Falls through to the per-position graph on any refusal.
2270        // Batched prefill is opt-in (CMF_BATCH_K>0). Default 0 = per-position
2271        // graph prefill. (Steady-state decode is provably identical either way —
2272        // token-graph submit and lm_head both unchanged — so this only trades
2273        // prefill wall.)
2274        if batch_k > 0
2275            && graph_prefill
2276            && task_mask.is_none()
2277            && !self.o1_active()
2278            && mtp.is_none()
2279            && !dyn_prefill
2280            && pos + 1 < input_ids.len()
2281        {
2282            let hs = self.hidden_size;
2283            let chunk = batch_k;
2284            while pos < input_ids.len() {
2285                let end = (pos + chunk).min(input_ids.len());
2286                let bk = end - pos;
2287                let mut hiddens = vec![0f32; bk * hs];
2288                for (j, &id) in input_ids[pos..end].iter().enumerate() {
2289                    hiddens[j * hs..(j + 1) * hs].copy_from_slice(&self.embed_single(id));
2290                }
2291                let positions: Vec<usize> = (pos..end).collect();
2292                let t_chunk = std::time::Instant::now();
2293                let ok_b = self.try_batch_graph_wgpu(&mut hiddens, &positions, bk, None);
2294                if std::env::var("CMF_GRAPH_PROF").is_ok() {
2295                    let ms = t_chunk.elapsed().as_secs_f64() * 1000.0;
2296                    eprintln!(
2297                        "batch-chunk: k={bk} ok={ok_b} {ms:.1} ms ({:.1} tok/s)",
2298                        bk as f64 / (ms / 1000.0)
2299                    );
2300                }
2301                {
2302                    use std::sync::atomic::{AtomicBool, Ordering};
2303                    static SAID: AtomicBool = AtomicBool::new(false);
2304                    if !SAID.swap(true, Ordering::Relaxed) {
2305                        if ok_b {
2306                            tracing::info!("batched prefill: ACTIVE (k={bk})");
2307                        } else {
2308                            tracing::warn!("batched prefill declined — per-position graph");
2309                        }
2310                    }
2311                }
2312                if ok_b {
2313                    hidden.copy_from_slice(&hiddens[(bk - 1) * hs..]);
2314                    pos = end;
2315                } else {
2316                    break; // unsupported → per-position graph handles the rest
2317                }
2318            }
2319        }
2320        while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2321            self.graph_want_logits = fuse_lm && pos + 1 == input_ids.len();
2322            hidden = self.forward_layers(&self.embed_single(input_ids[pos]), pos, task_mask);
2323            if let Some(m) = &mut mtp {
2324                if pos + 1 < input_ids.len() {
2325                    // `CMF_MTP_CHAIN_PROBE=k`: teacher-forced acceptance of a
2326                    // CHAINED draft — iterate the head on its own hidden k
2327                    // deep and score every depth against the prompt's real
2328                    // continuation. The economics of a k-token speculative
2329                    // round stand or fall on this table.
2330                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2331                        .ok()
2332                        .and_then(|v| v.parse().ok())
2333                        .unwrap_or(0);
2334                    if probe >= 1 && pos + 2 < input_ids.len() {
2335                        let (d1, mut hx) = self.mtp_step_h(m, &hidden, input_ids[pos + 1], pos);
2336                        let mut ok = d1 == input_ids[pos + 2];
2337                        Self::chain_probe_note(0, ok);
2338                        let mut d_prev = d1;
2339                        let mut extra = 0usize;
2340                        for j in 1..probe {
2341                            if pos + 2 + j >= input_ids.len() {
2342                                break;
2343                            }
2344                            let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 1 + j);
2345                            extra += 1;
2346                            ok = ok && dj == input_ids[pos + 2 + j];
2347                            Self::chain_probe_note(j, ok);
2348                            d_prev = dj;
2349                            hx = hj;
2350                        }
2351                        // The chain's rows are speculation, not the prompt —
2352                        // keep only the warmup row the plain path would add.
2353                        m.kv.truncate_last(extra);
2354                    } else {
2355                        let _ = self.mtp_step(m, &hidden, input_ids[pos + 1], pos);
2356                    }
2357                }
2358            }
2359            pos += 1;
2360        }
2361        if std::env::var("CMF_PREFILL_PROF").is_ok() {
2362            eprintln!(
2363                "prefill: {} tokens in {:.1} ms (batch_k={batch_k})",
2364                input_ids.len(),
2365                _tpf.elapsed().as_secs_f64() * 1000.0
2366            );
2367        }
2368        // Cancelled mid-prefill: the cache holds a partial prompt —
2369        // drop the reuse history and return an empty generation.
2370        if self
2371            .cancel
2372            .swap(false, std::sync::atomic::Ordering::Relaxed)
2373        {
2374            self.kv_history.clear();
2375            if let Some(m) = mtp {
2376                self.mtp = Some(m);
2377            }
2378            return Ok(GenerateResult {
2379                text: String::new(),
2380                token_ids: Vec::new(),
2381                prompt_tokens: input_ids.len(),
2382                tokens_generated: 0,
2383                finish_reason: "cancelled".to_string(),
2384                mtp_drafted: 0,
2385                mtp_accepted: 0,
2386                token_confidence: Vec::new(),
2387                traces: Vec::new(),
2388            });
2389        }
2390
2391        // Prompt absorbed → freeze the o1 layers' skeletons; from here
2392        // every decode step on those layers is O(W + m·dv + m²).
2393        self.o1_seal();
2394
2395        // Commit one token: push, check EOS, stream. Returns false = stop.
2396        macro_rules! commit {
2397            ($id:expr) => {{
2398                all_ids.push($id);
2399                generated += 1;
2400                if self.tokenizer.is_eos($id) {
2401                    finish_reason = "stop".to_string();
2402                    false
2403                } else {
2404                    let token_text = self.tokenizer.decode_token($id);
2405                    let mut go = true;
2406                    if let Some(ref mut cb) = on_token {
2407                        if !cb(&token_text) {
2408                            finish_reason = "cancelled".to_string();
2409                            go = false;
2410                        }
2411                    }
2412                    go
2413                }
2414            }};
2415        }
2416
2417        // Speculation is decided by MEASUREMENT, not by an acceptance
2418        // model. A k=4 round costs ~3.8 plain tokens on the 5090 (draft
2419        // 6.6 + verify 66.6 + commit 4.8 ms against a 20.6 ms token), so it
2420        // pays only when the head lands ~2.8 of 4 — predictable text (code,
2421        // structured output) does, free prose often does not, and the
2422        // ratio at which the two cross depends on the card and the context
2423        // depth. So: four speculative rounds timed, then eight plain
2424        // tokens timed, and the faster arm runs until a re-check 256
2425        // tokens later (context growth moves the balance). The trial
2426        // costs at most a few tokens of the slower arm per 256.
2427        let mut spec_trial = SpecTrial::Spec {
2428            t0: std::time::Instant::now(),
2429            gen0: generated,
2430            rounds: 0,
2431        };
2432        let mut spec_mon = SpecMon::default();
2433        let mut spec_watchdog_off = false;
2434        // ── Decode ──
2435        let mut next_pos = input_ids.len();
2436        'decode: while generated < max_tokens {
2437            if self
2438                .cancel
2439                .swap(false, std::sync::atomic::Ordering::Relaxed)
2440            {
2441                finish_reason = "cancelled".to_string();
2442                break 'decode;
2443            }
2444            // A rejected speculative draft already drew this position's
2445            // token from the residual distribution (graph_spec_step); it
2446            // is committed as-is — sampling again from the row's logits
2447            // would bias the stream toward the target's mode.
2448            let forced = self.spec_forced.take();
2449            let mut logits = match (forced, self.graph_logits.take()) {
2450                (Some(_), _) => Vec::new(),
2451                (None, Some(lg)) => lg,
2452                (None, None) => {
2453                    inference::rms_norm_into(
2454                        &hidden,
2455                        &self.weights.final_norm,
2456                        self.rms_eps,
2457                        self.norm_style,
2458                        &mut self.ws.n1,
2459                    );
2460                    self.lm_head_forward(&self.ws.n1)
2461                }
2462            };
2463            // CMF_LOGIT_DUMP=<path>: the first decode step's hidden + logits
2464            // as raw f32 (hidden first) — cross-backend numerics diffing.
2465            if generated
2466                == std::env::var("CMF_LOGIT_DUMP_STEP")
2467                    .ok()
2468                    .and_then(|v| v.parse().ok())
2469                    .unwrap_or(0)
2470            {
2471                if let Ok(path) = std::env::var("CMF_LOGIT_DUMP") {
2472                    let mut bytes: Vec<u8> = Vec::with_capacity((hidden.len() + logits.len()) * 4);
2473                    for v in hidden.iter().chain(logits.iter()) {
2474                        bytes.extend_from_slice(&v.to_le_bytes());
2475                    }
2476                    let _ = std::fs::write(&path, &bytes);
2477                }
2478            }
2479            let t_next = match forced {
2480                Some(c) => c,
2481                None => sampler::sample_with_scratch_pool(
2482                    &logits,
2483                    &self.sampler_config,
2484                    &all_ids,
2485                    &mut self.rng,
2486                    &mut self.sampler_scratch,
2487                    self.pool.as_deref(),
2488                ),
2489            };
2490            if self.confidence_on {
2491                confidence.push(if logits.is_empty() {
2492                    0.0
2493                } else {
2494                    sampler::top1_prob_pool(
2495                        self.pool.as_deref(),
2496                        &mut self.sampler_scratch,
2497                        &logits,
2498                        t_next,
2499                        calib_temp,
2500                    )
2501                });
2502            }
2503            if !logits.is_empty() {
2504                attention::recycle_buf(&mut logits);
2505            }
2506            if trace_on {
2507                // active_skill = the overlay in force while this token was
2508                // generated; recon/switched are filled after the post-emit
2509                // routing eval below (freshest coherence for this token).
2510                let skill = router.as_ref().and_then(|r| r.active_id());
2511                traces.push(TokenTrace {
2512                    t: generated,
2513                    token_id: t_next,
2514                    confidence: confidence.last().copied().unwrap_or(0.0),
2515                    active_skill: skill,
2516                    recon: None,
2517                    switched: false,
2518                });
2519            }
2520            if !commit!(t_next) {
2521                break 'decode;
2522            }
2523            if generated >= max_tokens {
2524                break 'decode;
2525            }
2526
2527            if self.kv_cache.needs_eviction() {
2528                // Say it ONCE, loudly: past this point the model keeps
2529                // talking but has lost half its context, and on a GDN
2530                // hybrid the graph's device state goes stale on top. The
2531                // Qwen3.8 bring-up spent a day reading this cliff as
2532                // three different model bugs.
2533                static SAID: std::sync::Once = std::sync::Once::new();
2534                SAID.call_once(|| {
2535                    tracing::warn!(
2536                        "KV cache full at {} positions — evicting half; quality \
2537                         will degrade. Raise CMF_MAX_SEQ.",
2538                        self.kv_cache.max_seq_len,
2539                    );
2540                });
2541                let keep = (self.kv_cache.max_seq_len / 2).max(1);
2542                self.kv_cache.evict(keep);
2543            }
2544
2545            // Advance the speculation trial: plain-phase accounting and
2546            // the periodic re-check happen here, on every token.
2547            if graph_spec {
2548                match spec_trial {
2549                    SpecTrial::Plain { t0, gen0 } if generated >= gen0 + 8 => {
2550                        spec_mon.plain_ms =
2551                            t0.elapsed().as_secs_f64() * 1e3 / (generated - gen0) as f64;
2552                        let keep = spec_mon.pays();
2553                        tracing::info!(
2554                            "speculation trial: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
2555                            spec_mon.tokens,
2556                            spec_mon.round_ms,
2557                            spec_mon.plain_ms,
2558                            if keep { "speculating" } else { "plain" }
2559                        );
2560                        spec_mon.fails = 0;
2561                        spec_trial = SpecTrial::Decided {
2562                            spec: keep,
2563                            recheck_at: if keep { usize::MAX } else { generated + 128 },
2564                        };
2565                    }
2566                    SpecTrial::Decided { recheck_at, .. } if generated >= recheck_at => {
2567                        spec_mon.n = 0;
2568                        spec_trial = SpecTrial::Spec {
2569                            t0: std::time::Instant::now(),
2570                            gen0: generated,
2571                            rounds: 0,
2572                        };
2573                    }
2574                    _ => {}
2575                }
2576                spec_watchdog_off = matches!(
2577                    spec_trial,
2578                    SpecTrial::Plain { .. } | SpecTrial::Decided { spec: false, .. }
2579                );
2580            }
2581            match &mut mtp {
2582                // ── Graph speculation: chain-draft, batch-verify on device ──
2583                #[cfg(feature = "gpu")]
2584                Some(m)
2585                    if graph_spec
2586                        && !spec_watchdog_off
2587                        && generated + 1 < max_tokens
2588                        && next_pos > 0 =>
2589                {
2590                    let t_round = std::time::Instant::now();
2591                    if let Some((extra, n_pos, new_h)) = self.graph_spec_step(
2592                        m,
2593                        &hidden,
2594                        t_next,
2595                        next_pos,
2596                        &mut drafted,
2597                        &mut accepted,
2598                        &mut all_ids,
2599                    ) {
2600                        next_pos = n_pos;
2601                        hidden = new_h;
2602                        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
2603                            eprintln!(
2604                                "spec-round wall {:.1} ms → {} tokens",
2605                                t_round.elapsed().as_secs_f64() * 1e3,
2606                                extra.len() + 1
2607                            );
2608                        }
2609                        // One speculative round done: the monitor counts it
2610                        // (round 1 untimed — it pays the batch scratch and
2611                        // the draft mirror), and the trial advances.
2612                        spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, extra.len() + 1);
2613                        // the round's tokens land in `generated` below; the
2614                        // plain phase must start counting AFTER them
2615                        spec_trial = Self::spec_trial_round(
2616                            spec_trial,
2617                            &mut spec_mon,
2618                            generated + extra.len() + 1,
2619                        );
2620                        let mut stopped = false;
2621                        for &id in &extra {
2622                            if self.confidence_on {
2623                                confidence.push(0.0);
2624                            }
2625                            if !commit!(id) {
2626                                stopped = true;
2627                                break;
2628                            }
2629                        }
2630                        if stopped {
2631                            break 'decode;
2632                        }
2633                        continue 'decode;
2634                    }
2635                    // Declined (batch graph refused): plain forward below —
2636                    // and a round that produced one token for the trial's
2637                    // ledger, so a graph that keeps refusing is measured out
2638                    // like a head that keeps missing (it was spinning
2639                    // forever on a file whose batch graph declines).
2640                    // A declined round is not a cheap one-token round — it
2641                    // is a verify that does not exist for this file (a
2642                    // healed q8_2f tail measured 760 drafts, 0 accepted, 33
2643                    // against 48.8 tok/s while the monitor called the draft
2644                    // alone "paying"). Count it as the losing streak in one.
2645                    spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, 1);
2646                    spec_mon.tokens = 0.0;
2647                    spec_mon.fails = 3;
2648                    spec_trial = Self::spec_trial_round(spec_trial, &mut spec_mon, generated + 1);
2649                    hidden = self.forward_layers(&self.embed_single(t_next), next_pos, task_mask);
2650                    next_pos += 1;
2651                    continue 'decode;
2652                }
2653                // ── Speculative: draft t+2, verify in a fused pair ──
2654                Some(m) if !graph_spec && generated + 1 < max_tokens => {
2655                    let draft = self.mtp_step(m, &hidden, t_next, next_pos - 1);
2656                    drafted += 1;
2657                    let emb1 = self.embed_single(t_next);
2658                    let emb2 = self.embed_single(draft);
2659                    let (h1, h2) = self.forward_pair(&emb1, &emb2, next_pos);
2660
2661                    inference::rms_norm_into(
2662                        &h1,
2663                        &self.weights.final_norm,
2664                        self.rms_eps,
2665                        self.norm_style,
2666                        &mut self.ws.n1,
2667                    );
2668                    let mut logits1 = self.lm_head_forward(&self.ws.n1);
2669                    let t_after = sampler::sample_with_scratch_pool(
2670                        &logits1,
2671                        &self.sampler_config,
2672                        &all_ids,
2673                        &mut self.rng,
2674                        &mut self.sampler_scratch,
2675                        self.pool.as_deref(),
2676                    );
2677                    if self.confidence_on {
2678                        confidence.push(sampler::top1_prob_pool(
2679                            self.pool.as_deref(),
2680                            &mut self.sampler_scratch,
2681                            &logits1,
2682                            t_after,
2683                            calib_temp,
2684                        ));
2685                    }
2686                    attention::recycle_buf(&mut logits1);
2687                    if trace_on {
2688                        // Speculative decode is mutually exclusive with
2689                        // dynamic routing (router is None here) — no skill.
2690                        traces.push(TokenTrace {
2691                            t: generated,
2692                            token_id: t_after,
2693                            confidence: confidence.last().copied().unwrap_or(0.0),
2694                            active_skill: None,
2695                            recon: None,
2696                            switched: false,
2697                        });
2698                    }
2699                    let stop = !commit!(t_after);
2700
2701                    if t_after == draft {
2702                        accepted += 1;
2703                        self.commit_linear_scratch();
2704                        let _ = self.mtp_step(m, &h1, t_after, next_pos);
2705                        hidden = h2;
2706                        next_pos += 2;
2707                    } else {
2708                        // The draft lane is wrong: roll its KV entry back.
2709                        for layer in &mut self.kv_cache.layers {
2710                            layer.truncate_last(1);
2711                        }
2712                        if !stop {
2713                            let _ = self.mtp_step(m, &h1, t_after, next_pos);
2714                            hidden = self.forward_layers(
2715                                &self.embed_single(t_after),
2716                                next_pos + 1,
2717                                None,
2718                            );
2719                        }
2720                        next_pos += 2;
2721                    }
2722                    if stop {
2723                        break 'decode;
2724                    }
2725                }
2726                // ── Vanilla: forward the sampled token ──
2727                _ => {
2728                    // ── DeepSeek-V4 speculative decode (CMF_DSV4_SPEC=1):
2729                    // draft five on the card, verify batched, commit the
2730                    // accepted prefix. Greedy only; a rejected token's state
2731                    // is restored and replayed, so output equals the walk. ──
2732                    #[cfg(feature = "gpu")]
2733                    if Self::dsv4_spec_on() && self.dsv4.is_some() {
2734                        static SAID: std::sync::Once = std::sync::Once::new();
2735                        SAID.call_once(|| {
2736                            eprintln!(
2737                                "dsv4-spec гейт: mtp={} mask={} router={} trace={} temp={} rep={} ",
2738                                !self.dsv4_mtp.is_empty(),
2739                                task_mask.is_none(),
2740                                router.is_none(),
2741                                !trace_on,
2742                                self.sampler_config.temperature < 1e-6,
2743                                self.sampler_config.repetition_penalty == 1.0,
2744                            );
2745                        });
2746                    }
2747                    #[cfg(feature = "gpu")]
2748                    if Self::dsv4_spec_on()
2749                        && self.dsv4.is_some()
2750                        && !self.dsv4_mtp.is_empty()
2751                        && task_mask.is_none()
2752                        && router.is_none()
2753                        && !trace_on
2754                        && self.sampler_config.temperature < 1e-6
2755                        && self.sampler_config.repetition_penalty == 1.0
2756                        && generated + 1 < max_tokens
2757                        && all_ids.len() >= 2
2758                    {
2759                        let tip_token = all_ids[all_ids.len() - 2];
2760                        if let Some((extra, n_pos)) = self.dsv4_spec_step(
2761                            tip_token,
2762                            t_next,
2763                            next_pos,
2764                            &mut drafted,
2765                            &mut accepted,
2766                        ) {
2767                            next_pos = n_pos;
2768                            let mut stopped = false;
2769                            for &id in &extra {
2770                                if self.confidence_on {
2771                                    confidence.push(0.0);
2772                                }
2773                                if !commit!(id) {
2774                                    stopped = true;
2775                                    break;
2776                                }
2777                            }
2778                            if stopped {
2779                                break 'decode;
2780                            }
2781                            continue 'decode;
2782                        }
2783                    }
2784                    self.graph_want_logits = fuse_lm;
2785                    // Greedy burst (CMF_MULTISTEP, default 8, 1 = off): while
2786                    // nothing observes per-token state — pure argmax sampling,
2787                    // no router/trace/confidence/mask — decode k tokens per
2788                    // submit and commit them wholesale. The trailing normal
2789                    // forward leaves logits for the loop top, as always.
2790                    let mut t_fwd = t_next;
2791                    let pure_greedy = self.sampler_config.temperature < 1e-6
2792                        && self.sampler_config.repetition_penalty == 1.0
2793                        && self.sampler_config.suppress_tokens.is_empty();
2794                    // Off by default: at every k the burst measured at or
2795                    // below the plain path on this graph shape (k=1 loses
2796                    // the argmax dispatches vs a 1 MB readback, k>=8 loses
2797                    // inter-step drains vs the saved sync). Experimental.
2798                    let burst_k = std::env::var("CMF_MULTISTEP")
2799                        .ok()
2800                        .and_then(|v| v.parse::<usize>().ok())
2801                        .unwrap_or(0);
2802                    if pure_greedy
2803                        && burst_k >= 1
2804                        && fuse_lm
2805                        && task_mask.is_none()
2806                        && router.is_none()
2807                        && !trace_on
2808                        && !self.confidence_on
2809                    {
2810                        let mut stopped = false;
2811                        loop {
2812                            let room = max_tokens.saturating_sub(generated);
2813                            if room <= 2 {
2814                                break;
2815                            }
2816                            let k = burst_k.min(room - 1);
2817                            if k < 1 {
2818                                break;
2819                            }
2820                            let Some(ids) = self.try_multi_burst(t_fwd, next_pos, k) else {
2821                                break;
2822                            };
2823                            next_pos += k;
2824                            for &id in &ids {
2825                                if !commit!(id) {
2826                                    stopped = true;
2827                                    break;
2828                                }
2829                            }
2830                            if stopped {
2831                                break;
2832                            }
2833                            t_fwd = *ids.last().unwrap();
2834                        }
2835                        if stopped {
2836                            break 'decode;
2837                        }
2838                    }
2839                    hidden = self.forward_layers(&self.embed_single(t_fwd), next_pos, task_mask);
2840                    next_pos += 1;
2841                    // Dynamic routing: the forward updated φ; ask the
2842                    // router whether to switch skills before the next token.
2843                    if let Some(r) = &mut router {
2844                        let phi = self.dyn_phi_ema.clone();
2845                        let decision = r.step(&phi, generated);
2846                        if let Some(new_active) = decision {
2847                            let _ = self.set_active_skill(new_active);
2848                        }
2849                        // Backfill this token's coherence + switch flag from
2850                        // the just-run eval (freshest measured values).
2851                        if trace_on {
2852                            if let Some(last) = traces.last_mut() {
2853                                let e = r.last_best_e();
2854                                last.recon = e.is_finite().then_some(e);
2855                                last.switched = decision.is_some();
2856                            }
2857                        }
2858                    }
2859                }
2860            }
2861        }
2862
2863        self.graph_want_logits = false;
2864        self.graph_logits = None;
2865        // Restore backbone overlay and re-attach the router for reuse.
2866        if router.is_some() {
2867            let _ = self.set_active_skill(None);
2868        }
2869        self.dyn_router = router.or(self.dyn_router.take());
2870        self.mtp = mtp.or(self.mtp.take());
2871
2872        let output_ids = &all_ids[input_ids.len()..];
2873        // Forwarded = prompt + all generated but the LAST sampled token
2874        // (emitted without being fed back). Exact only without MTP —
2875        // reuse is gated off when MTP is active.
2876        let forwarded = input_ids.len() + output_ids.len().saturating_sub(1);
2877        self.kv_history = all_ids[..forwarded.min(all_ids.len())].to_vec();
2878        confidence.truncate(output_ids.len()); // guard against any overshoot
2879        traces.truncate(output_ids.len());
2880        Ok(GenerateResult {
2881            text: self.tokenizer.decode(output_ids),
2882            token_ids: output_ids.to_vec(),
2883            prompt_tokens: input_ids.len(),
2884            tokens_generated: generated,
2885            finish_reason,
2886            mtp_drafted: drafted,
2887            mtp_accepted: accepted,
2888            token_confidence: confidence,
2889            traces,
2890        })
2891    }
2892
2893    /// One MTP step: feed `(hidden_p, token_{p+1})` into the draft head,
2894    /// advance its KV cache at position `p`, return the drafted token
2895    /// for position `p+2`.
2896    fn mtp_step(
2897        &mut self,
2898        m: &mut MtpModule,
2899        hidden: &[f32],
2900        next_token: u32,
2901        position: usize,
2902    ) -> u32 {
2903        self.mtp_step_h(m, hidden, next_token, position).0
2904    }
2905
2906    /// Tally for `CMF_MTP_CHAIN_PROBE`: per depth, how often the CHAIN is
2907    /// still an exact prefix of the real continuation. Printed every 128
2908    /// depth-0 samples so a killed run still shows its table.
2909    fn chain_probe_note(depth: usize, prefix_ok: bool) {
2910        use std::sync::Mutex;
2911        static T: Mutex<Vec<(u64, u64)>> = Mutex::new(Vec::new());
2912        let mut t = T.lock().unwrap();
2913        if t.len() <= depth {
2914            t.resize(depth + 1, (0, 0));
2915        }
2916        t[depth].0 += 1;
2917        t[depth].1 += prefix_ok as u64;
2918        if depth == 0 && t[0].0 % 128 == 0 {
2919            let line: Vec<String> = t
2920                .iter()
2921                .enumerate()
2922                .map(|(d, (n, k))| {
2923                    format!(
2924                        "d{}={:.0}%({n})",
2925                        d + 1,
2926                        100.0 * *k as f64 / (*n).max(1) as f64
2927                    )
2928                })
2929                .collect();
2930            eprintln!("mtp-chain: {}", line.join(" "));
2931        }
2932    }
2933
2934    /// `mtp_step` that also hands back the block's own output hidden — the
2935    /// state a CHAINED draft feeds the next step, the way a multi-token
2936    /// speculative round iterates the head on itself.
2937    /// One MTP block step from (trunk hidden, token): the head's LOGITS
2938    /// and the block's own hidden for chaining. The draft is argmax of the
2939    /// logits on the greedy path and a draw from their post-chain
2940    /// distribution on the sampling path.
2941    fn mtp_step_hl(
2942        &mut self,
2943        m: &mut MtpModule,
2944        hidden: &[f32],
2945        next_token: u32,
2946        position: usize,
2947    ) -> (Vec<f32>, Vec<f32>) {
2948        // The graph arm: the MTP block as a one-layer token graph with the
2949        // head fused — device attention over the block's own KV mirror,
2950        // one submit for block + head, hidden and logits back together.
2951        // Decided once per generation (see `mtp_graph_mode`).
2952        #[cfg(target_os = "macos")]
2953        if self.mtp_graph_mode != Some(false) && crate::gpu::q1_force() {
2954            if let Some(r) = self.mtp_step_metal(m, hidden, next_token, position, true) {
2955                self.mtp_graph_mode = Some(true);
2956                return r;
2957            }
2958            self.mtp_graph_mode = Some(false);
2959        }
2960        #[cfg(feature = "gpu")]
2961        if self.mtp_graph_mode != Some(false) {
2962            if let Some(r) = self.mtp_step_graph(m, hidden, next_token, position) {
2963                self.mtp_graph_mode = Some(true);
2964                return r;
2965            }
2966            if self.mtp_graph_mode == Some(true) {
2967                // The graph carried this generation's MTP KV and just
2968                // declined — the CPU cache is not current. A draft from
2969                // stale attention is still only a draft (verify decides),
2970                // but say so once.
2971                tracing::warn!("mtp graph declined mid-run — draft falls to the per-op path");
2972            }
2973            self.mtp_graph_mode = Some(false);
2974        }
2975        // fc concat order is [enorm(embed); hnorm(hidden)] — EMBEDDING
2976        // FIRST. Verified by the oracle (converter/mtp_oracle.py):
2977        // [emb;hid] → 45.8% acceptance, [hid;emb] → 0.00%.
2978        let e = self.embed_single(next_token);
2979        let mut cat = vec![0.0f32; 2 * self.hidden_size];
2980        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
2981        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
2982        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
2983        let mut x = vec![0.0f32; self.hidden_size];
2984        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
2985
2986        // One standard transformer block over the MTP's own cache.
2987        let lw = &m.layer;
2988        inference::rms_norm_into(
2989            &x,
2990            &lw.input_norm,
2991            self.rms_eps,
2992            self.norm_style,
2993            &mut self.ws.n1,
2994        );
2995        let attn = match &lw.attn {
2996            // MLA models carry no MTP head; this path cannot see them.
2997            AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
2998            AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
2999            AttnKind::Full {
3000                wq,
3001                wk,
3002                wv,
3003                wo,
3004                q_norm,
3005                k_norm,
3006                output_gate,
3007                softplus_gate,
3008                bias,
3009            } => {
3010                let mut cfg = self.attn_cfg(position);
3011                cfg.q_norm = q_norm.as_deref();
3012                cfg.k_norm = k_norm.as_deref();
3013                cfg.output_gate = *output_gate;
3014                cfg.softplus_gate = softplus_gate
3015                    .as_ref()
3016                    .map(|(gate, per_head)| (gate, *per_head));
3017                cfg.bias = bias
3018                    .as_ref()
3019                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
3020                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
3021            }
3022            AttnKind::Linear(_) | AttnKind::LinearGdn(_) | AttnKind::ShortConv(_) => {
3023                unreachable!("MTP block is full attention")
3024            }
3025        };
3026        for (i, &a) in attn.iter().enumerate() {
3027            x[i] += a;
3028        }
3029        inference::rms_norm_into(
3030            &x,
3031            &lw.post_norm,
3032            self.rms_eps,
3033            self.norm_style,
3034            &mut self.ws.p1,
3035        );
3036        let ffn = ffn_forward(&lw.ffn, &self.ws.p1, self.pool.as_deref(), None);
3037        for (i, &f) in ffn.iter().enumerate() {
3038            x[i] += f;
3039        }
3040
3041        inference::rms_norm_into(
3042            &x,
3043            &m.final_norm,
3044            self.rms_eps,
3045            self.norm_style,
3046            &mut self.ws.n1,
3047        );
3048        let lg = self.lm_head_forward(&self.ws.n1);
3049        (lg, x)
3050    }
3051
3052    /// `mtp_step_hl` reduced to the greedy draft: argmax of the head.
3053    fn mtp_step_h(
3054        &mut self,
3055        m: &mut MtpModule,
3056        hidden: &[f32],
3057        next_token: u32,
3058        position: usize,
3059    ) -> (u32, Vec<f32>) {
3060        let (mut lg, x) = self.mtp_step_hl(m, hidden, next_token, position);
3061        let draft = sampler::argmax(&lg);
3062        attention::recycle_buf(&mut lg);
3063        (draft, x)
3064    }
3065
3066    /// One speculative round for the trial: rounds 1..5 of a `Spec` phase
3067    /// advance it (the monitor already averaged this round); after five,
3068    /// the plain phase runs (once — a known plain rate decides at once);
3069    /// a decided speculation keeps re-checking the rule every round and
3070    /// stops after four losing rounds in a row.
3071    fn spec_trial_round(trial: SpecTrial, mon: &mut SpecMon, generated: usize) -> SpecTrial {
3072        match trial {
3073            SpecTrial::Spec { t0, gen0, rounds } => {
3074                let rounds = rounds + 1;
3075                if rounds >= 5 {
3076                    if mon.plain_ms > 0.0 {
3077                        let keep = mon.pays();
3078                        mon.fails = 0;
3079                        tracing::info!(
3080                            "speculation re-check: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
3081                            mon.tokens,
3082                            mon.round_ms,
3083                            mon.plain_ms,
3084                            if keep { "speculating" } else { "plain" }
3085                        );
3086                        SpecTrial::Decided {
3087                            spec: keep,
3088                            recheck_at: if keep { usize::MAX } else { generated + 128 },
3089                        }
3090                    } else {
3091                        SpecTrial::Plain {
3092                            t0: std::time::Instant::now(),
3093                            gen0: generated,
3094                        }
3095                    }
3096                } else {
3097                    SpecTrial::Spec { t0, gen0, rounds }
3098                }
3099            }
3100            SpecTrial::Decided { spec: true, .. } => {
3101                if mon.pays() {
3102                    mon.fails = 0;
3103                    trial
3104                } else {
3105                    mon.fails += 1;
3106                    if mon.fails >= 4 {
3107                        tracing::info!(
3108                            "speculation stopped: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok",
3109                            mon.tokens,
3110                            mon.round_ms,
3111                            mon.plain_ms
3112                        );
3113                        SpecTrial::Decided {
3114                            spec: false,
3115                            recheck_at: generated + 128,
3116                        }
3117                    } else {
3118                        trial
3119                    }
3120                }
3121            }
3122            other => other,
3123        }
3124    }
3125
3126    /// The MTP block's device-mirror id: the trunk's id with a high bit,
3127    /// so the (kv_id, layer) mirror keys never collide.
3128    fn mtp_kv_id(&self) -> u64 {
3129        self.graph_kv_id | (1u64 << 40)
3130    }
3131
3132    /// The MTP block's mirror layer index: 0 — its own kv_id keeps it
3133    /// apart from the trunk, and the BATCH graph (the warm-up path) keys
3134    /// its mirrors at layer 0 with no base of its own, so the draft's
3135    /// token graph must key the same slot.
3136    const MTP_LAYER_BASE: usize = 0;
3137
3138    /// The block's input from (trunk hidden, token): eh_proj · [enorm(e);
3139    /// hnorm(h)] — the same arithmetic the per-op path starts with.
3140    fn mtp_block_input(&mut self, m: &MtpModule, hidden: &[f32], next_token: u32) -> Vec<f32> {
3141        let e = self.embed_single(next_token);
3142        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3143        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3144        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3145        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3146        let mut x = vec![0.0f32; self.hidden_size];
3147        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3148        x
3149    }
3150
3151    /// Is the MTP block graphable at all (device up, full attention
3152    /// without softplus, dense FFN)? The plan itself is built per call.
3153    #[cfg(feature = "gpu")]
3154    fn mtp_graph_ok(&self, m: &MtpModule) -> bool {
3155        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0") {
3156            return false;
3157        }
3158        if !crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode)
3159            || !crate::gpu::enabled_here()
3160            || self.attn_softcap > 0.0
3161            || self.attention_heads_per_layer.is_some()
3162        {
3163            return false;
3164        }
3165        matches!(
3166            &m.layer.attn,
3167            AttnKind::Full {
3168                softplus_gate: None,
3169                ..
3170            }
3171        ) && matches!(&m.layer.ffn, FfnKind::Dense(_))
3172    }
3173
3174    /// One MTP block step on the wgpu token graph: block + fused head in
3175    /// one submit, the block hidden and the logits read back together.
3176    /// None = the graph cannot take this block (softplus gate, non-dense
3177    /// FFN, unquantized head, no device) — the caller keeps the per-op
3178    /// path for the whole generation.
3179    #[cfg(feature = "gpu")]
3180    fn mtp_step_graph(
3181        &mut self,
3182        m: &mut MtpModule,
3183        hidden: &[f32],
3184        next_token: u32,
3185        position: usize,
3186    ) -> Option<(Vec<f32>, Vec<f32>)> {
3187        if !self.mtp_graph_ok(m) {
3188            return None;
3189        }
3190        let lw = &m.layer;
3191        let AttnKind::Full {
3192            wq,
3193            wk,
3194            wv,
3195            wo,
3196            q_norm,
3197            k_norm,
3198            output_gate,
3199            softplus_gate,
3200            bias,
3201        } = &lw.attn
3202        else {
3203            return None;
3204        };
3205        if softplus_gate.is_some() {
3206            return None;
3207        }
3208        let FfnKind::Dense(d) = &lw.ffn else {
3209            return None;
3210        };
3211        // The block's input first: it borrows `self` mutably (embed scratch,
3212        // pool), the plan below borrows the weights immutably.
3213        let mut x = self.mtp_block_input(m, hidden, next_token);
3214        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
3215            let (_, i, kind, rs) = t.graph_weight()?;
3216            Some(crate::gpu::GraphW {
3217                idx: i,
3218                kind,
3219                row_scale: rs,
3220                data: &[],
3221            })
3222        }
3223        let (model, _, _, _) = wq.graph_weight()?;
3224        let model = model.clone();
3225        let (lm_gw, lm_rows) = {
3226            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
3227            (
3228                crate::gpu::GraphW {
3229                    idx: i,
3230                    kind,
3231                    row_scale: rs,
3232                    data: &[],
3233                },
3234                self.weights.lm_head.rows(),
3235            )
3236        };
3237        let layer = crate::gpu::GraphLayer {
3238            input_norm: &lw.input_norm,
3239            attn: crate::gpu::GraphAttn::Full {
3240                wq: gw(wq)?,
3241                wk: gw(wk)?,
3242                wv: gw(wv)?,
3243                wo: gw(wo)?,
3244                q_norm: q_norm.as_deref(),
3245                k_norm: k_norm.as_deref(),
3246                bias: bias
3247                    .as_ref()
3248                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
3249                output_gate: *output_gate,
3250                cpu_k: m.kv.k_heads(),
3251                cpu_v: m.kv.v_heads(),
3252            },
3253            post_norm: &lw.post_norm,
3254            ffn: crate::gpu::GraphFfn::Dense {
3255                gate: gw(&d.gate_proj)?,
3256                up: gw(&d.up_proj)?,
3257                down: gw(&d.down_proj)?,
3258            },
3259        };
3260        let nh = self.num_heads;
3261        let (nkv, hd, rd) = self.layer_geom(0);
3262        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
3263        let mut logits = Vec::new();
3264        let ok = crate::gpu::forward_token_graph(
3265            &model,
3266            self.mtp_kv_id(),
3267            std::slice::from_ref(&layer),
3268            &[None],
3269            self.o1_epoch,
3270            &self.inv_freq,
3271            &mut x,
3272            nh,
3273            nkv,
3274            hd,
3275            rd,
3276            self.hidden_size,
3277            self.intermediate_size,
3278            position,
3279            self.kv_cache.max_seq_len,
3280            gemma,
3281            self.rms_eps as f32,
3282            Some((&lm_gw, lm_rows)),
3283            &m.final_norm,
3284            &mut logits,
3285            &[],
3286            1,
3287            None,
3288            None,
3289            None,
3290            Self::MTP_LAYER_BASE,
3291            true,
3292        );
3293        if !ok {
3294            return None;
3295        }
3296        logits.resize(self.vocab_size, 0.0);
3297        Some((logits, x))
3298    }
3299
3300    /// The warm-ups of one speculative round on the device: every accepted
3301    /// (hidden, token) pair as ONE batched graph run over the MTP block
3302    /// (no head) — its kv_append lands the pairs in the block's mirror.
3303    /// `pairs` are consecutive positions from `first_pos`. False = the
3304    /// batch graph declined; the caller warms one by one on the token
3305    /// graph (prefix mode) instead.
3306    #[cfg(feature = "gpu")]
3307    fn mtp_warm_graph(
3308        &mut self,
3309        m: &mut MtpModule,
3310        pairs: &[(&[f32], u32)],
3311        first_pos: usize,
3312    ) -> bool {
3313        if pairs.is_empty() || !self.mtp_graph_ok(m) {
3314            return pairs.is_empty();
3315        }
3316        let hs = self.hidden_size;
3317        // Block inputs for every pair (eh_proj on the per-op path, one
3318        // matvec each — the plan's own prologue).
3319        let mut hiddens = Vec::with_capacity(pairs.len() * hs);
3320        for (h, t) in pairs {
3321            hiddens.extend_from_slice(&self.mtp_block_input(m, h, *t));
3322        }
3323        let lw = &m.layer;
3324        let AttnKind::Full {
3325            wq,
3326            wk,
3327            wv,
3328            wo,
3329            q_norm,
3330            k_norm,
3331            output_gate,
3332            bias,
3333            ..
3334        } = &lw.attn
3335        else {
3336            return false;
3337        };
3338        let FfnKind::Dense(d) = &lw.ffn else {
3339            return false;
3340        };
3341        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
3342            let (_, i, kind, rs) = t.graph_weight()?;
3343            Some(crate::gpu::GraphW {
3344                idx: i,
3345                kind,
3346                row_scale: rs,
3347                data: &[],
3348            })
3349        }
3350        let Some((model, _, _, _)) = wq.graph_weight() else {
3351            return false;
3352        };
3353        let model = model.clone();
3354        let (Some(gwq), Some(gwk), Some(gwv), Some(gwo), Some(gg), Some(gu), Some(gd)) = (
3355            gw(wq),
3356            gw(wk),
3357            gw(wv),
3358            gw(wo),
3359            gw(&d.gate_proj),
3360            gw(&d.up_proj),
3361            gw(&d.down_proj),
3362        ) else {
3363            return false;
3364        };
3365        let layer = crate::gpu::GraphLayer {
3366            input_norm: &lw.input_norm,
3367            attn: crate::gpu::GraphAttn::Full {
3368                wq: gwq,
3369                wk: gwk,
3370                wv: gwv,
3371                wo: gwo,
3372                q_norm: q_norm.as_deref(),
3373                k_norm: k_norm.as_deref(),
3374                bias: bias
3375                    .as_ref()
3376                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
3377                output_gate: *output_gate,
3378                cpu_k: m.kv.k_heads(),
3379                cpu_v: m.kv.v_heads(),
3380            },
3381            post_norm: &lw.post_norm,
3382            ffn: crate::gpu::GraphFfn::Dense {
3383                gate: gg,
3384                up: gu,
3385                down: gd,
3386            },
3387        };
3388        let positions: Vec<usize> = (first_pos..first_pos + pairs.len()).collect();
3389        let nh = self.num_heads;
3390        let (nkv, hd, rd) = self.layer_geom(0);
3391        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
3392        crate::gpu::forward_batch_graph(
3393            &model,
3394            self.mtp_kv_id(),
3395            std::slice::from_ref(&layer),
3396            &self.inv_freq,
3397            &mut hiddens,
3398            nh,
3399            nkv,
3400            hd,
3401            rd,
3402            hs,
3403            self.intermediate_size,
3404            &positions,
3405            self.kv_cache.max_seq_len,
3406            gemma,
3407            self.rms_eps as f32,
3408            pairs.len(),
3409            None,
3410        )
3411    }
3412
3413    /// The MTP block alone — advance its KV with a (hidden, token) pair the
3414    /// verify just proved, without paying the head. What keeps the draft's
3415    /// attention context warm between speculative rounds.
3416    fn mtp_warm(&mut self, m: &mut MtpModule, hidden: &[f32], next_token: u32, position: usize) {
3417        let e = self.embed_single(next_token);
3418        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3419        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3420        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3421        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3422        let mut x = vec![0.0f32; self.hidden_size];
3423        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3424        inference::rms_norm_into(
3425            &x,
3426            &m.layer.input_norm,
3427            self.rms_eps,
3428            self.norm_style,
3429            &mut self.ws.n1,
3430        );
3431        let attn = match &m.layer.attn {
3432            AttnKind::Full {
3433                wq,
3434                wk,
3435                wv,
3436                wo,
3437                q_norm,
3438                k_norm,
3439                output_gate,
3440                softplus_gate,
3441                bias,
3442            } => {
3443                let mut cfg = self.attn_cfg(position);
3444                cfg.q_norm = q_norm.as_deref();
3445                cfg.k_norm = k_norm.as_deref();
3446                cfg.output_gate = *output_gate;
3447                cfg.softplus_gate = softplus_gate.as_ref().map(|(g, p)| (g, *p));
3448                cfg.bias = bias
3449                    .as_ref()
3450                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
3451                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
3452            }
3453            _ => return,
3454        };
3455        let _ = attn;
3456    }
3457
3458    /// Speculative decode ON the wgpu whole-token graph: draft k with the
3459    /// MTP head, verify all of them plus the tip in ONE batched graph
3460    /// submit whose tail folds the head, commit the accepted prefix and
3461    /// roll the GDN state back to the last real position. Greedy only —
3462    /// output equals the plain graph's token for token, the way the DSV4
3463    /// verify equals the walk.
3464    #[cfg(feature = "gpu")]
3465    #[allow(clippy::too_many_arguments)]
3466    fn graph_spec_step(
3467        &mut self,
3468        m: &mut MtpModule,
3469        hidden: &[f32],
3470        t_next: u32,
3471        next_pos: usize,
3472        drafted: &mut usize,
3473        accepted: &mut usize,
3474        // The committed stream (prompt + generated so far, `t_next`
3475        // included): the sampler chain's penalties read it, and the
3476        // sampling arm extends it with the drafts position by position.
3477        all_ids: &mut Vec<u32>,
3478    ) -> Option<(Vec<u32>, usize, Vec<f32>)> {
3479        // 3 is the measured optimum on Qwen3.6-27B / RTX 5090 (medians
3480        // of three, greedy): 51.1 tok/s against a plain 49.4, where k=2
3481        // gives 46.1, k=4 50.0, k=5 47.4, k=6 45.2. Acceptance is 89-91%
3482        // throughout — what turns the curve over is the verify, which
3483        // costs ~7.4 ms per extra position, and the draft ~3 ms a step.
3484        // 4 since the draft moved onto the graph (Qwen3.8-27B / 5090:
3485        // k=3 51.2, k=4 51.8 with the per-op draft; the graph draft
3486        // halves the draft cost, so the extra draft is cheaper still).
3487        // 5 with the int8 verify (the default: measured 76.5 against
3488        // k=4's 72-74 and k=6's 74 on the 5090), 4 with the f32 one.
3489        #[cfg(target_os = "macos")]
3490        let metal_native = crate::gpu::q1_force();
3491        #[cfg(not(target_os = "macos"))]
3492        let metal_native = false;
3493        #[cfg(feature = "gpu")]
3494        let k_default = if metal_native {
3495            // the Metal verify's GEMM tile is 8 rows wide and flat in b:
3496            // seven drafts + the tip fill it for free
3497            7
3498        } else if crate::gpu_wgpu::verify_i8_on() {
3499            5
3500        } else {
3501            4
3502        };
3503        #[cfg(not(feature = "gpu"))]
3504        let k_default = 4;
3505        let k_spec: usize = std::env::var("CMF_GRAPH_SPEC_K")
3506            .ok()
3507            .and_then(|v| v.parse().ok())
3508            .filter(|&v| (1..=8).contains(&v))
3509            .unwrap_or(k_default);
3510        if next_pos == 0 {
3511            return None;
3512        }
3513        let t_round = std::time::Instant::now();
3514        // Submissions per phase — and they say where the round's money is.
3515        // Qwen3.6-27B on an RTX 5090, k=3:
3516        //
3517        //   draft   9.3 ms / 12 submissions   (four per MTP step)
3518        //   verify 52.8 ms /  1               (the batched graph)
3519        //   commit  5.4 ms /  6               (two per warm)
3520        //
3521        // The verify is already one submit. The draft's own work is 834 MB
3522        // a step — 0.8 ms at this card's measured 1056 GB/s — against 3.1
3523        // ms measured, so ~0.58 ms of every step is round trip, not
3524        // arithmetic, and the same holds for the warms. Eighteen round
3525        // trips a round at roughly half a millisecond each is ~11 ms of a
3526        // 68 ms round: fusing the MTP block into ONE submit the way the
3527        // trunk already is projects to ~64 tok/s against today's 50.9.
3528        // That is the largest measured item left on this path.
3529        let subs = || crate::gpu_wgpu::SUBMITS.load(std::sync::atomic::Ordering::Relaxed);
3530        let sub0 = subs();
3531        // Greedy without penalties verifies by argmax equality (bit-exact
3532        // against the plain path). Anything else is speculative SAMPLING:
3533        // each draft is a DRAW from the MTP head's post-chain distribution
3534        // q_j, kept for the accept test; the verify's rows give p_j.
3535        let cfg = self.sampler_config.clone();
3536        let penalized = !(cfg.repetition_penalty == 1.0
3537            && cfg.presence_penalty == 0.0
3538            && cfg.suppress_tokens.is_empty());
3539        // Three verify regimes: plain greedy (argmax of the raw rows),
3540        // greedy WITH penalties (argmax of the penalized rows — a single
3541        // pass each, no distributions), and sampling (draw / accept /
3542        // correct on post-chain distributions).
3543        let greedy_pen = cfg.temperature < 1e-6 && penalized;
3544        let sampling = cfg.temperature >= 1e-6;
3545        // Sampling with a top-k goes through the SPARSE chain: the dense
3546        // one builds nine 248k-float distributions a round (four drafts,
3547        // five verify rows) and measured 19-22 tok/s against a plain 40 —
3548        // the host, not the card. Sparse, the same nine cost tens of
3549        // microseconds each.
3550        let sparse = sampling && sampler::sparse_ok(&cfg);
3551        let base_len = all_ids.len();
3552        if sampling && !sparse && self.spec_q.len() < k_spec {
3553            self.spec_q.resize_with(k_spec, Vec::new);
3554        }
3555        if sparse && self.spec_qs.len() < k_spec {
3556            self.spec_qs.resize_with(k_spec, Vec::new);
3557        }
3558        // Draft the chain: first from the trunk's tip hidden, then the head
3559        // iterating on itself. Rows land in the MTP KV; the chain rows past
3560        // the first are speculation over speculative state and roll back
3561        // below, replaced by verified pairs.
3562        let mut drafts = Vec::with_capacity(k_spec);
3563        let mut hx = hidden.to_vec();
3564        // CMF_SPEC_DBG=1: draft 0 through BOTH MTP arms (graph and per-op)
3565        // from the same inputs — are the arms the difference, or the inputs?
3566        let spec_dbg = std::env::var("CMF_SPEC_DBG").is_ok();
3567        for j in 0..k_spec {
3568            let tok_in = if j == 0 { t_next } else { drafts[j - 1] };
3569            let mut dbg_ref: Option<(Vec<f32>, Vec<f32>)> = None;
3570            if spec_dbg {
3571                let saved = self.mtp_graph_mode;
3572                self.mtp_graph_mode = Some(false);
3573                let r = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
3574                self.mtp_graph_mode = saved;
3575                m.kv.truncate_last(1);
3576                dbg_ref = Some(r);
3577            }
3578            let (mut lg, hj) = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
3579            if let Some((lg_cpu, h_cpu)) = dbg_ref {
3580                let n = |v: &[f32]| v.iter().map(|x| x * x).sum::<f32>().sqrt();
3581                let dl = lg.iter().zip(&lg_cpu).fold(0f32, |m, (a, b)| m.max((a - b).abs()));
3582                let dh = hj.iter().zip(&h_cpu).fold(0f32, |m, (a, b)| m.max((a - b).abs()));
3583                eprintln!(
3584                    "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 {}",
3585                    next_pos - 1 + j,
3586                    sampler::argmax(&lg_cpu),
3587                    sampler::argmax(&lg),
3588                    n(&h_cpu),
3589                    n(&hj),
3590                    m.kv.seq_len
3591                );
3592            }
3593            let dj = if sparse {
3594                let mut q = std::mem::take(&mut self.spec_qs[j]);
3595                let ok = sampler::sparse_distribution_into(
3596                    &lg,
3597                    &cfg,
3598                    all_ids,
3599                    &mut self.sampler_scratch,
3600                    self.pool.as_deref(),
3601                    &mut q,
3602                );
3603                let d = if ok {
3604                    sampler::draw_sparse(&q, &mut self.rng)
3605                } else {
3606                    // everything filtered: the dense chain's greedy fallback
3607                    let t = sampler::argmax(&lg);
3608                    q.clear();
3609                    q.push((t, 1.0));
3610                    t
3611                };
3612                self.spec_qs[j] = q;
3613                all_ids.push(d);
3614                d
3615            } else if sampling {
3616                let mut q = std::mem::take(&mut self.spec_q[j]);
3617                sampler::distribution_into(
3618                    &lg,
3619                    &cfg,
3620                    all_ids,
3621                    &mut self.sampler_scratch,
3622                    self.pool.as_deref(),
3623                    &mut q,
3624                );
3625                let d = sampler::draw(&q, &mut self.rng);
3626                self.spec_q[j] = q;
3627                all_ids.push(d); // the next draft's penalties see this one
3628                d
3629            } else if greedy_pen {
3630                let d = sampler::argmax_penalized(
3631                    &lg,
3632                    &cfg,
3633                    all_ids,
3634                    &mut self.sampler_scratch,
3635                    self.pool.as_deref(),
3636                );
3637                all_ids.push(d);
3638                d
3639            } else {
3640                sampler::argmax(&lg)
3641            };
3642            attention::recycle_buf(&mut lg);
3643            drafts.push(dj);
3644            hx = hj;
3645        }
3646        all_ids.truncate(base_len);
3647        *drafted += k_spec;
3648        let t_draft = t_round.elapsed();
3649        let sub_draft = subs();
3650        // Verify batch: [t_next, d1 .. d_{k-1}] at next_pos.. — every row's
3651        // logits come back from the graph's own head.
3652        let b = k_spec + 1;
3653        let mut hiddens = vec![0.0f32; b * self.hidden_size];
3654        for (i, &t) in std::iter::once(&t_next).chain(drafts.iter()).enumerate() {
3655            let e = self.embed_single(t);
3656            hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&e);
3657        }
3658        let positions: Vec<usize> = (next_pos..next_pos + b).collect();
3659        let (lm_gw, lm_rows) = {
3660            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
3661            (
3662                crate::gpu::GraphW {
3663                    idx: i,
3664                    kind,
3665                    row_scale: rs,
3666                    data: &[],
3667                },
3668                self.weights.lm_head.rows(),
3669            )
3670        };
3671        let mut logits = Vec::new();
3672        let final_norm = self.weights.final_norm.clone();
3673        #[cfg(target_os = "macos")]
3674        let ok = if metal_native {
3675            let lm = self.weights.lm_head.q1_parts()?;
3676            self.try_batch_graph_metal(&mut hiddens, &positions, b, Some((lm, &final_norm, &mut logits)))
3677        } else {
3678            self.try_batch_graph_wgpu(
3679                &mut hiddens,
3680                &positions,
3681                b,
3682                Some(crate::gpu::SpecTail {
3683                    lm: lm_gw,
3684                    lm_rows,
3685                    final_norm: &final_norm,
3686                    logits_out: &mut logits,
3687                }),
3688            )
3689        };
3690        #[cfg(not(target_os = "macos"))]
3691        let ok = self.try_batch_graph_wgpu(
3692            &mut hiddens,
3693            &positions,
3694            b,
3695            Some(crate::gpu::SpecTail {
3696                lm: lm_gw,
3697                lm_rows,
3698                final_norm: &final_norm,
3699                logits_out: &mut logits,
3700            }),
3701        );
3702        if !ok {
3703            // Roll the draft rows back out of the MTP cache and decline —
3704            // the caller runs the plain path, nothing has changed.
3705            m.kv.truncate_last(k_spec);
3706            return None;
3707        }
3708        // `CMF_METAL_VERIFY_CHECK=1`: run the same b tokens through the
3709        // plain per-token path and compare each row's argmax + logits with
3710        // the verify's — the bring-up oracle for the batched graph. The
3711        // plain forwards mutate the CPU state; it is snapshotted and put
3712        // back, and the K/V mirrors re-pointed, before the round goes on.
3713        #[cfg(target_os = "macos")]
3714        if metal_native && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("1") {
3715            let snap: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3716            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
3717            let toks: Vec<u32> = std::iter::once(t_next).chain(drafts.iter().copied()).collect();
3718            let want_save = self.graph_want_logits;
3719            self.graph_want_logits = false;
3720            for (i, &t) in toks.iter().enumerate() {
3721                let hi = self.forward_layers(&self.embed_single(t), next_pos + i, None);
3722                let _ = self.graph_logits.take();
3723                // CMF_SPEC_PLAIN_HIDDEN=1: the next round drafts from the
3724                // plain path's hidden instead of the verify's (an experiment
3725                // on the chain's sensitivity to the half-GEMM noise)
3726                if std::env::var("CMF_SPEC_PLAIN_HIDDEN").as_deref() == Ok("1") {
3727                    hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&hi);
3728                }
3729                let ref_lg = self.logits_from_hidden(&hi);
3730                let row = &logits[i * lm_rows..(i + 1) * lm_rows];
3731                let ra = sampler::argmax(&ref_lg);
3732                let va = sampler::argmax(row);
3733                let mut md = 0f32;
3734                let mut rms = 0f64;
3735                for j in 0..lm_rows.min(ref_lg.len()) {
3736                    let d = (ref_lg[j] - row[j]).abs();
3737                    md = md.max(d);
3738                    rms += (d as f64) * (d as f64);
3739                }
3740                let mut hd = 0f32;
3741                for j in 0..self.hidden_size {
3742                    hd = hd.max((hi[j] - hiddens[i * self.hidden_size + j]).abs());
3743                }
3744                eprintln!(
3745                    "verify-check row {i} tok {t} pos {}: ref argmax {ra} verify argmax {va} {} | max|dlogit| {md:.3} rms {:.4} | max|dhidden| {hd:.4}",
3746                    next_pos + i,
3747                    if ra == va { "OK" } else { "MISMATCH" },
3748                    (rms / lm_rows as f64).sqrt()
3749                );
3750            }
3751            self.graph_want_logits = want_save;
3752            // restore IN PLACE: the pending verify graph wraps these very
3753            // allocations (zero-copy) — replacing the Vec would strand it
3754            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
3755                if l.linear_state.len() == st.len() {
3756                    l.linear_state.copy_from_slice(&st);
3757                } else {
3758                    l.linear_state = st;
3759                }
3760            }
3761            for (li, (l, n0)) in self.kv_cache.layers.iter_mut().zip(attn_lens).enumerate() {
3762                let extra = l.seq_len.saturating_sub(n0);
3763                if extra > 0 {
3764                    l.truncate_last(extra);
3765                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, n0);
3766                }
3767            }
3768        }
3769        let t_verify = t_round.elapsed();
3770        let sub_verify = subs();
3771        // Acceptance. Greedy: row i's argmax is the trunk's token after
3772        // input i. Sampling: accept draft i with min(1, p_i/q_i), and on
3773        // the first rejection draw the correction from max(0, p_i − q_i)
3774        // — that token is committed by the loop top as-is (spec_forced).
3775        let mut a = 0usize;
3776        let mut forced: Option<u32> = None;
3777        let ids: Vec<u32> = if sparse {
3778            let mut p = std::mem::take(&mut self.spec_ps);
3779            let mut res = std::mem::take(&mut self.spec_ress);
3780            while a < k_spec {
3781                let ok = sampler::sparse_distribution_into(
3782                    &logits[a * lm_rows..(a + 1) * lm_rows],
3783                    &cfg,
3784                    all_ids,
3785                    &mut self.sampler_scratch,
3786                    self.pool.as_deref(),
3787                    &mut p,
3788                );
3789                if !ok {
3790                    let t = sampler::argmax(&logits[a * lm_rows..(a + 1) * lm_rows]);
3791                    p.clear();
3792                    p.push((t, 1.0));
3793                }
3794                match sampler::spec_accept_or_correct_sparse(
3795                    &p,
3796                    &self.spec_qs[a],
3797                    drafts[a],
3798                    &mut self.rng,
3799                    &mut res,
3800                ) {
3801                    None => {
3802                        all_ids.push(drafts[a]);
3803                        a += 1;
3804                    }
3805                    Some(c) => {
3806                        forced = Some(c);
3807                        break;
3808                    }
3809                }
3810            }
3811            all_ids.truncate(base_len);
3812            self.spec_ps = p;
3813            self.spec_ress = res;
3814            drafts.clone()
3815        } else if sampling {
3816            let mut p = std::mem::take(&mut self.spec_p);
3817            let mut res = std::mem::take(&mut self.spec_res);
3818            while a < k_spec {
3819                sampler::distribution_into(
3820                    &logits[a * lm_rows..(a + 1) * lm_rows],
3821                    &cfg,
3822                    all_ids,
3823                    &mut self.sampler_scratch,
3824                    self.pool.as_deref(),
3825                    &mut p,
3826                );
3827                match sampler::spec_accept_or_correct(
3828                    &p,
3829                    &self.spec_q[a],
3830                    drafts[a],
3831                    &mut self.rng,
3832                    &mut res,
3833                    self.pool.as_deref(),
3834                ) {
3835                    None => {
3836                        all_ids.push(drafts[a]);
3837                        a += 1;
3838                    }
3839                    Some(c) => {
3840                        forced = Some(c);
3841                        break;
3842                    }
3843                }
3844            }
3845            all_ids.truncate(base_len);
3846            self.spec_p = p;
3847            self.spec_res = res;
3848            // the accepted drafts ARE the verified tokens after inputs 0..a
3849            drafts.clone()
3850        } else if greedy_pen {
3851            // Row i's penalized argmax, penalties over the stream that
3852            // includes the accepted drafts before it — the plain loop's
3853            // exact arithmetic, one pass per row, no working copy.
3854            let mut ids: Vec<u32> = Vec::with_capacity(b);
3855            for i in 0..b {
3856                let t = sampler::argmax_penalized(
3857                    &logits[i * lm_rows..(i + 1) * lm_rows],
3858                    &cfg,
3859                    all_ids,
3860                    &mut self.sampler_scratch,
3861                    self.pool.as_deref(),
3862                );
3863                ids.push(t);
3864                if i < k_spec && t == drafts[i] {
3865                    all_ids.push(t);
3866                } else {
3867                    break;
3868                }
3869            }
3870            all_ids.truncate(base_len);
3871            while a < k_spec && a < ids.len() && ids[a] == drafts[a] {
3872                a += 1;
3873            }
3874            // rows past the first mismatch were never scored; the loop
3875            // top re-samples the last verified row itself.
3876            ids
3877        } else {
3878            let ids: Vec<u32> = (0..b)
3879                .map(|i| sampler::argmax(&logits[i * lm_rows..(i + 1) * lm_rows]))
3880                .collect();
3881            while a < k_spec && ids[a] == drafts[a] {
3882                a += 1;
3883            }
3884            ids
3885        };
3886        if spec_dbg {
3887            eprintln!("spec-dbg round: t_next {t_next} drafts {:?} verified {:?} accepted {a}", drafts, ids);
3888        }
3889        // CMF_METAL_VERIFY_CHECK=2: the commit oracle — plain-forward the
3890        // a+1 accepted tokens from a snapshot, then diff the replayed GDN
3891        // states and the appended K/V rows against that.
3892        #[cfg(target_os = "macos")]
3893        let commit_ref: Option<(Vec<Vec<f32>>, Vec<(usize, Vec<f32>, Vec<f32>)>)> = if metal_native
3894            && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("2")
3895        {
3896            let snap: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3897            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
3898            let toks: Vec<u32> = std::iter::once(t_next).chain(drafts.iter().copied()).collect();
3899            let want_save = self.graph_want_logits;
3900            self.graph_want_logits = false;
3901            for (i, &t) in toks.iter().take(a + 1).enumerate() {
3902                let _ = self.forward_layers(&self.embed_single(t), next_pos + i, None);
3903                let _ = self.graph_logits.take();
3904            }
3905            self.graph_want_logits = want_save;
3906            let plain_states: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3907            let (nkv, hd) = (self.num_kv_heads, self.head_dim);
3908            let mut rows = Vec::new();
3909            for (li, (l, n0)) in self.kv_cache.layers.iter_mut().zip(attn_lens.iter()).enumerate() {
3910                let extra = l.seq_len.saturating_sub(*n0);
3911                if extra > 0 {
3912                    let mut kk = Vec::new();
3913                    let mut vv = Vec::new();
3914                    for g in 0..nkv {
3915                        kk.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
3916                        vv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
3917                    }
3918                    rows.push((li, kk, vv));
3919                    l.truncate_last(extra);
3920                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, *n0);
3921                }
3922            }
3923            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
3924                if l.linear_state.len() == st.len() {
3925                    l.linear_state.copy_from_slice(&st);
3926                } else {
3927                    l.linear_state = st;
3928                }
3929            }
3930            Some((plain_states, rows))
3931        } else {
3932            None
3933        };
3934        // a fully-accepted round needs no restore: every input was real.
3935        #[cfg(target_os = "macos")]
3936        if metal_native {
3937            // the Metal verify never wrote its states: the commit replays the
3938            // accepted prefix into the CPU owners and appends the K/V rows
3939            self.metal_verify_commit(a);
3940            if let Some((plain_states, rows)) = commit_ref {
3941                crate::gpu_metal::queue_fence();
3942                let (nkv, hd) = (self.num_kv_heads, self.head_dim);
3943                let mut worst_s = 0f32;
3944                let mut worst_li = 0usize;
3945                for (li, (l, ps)) in self.kv_cache.layers.iter().zip(&plain_states).enumerate() {
3946                    if l.linear_state.len() != ps.len() || ps.is_empty() {
3947                        continue;
3948                    }
3949                    let d = l.linear_state.iter().zip(ps).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
3950                    let n = ps.iter().fold(0f32, |m, y| m.max(y.abs()));
3951                    let rel = d / n.max(1e-6);
3952                    if rel > worst_s {
3953                        worst_s = rel;
3954                        worst_li = li;
3955                    }
3956                }
3957                let mut worst_k = 0f32;
3958                for (li, kk, vv) in &rows {
3959                    let l = &self.kv_cache.layers[*li];
3960                    let n0 = l.seq_len - (kk.len() / (nkv * hd));
3961                    let mut ck = Vec::new();
3962                    let mut cv = Vec::new();
3963                    for g in 0..nkv {
3964                        ck.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
3965                        cv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
3966                    }
3967                    if ck.len() == kk.len() {
3968                        let dk = ck.iter().zip(kk).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
3969                        let dv = cv.iter().zip(vv).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
3970                        worst_k = worst_k.max(dk).max(dv);
3971                    } else {
3972                        eprintln!("commit-check L{li}: kv row count mismatch {} vs {}", ck.len(), kk.len());
3973                    }
3974                }
3975                eprintln!(
3976                    "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}"
3977                );
3978            }
3979        } else if a + 1 < b {
3980            crate::gpu::gdn_spec_restore(self.graph_kv_id, a);
3981        }
3982        #[cfg(not(target_os = "macos"))]
3983        if a + 1 < b {
3984            crate::gpu::gdn_spec_restore(self.graph_kv_id, a);
3985        }
3986        *accepted += a;
3987        // MTP cache: keep the first draft row (its inputs were real), drop
3988        // the chain's, then append the verified pairs the round produced.
3989        // Each of those is a whole MTP block on the per-op path and they
3990        // cost 5.8 ms of a 69 ms round at k=3 — a third of what the
3991        // round's own draft costs. PRICED, and they earn it: skipping
3992        // them (`CMF_SPEC_WARM=0`) drops acceptance from 89% to 81% at
3993        // k=3 and 85% to 74% at k=4, and the tok/s goes nowhere at k=3
3994        // (50.3 against 50.5) and backwards at k=4 (48.1 against 50.1).
3995        // The knob stays so the next person can re-price it after the
3996        // warms are batched instead of assuming either way.
3997        m.kv.truncate_last(k_spec.saturating_sub(1));
3998        #[cfg(target_os = "macos")]
3999        if metal_native && self.mtp_graph_mode == Some(true) {
4000            // the mirror rows below the cut are the CPU rows: re-point,
4001            // no re-upload
4002            crate::gpu_metal::kv_mirror_set_stored(self.mtp_kv_id(), Self::MTP_LAYER_BASE, m.kv.seq_len);
4003        }
4004        let warm_off = std::env::var("CMF_SPEC_WARM").is_ok_and(|v| v == "0");
4005        if !warm_off && a > 0 {
4006            // Graph arm: all accepted pairs in ONE batched run over the
4007            // MTP block; the token graph one by one if the batch declines.
4008            let mut warmed = false;
4009            #[cfg(target_os = "macos")]
4010            if metal_native && self.mtp_graph_mode == Some(true) {
4011                // all accepted pairs in ONE b-row graph run over the MTP
4012                // block (its input projection folded in); one by one on
4013                // the token graph if that declines
4014                let pairs: Vec<(&[f32], u32)> = (0..a)
4015                    .map(|j| (&hiddens[j * self.hidden_size..(j + 1) * self.hidden_size], ids[j]))
4016                    .collect();
4017                warmed = self.mtp_warm_batch_metal(m, &pairs, next_pos);
4018                if !warmed {
4019                    warmed = true;
4020                    for j in 0..a {
4021                        let row = hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec();
4022                        if self.mtp_step_metal(m, &row, ids[j], next_pos + j, false).is_none() {
4023                            warmed = false;
4024                            break;
4025                        }
4026                    }
4027                }
4028            }
4029            if !warmed && self.mtp_graph_mode == Some(true) && !metal_native {
4030                let rows: Vec<Vec<f32>> = (0..a)
4031                    .map(|j| hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec())
4032                    .collect();
4033                let pairs: Vec<(&[f32], u32)> = rows
4034                    .iter()
4035                    .zip(ids.iter())
4036                    .map(|(r, &t)| (r.as_slice(), t))
4037                    .collect();
4038                warmed = self.mtp_warm_graph(m, &pairs, next_pos);
4039                if !warmed {
4040                    // Prefix-mode token graph per pair (kv_append inside).
4041                    warmed = true;
4042                    for j in 0..a {
4043                        if self
4044                            .mtp_step_graph(m, &rows[j], ids[j], next_pos + j)
4045                            .is_none()
4046                        {
4047                            warmed = false;
4048                            break;
4049                        }
4050                    }
4051                }
4052            }
4053            if !warmed {
4054                for j in 0..a {
4055                    let row = &hiddens[j * self.hidden_size..(j + 1) * self.hidden_size];
4056                    let row = row.to_vec();
4057                    self.mtp_warm(m, &row, ids[j], next_pos + j);
4058                }
4059            }
4060        }
4061        // The sampler's contract: logits of the LAST verified position —
4062        // unless a rejected draft already drew the correction, in which
4063        // case the loop top commits that token and samples nothing.
4064        if let Some(c) = forced {
4065            self.spec_forced = Some(c);
4066            self.graph_logits = None;
4067        } else {
4068            let mut row = logits[a * lm_rows..(a + 1) * lm_rows].to_vec();
4069            row.resize(self.vocab_size, 0.0);
4070            if let Some(c) = self.final_softcap {
4071                for l in row.iter_mut() {
4072                    *l = c * (*l / c).tanh();
4073                }
4074            }
4075            self.graph_logits = Some(row);
4076        }
4077        let new_hidden = hiddens[a * self.hidden_size..(a + 1) * self.hidden_size].to_vec();
4078        // Three phases, not two. The round's wall clock was 4 ms longer
4079        // than draft+verify and the difference had nowhere to be seen:
4080        // the accepted prefix re-runs the MTP block once per token to
4081        // keep the draft head's attention cache warm, and the GDN state
4082        // rolls back on any rejection. Both live here, after the verify.
4083        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
4084            let end = subs();
4085            eprintln!(
4086                "spec-round: draft {:.1} ms/{} sub | verify {:.1} ms/{} sub | \
4087                 commit {:.1} ms/{} sub (accepted {a} of {k_spec})",
4088                t_draft.as_secs_f64() * 1e3,
4089                sub_draft - sub0,
4090                (t_verify - t_draft).as_secs_f64() * 1e3,
4091                sub_verify - sub_draft,
4092                (t_round.elapsed() - t_verify).as_secs_f64() * 1e3,
4093                end - sub_verify,
4094            );
4095        }
4096        Some((drafts[..a].to_vec(), next_pos + a + 1, new_hidden))
4097    }
4098
4099    /// Micro-benchmark: two single-position forwards vs one fused pair
4100    /// from the current cache state (KV rewound after each probe).
4101    /// Returns (two_singles_ms, fused_pair_ms) per probe, or the (0, 0)
4102    /// sentinel when this model has no pair path to measure — the same
4103    /// answer the o1 arm gives, and the bench prints it the same way.
4104    /// (An architecture that loads its own layers leaves `weights.layers`
4105    /// empty; walking it here was an index panic, found by `bench` on
4106    /// deepseek_v4.)
4107    pub fn measure_pair_fusion(&mut self, iters: usize) -> (f64, f64) {
4108        if !self.pair_supported() {
4109            return (0.0, 0.0);
4110        }
4111        let emb1 = self.embed_single(1);
4112        let emb2 = self.embed_single(2);
4113        let pos = self.kv_cache.seq_len();
4114
4115        let t0 = std::time::Instant::now();
4116        for _ in 0..iters {
4117            let _ = self.forward_layers(&emb1, pos, None);
4118            let _ = self.forward_layers(&emb2, pos + 1, None);
4119            for l in &mut self.kv_cache.layers {
4120                l.truncate_last(2);
4121            }
4122        }
4123        let singles_ms = t0.elapsed().as_secs_f64() * 1000.0 / iters as f64;
4124
4125        let t1 = std::time::Instant::now();
4126        for _ in 0..iters {
4127            let _ = self.forward_pair(&emb1, &emb2, pos);
4128            for l in &mut self.kv_cache.layers {
4129                l.truncate_last(2);
4130            }
4131        }
4132        let pair_ms = t1.elapsed().as_secs_f64() * 1000.0 / iters as f64;
4133        (singles_ms, pair_ms)
4134    }
4135
4136    /// Fused two-position forward: weight rows are streamed from memory
4137    /// once per layer for both positions. Full layers → fused GQA pair;
4138    /// linear layers → vmf_phase pair (lane 2 state is tentative in the
4139    /// per-layer scratch until the draft is accepted).
4140    /// Whether the fused two-position path covers every layer kind in
4141    /// this model. MLA and KDA run per position (their pair arms are
4142    /// unreachable); the seq prefill falls back to singles for them.
4143    fn pair_supported(&self) -> bool {
4144        // An EMPTY layer stack means the architecture loaded its own and
4145        // this path has nothing to walk. Checking that directly, rather
4146        // than naming each such architecture, is what makes the guard hold
4147        // for the next one: `any()` over no layers is false, so a
4148        // feature-by-feature test says "supported" for a model that has no
4149        // layers here at all.
4150        !self.weights.layers.is_empty()
4151            && self.g3n.is_none()
4152            && !self
4153                .weights
4154                .layers
4155                .iter()
4156                .any(|lw| matches!(&lw.attn, AttnKind::Mla(_) | AttnKind::Kda(_)))
4157    }
4158
4159    fn forward_pair(
4160        &mut self,
4161        emb1: &[f32],
4162        emb2: &[f32],
4163        position: usize,
4164    ) -> (Vec<f32>, Vec<f32>) {
4165        let mut h1 = emb1.to_vec();
4166        let mut h2 = emb2.to_vec();
4167        let (_nkv, _hd, hs, _rd, eps) = (
4168            self.num_kv_heads,
4169            self.head_dim,
4170            self.hidden_size,
4171            self.rotary_dim,
4172            self.rms_eps,
4173        );
4174        let pool = self.pool.clone();
4175
4176        for li in 0..self.num_layers {
4177            let lw = &self.weights.layers[self.phys_layer(li)];
4178            // Norms into pipeline scratch (4 allocs/layer on the MTP
4179            // decode hot path before this).
4180            inference::rms_norm_into(
4181                &h1,
4182                &lw.input_norm,
4183                self.rms_eps,
4184                self.norm_style,
4185                &mut self.ws.n1,
4186            );
4187            inference::rms_norm_into(
4188                &h2,
4189                &lw.input_norm,
4190                self.rms_eps,
4191                self.norm_style,
4192                &mut self.ws.n2,
4193            );
4194
4195            let (a1, a2) = match &lw.attn {
4196                AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
4197                AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
4198                AttnKind::Linear(w) => {
4199                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
4200                    let layer = &mut self.kv_cache.layers[li];
4201                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4202                    vmf_phase_pair(
4203                        &self.ws.n1,
4204                        &self.ws.n2,
4205                        w,
4206                        &cfg,
4207                        state,
4208                        scratch,
4209                        self.pool.as_deref(),
4210                    )
4211                }
4212                AttnKind::LinearGdn(w) => {
4213                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
4214                    let layer = &mut self.kv_cache.layers[li];
4215                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4216                    gdn_pair(
4217                        &self.ws.n1,
4218                        &self.ws.n2,
4219                        w,
4220                        &cfg,
4221                        state,
4222                        scratch,
4223                        self.pool.as_deref(),
4224                    )
4225                }
4226                AttnKind::ShortConv(w) => {
4227                    let cfg = self
4228                        .short_conv_cfg
4229                        .expect("short-conv layer without short_conv_cfg");
4230                    let layer = &mut self.kv_cache.layers[li];
4231                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4232                    short_conv_pair(
4233                        &self.ws.n1,
4234                        &self.ws.n2,
4235                        w,
4236                        &cfg,
4237                        state,
4238                        scratch,
4239                        self.pool.as_deref(),
4240                    )
4241                }
4242                AttnKind::Full {
4243                    wq,
4244                    wk,
4245                    wv,
4246                    wo,
4247                    q_norm,
4248                    k_norm,
4249                    output_gate,
4250                    softplus_gate,
4251                    bias,
4252                } => {
4253                    let inv_freq_l = self.layer_inv_freq(li);
4254                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
4255                    let cfg = QwenAttnCfg {
4256                        num_heads: self.layer_num_heads(li),
4257                        num_kv_heads: nkv_l,
4258                        head_dim: hd_l,
4259                        hidden_size: hs,
4260                        position,
4261                        inv_freq: &inv_freq_l,
4262                        rotary_dim: rd_l,
4263                        scale: self.attn_scale,
4264                        softcap: self.attn_softcap,
4265                        window: self.layer_window(li),
4266                        v_norm: self.attn_v_norm,
4267                        q_norm: q_norm.as_deref(),
4268                        k_norm: k_norm.as_deref(),
4269                        output_gate: *output_gate,
4270                        softplus_gate: softplus_gate
4271                            .as_ref()
4272                            .map(|(gate, per_head)| (gate, *per_head)),
4273                        rope_scale: self.layer_rope_scale(li),
4274                        bias: bias
4275                            .as_ref()
4276                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
4277                        rms_eps: eps,
4278                        norm_style: self.norm_style,
4279                        pool: pool.as_deref(),
4280                    };
4281                    attention::qwen_attention_pair(
4282                        &self.ws.n1,
4283                        &self.ws.n2,
4284                        wq,
4285                        wk,
4286                        wv,
4287                        wo,
4288                        &mut self.kv_cache.layers[li],
4289                        &cfg,
4290                    )
4291                }
4292            };
4293            let (a1, a2) = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
4294                Some(w) => (
4295                    inference::rms_norm(&a1, w, self.rms_eps, self.norm_style),
4296                    inference::rms_norm(&a2, w, self.rms_eps, self.norm_style),
4297                ),
4298                None => (a1, a2),
4299            };
4300            for i in 0..self.hidden_size {
4301                h1[i] += a1[i];
4302                h2[i] += a2[i];
4303            }
4304            let (mut a1, mut a2) = (a1, a2);
4305            attention::recycle_buf(&mut a1);
4306            attention::recycle_buf(&mut a2);
4307
4308            let lw = &self.weights.layers[self.phys_layer(li)];
4309            inference::rms_norm_into(
4310                &h1,
4311                &lw.post_norm,
4312                self.rms_eps,
4313                self.norm_style,
4314                &mut self.ws.p1,
4315            );
4316            inference::rms_norm_into(
4317                &h2,
4318                &lw.post_norm,
4319                self.rms_eps,
4320                self.norm_style,
4321                &mut self.ws.p2,
4322            );
4323            let (f1, f2) = match &lw.ffn {
4324                // Dual-branch layers need the raw residuals — run the
4325                // two positions through the same fn decode uses.
4326                FfnKind::DenseMoe(dm) => (
4327                    dense_moe_ffn(
4328                        dm,
4329                        &self.ws.p1,
4330                        &h1,
4331                        self.rms_eps,
4332                        self.norm_style,
4333                        self.pool.as_deref(),
4334                    ),
4335                    dense_moe_ffn(
4336                        dm,
4337                        &self.ws.p2,
4338                        &h2,
4339                        self.rms_eps,
4340                        self.norm_style,
4341                        self.pool.as_deref(),
4342                    ),
4343                ),
4344                _ => ffn_forward_pair(
4345                    &lw.ffn,
4346                    &self.ws.p1,
4347                    &self.ws.p2,
4348                    self.pool.as_deref(),
4349                    None,
4350                ),
4351            };
4352            let (f1, f2) = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
4353                Some(w) => (
4354                    inference::rms_norm(&f1, w, self.rms_eps, self.norm_style),
4355                    inference::rms_norm(&f2, w, self.rms_eps, self.norm_style),
4356                ),
4357                None => (f1, f2),
4358            };
4359            for i in 0..self.hidden_size {
4360                h1[i] += f1[i];
4361                h2[i] += f2[i];
4362            }
4363            let (mut f1, mut f2) = (f1, f2);
4364            attention::recycle_buf(&mut f1);
4365            attention::recycle_buf(&mut f2);
4366            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
4367                for i in 0..self.hidden_size {
4368                    h1[i] *= sc;
4369                    h2[i] *= sc;
4370                }
4371            }
4372            // Looped Transformer: apply final norm at the end of each loop iteration.
4373            if self.is_loop_end(li) && li + 1 < self.num_layers {
4374                h1 = inference::rms_norm(
4375                    &h1,
4376                    &self.weights.final_norm,
4377                    self.rms_eps,
4378                    self.norm_style,
4379                );
4380                h2 = inference::rms_norm(
4381                    &h2,
4382                    &self.weights.final_norm,
4383                    self.rms_eps,
4384                    self.norm_style,
4385                );
4386            }
4387        }
4388        (h1, h2)
4389    }
4390
4391    /// Commit lane-2 linear states after an accepted draft.
4392    fn commit_linear_scratch(&mut self) {
4393        for layer in &mut self.kv_cache.layers {
4394            if !layer.linear_scratch.is_empty() {
4395                std::mem::swap(&mut layer.linear_state, &mut layer.linear_scratch);
4396                layer.linear_scratch.clear();
4397            }
4398        }
4399    }
4400
4401    /// Forward a full id sequence from a fresh cache and return the
4402    /// logits after the last position (golden-parity harness, bench).
4403    pub fn forward_ids(
4404        &mut self,
4405        ids: &[u32],
4406        task_mask: Option<&TaskMask>,
4407    ) -> Result<Vec<f32>, String> {
4408        if ids.is_empty() {
4409            return Err("empty id sequence".to_string());
4410        }
4411        self.kv_cache.clear();
4412        self.kv_history.clear();
4413        self.o1_begin();
4414        let mut hidden = vec![0.0f32; self.hidden_size];
4415        let mut pos = 0usize;
4416        // Same routing predicate generation uses. Two reasons it must be
4417        // the same one: (1) a GDN hybrid's recurrent state is GPU-
4418        // resident, and a batched CPU prefill would build it on the host
4419        // only — decode then reads buffers the prefill never wrote;
4420        // (2) bench times THIS function and calls the result "prefill",
4421        // so a different path here reports a number production never
4422        // sees (W2 on 2×5090: 8.7 tok/s reported against 125 real).
4423        if self.can_prefill_batched() && !self.graph_prefill_preferred() && ids.len() > 2 {
4424            // prefill-GEMM in chunks; only the last position's hidden is
4425            // needed. (o1-compatible: the batch path attends per position
4426            // through qwen_attention, which carries the collection hook.)
4427            let chunk = prefill_chunk();
4428            let hs = self.hidden_size;
4429            while pos < ids.len() {
4430                let end = (pos + chunk).min(ids.len());
4431                let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
4432                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
4433                pos = end;
4434            }
4435        }
4436        // Same guards as generation's prefill — INCLUDING the graph one.
4437        // The CPU pair walk was intercepting positions that the resident
4438        // token graph would have run itself: on a GDN hybrid over wgpu
4439        // that is 89 ms of host forward against 7 ms of device submit,
4440        // and it made prefill look 12× slower than it is (W2 on an RTX
4441        // 5090, ctx 512: 11.2 tok/s with the walk, 136.6 without).
4442        // CMF_PAIR=0 opts out; a model whose layers live outside
4443        // `weights.layers` has no pair walk to take.
4444        if task_mask.is_none()
4445            && !self.graph_prefill_preferred()
4446            && !std::env::var("CMF_PAIR").is_ok_and(|v| v == "0")
4447            && self.pair_supported()
4448        {
4449            while pos + 1 < ids.len() {
4450                let e1 = self.embed_single(ids[pos]);
4451                let e2 = self.embed_single(ids[pos + 1]);
4452                let (_, h2) = self.forward_pair(&e1, &e2, pos);
4453                self.commit_linear_scratch();
4454                hidden = h2;
4455                pos += 2;
4456            }
4457        }
4458        while pos < ids.len() {
4459            hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
4460            pos += 1;
4461        }
4462        // Harness contract: after forward_ids the cache is decode-ready —
4463        // under o1 that means sealed (bench measures the seal as part of
4464        // prefill, honestly).
4465        self.o1_seal();
4466        let normed = inference::rms_norm(
4467            &hidden,
4468            &self.weights.final_norm,
4469            self.rms_eps,
4470            self.norm_style,
4471        );
4472        Ok(self.lm_head_forward(&normed))
4473    }
4474
4475    /// Teacher-forced perplexity over a token sequence (phase-C gate:
4476    /// honest quant comparisons instead of prompt vibes).
4477    ///
4478    /// Attention is EXACT even on a model whose layers are flagged for
4479    /// the O(1) kernel — scoring the backbone is the default on purpose
4480    /// (it is the yardstick). `nll_ids_o1` scores the CONVERTED model.
4481    pub fn ppl_ids(&mut self, ids: &[u32]) -> f64 {
4482        let (nll, cnt) = self.nll_ids_from(ids, 0);
4483        (nll / cnt.max(1) as f64).exp()
4484    }
4485
4486    /// DTG-MA calibration pass (Patent 2): run `ids` through the model
4487    /// (CPU path, per position) and return each layer's per-neuron
4488    /// activation mass Σ|silu(gate)·up| — the statistic the task-guided
4489    /// FFN mask is derived from.
4490    pub fn probe_ffn_mass(&mut self, ids: &[u32]) -> Vec<Vec<f64>> {
4491        self.kv_cache.clear();
4492        self.kv_history.clear();
4493        FFN_PROBE.with(|p| {
4494            *p.borrow_mut() = Some(vec![vec![0f64; self.intermediate_size]; self.num_layers]);
4495        });
4496        crate::gpu::cpu_scope(|| {
4497            for (pos, &id) in ids.iter().enumerate() {
4498                let emb = self.embed_single(id);
4499                let _ = self.forward_layers(&emb, pos, None);
4500            }
4501        });
4502        self.kv_cache.clear();
4503        self.kv_history.clear();
4504        FFN_PROBE
4505            .with(|p| p.borrow_mut().take())
4506            .unwrap_or_default()
4507    }
4508
4509    /// Teacher-forced PPL with a task mask active (sparse execution) —
4510    /// the quality gate for a DTG-MA-masked skill. Sequential per
4511    /// position: the batched prefill path is dense-only.
4512    pub fn ppl_ids_masked(&mut self, ids: &[u32], mask: &TaskMask) -> f64 {
4513        self.kv_cache.clear();
4514        self.kv_history.clear();
4515        let mut nll = 0f64;
4516        let mut cnt = 0usize;
4517        let mut hidden = vec![0f32; self.hidden_size];
4518        for (pos, &id) in ids.iter().enumerate() {
4519            if pos > 0 {
4520                inference::rms_norm_into(
4521                    &hidden,
4522                    &self.weights.final_norm,
4523                    self.rms_eps,
4524                    self.norm_style,
4525                    &mut self.ws.n1,
4526                );
4527                let mut logits = self.lm_head_forward(&self.ws.n1);
4528                let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
4529                let sum: f64 = logits.iter().map(|&v| ((v - max) as f64).exp()).sum();
4530                let p = ((logits[id as usize] - max) as f64).exp() / sum.max(1e-300);
4531                nll -= p.max(1e-300).ln();
4532                cnt += 1;
4533                attention::recycle_buf(&mut logits);
4534            }
4535            let emb = self.embed_single(id);
4536            hidden = self.forward_layers(&emb, pos, Some(mask));
4537        }
4538        self.kv_cache.clear();
4539        self.kv_history.clear();
4540        (nll / cnt.max(1) as f64).exp()
4541    }
4542
4543    /// Teacher-forced NLL sum + scored-token count over positions
4544    /// `start..len-1`, attention EXACT. Positions below `start` still
4545    /// run — they are the context — they are just not scored, so this
4546    /// pairs with `nll_ids_o1(ids, start)` over the very same tokens.
4547    ///
4548    /// Returning (nll, cnt) rather than a ppl is what lets a windowed
4549    /// caller combine windows before the exp, so every scored token
4550    /// weighs the same regardless of how the windows are cut.
4551    /// `nll_ids_from` with a task mask held active at every position.
4552    ///
4553    /// The batched prefill path does not thread masks, so this walks the
4554    /// per-position forward — slower, but it scores the file exactly the
4555    /// way `run --task` will serve it, which is the point of the gate
4556    /// that calls it. With `None` it defers to the fast path.
4557    /// Masked scoring rides the SAME batched sweep as unmasked scoring —
4558    /// the masked-inference fast path: `prefill_batch_masked` lands the
4559    /// per-visit FFN rows on the activations inside the fused arms. The
4560    /// per-position loop below remains only as the no-batch fallback.
4561    pub fn nll_ids_masked(
4562        &mut self,
4563        ids: &[u32],
4564        start: usize,
4565        task_mask: Option<&TaskMask>,
4566    ) -> (f64, usize) {
4567        self.nll_ids_inner(ids, start, task_mask)
4568    }
4569
4570    pub fn nll_ids_from(&mut self, ids: &[u32], start: usize) -> (f64, usize) {
4571        self.nll_ids_inner(ids, start, None)
4572    }
4573
4574    fn nll_ids_inner(
4575        &mut self,
4576        ids: &[u32],
4577        start: usize,
4578        task_mask: Option<&TaskMask>,
4579    ) -> (f64, usize) {
4580        self.kv_cache.clear();
4581        self.kv_history.clear();
4582        let mut nll = 0f64;
4583        let mut cnt = 0usize;
4584        if self.can_prefill_batched() {
4585            // prefill-GEMM: layer-major position chunks, lm_head batched
4586            // (254MB lm_head read once per chunk, not per position).
4587            // The layer chunk is large (grouping positions by MoE experts
4588            // wins with size), lm_head in sub-blocks (logit buffer
4589            // 32×vocab ≈ 32MB instead of 128×).
4590            const CHUNK: usize = 128;
4591            const LM_SUB: usize = 32;
4592            let n = ids.len().saturating_sub(1);
4593            let hs = self.hidden_size;
4594            let rows = self.weights.lm_head.rows();
4595            let mut pos = 0usize;
4596            while pos < n {
4597                let end = (pos + CHUNK).min(n);
4598                let bsz = end - pos;
4599                let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
4600                let mut k0 = 0usize;
4601                while k0 < bsz {
4602                    let k1 = (k0 + LM_SUB).min(bsz);
4603                    let sb = k1 - k0;
4604                    // Sub-block entirely below the scored range: the KV
4605                    // it just built is all this pass needed from it.
4606                    if pos + k1 <= start {
4607                        k0 = k1;
4608                        continue;
4609                    }
4610                    let mut normed = vec![0.0f32; sb * hs];
4611                    for k in 0..sb {
4612                        let r = inference::rms_norm(
4613                            &hb[(k0 + k) * hs..(k0 + k + 1) * hs],
4614                            &self.weights.final_norm,
4615                            self.rms_eps,
4616                            self.norm_style,
4617                        );
4618                        normed[k * hs..(k + 1) * hs].copy_from_slice(&r);
4619                    }
4620                    let mut logits = vec![0.0f32; sb * rows];
4621                    self.weights
4622                        .lm_head
4623                        .matmat(&normed, sb, &mut logits, self.pool.as_deref());
4624                    for k in 0..sb {
4625                        if pos + k0 + k < start {
4626                            continue;
4627                        }
4628                        let lg = &mut logits[k * rows..k * rows + self.vocab_size.min(rows)];
4629                        if let Some(mu) = self.logit_multiplier {
4630                            for v in lg.iter_mut() {
4631                                *v *= mu;
4632                            }
4633                        }
4634                        // Gemma-class final-logit soft-capping: the
4635                        // decode paths apply it; scoring must too, or
4636                        // the uncapped softmax misprices every token.
4637                        if let Some(c) = self.final_softcap {
4638                            for v in lg.iter_mut() {
4639                                *v = c * (*v / c).tanh();
4640                            }
4641                        }
4642                        let lg = &logits[k * rows..k * rows + self.vocab_size.min(rows)];
4643                        let target = ids[pos + k0 + k + 1] as usize;
4644                        let max = lg.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4645                        let lse: f64 = lg
4646                            .iter()
4647                            .map(|&v| ((v - max) as f64).exp())
4648                            .sum::<f64>()
4649                            .ln()
4650                            + max as f64;
4651                        nll += lse - lg[target] as f64;
4652                        cnt += 1;
4653                        if std::env::var("CMF_PPL_TRACE").is_ok() {
4654                            let top = lg
4655                                .iter()
4656                                .enumerate()
4657                                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4658                                .map(|(i, _)| i)
4659                                .unwrap_or(0);
4660                            eprintln!(
4661                                "BTRACE pos {} target {} nll {:.4} top {} lg_t {:.3} lg_top {:.3}",
4662                                pos + k0 + k,
4663                                target,
4664                                lse - lg[target] as f64,
4665                                top,
4666                                lg[target],
4667                                lg[top]
4668                            );
4669                        }
4670                    }
4671                    k0 = k1;
4672                }
4673                pos = end;
4674            }
4675            self.kv_cache.clear();
4676            self.kv_history.clear();
4677            return (nll, cnt);
4678        }
4679        for pos in 0..ids.len().saturating_sub(1) {
4680            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
4681            // Architectures whose head lives inside their own stack return
4682            // the logits out of band and a zero hidden — DeepSeek-V4 folds
4683            // its hyper-connection copies between the last layer and the
4684            // norm, so it cannot hand back a vector this loop could use.
4685            // Scoring the zeros gave a perplexity of exactly the vocabulary
4686            // size, which is a uniform distribution reported as a
4687            // measurement. `generate` already reads this channel.
4688            let out_of_band = self.graph_logits.take();
4689            if pos < start {
4690                continue;
4691            }
4692            let logits = match out_of_band {
4693                Some(lg) => lg,
4694                None => {
4695                    let normed = inference::rms_norm(
4696                        &hidden,
4697                        &self.weights.final_norm,
4698                        self.rms_eps,
4699                        self.norm_style,
4700                    );
4701                    // lm_head_forward applies the final-logit softcap itself
4702                    // — capping again here double-squashed gemma-class
4703                    // logits (tanh∘tanh) and reported a flattered ppl.
4704                    self.lm_head_forward(&normed)
4705                }
4706            };
4707            let target = ids[pos + 1] as usize;
4708            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4709            let lse: f64 = logits
4710                .iter()
4711                .map(|&v| ((v - max) as f64).exp())
4712                .sum::<f64>()
4713                .ln()
4714                + max as f64;
4715            let tok_nll = lse - logits[target] as f64;
4716            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4717                let top = logits
4718                    .iter()
4719                    .enumerate()
4720                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4721                    .map(|(i, _)| i)
4722                    .unwrap_or(0);
4723                eprintln!(
4724                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4725                    logits[target], logits[top]
4726                );
4727            }
4728            nll += tok_nll;
4729            cnt += 1;
4730        }
4731        self.kv_cache.clear();
4732        self.kv_history.clear();
4733        (nll, cnt)
4734    }
4735
4736    /// Teacher-forced NLL of the CONVERTED model: the O(1) Nyström path
4737    /// is ACTIVE over the scored positions. Returns (nll sum, scored
4738    /// count) over `prefill..len-1`.
4739    ///
4740    /// Runtime discipline, deliberately NOT the matrix probe's: the
4741    /// first `prefill` tokens run the exact prompt pass — that pass is
4742    /// what freezes the landmarks and M — and every scored position then
4743    /// goes through `NystromState::step()`, the same code decode runs.
4744    /// So the landmarks are PREFILL-frozen (what ships), not
4745    /// full-sequence oracles (what the published probe measured), and
4746    /// every scored row carries a real far field rather than sitting
4747    /// inside the exact window.
4748    ///
4749    /// Pair with `nll_ids_from(ids, prefill)` for the exact baseline
4750    /// over the identical token set — that ratio is the honest one.
4751    pub fn nll_ids_o1(&mut self, ids: &[u32], prefill: usize) -> (f64, usize) {
4752        self.kv_cache.clear();
4753        self.kv_history.clear();
4754        self.o1_begin();
4755        let n = ids.len().saturating_sub(1);
4756        let p = prefill.min(n);
4757        // Exact prompt pass over ids[..p]: the seal consumes its q/k/v.
4758        let mut pos = 0usize;
4759        if self.can_prefill_batched() {
4760            const CHUNK: usize = 128;
4761            while pos < p {
4762                let end = (pos + CHUNK).min(p);
4763                let _ = self.prefill_batch(&ids[pos..end], pos);
4764                pos = end;
4765            }
4766        } else {
4767            while pos < p {
4768                let _ = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4769                pos += 1;
4770            }
4771        }
4772        self.o1_seal();
4773
4774        let mut nll = 0f64;
4775        let mut cnt = 0usize;
4776        for pos in p..n {
4777            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4778            let normed = inference::rms_norm(
4779                &hidden,
4780                &self.weights.final_norm,
4781                self.rms_eps,
4782                self.norm_style,
4783            );
4784            // lm_head_forward applies the final-logit softcap itself —
4785            // capping again here double-squashed gemma-class logits
4786            // (tanh∘tanh) and reported a flattered ppl.
4787            let logits = self.lm_head_forward(&normed);
4788            let target = ids[pos + 1] as usize;
4789            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4790            let lse: f64 = logits
4791                .iter()
4792                .map(|&v| ((v - max) as f64).exp())
4793                .sum::<f64>()
4794                .ln()
4795                + max as f64;
4796            let tok_nll = lse - logits[target] as f64;
4797            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4798                let top = logits
4799                    .iter()
4800                    .enumerate()
4801                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4802                    .map(|(i, _)| i)
4803                    .unwrap_or(0);
4804                eprintln!(
4805                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4806                    logits[target], logits[top]
4807                );
4808            }
4809            nll += tok_nll;
4810            cnt += 1;
4811        }
4812        self.kv_cache.clear();
4813        self.kv_history.clear();
4814        (nll, cnt)
4815    }
4816
4817    /// Teacher-forced calibration data (B1): for each position, whether the
4818    /// argmax equals the actual next token, and the top-1 softmax prob
4819    /// (Born mass) under EACH temperature in `temps` — all from ONE forward
4820    /// pass (argmax/correctness are temperature-invariant; only p_max
4821    /// reshapes). Feeds `cortiq calibrate` (reliability/ECE + temperature
4822    /// fit): is the model's confidence a true property, or does it need a
4823    /// measured scaling?
4824    pub fn calib_ids(&mut self, ids: &[u32], temps: &[f32]) -> (Vec<bool>, Vec<Vec<f32>>) {
4825        self.kv_cache.clear();
4826        self.kv_history.clear();
4827        let n = ids.len().saturating_sub(1);
4828        let mut correct = Vec::with_capacity(n);
4829        let mut pmax = Vec::with_capacity(n);
4830        for pos in 0..n {
4831            let emb = self.embed_single(ids[pos]);
4832            let hidden = self.forward_layers(&emb, pos, None);
4833            let normed = inference::rms_norm(
4834                &hidden,
4835                &self.weights.final_norm,
4836                self.rms_eps,
4837                self.norm_style,
4838            );
4839            // lm_head_forward applies the final-logit softcap itself —
4840            // capping again here double-squashed gemma-class logits
4841            // (tanh∘tanh) and reported a flattered ppl.
4842            let logits = self.lm_head_forward(&normed);
4843            let target = ids[pos + 1] as usize;
4844            let (mut amax, mut mval) = (0usize, f32::NEG_INFINITY);
4845            for (i, &v) in logits.iter().enumerate() {
4846                if v > mval {
4847                    mval = v;
4848                    amax = i;
4849                }
4850            }
4851            correct.push(amax == target);
4852            let row: Vec<f32> = temps
4853                .iter()
4854                .map(|&t| {
4855                    let tt = t.max(1e-3);
4856                    let s: f32 = logits.iter().map(|&v| ((v - mval) / tt).exp()).sum();
4857                    1.0 / s.max(1e-12) // numerator at the max is exp(0)=1
4858                })
4859                .collect();
4860            pmax.push(row);
4861        }
4862        self.kv_cache.clear();
4863        self.kv_history.clear();
4864        (correct, pmax)
4865    }
4866
4867    /// Teacher-forced PPL with the dynamic router driving per-window
4868    /// skill switches (VMF experiment №2 measurement). Sequential (φ
4869    /// must update per token), returns (ppl, switch_count). The router
4870    /// must be enabled (`enable_dynamic_routing`); else this equals
4871    /// plain `ppl_ids`. The active skill when scoring token t shapes the
4872    /// logits for t+1 — on-policy over the held-out text itself.
4873    pub fn ppl_ids_dynamic(&mut self, ids: &[u32]) -> (f64, usize) {
4874        let mut router = match self.dyn_router.take() {
4875            Some(r) => r,
4876            None => return (self.ppl_ids(ids), 0),
4877        };
4878        router.reset();
4879        self.dyn_phi_seen = 0;
4880        let _ = self.set_active_skill(None);
4881
4882        self.kv_cache.clear();
4883
4884        self.kv_history.clear();
4885        let mut nll = 0f64;
4886        let mut cnt = 0usize;
4887        for pos in 0..ids.len().saturating_sub(1) {
4888            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4889            let normed = inference::rms_norm(
4890                &hidden,
4891                &self.weights.final_norm,
4892                self.rms_eps,
4893                self.norm_style,
4894            );
4895            // lm_head_forward applies the final-logit softcap itself —
4896            // capping again here double-squashed gemma-class logits
4897            // (tanh∘tanh) and reported a flattered ppl.
4898            let logits = self.lm_head_forward(&normed);
4899            let target = ids[pos + 1] as usize;
4900            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4901            let lse: f64 = logits
4902                .iter()
4903                .map(|&v| ((v - max) as f64).exp())
4904                .sum::<f64>()
4905                .ln()
4906                + max as f64;
4907            let tok_nll = lse - logits[target] as f64;
4908            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4909                let top = logits
4910                    .iter()
4911                    .enumerate()
4912                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4913                    .map(|(i, _)| i)
4914                    .unwrap_or(0);
4915                eprintln!(
4916                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4917                    logits[target], logits[top]
4918                );
4919            }
4920            nll += tok_nll;
4921            cnt += 1;
4922            // Route on the evolving φ (drives the NEXT token's skill).
4923            let phi = self.dyn_phi_ema.clone();
4924            if let Some(new_active) = router.step(&phi, pos) {
4925                let _ = self.set_active_skill(new_active);
4926            }
4927        }
4928        let switches = router.switches.len();
4929        let _ = self.set_active_skill(None);
4930        self.dyn_router = Some(router);
4931        self.kv_cache.clear();
4932        self.kv_history.clear();
4933        ((nll / cnt.max(1) as f64).exp(), switches)
4934    }
4935
4936    /// Routing probe φ (spec §9): mean-pooled hidden after `layer`.
4937    pub fn probe_phi(&mut self, ids: &[u32], layer: usize) -> Vec<f32> {
4938        self.kv_cache.clear();
4939        self.kv_history.clear();
4940        let mut acc = vec![0f32; self.hidden_size];
4941        for (pos, &id) in ids.iter().enumerate() {
4942            let h = self.forward_layers_upto(&self.embed_single(id), pos, None, Some(layer));
4943            for (a, v) in acc.iter_mut().zip(&h) {
4944                *a += v;
4945            }
4946        }
4947        let n = ids.len().max(1) as f32;
4948        for a in acc.iter_mut() {
4949            *a /= n;
4950        }
4951        self.kv_cache.clear();
4952        self.kv_history.clear();
4953        acc
4954    }
4955
4956    /// Layer-major batched prefill (prefill-GEMM): full-attention —
4957    /// per-position with the existing operators (KV grows naturally,
4958    /// causality preserved), GDN projections / FFN / MoE — batched
4959    /// (a weight row is read from DRAM once per chunk, not per
4960    /// position). Returns the hidden of all positions [b × hidden].
4961    fn prefill_batch(&mut self, ids: &[u32], start_pos: usize) -> Vec<f32> {
4962        self.prefill_batch_masked(ids, start_pos, None)
4963    }
4964
4965    /// `prefill_batch` with a task mask honored on the dense-FFN panels
4966    /// (the masked-inference fast path: full fused compute, mask lands on
4967    /// the activations). The whole-chunk GPU graph is skipped for masked
4968    /// layers by the callers' arms; the per-GEMM device paths stay in
4969    /// play because the zeroing happens on the host between them.
4970    fn prefill_batch_masked(
4971        &mut self,
4972        ids: &[u32],
4973        start_pos: usize,
4974        task_mask: Option<&TaskMask>,
4975    ) -> Vec<f32> {
4976        self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, usize::MAX)
4977    }
4978
4979    /// The layer-major batched walk over a layer span [from..upto_excl):
4980    /// the whole prefill machinery (chunk graph, batched attends, GEMM
4981    /// panels) for a PARTIAL stack — the network split's prefill rides
4982    /// the same canon as the local one. Input is token ids (embeds
4983    /// itself, coordinator side) or ready boundary hiddens (worker side).
4984    fn prefill_batch_span(
4985        &mut self,
4986        input: PrefillIn<'_>,
4987        start_pos: usize,
4988        task_mask: Option<&TaskMask>,
4989        from: usize,
4990        upto_excl: usize,
4991    ) -> Vec<f32> {
4992        let hs = self.hidden_size;
4993        let b = match input {
4994            PrefillIn::Ids(ids) => ids.len(),
4995            PrefillIn::Hidden(hb) => hb.len() / hs,
4996        };
4997        let upto_excl = upto_excl.min(self.num_layers);
4998        // The CPU embed is deferred: when the chunk graph takes the run
4999        // from layer 0 it gathers the embeddings on the device instead.
5000        // A hidden input is ready by definition.
5001        let mut h: Vec<f32>;
5002        let mut h_ready;
5003        match input {
5004            PrefillIn::Ids(_) => {
5005                h = vec![0.0; b * hs];
5006                h_ready = false;
5007            }
5008            PrefillIn::Hidden(hb) => {
5009                h = hb.to_vec();
5010                h_ready = true;
5011            }
5012        }
5013        let fill_h = |h: &mut Vec<f32>, me: &Self| {
5014            if let PrefillIn::Ids(ids) = input {
5015                for (bi, &id) in ids.iter().enumerate() {
5016                    let e = me.embed_single(id);
5017                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&e);
5018                }
5019                if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
5020                    if let Some(t) = tp.parse::<usize>().ok() {
5021                        if t >= start_pos && t < start_pos + ids.len() {
5022                            let bi = t - start_pos;
5023                            let row = &h[bi * hs..(bi + 1) * hs];
5024                            let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
5025                            eprintln!(
5026                                "BATCH pos {t} embed: id {} |h| = {n:.6} h0 {:.6} h1 {:.6} | b={} start={start_pos} ids[..8]={:?}",
5027                                ids[bi], row[0], row[1], ids.len(), &ids[..ids.len().min(8)]
5028                            );
5029                        }
5030                    }
5031                }
5032            }
5033        };
5034        let (_nkv, _hd, _rd, eps) = (
5035            self.num_kv_heads,
5036            self.head_dim,
5037            self.rotary_dim,
5038            self.rms_eps,
5039        );
5040        let pool = self.pool.clone();
5041        let norm_style = self.norm_style;
5042
5043        #[cfg(target_os = "macos")]
5044        let mut chunk_skip_until = 0usize;
5045        for li in from..upto_excl {
5046            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU
5047            // GPU chunk graph (default-on under CMF_GPU=1): a run of
5048            // consecutive eligible layers for the whole chunk in ONE
5049            // Metal submission — norm, QKV, RoPE with fused mirror
5050            // append, causal attend, O, FFN, hidden device-resident
5051            // across the run. Any refusal falls through to the CPU path.
5052            #[cfg(target_os = "macos")]
5053            if task_mask.is_none() {
5054                if li < chunk_skip_until {
5055                    continue;
5056                }
5057                // Device-side embedding needs a q8_row embedding matrix;
5058                // with any other layout the CPU fills `h` first and the
5059                // graph starts from a ready hidden (refusing the whole
5060                // run over the embedding alone kept q4t models — the
5061                // whole Nanbeige/Bonsai class — on the CPU prefill).
5062                if !h_ready && li == 0 && self.weights.embed_tokens.q8_row_parts().is_none() {
5063                    fill_h(&mut h, self);
5064                    h_ready = true;
5065                }
5066                let ids_for_embed = match input {
5067                    PrefillIn::Ids(ids) => (!h_ready && li == 0).then_some(ids),
5068                    PrefillIn::Hidden(_) => None,
5069                };
5070                let end = self.chunk_run_gpu(li, &mut h, b, start_pos, ids_for_embed, upto_excl);
5071                if end > li {
5072                    h_ready = true;
5073                    chunk_skip_until = end;
5074                    // Looped Transformer: the graph stopped at a loop
5075                    // boundary — apply final norm before the next iteration.
5076                    if self.is_loop_end(end - 1) && end < self.num_layers {
5077                        for bi in 0..b {
5078                            let normed = inference::rms_norm(
5079                                &h[bi * hs..(bi + 1) * hs],
5080                                &self.weights.final_norm,
5081                                eps,
5082                                norm_style,
5083                            );
5084                            h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
5085                        }
5086                    }
5087                    continue;
5088                }
5089            }
5090            if !h_ready {
5091                fill_h(&mut h, self);
5092                h_ready = true;
5093            }
5094            let lw = &self.weights.layers[self.phys_layer(li)];
5095            // ── attention ──
5096            match &lw.attn {
5097                AttnKind::Kda(w) => {
5098                    // Projections batched, recurrence sequential.
5099                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
5100                    let mut normed = vec![0.0f32; b * hs];
5101                    for bi in 0..b {
5102                        inference::rms_norm_into(
5103                            &h[bi * hs..(bi + 1) * hs],
5104                            &lw.input_norm,
5105                            eps,
5106                            norm_style,
5107                            &mut normed[bi * hs..(bi + 1) * hs],
5108                        );
5109                    }
5110                    let attn = crate::linear_core::kda_forward_batch(
5111                        &normed,
5112                        b,
5113                        w,
5114                        &cfg,
5115                        &mut self.kv_cache.layers[li].linear_state,
5116                        pool.as_deref(),
5117                    );
5118                    for (dst, &a) in h.iter_mut().zip(&attn) {
5119                        *dst += a;
5120                    }
5121                }
5122                AttnKind::LinearGdn(w) => {
5123                    // Projections batched, recurrence sequential.
5124                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
5125                    let mut normed = vec![0.0f32; b * hs];
5126                    for bi in 0..b {
5127                        let r = inference::rms_norm(
5128                            &h[bi * hs..(bi + 1) * hs],
5129                            &lw.input_norm,
5130                            eps,
5131                            norm_style,
5132                        );
5133                        normed[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5134                    }
5135                    let attn = crate::linear_core::gdn_forward_batch(
5136                        &normed,
5137                        b,
5138                        w,
5139                        &cfg,
5140                        &mut self.kv_cache.layers[li].linear_state,
5141                        pool.as_deref(),
5142                    );
5143                    for (dst, &a) in h.iter_mut().zip(&attn) {
5144                        *dst += a;
5145                    }
5146                }
5147                AttnKind::ShortConv(w) => {
5148                    // Projections batched over the chunk; the conv walks the
5149                    // contiguous positions in order (same ring as decode).
5150                    let cfg = self
5151                        .short_conv_cfg
5152                        .expect("short-conv layer without short_conv_cfg");
5153                    let mut normed = vec![0.0f32; b * hs];
5154                    for bi in 0..b {
5155                        inference::rms_norm_into(
5156                            &h[bi * hs..(bi + 1) * hs],
5157                            &lw.input_norm,
5158                            eps,
5159                            norm_style,
5160                            &mut normed[bi * hs..(bi + 1) * hs],
5161                        );
5162                    }
5163                    let attn = short_conv_forward_batch(
5164                        &normed,
5165                        b,
5166                        w,
5167                        &cfg,
5168                        &mut self.kv_cache.layers[li].linear_state,
5169                        pool.as_deref(),
5170                    );
5171                    for (dst, &a) in h.iter_mut().zip(&attn) {
5172                        *dst += a;
5173                    }
5174                }
5175                AttnKind::Mla(w) => {
5176                    // Per-position prefill (correctness first; latent
5177                    // batching is a later optimization).
5178                    let inv_freq_l = self.layer_inv_freq(li);
5179                    let rs = self.layer_rope_scale(li);
5180                    let mut normed = vec![0.0f32; hs];
5181                    for bi in 0..b {
5182                        inference::rms_norm_into(
5183                            &h[bi * hs..(bi + 1) * hs],
5184                            &lw.input_norm,
5185                            eps,
5186                            norm_style,
5187                            &mut normed,
5188                        );
5189                        let ao = mla_attention(
5190                            w,
5191                            &normed,
5192                            &mut self.kv_cache.layers[li],
5193                            start_pos + bi,
5194                            &inv_freq_l,
5195                            rs,
5196                            eps,
5197                            pool.as_deref(),
5198                        );
5199                        for (dst, &a) in h[bi * hs..(bi + 1) * hs].iter_mut().zip(&ao) {
5200                            *dst += a;
5201                        }
5202                    }
5203                }
5204                AttnKind::Full {
5205                    wq,
5206                    wk,
5207                    wv,
5208                    wo,
5209                    q_norm,
5210                    k_norm,
5211                    output_gate,
5212                    softplus_gate,
5213                    bias,
5214                } => {
5215                    // Chunk-GEMM QKV/O; per-position causal attention
5216                    // inside (roadmap §3 P0 — full-attention prefill no
5217                    // longer re-reads the projection weights b times).
5218                    let mut normed = vec![0.0f32; b * hs];
5219                    for bi in 0..b {
5220                        inference::rms_norm_into(
5221                            &h[bi * hs..(bi + 1) * hs],
5222                            &lw.input_norm,
5223                            eps,
5224                            norm_style,
5225                            &mut normed[bi * hs..(bi + 1) * hs],
5226                        );
5227                    }
5228                    let inv_freq_l = self.layer_inv_freq(li);
5229                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
5230                    let cfg = QwenAttnCfg {
5231                        num_heads: self.layer_num_heads(li),
5232                        num_kv_heads: nkv_l,
5233                        head_dim: hd_l,
5234                        hidden_size: hs,
5235                        position: start_pos,
5236                        inv_freq: &inv_freq_l,
5237                        rotary_dim: rd_l,
5238                        scale: self.attn_scale,
5239                        softcap: self.attn_softcap,
5240                        window: self.layer_window(li),
5241                        v_norm: self.attn_v_norm,
5242                        q_norm: q_norm.as_deref(),
5243                        k_norm: k_norm.as_deref(),
5244                        output_gate: *output_gate,
5245                        softplus_gate: softplus_gate
5246                            .as_ref()
5247                            .map(|(gate, per_head)| (gate, *per_head)),
5248                        rope_scale: self.layer_rope_scale(li),
5249                        bias: bias
5250                            .as_ref()
5251                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
5252                        rms_eps: eps,
5253                        norm_style,
5254                        pool: pool.as_deref(),
5255                    };
5256                    let mut attn = attention::qwen_attention_batch(
5257                        &normed,
5258                        b,
5259                        wq,
5260                        wk,
5261                        wv,
5262                        wo,
5263                        &mut self.kv_cache.layers[li],
5264                        &cfg,
5265                    );
5266                    if let Some(w) = &lw.attn_out_norm {
5267                        for bi in 0..b {
5268                            inference::rms_norm_into(
5269                                &attn[bi * hs..(bi + 1) * hs],
5270                                w,
5271                                eps,
5272                                norm_style,
5273                                &mut normed[bi * hs..(bi + 1) * hs],
5274                            );
5275                        }
5276                        attn.copy_from_slice(&normed);
5277                    }
5278                    for (dst, &a) in h.iter_mut().zip(&attn) {
5279                        *dst += a;
5280                    }
5281                }
5282                AttnKind::Linear(w) => {
5283                    for bi in 0..b {
5284                        let normed = inference::rms_norm(
5285                            &h[bi * hs..(bi + 1) * hs],
5286                            &lw.input_norm,
5287                            eps,
5288                            norm_style,
5289                        );
5290                        vmf_phase_forward(
5291                            &normed,
5292                            w,
5293                            &self.vmf_cfg.expect("linear layer without vmf_cfg"),
5294                            &mut self.kv_cache.layers[li].linear_state,
5295                            pool.as_deref(),
5296                        )
5297                        .iter()
5298                        .enumerate()
5299                        .for_each(|(i, &a)| h[bi * hs + i] += a);
5300                    }
5301                }
5302            }
5303
5304            // ── FFN batched ──
5305            let lw = &self.weights.layers[self.phys_layer(li)];
5306            let mut post = vec![0.0f32; b * hs];
5307            for bi in 0..b {
5308                let r =
5309                    inference::rms_norm(&h[bi * hs..(bi + 1) * hs], &lw.post_norm, eps, norm_style);
5310                post[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5311            }
5312            // A restrictive per-visit FFN row lands on the activations
5313            // inside the dense arm; an all-open row costs nothing.
5314            let mask_row = task_mask
5315                .filter(|m| m.ffn_active_count(li) < self.intermediate_size)
5316                .and_then(|m| m.ffn_masks.get(li))
5317                .map(|v| v.as_slice());
5318            let mut ffn = match &lw.ffn {
5319                FfnKind::Dense(d) => dense_ffn_batch(d, &post, b, pool.as_deref(), mask_row),
5320                FfnKind::Moe(m) => moe_ffn_batch(m, &post, b, hs, pool.as_deref(), None),
5321                // Dual-branch layers run per position (the expert branch
5322                // reads the raw residual — nothing to batch yet).
5323                FfnKind::DenseMoe(dm) => {
5324                    let mut out = vec![0.0f32; b * hs];
5325                    for bi in 0..b {
5326                        let r = dense_moe_ffn(
5327                            dm,
5328                            &post[bi * hs..(bi + 1) * hs],
5329                            &h[bi * hs..(bi + 1) * hs],
5330                            eps,
5331                            norm_style,
5332                            pool.as_deref(),
5333                        );
5334                        out[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5335                    }
5336                    out
5337                }
5338            };
5339            if let Some(w) = &lw.ffn_out_norm {
5340                for bi in 0..b {
5341                    inference::rms_norm_into(
5342                        &ffn[bi * hs..(bi + 1) * hs],
5343                        w,
5344                        eps,
5345                        norm_style,
5346                        &mut post[bi * hs..(bi + 1) * hs],
5347                    );
5348                }
5349                ffn.copy_from_slice(&post);
5350            }
5351            for (dst, &f) in h.iter_mut().zip(&ffn) {
5352                *dst += f;
5353            }
5354            if let Some(sc) = lw.layer_scale {
5355                for v in h.iter_mut() {
5356                    *v *= sc;
5357                }
5358            }
5359            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
5360                if let Some(t) = tp.parse::<usize>().ok() {
5361                    if t >= start_pos && t < start_pos + b {
5362                        let bi = t - start_pos;
5363                        let row = &h[bi * hs..(bi + 1) * hs];
5364                        let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
5365                        eprintln!(
5366                            "BATCH pos {t} after layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
5367                            row[0], row[1]
5368                        );
5369                    }
5370                }
5371            }
5372            // CMF_DEBUG_LAYERS=1: per-layer hidden-state health of the
5373            // LAST prompt position — the knife for "which layer type
5374            // breaks first" on a new architecture.
5375            if std::env::var("CMF_DEBUG_LAYERS").is_ok() {
5376                let row = &h[(b - 1) * hs..b * hs];
5377                let rms =
5378                    (row.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / hs as f64).sqrt();
5379                let mx = row.iter().fold(0f32, |m, &v| m.max(v.abs()));
5380                eprintln!(
5381                    "layer {li:>3} {:>10} ffn={:<5} rms={rms:>12.4} max={mx:>12.4}",
5382                    match &self.weights.layers[self.phys_layer(li)].attn {
5383                        AttnKind::LinearGdn(_) => "gdn",
5384                        AttnKind::Linear(_) => "vmf",
5385                        AttnKind::ShortConv(_) => "conv",
5386                        _ => "attn",
5387                    },
5388                    match &lw.ffn {
5389                        FfnKind::Moe(_) => "moe",
5390                        FfnKind::Dense(_) => "dense",
5391                        FfnKind::DenseMoe(_) => "dense+moe",
5392                    },
5393                );
5394            }
5395            // Looped Transformer: apply final norm at the end of each loop iteration.
5396            if self.is_loop_end(li) && li + 1 < self.num_layers {
5397                for bi in 0..b {
5398                    let normed = inference::rms_norm(
5399                        &h[bi * hs..(bi + 1) * hs],
5400                        &self.weights.final_norm,
5401                        eps,
5402                        norm_style,
5403                    );
5404                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
5405                }
5406            }
5407            if std::env::var("CMF_TRACE_H").is_ok() {
5408                let n = h[..hs].iter().map(|v| v.abs()).sum::<f32>() / hs as f32;
5409                let mx = h[..hs].iter().fold(0.0f32, |a, &v| a.max(v.abs()));
5410                eprintln!(
5411                    "layer {li}: mean|h|={n:.4} max|h|={mx:.2} scale={:?}",
5412                    lw.layer_scale
5413                );
5414            }
5415        }
5416        crate::gpu::set_layer(-1); // lm_head/final ops outside layer-split
5417        h
5418    }
5419
5420    /// Embed a single token.
5421    fn embed_single(&self, id: u32) -> Vec<f32> {
5422        let mut out = vec![0.0f32; self.hidden_size];
5423        if (id as usize) < self.weights.embed_tokens.rows() {
5424            self.weights.embed_tokens.row_f32(id as usize, &mut out);
5425        }
5426        if self.embed_multiplier != 1.0 {
5427            for v in out.iter_mut() {
5428                *v *= self.embed_multiplier;
5429            }
5430        }
5431        // DeepSeek-V4's hash layers route by TOKEN ID, so the id has to
5432        // reach the forward. It rides in slot 0 (the forward re-reads the
5433        // real embedding itself from the table).
5434        if self.dsv4.is_some() {
5435            let mut v = vec![0.0f32; self.hidden_size.max(1)];
5436            v[0] = id as f32;
5437            return v;
5438        }
5439        // Gemma-3n: the per-layer-embedding half needs the token ID, so
5440        // it rides appended to the embedding; the g3n forward splits it.
5441        if let Some(b) = &self.g3n {
5442            return b.0.extend_embedding(id, &out, self.pool.as_deref());
5443        }
5444        out
5445    }
5446
5447    /// A run of consecutive prefill layers on the GPU for the whole
5448    /// chunk (default-on under CMF_GPU=1; CMF_GPU_CHUNK=0 disables).
5449    /// Eligibility per layer: q8_row weights, plain full attention
5450    /// (no output gate), F32 KV, no o1/masks/gemma extras. Returns the
5451    /// first layer index NOT processed (== `li0` when the run is empty).
5452    #[cfg(target_os = "macos")]
5453    fn chunk_run_gpu(
5454        &mut self,
5455        li0: usize,
5456        h: &mut [f32],
5457        b: usize,
5458        pos0: usize,
5459        embed_ids: Option<&[u32]>,
5460        cap: usize,
5461    ) -> usize {
5462        // (The old streaming attend needed a depth bound at ~1k; the
5463        // GEMM attention scales like the CPU path and lifted it.)
5464        // CMF_GPU_CHUNK=0 disables the graph.
5465        if !crate::gpu::enabled_here()
5466            || std::env::var("CMF_GPU_CHUNK")
5467                .map(|v| v == "0")
5468                .unwrap_or(false)
5469            || b < 32
5470            || self.swa.is_some()
5471            || self.global_attn.is_some()
5472            || self.attn_v_norm
5473            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
5474        {
5475            return li0;
5476        }
5477        let Some(model) = self.model.clone() else {
5478            return li0;
5479        };
5480        let inv_freq = self.inv_freq.clone();
5481        let (nh, nkv, hd, hs) = (
5482            self.num_heads,
5483            self.num_kv_heads,
5484            self.head_dim,
5485            self.hidden_size,
5486        );
5487        // Collect the longest run of consecutive eligible layers.
5488        // Looped Transformer: stop at the loop boundary so the CPU can
5489        // apply loop_final_norm between iterations.
5490        let loop_end = if self.loop_final_norm {
5491            ((li0 / self.physical_layers) + 1) * self.physical_layers
5492        } else {
5493            self.num_layers
5494        };
5495        let mut layers: Vec<crate::gpu_metal::ChunkLayer> = Vec::new();
5496        let mut stored_at: Vec<usize> = Vec::new();
5497        for li in li0..self.num_layers.min(loop_end).min(cap) {
5498            let lw = &self.weights.layers[self.phys_layer(li)];
5499            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
5500                break;
5501            }
5502            let AttnKind::Full {
5503                wq,
5504                wk,
5505                wv,
5506                wo,
5507                q_norm,
5508                k_norm,
5509                output_gate: false,
5510                softplus_gate: None,
5511                bias,
5512            } = &lw.attn
5513            else {
5514                break;
5515            };
5516            let FfnKind::Dense(d) = &lw.ffn else { break };
5517            if d.act != Act::Silu {
5518                break;
5519            }
5520            // q8_row (row_scale populated), or q4_tiled / q4tp (row_scale
5521            // empty — their scales are in the payload). Mixing across the
5522            // seven projections of one layer is fine; the encoder branches
5523            // per weight on the tensor's dtype. Anything else refuses.
5524            fn cw(t: &QTensor) -> Option<(usize, usize, usize, &[f32])> {
5525                t.q8_row_parts()
5526                    .or_else(|| t.q4t_parts().map(|(i, r, c)| (i, r, c, &[][..])))
5527                    .or_else(|| t.q4tp_parts().map(|(i, r, c)| (i, r, c, &[][..])))
5528            }
5529            let parts = (
5530                cw(wq),
5531                cw(wk),
5532                cw(wv),
5533                cw(wo),
5534                cw(&d.gate_proj),
5535                cw(&d.up_proj),
5536                cw(&d.down_proj),
5537            );
5538            let (Some(pq), Some(pk), Some(pv), Some(po), Some(pg), Some(pu), Some(pd)) = parts
5539            else {
5540                break;
5541            };
5542            let layer = &self.kv_cache.layers[li];
5543            if layer.mode != crate::kv_cache::KvMode::F32 || layer.o1.is_some() {
5544                break;
5545            }
5546            stored_at.push(layer.head_len(0));
5547            layers.push(crate::gpu_metal::ChunkLayer {
5548                model: &model,
5549                kv_id: self.graph_kv_id,
5550                layer: li,
5551                wq: pq,
5552                wk: pk,
5553                wv: pv,
5554                wo: po,
5555                gate: pg,
5556                up: pu,
5557                down: pd,
5558                input_norm: &lw.input_norm,
5559                post_norm: &lw.post_norm,
5560                bias: bias
5561                    .as_ref()
5562                    .map(|(a, bb, cc)| (a.as_slice(), bb.as_slice(), cc.as_slice())),
5563                q_norm: q_norm.as_deref(),
5564                k_norm: k_norm.as_deref(),
5565                inv_freq: &inv_freq,
5566                rd: self.rotary_dim,
5567                nh,
5568                nkv,
5569                hd,
5570                hs,
5571                inter: d.gate_proj.rows(),
5572                gemma: matches!(self.norm_style, cortiq_core::NormStyle::Gemma),
5573                eps: self.rms_eps as f32,
5574            });
5575        }
5576        if layers.is_empty() {
5577            return li0;
5578        }
5579        let row = nkv * hd;
5580        let mut store: Vec<(Vec<f32>, Vec<f32>, Vec<f32>)> = stored_at
5581            .iter()
5582            .map(|&st| (vec![0f32; b * row], vec![0f32; b * row], vec![0f32; st + b]))
5583            .collect();
5584        let mut io: Vec<crate::gpu_metal::ChunkIo> = Vec::with_capacity(layers.len());
5585        for (i, (ok, ov, oi)) in store.iter_mut().enumerate() {
5586            let li = layers[i].layer;
5587            let layer = &self.kv_cache.layers[li];
5588            io.push(crate::gpu_metal::ChunkIo {
5589                cpu_stored: stored_at[i],
5590                cpu_k: (0..nkv).map(|g| layer.head_keys(g)).collect(),
5591                cpu_v: (0..nkv).map(|g| layer.head_values(g)).collect(),
5592                out_k: ok,
5593                out_v: ov,
5594                imp: oi,
5595            });
5596        }
5597        let n_run = layers.len();
5598        let last = layers.last().map(|l| l.layer + 1).unwrap_or(li0);
5599        // Device-side embedding when the run starts the model and the
5600        // embedding matrix is q8_row-mapped.
5601        let ep = embed_ids.and_then(|ids| {
5602            self.weights
5603                .embed_tokens
5604                .q8_row_parts()
5605                .map(|(idx, rows, _c, rs)| crate::gpu_metal::ChunkEmbed {
5606                    idx,
5607                    rows,
5608                    row_scale: rs,
5609                    ids,
5610                    mult: self.embed_multiplier,
5611                })
5612        });
5613        if embed_ids.is_some() && ep.is_none() {
5614            return li0;
5615        }
5616        if !crate::gpu_metal::chunk_run_gpu(&layers, &mut io, h, b, pos0, ep.as_ref()) {
5617            return li0;
5618        }
5619        drop(io);
5620        drop(layers);
5621        // CPU caches stay the owners of record: append the chunk rows
5622        // and bank the importance masses per layer.
5623        for (i, (ok, ov, oi)) in store.iter().enumerate().take(n_run) {
5624            let li = li0 + i;
5625            let layer = &mut self.kv_cache.layers[li];
5626            for bi in 0..b {
5627                layer.append(
5628                    &ok[bi * row..(bi + 1) * row],
5629                    &ov[bi * row..(bi + 1) * row],
5630                    &[],
5631                );
5632            }
5633            layer.accumulate_imp(oi);
5634        }
5635        last
5636    }
5637
5638    /// Is layer `li` a sliding-window (local-RoPE) layer? Gemma-3:
5639    /// every `pattern`-th layer is global, the rest are local.
5640    fn layer_is_local(&self, li: usize) -> bool {
5641        if let Some(layers) = &self.sliding_layers {
5642            return layers.get(li).copied().unwrap_or(false);
5643        }
5644        match self.swa {
5645            Some((_, pattern)) => (li + 1) % pattern.max(1) != 0,
5646            None => false,
5647        }
5648    }
5649
5650    /// The RoPE table for layer `li` (local layers may have their own;
5651    /// Gemma-4 global layers use the proportional padded table).
5652    fn layer_inv_freq(&self, li: usize) -> std::sync::Arc<Vec<f32>> {
5653        if self.layer_is_local(li) {
5654            if let Some(f) = &self.inv_freq_local {
5655                return f.clone();
5656            }
5657        } else if let Some(f) = &self.inv_freq_global {
5658            return f.clone();
5659        }
5660        self.inv_freq.clone()
5661    }
5662
5663    /// The attend window for layer `li` (None = full context).
5664    fn layer_window(&self, li: usize) -> Option<usize> {
5665        self.swa
5666            .and_then(|(w, _)| self.layer_is_local(li).then_some(w))
5667    }
5668
5669    fn layer_num_heads(&self, li: usize) -> usize {
5670        self.attention_heads_per_layer
5671            .as_ref()
5672            .and_then(|v| v.get(li).copied())
5673            .unwrap_or(self.num_heads)
5674    }
5675
5676    fn layer_rope_scale(&self, li: usize) -> f32 {
5677        if self.layer_is_local(li) {
5678            self.rope_scale_local
5679        } else {
5680            self.rope_scale
5681        }
5682    }
5683
5684    /// Attention geometry of layer `li`: (num_kv_heads, head_dim,
5685    /// rotary_dim). Gemma-4 global layers override all three.
5686    fn layer_geom(&self, li: usize) -> (usize, usize, usize) {
5687        if !self.layer_is_local(li) {
5688            if let Some((ghd, gkv)) = self.global_attn {
5689                return (gkv, ghd, ghd);
5690            }
5691        }
5692        (
5693            self.num_kv_heads,
5694            self.head_dim,
5695            if self.layer_is_local(li) {
5696                self.rotary_dim_local.unwrap_or(self.rotary_dim)
5697            } else {
5698                self.rotary_dim
5699            },
5700        )
5701    }
5702
5703    /// Forward one position through all layers (hybrid dispatch).
5704    fn forward_layers(
5705        &mut self,
5706        hidden: &[f32],
5707        position: usize,
5708        task_mask: Option<&TaskMask>,
5709    ) -> Vec<f32> {
5710        self.forward_layers_upto(hidden, position, task_mask, None)
5711    }
5712
5713    // ── Network pipeline-split building blocks (coordinator/worker) ──
5714    // A remote worker owns layers [from ..= upto] and their KV; the
5715    // coordinator owns the rest plus embed / final norm / head. Attention
5716    // causality is per-layer, so a whole prompt's boundary hiddens ship
5717    // as one batch and decode ships one vector per token.
5718
5719    /// Embed one token id (embed multiplier applied).
5720    pub fn embed_id(&self, id: u32) -> Vec<f32> {
5721        self.embed_single(id)
5722    }
5723
5724    /// Refuse the archs/modes whose forward cannot be cut at a layer
5725    /// boundary. Loud by design: a split that silently changed the math
5726    /// would be a chimera.
5727    pub fn split_supported(&self) -> Result<(), String> {
5728        if self.dsv4.is_some() {
5729            return Err(
5730                "network split: DeepSeek-V4 runs its own fused stack (not splittable yet)".into(),
5731            );
5732        }
5733        if self.g3n.is_some() {
5734            return Err(
5735                "network split: Gemma-3n runs its own AltUp stack (not splittable yet)".into(),
5736            );
5737        }
5738        Ok(())
5739    }
5740
5741    /// Forward `hidden` through layers [from ..= upto] at `position`,
5742    /// appending those layers' KV/state. Both split sides call this
5743    /// over their own range; a task mask applies to the span's own
5744    /// layers (each side masks what it runs).
5745    pub fn forward_span(
5746        &mut self,
5747        hidden: &[f32],
5748        position: usize,
5749        from: usize,
5750        upto: usize,
5751        task_mask: Option<&TaskMask>,
5752    ) -> Result<Vec<f32>, String> {
5753        self.split_supported()?;
5754        if from > upto || upto >= self.num_layers {
5755            return Err(format!(
5756                "forward_span: layer range {from}..={upto} outside 0..{}",
5757                self.num_layers
5758            ));
5759        }
5760        if hidden.len() != self.hidden_size {
5761            return Err(format!(
5762                "forward_span: hidden len {} ≠ hidden_size {}",
5763                hidden.len(),
5764                self.hidden_size
5765            ));
5766        }
5767        Ok(self.forward_layers_span(hidden, position, task_mask, from, Some(upto)))
5768    }
5769
5770    /// Final norm + lm_head over a boundary hidden (the final-logit
5771    /// softcap is applied by lm_head_forward itself).
5772    pub fn logits_from_hidden(&mut self, hidden: &[f32]) -> Vec<f32> {
5773        let normed = inference::rms_norm(
5774            hidden,
5775            &self.weights.final_norm,
5776            self.rms_eps,
5777            self.norm_style,
5778        );
5779        self.lm_head_forward(&normed)
5780    }
5781
5782    /// Sample the next token with this pipeline's sampler state.
5783    pub fn sample_next(&mut self, logits: &[f32], past_tokens: &[u32]) -> u32 {
5784        sampler::sample_with_scratch(
5785            logits,
5786            &self.sampler_config,
5787            past_tokens,
5788            &mut self.rng,
5789            &mut self.sampler_scratch,
5790        )
5791    }
5792
5793    /// Fresh sequence: clear KV, reuse history and device mirrors.
5794    pub fn reset_session(&mut self) {
5795        self.kv_cache.clear();
5796        self.kv_history.clear();
5797        crate::gpu::graph_kv_reset(self.graph_kv_id);
5798    }
5799
5800    /// Batched span prefill from token ids (coordinator side): embed +
5801    /// layers [0 ..= upto]; returns the boundary hiddens of ALL positions
5802    /// (ids.len() × hidden). Rides the same layer-major machinery as the
5803    /// local prefill; falls back to the per-position walk under
5804    /// CMF_PREFILL=seq.
5805    pub fn prefill_span_ids(
5806        &mut self,
5807        ids: &[u32],
5808        start_pos: usize,
5809        upto: usize,
5810        task_mask: Option<&TaskMask>,
5811    ) -> Result<Vec<f32>, String> {
5812        self.split_supported()?;
5813        if upto >= self.num_layers {
5814            return Err(format!(
5815                "prefill_span_ids: upto {upto} outside 0..{}",
5816                self.num_layers
5817            ));
5818        }
5819        // Same predicate as the whole-stack prefill: a span whose GDN
5820        // state lives on the device must walk positions through the
5821        // graph, not through the batched CPU span.
5822        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
5823            Ok(self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, upto + 1))
5824        } else {
5825            let hs = self.hidden_size;
5826            let mut out = Vec::with_capacity(ids.len() * hs);
5827            for (i, &id) in ids.iter().enumerate() {
5828                let emb = self.embed_id(id);
5829                out.extend_from_slice(&self.forward_span(
5830                    &emb,
5831                    start_pos + i,
5832                    0,
5833                    upto,
5834                    task_mask,
5835                )?);
5836            }
5837            Ok(out)
5838        }
5839    }
5840
5841    /// Batched span prefill from boundary hiddens (worker side): layers
5842    /// [from ..= upto] for every position in the batch; returns the batch.
5843    pub fn prefill_span_hidden(
5844        &mut self,
5845        hidden: &[f32],
5846        start_pos: usize,
5847        from: usize,
5848        upto: usize,
5849        task_mask: Option<&TaskMask>,
5850    ) -> Result<Vec<f32>, String> {
5851        self.split_supported()?;
5852        let hs = self.hidden_size;
5853        if hidden.is_empty() || hidden.len() % hs != 0 {
5854            return Err(format!(
5855                "prefill_span_hidden: {} floats is not a multiple of hidden {hs}",
5856                hidden.len()
5857            ));
5858        }
5859        if from > upto || upto >= self.num_layers {
5860            return Err(format!(
5861                "prefill_span_hidden: layer range {from}..={upto} outside 0..{}",
5862                self.num_layers
5863            ));
5864        }
5865        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
5866            Ok(self.prefill_batch_span(
5867                PrefillIn::Hidden(hidden),
5868                start_pos,
5869                task_mask,
5870                from,
5871                upto + 1,
5872            ))
5873        } else {
5874            let b = hidden.len() / hs;
5875            let mut out = Vec::with_capacity(hidden.len());
5876            for i in 0..b {
5877                let h = self.forward_span(
5878                    &hidden[i * hs..(i + 1) * hs],
5879                    start_pos + i,
5880                    from,
5881                    upto,
5882                    task_mask,
5883                )?;
5884                out.extend_from_slice(&h);
5885            }
5886            Ok(out)
5887        }
5888    }
5889
5890    /// Build the whole-token wgpu graph for a pure-attention q1 model (every
5891    /// layer Full q1 + dense q1 FFN, no gate/bias). Returns the post-stack
5892    /// hidden (caller does final norm + lm_head), or None to fall back.
5893    fn try_token_graph_wgpu(
5894        &self,
5895        hidden: &[f32],
5896        position: usize,
5897        logits_out: &mut Vec<f32>,
5898        layers_run: &mut usize,
5899    ) -> Option<Vec<f32>> {
5900        self.try_token_graph_wgpu_steps(
5901            hidden,
5902            position,
5903            logits_out,
5904            1,
5905            None,
5906            Some(layers_run),
5907            0,
5908            self.num_layers,
5909        )
5910    }
5911
5912    /// The span twin (network split): the graph covers [from..upto_excl)
5913    /// — one submit per SEGMENT per token. lm_head folds in only when
5914    /// the span reaches the last layer.
5915    fn try_token_graph_wgpu_span(
5916        &self,
5917        hidden: &[f32],
5918        position: usize,
5919        logits_out: &mut Vec<f32>,
5920        from: usize,
5921        upto_excl: usize,
5922        layers_run: &mut usize,
5923    ) -> Option<Vec<f32>> {
5924        self.try_token_graph_wgpu_steps(
5925            hidden,
5926            position,
5927            logits_out,
5928            1,
5929            None,
5930            Some(layers_run),
5931            from,
5932            upto_excl,
5933        )
5934    }
5935
5936    /// Greedy burst: forward `t_next` and let the device pick + re-embed
5937    /// the next k−1 tokens — k frames, ONE submit, k ids back. The ZML
5938    /// trade, on wgpu. None ⇒ caller keeps the per-token path.
5939    fn try_multi_burst(&self, t_next: u32, position: usize, k: usize) -> Option<Vec<u32>> {
5940        if self.o1_active() || self.attn_softcap > 0.0 {
5941            return None;
5942        }
5943        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
5944        if !graph_on || crate::gpu::graph_unsupported() {
5945            // Same memo as the decode site: this path builds the very
5946            // same graph, so a model it cannot build for must not be
5947            // walked again here either. Missing this guard was worth
5948            // 2.5x on an Adreno — 0.361 tok/s against 0.905 — because
5949            // the burst retried per token what decode had already given
5950            // up on.
5951            return None;
5952        }
5953        let emb = self.embed_single(t_next);
5954        let mut lg = Vec::new();
5955        let mut ids = Vec::new();
5956        self.try_token_graph_wgpu_steps(
5957            &emb,
5958            position,
5959            &mut lg,
5960            k,
5961            Some(&mut ids),
5962            None,
5963            0,
5964            self.num_layers,
5965        )?;
5966        (ids.len() == k).then_some(ids)
5967    }
5968
5969    /// Multi-step greedy: k whole frames in ONE submit, argmax and re-embed
5970    /// on the device. `ids_out` receives the k winner ids; the hidden/logits
5971    /// outputs are NOT produced in that mode.
5972    fn try_token_graph_wgpu_steps(
5973        &self,
5974        hidden: &[f32],
5975        position: usize,
5976        logits_out: &mut Vec<f32>,
5977        steps: usize,
5978        ids_out: Option<&mut Vec<u32>>,
5979        layers_run: Option<&mut usize>,
5980        from: usize,
5981        upto_excl: usize,
5982    ) -> Option<Vec<f32>> {
5983        // O(1) Nyström decode runs off the sealed state, not the KV cache the
5984        // graph mirrors — never take the graph while o1 is active.
5985        let o1_gpu = std::env::var("CMF_O1_GPU").as_deref() == Ok("1");
5986        if (self.o1_active() && !o1_gpu) || self.attn_softcap > 0.0 {
5987            // Softcapped scores have no graph kernel yet — CPU owns them.
5988            // o1 rides the graph only behind CMF_O1_GPU=1 while the port
5989            // proves itself; without it the CPU path owns o1 as before.
5990            return None;
5991        }
5992        // Per-layer sealed o1 state for the graph. During prefill the
5993        // state is still Collecting -> views are None -> the graph
5994        // refuses below and the CPU prefill records the q trace and
5995        // seals, exactly as the o1 design requires.
5996        let o1_views: Vec<Option<Vec<crate::nystrom::O1DeviceView<'_>>>> = (from..upto_excl)
5997            .map(|li| {
5998                if !o1_gpu {
5999                    return None;
6000                }
6001                self.kv_cache.layers[self.phys_layer(li)].o1_views()
6002            })
6003            .collect();
6004        if self.o1_active() && o1_gpu {
6005            // Any o1 layer not sealed (or degenerate exact-only) keeps the
6006            // whole token on the CPU: half-graph forwards would desync.
6007            let want: usize = (from..upto_excl)
6008                .filter(|li| !matches!(self.kv_cache.layers[self.phys_layer(*li)].o1, None))
6009                .count();
6010            let have = o1_views.iter().filter(|v| v.is_some()).count();
6011            if want == 0 || have != want {
6012                // The silent twin of the gpu-side o1 gates, found the
6013                // same way: a 15x decode drop with an empty log. Views
6014                // stay None until the layer's state SEALS, so `have`
6015                // lagging `want` early in a run is the o1 design working
6016                // — but it must say so, or the next reader spends a
6017                // night proving the kernels innocent.
6018                // On CHANGE, not once: the first decline is the legal
6019                // unsealed prefill, and a once-print buries the state
6020                // that matters — what the count reads AFTER the seal.
6021                use std::sync::atomic::{AtomicUsize, Ordering};
6022                static LAST: AtomicUsize = AtomicUsize::new(usize::MAX);
6023                let code = have * 1000 + want;
6024                if LAST.swap(code, Ordering::Relaxed) != code {
6025                    tracing::warn!(
6026                        "o1 graph: {have} of {want} layers sealed — per-op until all seal"
6027                    );
6028                }
6029                return None;
6030            }
6031        }
6032        let nh = self.num_heads;
6033        let (nkv, hd, rd) = self.layer_geom(0);
6034        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
6035        let mut layers = Vec::with_capacity(upto_excl - from);
6036        let mut model = None;
6037        let dbg = std::env::var("CMF_GRAPH_DEBUG").is_ok();
6038        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
6039            if let Some((_, i, kind, rs)) = t.graph_weight() {
6040                return Some(crate::gpu::GraphW {
6041                    idx: i,
6042                    kind,
6043                    row_scale: rs,
6044                    data: &[],
6045                });
6046            }
6047            // Small unquantized projections (GDN in_proj_a/b) stay f32.
6048            t.as_f32().map(|d| crate::gpu::GraphW {
6049                idx: 0,
6050                kind: 4,
6051                row_scale: &[],
6052                data: d,
6053            })
6054        }
6055        for li in from..upto_excl {
6056            let lw = &self.weights.layers[self.phys_layer(li)];
6057            if dbg {
6058                let ak = match &lw.attn {
6059                    AttnKind::Mla(_) => "Mla".into(),
6060                    AttnKind::Full {
6061                        output_gate, bias, ..
6062                    } => format!("Full gate={output_gate} bias={}", bias.is_some()),
6063                    AttnKind::LinearGdn(_) => "LinearGdn".into(),
6064                    AttnKind::Kda(_) => "Kda".into(),
6065                    AttnKind::Linear(_) => "Linear".into(),
6066                    AttnKind::ShortConv(_) => "ShortConv".into(),
6067                };
6068                let fk = match &lw.ffn {
6069                    FfnKind::Dense(_) => "Dense",
6070                    FfnKind::Moe(_) => "Moe",
6071                    FfnKind::DenseMoe(_) => "DenseMoe",
6072                };
6073                eprintln!("graph L{li}: attn={ak} ffn={fk}");
6074            }
6075            let gffn = match &lw.ffn {
6076                FfnKind::DenseMoe(_) => return None, // dual branch: CPU path
6077                FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
6078                    gate: gw(&d.gate_proj)?,
6079                    up: gw(&d.up_proj)?,
6080                    down: gw(&d.down_proj)?,
6081                },
6082                FfnKind::Moe(m) => {
6083                    // v1 scope: softmax router + shared expert + uniform
6084                    // q4t expert trios (the MoE-hybrid coder class). The
6085                    // biased/sigmoid routers and adaptive τ keep the CPU
6086                    // path, where they are implemented.
6087                    if m.router_sigmoid
6088                        || m.expert_bias.is_some()
6089                        || m.route_tau.is_some()
6090                        || m.mask.is_some()
6091                    {
6092                        return None;
6093                    }
6094                    let (se, sg) = m.shared.as_ref()?;
6095                    let sgate = gw(sg.as_ref()?)?;
6096                    let router = gw(&m.router)?;
6097                    let inter = m.experts.first()?.gate_proj.rows();
6098                    let mut experts = Vec::with_capacity(m.experts.len() + 1);
6099                    // q4t or q4tp, but not both in one layer — the kernels
6100                    // are picked per layer, not per expert.
6101                    let mut q4tp: Option<bool> = None;
6102                    // The mixed 2-bit profile: q2tp gate/up over a q4tp
6103                    // down. Uniform across the layer, like `q4tp` itself.
6104                    let mut gu_q2: Option<bool> = None;
6105                    for e in m.experts.iter().chain(std::iter::once(se)) {
6106                        if !matches!(e.act, Act::Silu)
6107                            || e.gate_proj.rows() != inter
6108                            || e.up_proj.rows() != inter
6109                        {
6110                            return None;
6111                        }
6112                        let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
6113                            Some((mm, gi)) => (
6114                                mm,
6115                                gi,
6116                                e.up_proj.mapped_q4t()?.1,
6117                                e.down_proj.mapped_q4t()?.1,
6118                                false,
6119                                false,
6120                            ),
6121                            None => match e.gate_proj.mapped_q2tp() {
6122                                Some((mm, gi)) => (
6123                                    mm,
6124                                    gi,
6125                                    e.up_proj.mapped_q2tp()?.1,
6126                                    e.down_proj.mapped_q4tp()?.1,
6127                                    true,
6128                                    true,
6129                                ),
6130                                None => {
6131                                    let (mm, gi) = e.gate_proj.mapped_q4tp()?;
6132                                    (
6133                                        mm,
6134                                        gi,
6135                                        e.up_proj.mapped_q4tp()?.1,
6136                                        e.down_proj.mapped_q4tp()?.1,
6137                                        true,
6138                                        false,
6139                                    )
6140                                }
6141                            },
6142                        };
6143                        if *q4tp.get_or_insert(is_p) != is_p || *gu_q2.get_or_insert(is_q2) != is_q2
6144                        {
6145                            // The shared expert rides in the same packed
6146                            // buffer as the routed ones, so a layer that
6147                            // mixes layouts cannot be indexed by one stride.
6148                            // Say so: the symptom is a whole model quietly
6149                            // running its MoE on the CPU.
6150                            tracing::warn!(
6151                                "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."
6152                            );
6153                            return None;
6154                        }
6155                        model.get_or_insert_with(|| mm.clone());
6156                        experts.push((gi, ui, di));
6157                    }
6158                    crate::gpu::GraphFfn::Moe {
6159                        router,
6160                        shared_gate: sgate,
6161                        experts,
6162                        n_exp: m.experts.len(),
6163                        // CMF_TOPK_PROBE: timing probe only — output is WRONG.
6164                        // Fewer experts shrink the MoE arithmetic while the
6165                        // dispatch count stays identical, which is the only
6166                        // clean way to tell a launch-bound decode from a
6167                        // compute-bound one.
6168                        top_k: std::env::var("CMF_TOPK_PROBE")
6169                            .ok()
6170                            .and_then(|v| v.parse::<usize>().ok())
6171                            .filter(|k| *k > 0 && *k <= m.top_k)
6172                            .unwrap_or(m.top_k),
6173                        inter,
6174                        norm_topk: m.norm_topk_prob,
6175                        q4tp: q4tp?,
6176                        gu_q2: gu_q2.unwrap_or(false),
6177                    }
6178                }
6179            };
6180            let attn = match &lw.attn {
6181                AttnKind::Full {
6182                    wq,
6183                    wk,
6184                    wv,
6185                    wo,
6186                    q_norm,
6187                    k_norm,
6188                    output_gate,
6189                    softplus_gate,
6190                    bias,
6191                } => {
6192                    if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
6193                        return None;
6194                    }
6195                    let (m, _, _, _) = wq.graph_weight()?;
6196                    model = Some(m.clone());
6197                    crate::gpu::GraphAttn::Full {
6198                        wq: gw(wq)?,
6199                        wk: gw(wk)?,
6200                        wv: gw(wv)?,
6201                        wo: gw(wo)?,
6202                        q_norm: q_norm.as_deref(),
6203                        k_norm: k_norm.as_deref(),
6204                        bias: bias
6205                            .as_ref()
6206                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
6207                        output_gate: *output_gate,
6208                        cpu_k: self.kv_cache.layers[li].k_heads(),
6209                        cpu_v: self.kv_cache.layers[li].v_heads(),
6210                    }
6211                }
6212                AttnKind::LinearGdn(w) => {
6213                    let cfg = self.gdn_cfg?;
6214                    let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
6215                    model = Some(m.clone());
6216                    crate::gpu::GraphAttn::Gdn {
6217                        qkv: gw(&w.in_proj_qkv)?,
6218                        z: gw(&w.in_proj_z)?,
6219                        a: gw(&w.in_proj_a)?,
6220                        b: gw(&w.in_proj_b)?,
6221                        out: gw(&w.out_proj)?,
6222                        conv1d: &w.conv1d,
6223                        a_log: &w.a_log,
6224                        dt_bias: &w.dt_bias,
6225                        norm: &w.norm,
6226                        nv: cfg.num_v_heads,
6227                        nk: cfg.num_k_heads,
6228                        dk: cfg.key_head_dim,
6229                        dv: cfg.value_head_dim,
6230                        kk: cfg.conv_kernel,
6231                        cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
6232                    }
6233                }
6234                _ => return None,
6235            };
6236            layers.push(crate::gpu::GraphLayer {
6237                input_norm: &lw.input_norm,
6238                attn,
6239                post_norm: &lw.post_norm,
6240                ffn: gffn,
6241            });
6242        }
6243        let model = model?;
6244        // Fold final-norm + lm_head into the graph when this call wants logits
6245        // and the lm_head is a graphable (quantized) weight — the graph then
6246        // reads back logits (into logits_out) instead of the hidden, dropping
6247        // the separate CPU/GPU lm_head op + its sync. Never the f32 fallback:
6248        // an unquantized lm_head is vocab·hidden and must not be uploaded.
6249        let lm_gw = if upto_excl == self.num_layers
6250            && self.graph_want_logits
6251            && std::env::var("CMF_GPU_LMHEAD")
6252                .map(|v| v != "0")
6253                .unwrap_or(true)
6254        {
6255            self.weights.lm_head.graph_weight().map(|(_, i, kind, rs)| {
6256                (
6257                    crate::gpu::GraphW {
6258                        idx: i,
6259                        kind,
6260                        row_scale: rs,
6261                        data: &[],
6262                    },
6263                    self.weights.lm_head.rows(),
6264                )
6265            })
6266        } else {
6267            None
6268        };
6269        let lm = lm_gw.as_ref().map(|(gw, rows)| (gw, *rows));
6270        // Multi-step re-embeds the winner on the device.
6271        let emb_gw = if steps > 1 {
6272            self.weights
6273                .embed_tokens
6274                .graph_weight()
6275                .map(|(_, i, kind, rs)| {
6276                    (
6277                        crate::gpu::GraphW {
6278                            idx: i,
6279                            kind,
6280                            row_scale: rs,
6281                            data: &[],
6282                        },
6283                        self.weights.embed_tokens.rows(),
6284                        self.embed_multiplier as f32,
6285                    )
6286                })
6287        } else {
6288            None
6289        };
6290
6291        // Loop boundaries: virtual layer indices after which final_norm is
6292        // applied (mid-stack only; the GLOBAL last layer's norm folds into
6293        // lm_head). Span-relative — the executor compares its enumerate
6294        // index. A span ending mid-stack keeps its boundary norm even when
6295        // it is the span's own last layer.
6296        let loop_norm_at: Vec<usize> = if self.loop_final_norm {
6297            (from..upto_excl.min(self.num_layers - 1))
6298                .filter(|&li| (li + 1) % self.physical_layers == 0)
6299                .map(|li| li - from)
6300                .collect()
6301        } else {
6302            Vec::new()
6303        };
6304        let mut h = hidden.to_vec();
6305        crate::gpu::forward_token_graph(
6306            &model,
6307            self.graph_kv_id,
6308            &layers,
6309            &o1_views,
6310            self.o1_epoch,
6311            &self.inv_freq,
6312            &mut h,
6313            nh,
6314            nkv,
6315            hd,
6316            rd,
6317            self.hidden_size,
6318            self.intermediate_size,
6319            position,
6320            self.kv_cache.max_seq_len,
6321            gemma,
6322            self.rms_eps as f32,
6323            lm,
6324            &self.weights.final_norm,
6325            logits_out,
6326            &loop_norm_at,
6327            steps,
6328            emb_gw.as_ref().map(|(gw, rows, m)| (gw, *rows, *m)),
6329            ids_out,
6330            layers_run,
6331            from,
6332            false,
6333        )
6334        .then_some(h)
6335    }
6336
6337    /// Batched prefill: k contiguous prompt positions through the whole wgpu
6338    /// graph in ONE submit (projections/FFN as GEMMs). `hiddens` is [k·hidden]
6339    /// in/out (embeddings in, layer output out); KV mirror / GDN state advance.
6340    /// false ⇒ unsupported → caller keeps the per-position graph.
6341    /// The b-row Metal graph plan for the whole model: every layer as a
6342    /// GDN run or a full-attention item, all-or-nothing (a layer outside the
6343    /// graph's contract → None, the caller runs plain). Shared by the
6344    /// speculative verify and the batched prefill.
6345    #[cfg(target_os = "macos")]
6346    #[allow(clippy::type_complexity)]
6347    fn metal_rows_plan(&self) -> Option<(Vec<MetalRowsItem<'_>>, std::sync::Arc<cortiq_core::CmfModel>, Option<crate::gpu_metal::GdnGpuCfg>)> {
6348        use crate::gpu_metal::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, MetalFfn};
6349        if !crate::gpu::q1_force()
6350            || !crate::gpu::enabled_here()
6351            || std::env::var("CMF_GPU_BLOCK").map(|v| v == "0").unwrap_or(false)
6352            || self.attn_softcap > 0.0
6353            || self.o1_active()
6354            || self.swa.is_some()
6355            || self.global_attn.is_some()
6356            || self.attention_heads_per_layer.is_some()
6357            || self.attn_v_norm
6358            || self.loop_final_norm
6359            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
6360        {
6361            return None;
6362        }
6363        let attend_contract = self.head_dim % 4 == 0
6364            && self.head_dim <= 256
6365            && self.rotary_dim >= 2
6366            && self.rotary_dim <= self.head_dim
6367            && (self.rotary_dim / 2) % 32 == 0
6368            && self.num_kv_heads > 0
6369            && self.num_heads % self.num_kv_heads == 0;
6370        if !attend_contract {
6371            return None;
6372        }
6373        let mut plan: Vec<MetalRowsItem> = Vec::new();
6374        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
6375        for li in 0..self.num_layers {
6376            let lw = &self.weights.layers[self.phys_layer(li)];
6377            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
6378                return None;
6379            }
6380            let ffn = match &lw.ffn {
6381                FfnKind::Dense(d) if d.act == Act::Silu => {
6382                    let (Some(g), Some(u), Some(dn)) =
6383                        (d.gate_proj.q1_parts(), d.up_proj.q1_parts(), d.down_proj.q1_parts())
6384                    else {
6385                        return None;
6386                    };
6387                    MetalFfn::Dense { gate: g, up: u, down: dn }
6388                }
6389                _ => return None,
6390            };
6391            match &lw.attn {
6392                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
6393                    let (Some(qkv), Some(z), Some(a), Some(bb), Some(out)) = (
6394                        w.in_proj_qkv.q1_parts(),
6395                        w.in_proj_z.q1_parts(),
6396                        w.in_proj_a.f32_parts(),
6397                        w.in_proj_b.f32_parts(),
6398                        w.out_proj.q1_parts(),
6399                    ) else {
6400                        return None;
6401                    };
6402                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
6403                        model_ref.get_or_insert_with(|| model.clone());
6404                    }
6405                    let gl = GdnGpuLayer {
6406                        attn_norm: &lw.input_norm,
6407                        post_norm: &lw.post_norm,
6408                        qkv,
6409                        z,
6410                        a,
6411                        b: bb,
6412                        out,
6413                        ffn,
6414                        conv1d: &w.conv1d,
6415                        a_log: &w.a_log,
6416                        dt_bias: &w.dt_bias,
6417                        gnorm: &w.norm,
6418                    };
6419                    match plan.last_mut() {
6420                        Some(MetalRowsItem::Gdn { run, .. }) => run.push(gl),
6421                        _ => plan.push(MetalRowsItem::Gdn { run: vec![gl], first: li }),
6422                    }
6423                }
6424                AttnKind::Full {
6425                    wq,
6426                    wk,
6427                    wv,
6428                    wo,
6429                    q_norm,
6430                    k_norm,
6431                    output_gate,
6432                    softplus_gate: None,
6433                    bias: None,
6434                } => {
6435                    let (Some(pq), Some(pk), Some(pv), Some(po)) =
6436                        (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts())
6437                    else {
6438                        return None;
6439                    };
6440                    if let QTensor::Mapped { model, .. } = wq {
6441                        model_ref.get_or_insert_with(|| model.clone());
6442                    }
6443                    let cache = &self.kv_cache.layers[li];
6444                    if cache.mode != crate::kv_cache::KvMode::F32 || cache.o1.is_some() {
6445                        return None;
6446                    }
6447                    plan.push(MetalRowsItem::Attn {
6448                        l: AttnGpuLayer {
6449                            attn_norm: &lw.input_norm,
6450                            post_norm: &lw.post_norm,
6451                            wq: pq,
6452                            wk: pk,
6453                            wv: pv,
6454                            wo: po,
6455                            ffn,
6456                        },
6457                        li,
6458                        q_norm: q_norm.as_deref(),
6459                        k_norm: k_norm.as_deref(),
6460                        output_gate: *output_gate,
6461                    });
6462                }
6463                _ => return None,
6464            }
6465        }
6466        let model = model_ref?;
6467        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
6468            nv: cfg.num_v_heads,
6469            nk: cfg.num_k_heads,
6470            dk: cfg.key_head_dim,
6471            dv: cfg.value_head_dim,
6472            kk: cfg.conv_kernel,
6473            hidden: self.hidden_size,
6474            inter: self.intermediate_size,
6475            c_dim: cfg.conv_dim(),
6476            eps: cfg.rms_eps as f32,
6477            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6478        });
6479        Some((plan, model, gcfg))
6480    }
6481
6482    /// `AttnDeviceParams` for a plan item over the CPU cache as it stands.
6483    #[cfg(target_os = "macos")]
6484    #[allow(clippy::too_many_arguments)]
6485    fn metal_attn_params<'a>(
6486        li: usize,
6487        cache: &'a crate::kv_cache::LayerKvCache,
6488        q_norm: Option<&'a [f32]>,
6489        k_norm: Option<&'a [f32]>,
6490        output_gate: bool,
6491        inv_freq: &'a [f32],
6492        geom: (usize, usize, usize, usize),
6493        pos0: usize,
6494        kv_id: u64,
6495        eps: f32,
6496        gemma: bool,
6497    ) -> (crate::gpu_metal::AttnDeviceParams<'a>, usize) {
6498        let (nh, nkv, hd, rd) = geom;
6499        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
6500        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
6501        let cpu_stored = cpu_k[0].len() / hd;
6502        (
6503            crate::gpu_metal::AttnDeviceParams {
6504                kv_id,
6505                layer: li,
6506                nh,
6507                nkv,
6508                hd,
6509                rd,
6510                position: pos0,
6511                eps,
6512                gemma,
6513                output_gate,
6514                q_norm,
6515                k_norm,
6516                inv_freq,
6517                cpu_k,
6518                cpu_v,
6519                cpu_stored,
6520                o1: None,
6521            },
6522            cpu_stored,
6523        )
6524    }
6525
6526    /// Run the rows plan over `hiddens` (b rows at `pos0..`): validate,
6527    /// encode every item, optionally the head, sync. Returns the graph
6528    /// (for the commit / state finish) plus the GDN layer indices and the
6529    /// attention layers with the row count they were encoded against.
6530    #[cfg(target_os = "macos")]
6531    #[allow(clippy::type_complexity)]
6532    fn metal_rows_run(
6533        &mut self,
6534        hiddens: &mut [f32],
6535        pos0: usize,
6536        b: usize,
6537        prefill: bool,
6538        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
6539    ) -> Option<MetalVerifyPending> {
6540        use crate::gpu_metal::{GraphDims, VerifyGraph};
6541        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
6542        for l in &mut self.kv_cache.layers {
6543            if l.linear_state.len() != want && want > 0 {
6544                l.linear_state = vec![0f32; want];
6545            }
6546        }
6547        let (plan, model, gcfg) = self.metal_rows_plan()?;
6548        let dims = GraphDims {
6549            hidden: self.hidden_size,
6550            eps: self.rms_eps as f32,
6551            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6552        };
6553        let mut graph = if prefill {
6554            VerifyGraph::new_prefill(&model, dims, hiddens, b)?
6555        } else {
6556            VerifyGraph::new(&model, dims, hiddens, b)?
6557        };
6558        let geom = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
6559        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
6560        let eps = self.rms_eps as f32;
6561        let kv_id = self.graph_kv_id;
6562        let inv_freq = self.inv_freq.clone();
6563        for item in &plan {
6564            let ok = match item {
6565                MetalRowsItem::Gdn { run, .. } => gcfg
6566                    .as_ref()
6567                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
6568                    .unwrap_or(false),
6569                MetalRowsItem::Attn { l, li, q_norm, k_norm, output_gate } => {
6570                    let (p, _) = Self::metal_attn_params(*li, &self.kv_cache.layers[*li], *q_norm, *k_norm, *output_gate, &inv_freq, geom, pos0, kv_id, eps, gemma);
6571                    graph.attn_ok(l, &p)
6572                }
6573            };
6574            if !ok {
6575                use std::sync::atomic::{AtomicBool, Ordering};
6576                static SAID: AtomicBool = AtomicBool::new(false);
6577                if !SAID.swap(true, Ordering::Relaxed) {
6578                    tracing::warn!("metal rows graph: a layer failed preflight — declining");
6579                }
6580                return None;
6581            }
6582        }
6583        let lm = match &spec {
6584            Some((lm, _, _)) => {
6585                if !graph.lm_head_ok(*lm) {
6586                    return None;
6587                }
6588                Some(*lm)
6589            }
6590            None => None,
6591        };
6592        let mut gdn_layers = Vec::new();
6593        let mut attn_layers = Vec::new();
6594        for item in &plan {
6595            match item {
6596                MetalRowsItem::Gdn { run, first } => {
6597                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
6598                        .iter()
6599                        .map(|l| l.linear_state.as_slice())
6600                        .collect();
6601                    if !graph.encode_gdn_run_b(run, &ro, gcfg.as_ref().unwrap()) {
6602                        return None;
6603                    }
6604                    gdn_layers.extend(*first..*first + run.len());
6605                }
6606                MetalRowsItem::Attn { l, li, q_norm, k_norm, output_gate } => {
6607                    let (p, cpu_stored) = Self::metal_attn_params(*li, &self.kv_cache.layers[*li], *q_norm, *k_norm, *output_gate, &inv_freq, geom, pos0, kv_id, eps, gemma);
6608                    if !graph.encode_attn_b(l, &p) {
6609                        return None;
6610                    }
6611                    attn_layers.push((*li, cpu_stored));
6612                }
6613            }
6614        }
6615        if let (Some(lm), Some((_, final_norm, _))) = (lm, spec.as_ref()) {
6616            if !graph.encode_lm_head_b(final_norm, lm) {
6617                return None;
6618            }
6619        }
6620        graph.sync();
6621        if let Some((lm, _, logits)) = spec {
6622            logits.resize(b * lm.1, 0.0);
6623            graph.read_logits(logits);
6624        }
6625        graph.read_hidden(hiddens);
6626        Some(MetalVerifyPending { graph, gdn_layers, attn_layers })
6627    }
6628
6629    /// Native-Metal twin of `try_batch_graph_wgpu`: the b rows through the
6630    /// whole model on the `VerifyGraph` (one submit), the head folded in
6631    /// when `spec` asks; `hiddens` come back as the last layer's output
6632    /// rows, `spec.2` as `[b][lm_rows]` logits. The graph is parked in
6633    /// `metal_verify` for `metal_verify_commit`.
6634    #[cfg(target_os = "macos")]
6635    fn try_batch_graph_metal(
6636        &mut self,
6637        hiddens: &mut [f32],
6638        positions: &[usize],
6639        b: usize,
6640        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
6641    ) -> bool {
6642        let _t0 = std::time::Instant::now();
6643        if positions.len() != b
6644            || positions.windows(2).any(|w| w[1] != w[0] + 1)
6645            || hiddens.len() != b * self.hidden_size
6646        {
6647            return false;
6648        }
6649        let Some(pending) = self.metal_rows_run(hiddens, positions[0], b, false, spec) else {
6650            return false;
6651        };
6652        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
6653            eprintln!("metal-verify: {:.1} ms | b={b}", _t0.elapsed().as_secs_f64() * 1e3);
6654        }
6655        self.metal_verify = Some(pending);
6656        true
6657    }
6658
6659    /// Batched prefill on the Metal rows graph: `ids` (≤ 512) at
6660    /// `start_pos..`, states written in place, K/V rows appended to the
6661    /// CPU caches; returns every position's output hidden (`[b][hidden]`).
6662    /// None = the graph declined before touching anything.
6663    #[cfg(target_os = "macos")]
6664    fn prefill_batch_metal(&mut self, ids: &[u32], start_pos: usize) -> Option<Vec<f32>> {
6665        let b = ids.len();
6666        if b == 0 || b > 512 {
6667            return None;
6668        }
6669        let hs = self.hidden_size;
6670        let mut hiddens = vec![0f32; b * hs];
6671        for (j, &id) in ids.iter().enumerate() {
6672            let e = self.embed_single(id);
6673            hiddens[j * hs..(j + 1) * hs].copy_from_slice(&e);
6674        }
6675        let mut pending = self.metal_rows_run(&mut hiddens, start_pos, b, true, None)?;
6676        // states are final: copy them to the owners
6677        let idxs = pending.gdn_layers.clone();
6678        let mut outs: Vec<&mut [f32]> = self
6679            .kv_cache
6680            .layers
6681            .iter_mut()
6682            .enumerate()
6683            .filter(|(i, _)| idxs.binary_search(i).is_ok())
6684            .map(|(_, l)| l.linear_state.as_mut_slice())
6685            .collect();
6686        pending.graph.finish_states(&mut outs);
6687        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
6688        let mut kbuf = vec![0f32; b * nkv * hd];
6689        let mut vbuf = vec![0f32; b * nkv * hd];
6690        for (li, cpu_stored) in &pending.attn_layers {
6691            if crate::gpu_metal::kv_mirror_read_rows(self.graph_kv_id, *li, nkv, hd, *cpu_stored, b, &mut kbuf, &mut vbuf) {
6692                let cache = &mut self.kv_cache.layers[*li];
6693                for r in 0..b {
6694                    cache.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6695                }
6696                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + b);
6697            }
6698        }
6699        Some(hiddens)
6700    }
6701
6702    /// Commit a Metal verify round: replay the GDN recurrences over the
6703    /// `a + 1` accepted positions into the CPU states, append the accepted
6704    /// K/V rows from the mirrors to the CPU caches, re-point the mirrors.
6705    #[cfg(target_os = "macos")]
6706    fn metal_verify_commit(&mut self, a: usize) -> bool {
6707        let Some(mut pending) = self.metal_verify.take() else {
6708            return false;
6709        };
6710        let n = a + 1;
6711        // encode order == ascending layer order (the plan walks 0..layers)
6712        let idxs = pending.gdn_layers.clone();
6713        let mut outs: Vec<&mut [f32]> = self
6714            .kv_cache
6715            .layers
6716            .iter_mut()
6717            .enumerate()
6718            .filter(|(i, _)| idxs.binary_search(i).is_ok())
6719            .map(|(_, l)| l.linear_state.as_mut_slice())
6720            .collect();
6721        if !pending.graph.commit(n, &mut outs) {
6722            return false;
6723        }
6724        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
6725        let mut kbuf = vec![0f32; n * nkv * hd];
6726        let mut vbuf = vec![0f32; n * nkv * hd];
6727        for (li, cpu_stored) in &pending.attn_layers {
6728            if crate::gpu_metal::kv_mirror_read_rows(self.graph_kv_id, *li, nkv, hd, *cpu_stored, n, &mut kbuf, &mut vbuf) {
6729                let cache = &mut self.kv_cache.layers[*li];
6730                for r in 0..n {
6731                    cache.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6732                }
6733                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + n);
6734            }
6735        }
6736        true
6737    }
6738
6739    /// The round's warm-ups as ONE b-row graph run over the MTP block on
6740    /// Metal: `pairs` = (trunk hidden, next token) at consecutive positions
6741    /// from `first_pos`; the block's input projection is folded in, the
6742    /// appended K/V rows are pulled into the CPU MTP cache. False = the
6743    /// graph declined (nothing appended).
6744    #[cfg(target_os = "macos")]
6745    fn mtp_warm_batch_metal(&mut self, m: &mut MtpModule, pairs: &[(&[f32], u32)], first_pos: usize) -> bool {
6746        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, VerifyGraph};
6747        let b = pairs.len();
6748        if b == 0 || b > 512 || m.kv.mode != crate::kv_cache::KvMode::F32 || m.kv.o1.is_some() {
6749            return false;
6750        }
6751        let AttnKind::Full { wq, wk, wv, wo, q_norm, k_norm, output_gate, softplus_gate: None, bias: None } = &m.layer.attn else {
6752            return false;
6753        };
6754        let FfnKind::Dense(d) = &m.layer.ffn else { return false };
6755        let (Some(pq), Some(pk), Some(pv), Some(po)) = (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts()) else {
6756            return false;
6757        };
6758        let (Some(g), Some(u), Some(dn)) = (d.gate_proj.q1_parts(), d.up_proj.q1_parts(), d.down_proj.q1_parts()) else {
6759            return false;
6760        };
6761        let Some(eh) = m.eh_proj.q1_parts() else { return false };
6762        let QTensor::Mapped { model, .. } = wq else { return false };
6763        let model = model.clone();
6764        let hs = self.hidden_size;
6765        // [enorm(embed(tok)); hnorm(hidden)] rows
6766        let mut cat = vec![0f32; b * 2 * hs];
6767        for (j, (h, tok)) in pairs.iter().enumerate() {
6768            let e = self.embed_single(*tok);
6769            let (ce, ch) = cat[j * 2 * hs..(j + 1) * 2 * hs].split_at_mut(hs);
6770            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, ce);
6771            inference::rms_norm_into(h, &m.hnorm, self.rms_eps, self.norm_style, ch);
6772        }
6773        let dims = GraphDims { hidden: hs, eps: self.rms_eps as f32, gemma: self.norm_style == cortiq_core::NormStyle::Gemma };
6774        let Some(mut graph) = VerifyGraph::new_via_proj(&model, dims, eh, &cat, b) else {
6775            return false;
6776        };
6777        let l = AttnGpuLayer {
6778            attn_norm: &m.layer.input_norm,
6779            post_norm: &m.layer.post_norm,
6780            wq: pq,
6781            wk: pk,
6782            wv: pv,
6783            wo: po,
6784            ffn: MetalFfn::Dense { gate: g, up: u, down: dn },
6785        };
6786        let (nh, nkv, hd, rd) = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
6787        let inv_freq = self.inv_freq.clone();
6788        let cpu_stored;
6789        {
6790            let cache = &m.kv;
6791            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
6792            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
6793            cpu_stored = cpu_k[0].len() / hd;
6794            if cpu_stored != first_pos {
6795                return false;
6796            }
6797            let p = AttnDeviceParams {
6798                kv_id: self.mtp_kv_id(),
6799                layer: Self::MTP_LAYER_BASE,
6800                nh,
6801                nkv,
6802                hd,
6803                rd,
6804                position: first_pos,
6805                eps: self.rms_eps as f32,
6806                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6807                output_gate: *output_gate,
6808                q_norm: q_norm.as_deref(),
6809                k_norm: k_norm.as_deref(),
6810                inv_freq: &inv_freq,
6811                cpu_k,
6812                cpu_v,
6813                cpu_stored,
6814                o1: None,
6815            };
6816            if !graph.attn_ok(&l, &p) || !graph.encode_attn_b(&l, &p) {
6817                return false;
6818            }
6819        }
6820        graph.sync();
6821        let mut kbuf = vec![0f32; b * nkv * hd];
6822        let mut vbuf = vec![0f32; b * nkv * hd];
6823        if !crate::gpu_metal::kv_mirror_read_rows(self.mtp_kv_id(), Self::MTP_LAYER_BASE, nkv, hd, cpu_stored, b, &mut kbuf, &mut vbuf) {
6824            return false;
6825        }
6826        for r in 0..b {
6827            m.kv.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6828        }
6829        crate::gpu_metal::kv_mirror_set_stored(self.mtp_kv_id(), Self::MTP_LAYER_BASE, cpu_stored + b);
6830        true
6831    }
6832
6833    /// Draft-head shortlist size: `CMF_DRAFT_VOCAB` rows (default 65536,
6834    /// capped at the head; 0 = full head).
6835    fn draft_vocab_rows(head_rows: usize) -> usize {
6836        static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
6837        let n = *N.get_or_init(|| {
6838            std::env::var("CMF_DRAFT_VOCAB")
6839                .ok()
6840                .and_then(|v| v.parse().ok())
6841                .unwrap_or(65536)
6842        });
6843        if n == 0 { head_rows } else { n.min(head_rows) }
6844    }
6845
6846    /// One MTP block step on the native Metal token graph: block input on
6847    /// the host, the attention layer + FFN device-resident over the MTP
6848    /// mirror, the head folded in when `want_logits`. The appended K/V row
6849    /// is pulled into the CPU MTP cache (owner of record) after the sync.
6850    #[cfg(target_os = "macos")]
6851    fn mtp_step_metal(
6852        &mut self,
6853        m: &mut MtpModule,
6854        hidden: &[f32],
6855        next_token: u32,
6856        position: usize,
6857        want_logits: bool,
6858    ) -> Option<(Vec<f32>, Vec<f32>)> {
6859        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, TokenGraph};
6860        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0")
6861            || !crate::gpu::q1_force()
6862            || !crate::gpu::enabled_here()
6863            || self.attn_softcap > 0.0
6864            || self.attention_heads_per_layer.is_some()
6865            || m.kv.mode != crate::kv_cache::KvMode::F32
6866            || m.kv.o1.is_some()
6867        {
6868            return None;
6869        }
6870        let AttnKind::Full {
6871            wq,
6872            wk,
6873            wv,
6874            wo,
6875            q_norm,
6876            k_norm,
6877            output_gate,
6878            softplus_gate: None,
6879            bias: None,
6880        } = &m.layer.attn
6881        else {
6882            return None;
6883        };
6884        let FfnKind::Dense(d) = &m.layer.ffn else { return None };
6885        if d.act != Act::Silu {
6886            return None;
6887        }
6888        let (pq, pk, pv, po) = (wq.q1_parts()?, wk.q1_parts()?, wv.q1_parts()?, wo.q1_parts()?);
6889        let (g, u, dn) = (d.gate_proj.q1_parts()?, d.up_proj.q1_parts()?, d.down_proj.q1_parts()?);
6890        let QTensor::Mapped { model, .. } = wq else { return None };
6891        let model = model.clone();
6892        let lm = if want_logits { Some(self.weights.lm_head.q1_parts()?) } else { None };
6893        let dims = GraphDims {
6894            hidden: self.hidden_size,
6895            eps: self.rms_eps as f32,
6896            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6897        };
6898        // The block input `eh_proj · [enorm(e); hnorm(h)]` rides in the
6899        // graph (one submit a step); the host per-op matvec if it cannot.
6900        let hs = self.hidden_size;
6901        let mut x = vec![0f32; hs];
6902        let mut graph = TokenGraph::new(&model, dims, &x)?;
6903        let mut folded = false;
6904        if let Some(eh) = m.eh_proj.q1_parts() {
6905            let e = self.embed_single(next_token);
6906            let mut cat = vec![0.0f32; 2 * hs];
6907            let (cat_e, cat_h) = cat.split_at_mut(hs);
6908            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
6909            inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
6910            folded = graph.encode_input_proj(eh, &cat);
6911        }
6912        if !folded {
6913            x = self.mtp_block_input(m, hidden, next_token);
6914            graph = TokenGraph::new(&model, dims, &x)?;
6915        }
6916        let l = AttnGpuLayer {
6917            attn_norm: &m.layer.input_norm,
6918            post_norm: &m.layer.post_norm,
6919            wq: pq,
6920            wk: pk,
6921            wv: pv,
6922            wo: po,
6923            ffn: MetalFfn::Dense { gate: g, up: u, down: dn },
6924        };
6925        let (nh, nkv, hd, rd) = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
6926        let inv_freq = self.inv_freq.clone();
6927        {
6928            let cache = &m.kv;
6929            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
6930            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
6931            let cpu_stored = cpu_k[0].len() / hd;
6932            let p = AttnDeviceParams {
6933                kv_id: self.mtp_kv_id(),
6934                layer: Self::MTP_LAYER_BASE,
6935                nh,
6936                nkv,
6937                hd,
6938                rd,
6939                position,
6940                eps: self.rms_eps as f32,
6941                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6942                output_gate: *output_gate,
6943                q_norm: q_norm.as_deref(),
6944                k_norm: k_norm.as_deref(),
6945                inv_freq: &inv_freq,
6946                cpu_k,
6947                cpu_v,
6948                cpu_stored,
6949                o1: None,
6950            };
6951            if !graph.attn_device_ok(&l, &p) || !graph.encode_attn_device(&l, &p) {
6952                return None;
6953            }
6954        }
6955        // The draft's head over a vocabulary SHORTLIST (the first
6956        // CMF_DRAFT_VOCAB rows — BPE ids run roughly by merge rank, so the
6957        // low ids carry the mass): the verify keeps the full head, so a true
6958        // token past the cut is only a rejected draft, never a wrong token.
6959        // 662 MB a step on Qwen3.8 becomes 170 MB at 65536.
6960        let draft_rows = if let Some(lm) = lm { Self::draft_vocab_rows(lm.1) } else { 0 };
6961        if let Some(lm) = lm {
6962            if !graph.lm_head_ok(lm) {
6963                return None;
6964            }
6965            if draft_rows < lm.1 {
6966                if !graph.encode_lm_head_part(&m.final_norm, lm, draft_rows) {
6967                    return None;
6968                }
6969            } else {
6970                graph.encode_lm_head(&m.final_norm, lm);
6971            }
6972        }
6973        graph.sync();
6974        let mut logits = Vec::new();
6975        if let Some(lm) = lm {
6976            let n_read = draft_rows.min(lm.1).min(self.vocab_size);
6977            logits = attention::take_buf(n_read);
6978            graph.read_logits(&mut logits);
6979            // ids past the shortlist: never drafted (−∞ in every chain)
6980            logits.resize(self.vocab_size, f32::NEG_INFINITY);
6981        }
6982        graph.finish(&mut x);
6983        let mut krow = attention::take_buf(nkv * hd);
6984        let mut vrow = attention::take_buf(nkv * hd);
6985        if crate::gpu_metal::kv_mirror_read_last(self.mtp_kv_id(), Self::MTP_LAYER_BASE, nkv, hd, &mut krow, &mut vrow) {
6986            m.kv.append(&krow, &vrow, &[]);
6987        }
6988        attention::recycle_buf(&mut krow);
6989        attention::recycle_buf(&mut vrow);
6990        Some((logits, x))
6991    }
6992
6993    fn try_batch_graph_wgpu(
6994        &self,
6995        hiddens: &mut [f32],
6996        positions: &[usize],
6997        k: usize,
6998        spec: Option<crate::gpu::SpecTail<'_>>,
6999    ) -> bool {
7000        let _tb = std::time::Instant::now();
7001        if self.attn_softcap > 0.0 {
7002            return false; // capped scores: no graph kernel — CPU path
7003        }
7004        if self.o1_active() {
7005            return false;
7006        }
7007        let nh = self.num_heads;
7008        let (nkv, hd, rd) = self.layer_geom(0);
7009        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
7010        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
7011            if let Some((_, i, kind, rs)) = t.graph_weight() {
7012                return Some(crate::gpu::GraphW {
7013                    idx: i,
7014                    kind,
7015                    row_scale: rs,
7016                    data: &[],
7017                });
7018            }
7019            t.as_f32().map(|d| crate::gpu::GraphW {
7020                idx: 0,
7021                kind: 4,
7022                row_scale: &[],
7023                data: d,
7024            })
7025        }
7026        let built: Option<(
7027            Vec<crate::gpu::GraphLayer<'_>>,
7028            std::sync::Arc<cortiq_core::CmfModel>,
7029        )> = (|| {
7030            let mut layers = Vec::with_capacity(self.num_layers);
7031            let mut model = None;
7032            for li in 0..self.num_layers {
7033                let lw = &self.weights.layers[self.phys_layer(li)];
7034                // MoE routes per token, so its experts are encoded token by
7035                // token inside the batched submit while attention and the
7036                // projections stay GEMMs. Refusing MoE here is what left
7037                // prefill running one position at a time: 33 tok/s against
7038                // 54 on decode, i.e. reading the prompt was slower than
7039                // writing the answer.
7040                let gffn = match &lw.ffn {
7041                    FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
7042                        gate: gw(&d.gate_proj)?,
7043                        up: gw(&d.up_proj)?,
7044                        down: gw(&d.down_proj)?,
7045                    },
7046                    FfnKind::Moe(m) => {
7047                        if m.router_sigmoid
7048                            || m.expert_bias.is_some()
7049                            || m.route_tau.is_some()
7050                            || m.mask.is_some()
7051                        {
7052                            return None;
7053                        }
7054                        let (se, sg) = m.shared.as_ref()?;
7055                        let sgate = gw(sg.as_ref()?)?;
7056                        let router = gw(&m.router)?;
7057                        let inter = m.experts.first()?.gate_proj.rows();
7058                        let mut experts = Vec::with_capacity(m.experts.len() + 1);
7059                        let mut q4tp: Option<bool> = None;
7060                        let mut gu_q2: Option<bool> = None;
7061                        for e in m.experts.iter().chain(std::iter::once(se)) {
7062                            if !matches!(e.act, Act::Silu)
7063                                || e.gate_proj.rows() != inter
7064                                || e.up_proj.rows() != inter
7065                            {
7066                                return None;
7067                            }
7068                            // Same ladder as the token graph: q4t → q2tp
7069                            // (mixed profile: 2-bit gate/up over a q4tp
7070                            // down) → q4tp. Uniform across the layer.
7071                            let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
7072                                Some((mm, gi)) => (
7073                                    mm,
7074                                    gi,
7075                                    e.up_proj.mapped_q4t()?.1,
7076                                    e.down_proj.mapped_q4t()?.1,
7077                                    false,
7078                                    false,
7079                                ),
7080                                None => match e.gate_proj.mapped_q2tp() {
7081                                    Some((mm, gi)) => (
7082                                        mm,
7083                                        gi,
7084                                        e.up_proj.mapped_q2tp()?.1,
7085                                        e.down_proj.mapped_q4tp()?.1,
7086                                        true,
7087                                        true,
7088                                    ),
7089                                    None => {
7090                                        let (mm, gi) = e.gate_proj.mapped_q4tp()?;
7091                                        (
7092                                            mm,
7093                                            gi,
7094                                            e.up_proj.mapped_q4tp()?.1,
7095                                            e.down_proj.mapped_q4tp()?.1,
7096                                            true,
7097                                            false,
7098                                        )
7099                                    }
7100                                },
7101                            };
7102                            if *q4tp.get_or_insert(is_p) != is_p
7103                                || *gu_q2.get_or_insert(is_q2) != is_q2
7104                            {
7105                                return None;
7106                            }
7107                            model.get_or_insert_with(|| mm.clone());
7108                            experts.push((gi, ui, di));
7109                        }
7110                        crate::gpu::GraphFfn::Moe {
7111                            router,
7112                            shared_gate: sgate,
7113                            experts,
7114                            n_exp: m.experts.len(),
7115                            top_k: m.top_k,
7116                            inter,
7117                            norm_topk: m.norm_topk_prob,
7118                            q4tp: q4tp?,
7119                            gu_q2: gu_q2.unwrap_or(false),
7120                        }
7121                    }
7122                    _ => return None,
7123                };
7124                let attn = match &lw.attn {
7125                    AttnKind::Full {
7126                        wq,
7127                        wk,
7128                        wv,
7129                        wo,
7130                        q_norm,
7131                        k_norm,
7132                        output_gate,
7133                        softplus_gate,
7134                        bias,
7135                    } => {
7136                        if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
7137                            return None;
7138                        }
7139                        let (m, _, _, _) = wq.graph_weight()?;
7140                        model = Some(m.clone());
7141                        crate::gpu::GraphAttn::Full {
7142                            wq: gw(wq)?,
7143                            wk: gw(wk)?,
7144                            wv: gw(wv)?,
7145                            wo: gw(wo)?,
7146                            q_norm: q_norm.as_deref(),
7147                            k_norm: k_norm.as_deref(),
7148                            bias: bias
7149                                .as_ref()
7150                                .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
7151                            output_gate: *output_gate,
7152                            cpu_k: self.kv_cache.layers[li].k_heads(),
7153                            cpu_v: self.kv_cache.layers[li].v_heads(),
7154                        }
7155                    }
7156                    AttnKind::LinearGdn(w) => {
7157                        let cfg = self.gdn_cfg?;
7158                        let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
7159                        model = Some(m.clone());
7160                        crate::gpu::GraphAttn::Gdn {
7161                            qkv: gw(&w.in_proj_qkv)?,
7162                            z: gw(&w.in_proj_z)?,
7163                            a: gw(&w.in_proj_a)?,
7164                            b: gw(&w.in_proj_b)?,
7165                            out: gw(&w.out_proj)?,
7166                            conv1d: &w.conv1d,
7167                            a_log: &w.a_log,
7168                            dt_bias: &w.dt_bias,
7169                            norm: &w.norm,
7170                            nv: cfg.num_v_heads,
7171                            nk: cfg.num_k_heads,
7172                            dk: cfg.key_head_dim,
7173                            dv: cfg.value_head_dim,
7174                            kk: cfg.conv_kernel,
7175                            cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
7176                        }
7177                    }
7178                    _ => return None,
7179                };
7180                layers.push(crate::gpu::GraphLayer {
7181                    input_norm: &lw.input_norm,
7182                    attn,
7183                    post_norm: &lw.post_norm,
7184                    ffn: gffn,
7185                });
7186            }
7187            Some((layers, model?))
7188        })();
7189        let Some((layers, model)) = built else {
7190            {
7191                use std::sync::atomic::{AtomicBool, Ordering};
7192                static SAID: AtomicBool = AtomicBool::new(false);
7193                if !SAID.swap(true, Ordering::Relaxed) {
7194                    tracing::warn!("batch graph: BUILDER refused (layer weights/kinds)");
7195                }
7196            }
7197            return false;
7198        };
7199        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
7200            eprintln!("batch-build: {:.1} ms", _tb.elapsed().as_secs_f64() * 1e3);
7201        }
7202        crate::gpu::forward_batch_graph(
7203            &model,
7204            self.graph_kv_id,
7205            &layers,
7206            &self.inv_freq,
7207            hiddens,
7208            nh,
7209            nkv,
7210            hd,
7211            rd,
7212            self.hidden_size,
7213            self.intermediate_size,
7214            positions,
7215            self.kv_cache.max_seq_len,
7216            gemma,
7217            self.rms_eps as f32,
7218            k,
7219            spec,
7220        )
7221    }
7222
7223    /// Same, stopping after layer `upto` inclusive (routing probe φ).
7224    /// `CMF_DSV4_DRAFT_PROBE=1` — grade the draft against what the trunk goes on
7225    /// to produce. Off by default; it runs a whole draft per decoded token.
7226    fn draft_probe() -> bool {
7227        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7228        *ON.get_or_init(|| std::env::var("CMF_DSV4_DRAFT_PROBE").is_ok_and(|v| v != "0"))
7229    }
7230
7231    /// `CMF_DSV4_DRAFT_PROBE=1`: measure how much of the draft the trunk
7232    /// would have agreed with, WITHOUT verifying or rolling anything back.
7233    ///
7234    /// The number this produces decides the whole speculation design — at
7235    /// acceptance a, a block of B positions yields 1 + a + a² + ... tokens
7236    /// per trunk pass — so it is worth measuring before any of the machinery
7237    /// that would exploit it exists. Each draft is parked with the position
7238    /// it was made at, and graded as the real tokens arrive.
7239    /// `CMF_DSV4_SPEC=1` — the DeepSeek-V4 speculative decode: draft five
7240    /// on the card, verify them in one batched trunk pass, commit the
7241    /// accepted prefix, roll the rest back.
7242    #[cfg(feature = "gpu")]
7243    fn dsv4_spec_on() -> bool {
7244        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7245        *ON.get_or_init(|| {
7246            std::env::var("CMF_DSV4_SPEC")
7247                .map(|v| v != "0")
7248                .unwrap_or(true)
7249        })
7250    }
7251
7252    /// One speculative round at the decode tip. `t_next` is the token the
7253    /// sampler just committed for `next_pos`. Returns the EXTRA accepted
7254    /// tokens (possibly none) and the new position, with `graph_logits`
7255    /// left holding the last accepted position's logits — exactly what the
7256    /// loop top expects. `None` means "speculate not this round": nothing
7257    /// was committed, the caller forwards normally.
7258    #[cfg(feature = "gpu")]
7259    fn dsv4_spec_step(
7260        &mut self,
7261        tip_token: u32,
7262        t_next: u32,
7263        next_pos: usize,
7264        drafted: &mut usize,
7265        accepted_ctr: &mut usize,
7266    ) -> Option<(Vec<u32>, usize)> {
7267        let t_all = std::time::Instant::now();
7268        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7269            thread_local! {
7270                static LAST: std::cell::Cell<Option<std::time::Instant>> =
7271                    const { std::cell::Cell::new(None) };
7272            }
7273            LAST.with(|l| {
7274                if let Some(prev) = l.get() {
7275                    eprintln!(
7276                        "между раундами {:.1} мс",
7277                        prev.elapsed().as_secs_f64() * 1e3
7278                    );
7279                }
7280                l.set(Some(std::time::Instant::now()));
7281            });
7282        }
7283        if std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
7284            eprintln!("spec_step: вход pos={next_pos}");
7285        }
7286        let n_layers = self.dsv4.as_ref().map(|b| b.1.len())?;
7287        let cfg = self.dsv4.as_ref().map(|b| b.2)?;
7288        // The draft state and its capture, armed exactly as the probe does.
7289        if self.dspark.is_none() {
7290            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7291            if t.is_empty() {
7292                return None;
7293            }
7294            crate::dsv4::dspark_arm(&t, cfg.dim);
7295            self.dspark = Some(crate::dsv4::DsparkState::new(
7296                self.dsv4_mtp.len(),
7297                &cfg,
7298                t.len(),
7299            ));
7300        }
7301        let targets = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7302        let pack = crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg);
7303        if pack.is_none() && std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
7304            eprintln!("spec_step: пак не построился (targets {targets:?})");
7305        }
7306        let pack = pack?;
7307        let block = crate::dsv4::dspark_block();
7308        let b_box = self.dsv4.as_mut()?;
7309        let (g, layers, st) = (&b_box.0, &b_box.1, &mut b_box.3);
7310        let ds = self.dspark.as_mut()?;
7311        // The tip's captures: either this token ran on a normal path that
7312        // filled the thread-local, or the previous spec round left them.
7313        let dbg = std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok();
7314        if !crate::dsv4::dspark_take(&mut ds.main_hidden) && !ds.have_hidden {
7315            if dbg {
7316                eprintln!("spec_step: нет захвата");
7317            }
7318            return None;
7319        }
7320        ds.have_hidden = true;
7321        let tip_pos = next_pos.checked_sub(1)?;
7322        let draft_started = std::time::Instant::now();
7323        let mut conf = Vec::new();
7324        let props = crate::dsv4::dspark_draft_gpu(
7325            g,
7326            &self.dsv4_mtp,
7327            &cfg,
7328            ds,
7329            pack,
7330            st.kv_id,
7331            tip_token,
7332            tip_pos,
7333            self.pool.as_deref(),
7334            &mut conf,
7335        );
7336        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
7337        *drafted += block;
7338        if props.is_empty() || props[0] != t_next {
7339            if dbg {
7340                eprintln!(
7341                    "spec_step: черновик {} (props0={:?} t_next={t_next})",
7342                    if props.is_empty() {
7343                        "пуст"
7344                    } else {
7345                        "мимо"
7346                    },
7347                    props.first()
7348                );
7349            }
7350            return None;
7351        }
7352        let mut k_verify = crate::dsv4::dspark_verify_k().min(props.len());
7353        // Adaptive depth: positions the draft itself doubts are paid for on
7354        // every verify and delivered almost never (natural-text survival
7355        // [.67 .50 .29 .08 .04]). `CMF_DSPARK_CONF_MIN=p` trims the fed
7356        // prefix at the first proposal whose confidence drops below p; on
7357        // predictable text the confidences stay high and nothing changes.
7358        let conf_min = {
7359            static M: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
7360            *M.get_or_init(|| {
7361                std::env::var("CMF_DSPARK_CONF_MIN")
7362                    .ok()
7363                    .and_then(|v| v.parse().ok())
7364                    .unwrap_or(0.0)
7365            })
7366        };
7367        if conf_min > 0.0 && conf.len() >= props.len() {
7368            let mut keep = 1usize;
7369            while keep < k_verify && conf.get(keep).copied().unwrap_or(0.0) >= conf_min {
7370                keep += 1;
7371            }
7372            k_verify = k_verify.min(keep.max(2));
7373        }
7374        if k_verify < 2 {
7375            return None;
7376        }
7377        let mut fed = Vec::with_capacity(k_verify);
7378        fed.push(t_next);
7379        fed.extend_from_slice(&props[1..k_verify]);
7380        let mut argmax = Vec::new();
7381        let mut logits_all = Vec::new();
7382        let mut walked = Vec::new();
7383        let txn = crate::dsv4::dsv4_verify_chunk(
7384            g,
7385            layers,
7386            &cfg,
7387            st,
7388            &fed,
7389            next_pos,
7390            &self.inv_freq,
7391            self.pool.as_deref(),
7392            &targets,
7393            &mut argmax,
7394            &mut logits_all,
7395            &mut walked,
7396        );
7397        if txn.is_none() && dbg {
7398            eprintln!("spec_step: verify отказал");
7399        }
7400        let txn = txn?;
7401        let b = fed.len();
7402        let mut accepted = 1usize;
7403        while accepted < b && fed[accepted] == argmax[accepted - 1] {
7404            accepted += 1;
7405        }
7406        // `CMF_DSV4_SPEC_FORCE_REJECT=1` — accept nothing beyond the known
7407        // token, every round: the pure rollback exerciser. The output must
7408        // stay byte-identical to the plain walk; anything else is a
7409        // transaction bug, isolated from the acceptance logic.
7410        if std::env::var("CMF_DSV4_SPEC_FORCE_REJECT").is_ok_and(|v| v != "0") {
7411            accepted = 1;
7412        }
7413        if std::env::var("CMF_DSV4_SPEC_TRACE").is_ok() {
7414            eprintln!("spec@{next_pos}: fed={fed:?} argmax={argmax:?} accepted={accepted}");
7415        }
7416        let t_fin = std::time::Instant::now();
7417        if !crate::dsv4::dsv4_spec_finish(
7418            g,
7419            layers,
7420            &cfg,
7421            st,
7422            txn,
7423            accepted,
7424            &fed,
7425            &self.inv_freq,
7426            self.pool.as_deref(),
7427        ) {
7428            tracing::warn!("dsv4: спекулятивный откат не удался — состояние подозрительно");
7429            return None;
7430        }
7431        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7432            eprintln!(
7433                "finish(k={accepted}): {:.1} мс",
7434                t_fin.elapsed().as_secs_f64() * 1e3
7435            );
7436        }
7437        *accepted_ctr += accepted - 1;
7438        // Captures per accepted token: device targets photographed by the
7439        // batch, host targets from the verify's own walk. The last one
7440        // becomes the new tip's draft input; every one owes the ring an
7441        // entry for its position.
7442        let (hc, dim) = (cfg.hc_mult, cfg.dim);
7443        // A PARTIAL capture layer never rides the chain, so the batch has
7444        // no photograph of it — its tip capture comes from the walk's own
7445        // note like any host layer's. Filtering on the device set alone
7446        // handed the draft a never-written photo slot for exactly the
7447        // most important input (the last layer feeds main_proj), and the
7448        // split configurations drafted at 27% no matter the residency.
7449        let dev_caps: Vec<usize> = targets
7450            .iter()
7451            .copied()
7452            .filter(|&t| {
7453                st.dev_set.get(t).copied().unwrap_or(false)
7454                    && !st.partial_set.get(t).copied().unwrap_or(false)
7455            })
7456            .collect();
7457        let mut caps_all = vec![0.0f32; dev_caps.len() * b * hc * dim];
7458        if !crate::gpu_wgpu::dsv4_spec_cap_read_all(b, dev_caps.len(), hc * dim, &mut caps_all) {
7459            return None;
7460        }
7461        for t in 0..accepted {
7462            let tip = t + 1 == accepted;
7463            for (slot, &tl) in targets.iter().enumerate() {
7464                if let Some(di) = dev_caps.iter().position(|&d| d == tl) {
7465                    let lo = (di * b + t) * hc * dim;
7466                    crate::dsv4::dspark_capture(
7467                        &caps_all[lo..lo + hc * dim],
7468                        &cfg,
7469                        slot,
7470                        &mut ds.main_hidden,
7471                    );
7472                } else if tip
7473                    && crate::dsv4::dspark_peek_slot(slot, dim, {
7474                        let lo = slot * dim;
7475                        &mut ds.main_hidden[lo..lo + dim]
7476                    })
7477                {
7478                    // The tip's host-layer captures are the walk's own
7479                    // per-layer notes — exact. (The walk that ran last ended
7480                    // on exactly this token, on both the accept-all and the
7481                    // rollback path.)
7482                } else {
7483                    // Intermediate tokens: the post-tail state stands in for
7484                    // the per-layer capture on host targets below the last
7485                    // layer. Ring-entry quality only; the tip is exact.
7486                    crate::dsv4::dspark_capture(
7487                        &walked[t * hc * dim..(t + 1) * hc * dim],
7488                        &cfg,
7489                        slot,
7490                        &mut ds.main_hidden,
7491                    );
7492                }
7493            }
7494            crate::dsv4::dspark_ring_append(
7495                g,
7496                &self.dsv4_mtp,
7497                &cfg,
7498                ds,
7499                next_pos + t,
7500                self.pool.as_deref(),
7501            );
7502        }
7503        let row = logits_all[(accepted - 1) * cfg.vocab..accepted * cfg.vocab].to_vec();
7504        self.graph_logits = Some(row);
7505        // The speculative loop never runs the probe, so the trunk tally has
7506        // no other place to cycle. Armed only when someone asked for the
7507        // dump; the host tail is the only tallying path here, which is
7508        // precisely the population a partial pack would serve.
7509        if std::env::var("CMF_DSV4_TRUNK_PICK_DUMP").is_ok() {
7510            crate::dsv4::trunk_freq_note(&crate::dsv4::pick_tally_take());
7511            crate::dsv4::pick_tally_arm();
7512        }
7513        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7514            eprintln!(
7515                "spec_step total {:.1} мс (k={accepted})",
7516                t_all.elapsed().as_secs_f64() * 1e3
7517            );
7518        }
7519        Some((fed[1..accepted].to_vec(), next_pos + accepted))
7520    }
7521
7522    fn dspark_probe(&mut self, position: usize, token_id: u32) {
7523        if self.dsv4_mtp.is_empty() || !Self::draft_probe() {
7524            return;
7525        }
7526        // What the trunk just routed to, for this token.
7527        let trunk_now = crate::dsv4::pick_tally_take();
7528        crate::dsv4::trunk_freq_note(&trunk_now);
7529        if !trunk_now.is_empty() {
7530            self.dspark_trunk_picks.push(trunk_now);
7531            let keep = crate::dsv4::dspark_block();
7532            if self.dspark_trunk_picks.len() > keep {
7533                self.dspark_trunk_picks.remove(0);
7534            }
7535        }
7536        // Grade whatever is waiting: the token just decoded sits at
7537        // `position`, so it answers the draft made at `position - 1 - i`.
7538        for p in std::mem::take(&mut self.dspark_pending) {
7539            let Some(i) = position.checked_sub(p.0 + 1) else {
7540                continue;
7541            };
7542            let mut p = p;
7543            if i < p.1.len() {
7544                if p.2 && p.1[i] == token_id {
7545                    p.3 = i + 1;
7546                } else {
7547                    p.2 = false;
7548                }
7549                if i + 1 < p.1.len() {
7550                    self.dspark_pending.push(p);
7551                    continue;
7552                }
7553            }
7554            self.dspark_hist.push(p.3);
7555            self.dspark_real.push(token_id);
7556        }
7557        let Some(b) = &mut self.dsv4 else { return };
7558        let (g, layers, cfg) = (&b.0, &b.1, b.2);
7559        let n_layers = layers.len();
7560        if self.dspark.is_none() {
7561            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7562            if t.is_empty() {
7563                return;
7564            }
7565            eprintln!(
7566                "DSpark: захват со слоёв {t:?}, блок {}",
7567                crate::dsv4::dspark_block()
7568            );
7569            crate::dsv4::dspark_arm(&t, cfg.dim);
7570            self.dspark = Some(crate::dsv4::DsparkState::new(
7571                self.dsv4_mtp.len(),
7572                &cfg,
7573                t.len(),
7574            ));
7575        }
7576        let ds = self.dspark.as_mut().unwrap();
7577        if !crate::dsv4::dspark_take(&mut ds.main_hidden) {
7578            return; // this token ran on a path that captures nothing
7579        }
7580        let mut conf = Vec::new();
7581        crate::dsv4::pick_tally_arm();
7582        // The trunk has already consumed the adaptive VRAM budget. Until the
7583        // draft owns an explicit bounded device pack, its tensors are an
7584        // out-of-core CPU/disk tier by contract: never let per-op probes try
7585        // to squeeze another multi-gigabyte MTP expert cache onto the card.
7586        let draft_started = std::time::Instant::now();
7587        #[cfg(feature = "gpu")]
7588        let gpu_draft = crate::dsv4::dspark_gpu_on();
7589        #[cfg(not(feature = "gpu"))]
7590        let gpu_draft = false;
7591        let props = if gpu_draft {
7592            #[cfg(feature = "gpu")]
7593            {
7594                let kv_id = b.3.kv_id;
7595                match crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg) {
7596                    Some(pk) => crate::dsv4::dspark_draft_gpu(
7597                        g,
7598                        &self.dsv4_mtp,
7599                        &cfg,
7600                        ds,
7601                        pk,
7602                        kv_id,
7603                        token_id,
7604                        position,
7605                        self.pool.as_deref(),
7606                        &mut conf,
7607                    ),
7608                    None => Vec::new(),
7609                }
7610            }
7611            #[cfg(not(feature = "gpu"))]
7612            Vec::new()
7613        } else {
7614            crate::gpu::cpu_scope(|| {
7615                crate::dsv4::dspark_draft(
7616                    g,
7617                    &self.dsv4_mtp,
7618                    &cfg,
7619                    ds,
7620                    token_id,
7621                    position,
7622                    self.pool.as_deref(),
7623                    &mut conf,
7624                )
7625            })
7626        };
7627        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
7628        let draft_picks = crate::dsv4::pick_tally_take();
7629        crate::dsv4::dspark_freq_note(&draft_picks);
7630        // Re-arm for the NEXT trunk token; the probe runs after the forward,
7631        // so this is the only place that can.
7632        crate::dsv4::pick_tally_arm();
7633        if !props.is_empty() {
7634            // Two ratios, side by side: what a batched verify over the trunk
7635            // would read against what it asks for, and the same for the
7636            // draft's three stages. Near 1.0 means a batch amortises nothing.
7637            let (tu, tt) = {
7638                let flat: Vec<(usize, Vec<usize>)> = self
7639                    .dspark_trunk_picks
7640                    .iter()
7641                    .flat_map(|v| v.iter().cloned())
7642                    .collect();
7643                // Per layer, across the window of tokens.
7644                let mut per: std::collections::HashMap<usize, Vec<usize>> =
7645                    std::collections::HashMap::new();
7646                for (li, picks) in flat {
7647                    per.entry(li).or_default().extend(picks);
7648                }
7649                let n = per.len().max(1);
7650                let mut u = 0usize;
7651                let mut t = 0usize;
7652                for (_, v) in per {
7653                    t += v.len();
7654                    u += v.iter().collect::<std::collections::HashSet<_>>().len();
7655                }
7656                (u / n, t / n)
7657            };
7658            let (du, dt) = crate::dsv4::tally_unique(&draft_picks);
7659            self.dspark_exp.push((tu, tt, du, dt));
7660            self.dspark_pending.push((position, props, true, 0));
7661        }
7662        if self.dspark_hist.len() >= 8 && self.dspark_hist.len() % 8 == 0 {
7663            let n = self.dspark_hist.len() as f32;
7664            let mean: f32 = self.dspark_hist.iter().sum::<usize>() as f32 / n;
7665            let block = crate::dsv4::dspark_block();
7666            let mut at = vec![0usize; block + 1];
7667            for &k in &self.dspark_hist {
7668                at[k] += 1;
7669            }
7670            // Prefix survival: S_i = P(the first i positions all held).
7671            let mut surv = Vec::with_capacity(block);
7672            for i in 1..=block {
7673                let k = at[i..].iter().sum::<usize>() as f32 / n;
7674                surv.push(format!("{k:.2}"));
7675            }
7676            let distinct = self
7677                .dspark_real
7678                .iter()
7679                .collect::<std::collections::HashSet<_>>()
7680                .len();
7681            let (tu, tt, du, dt) = self.dspark_exp.iter().fold((0, 0, 0, 0), |a, b| {
7682                (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3)
7683            });
7684            let m = self.dspark_exp.len().max(1);
7685            eprintln!(
7686                "DSpark: черновиков {}, принято в среднем {mean:.2} из {block} \
7687                 (токенов за проход {:.2}), распределение {at:?}, выживание [{}]",
7688                self.dspark_hist.len(),
7689                mean + 1.0,
7690                surv.join(" ")
7691            );
7692            eprintln!(
7693                "DSpark: разных токенов {distinct} из {} (вырожденность), \
7694                 эксперты ствол {}/{} на слой за {block} токенов, \
7695                 черновик {}/{} за блок, draft {:.2} мс/блок",
7696                self.dspark_real.len(),
7697                tu / m,
7698                tt / m,
7699                du / m,
7700                dt / m,
7701                self.dspark_draft_ns as f64 / self.dspark_exp.len().max(1) as f64 / 1e6
7702            );
7703        }
7704    }
7705
7706    fn forward_layers_upto(
7707        &mut self,
7708        hidden: &[f32],
7709        position: usize,
7710        task_mask: Option<&TaskMask>,
7711        upto: Option<usize>,
7712    ) -> Vec<f32> {
7713        // In-process multi-GPU: each segment runs pinned to its card,
7714        // and the only thing crossing the boundary is one hidden vector
7715        // that never leaves this address space. Same layer split the
7716        // network mode does, minus the second process, the socket, the
7717        // serialization and the dir_hash handshake.
7718        if let Some(plan) = self.gpu_plan.clone() {
7719            if upto.is_none() && plan.len() > 1 {
7720                let mut h = hidden.to_vec();
7721                for &(dev, from, upto_incl) in plan.iter() {
7722                    h = crate::gpu::with_device(dev, || {
7723                        self.forward_layers_span(&h, position, task_mask, from, Some(upto_incl))
7724                    });
7725                }
7726                return h;
7727            }
7728        }
7729        self.forward_layers_span(hidden, position, task_mask, 0, upto)
7730    }
7731
7732    /// Split this pipeline's layer stack across local GPUs: segment i
7733    /// runs on `devices[i]`. Contiguous and even by layer count — the
7734    /// VRAM-weighted planner is the next step, and an uneven card pair
7735    /// is why it will be needed. `None` clears the plan.
7736    pub fn set_gpu_plan(&mut self, devices: Option<&[usize]>) -> Result<(), String> {
7737        self.set_gpu_plan_at(devices, None)
7738    }
7739
7740    /// The same, with an explicit first boundary (`--peer-split`): card
7741    /// 0 takes layers `[0..at)`, the rest split what remains. Uneven
7742    /// cards, or an attention-heavy head, are why this knob exists.
7743    pub fn set_gpu_plan_at(
7744        &mut self,
7745        devices: Option<&[usize]>,
7746        at: Option<usize>,
7747    ) -> Result<(), String> {
7748        let Some(devs) = devices.filter(|d| d.len() > 1) else {
7749            self.gpu_plan = None;
7750            return Ok(());
7751        };
7752        self.split_supported()?;
7753        let n = self.num_layers;
7754        if devs.len() > n {
7755            return Err(format!("{} devices for {n} layers", devs.len()));
7756        }
7757        if let Some(k) = at {
7758            if k == 0 || k >= n {
7759                return Err(format!("split at {k}: the model has {n} layers"));
7760            }
7761            if devs.len() == 2 {
7762                self.gpu_plan = Some(std::sync::Arc::new(vec![
7763                    (devs[0], 0, k - 1),
7764                    (devs[1], k, n - 1),
7765                ]));
7766                return Ok(());
7767            }
7768            return Err(format!(
7769                "an explicit split point takes exactly 2 devices, got {}",
7770                devs.len()
7771            ));
7772        }
7773        let per = n.div_ceil(devs.len());
7774        let mut plan = Vec::with_capacity(devs.len());
7775        let mut from = 0usize;
7776        for &d in devs {
7777            if from >= n {
7778                break;
7779            }
7780            let upto = (from + per - 1).min(n - 1);
7781            plan.push((d, from, upto));
7782            from = upto + 1;
7783        }
7784        self.gpu_plan = Some(std::sync::Arc::new(plan));
7785        Ok(())
7786    }
7787
7788    /// The active in-process split, if any: (device, first layer, last).
7789    pub fn gpu_plan(&self) -> Option<Vec<(usize, usize, usize)>> {
7790        self.gpu_plan.as_ref().map(|p| p.as_ref().clone())
7791    }
7792
7793    /// Layer span [from ..= upto] (upto None = last layer): the building
7794    /// block the network pipeline-split rides on. `from > 0` skips the
7795    /// arch escape hatches (the pub `forward_span` refuses those archs
7796    /// first) and the whole-token graph — the plain per-layer loop is
7797    /// the canonical executor for a partial stack.
7798    fn forward_layers_span(
7799        &mut self,
7800        hidden: &[f32],
7801        position: usize,
7802        task_mask: Option<&TaskMask>,
7803        from: usize,
7804        upto: Option<usize>,
7805    ) -> Vec<f32> {
7806        debug_assert!(from == 0 || (self.dsv4.is_none() && self.g3n.is_none()));
7807        // DeepSeek-V4 runs its own stack: the state is hc_mult copies, and
7808        // the forward returns LOGITS, not a hidden — the head is inside it
7809        // (the final fold sits between the last layer and the norm). The
7810        // token id rides in `hidden[0]`, written by embed_single, because
7811        // the hash layers route by id rather than by content.
7812        if let Some(b) = &mut self.dsv4 {
7813            let _ = (task_mask, upto);
7814            let token_id = hidden.first().copied().unwrap_or(0.0) as u32;
7815            let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
7816            st.pos = position;
7817            let mut logits = Vec::new();
7818            crate::dsv4::forward_token(
7819                g,
7820                layers,
7821                &cfg,
7822                st,
7823                token_id,
7824                &self.inv_freq,
7825                self.pool.as_deref(),
7826                &mut logits,
7827            );
7828            self.graph_logits = Some(logits);
7829            self.dspark_probe(position, token_id);
7830            // The caller expects a hidden; the logits went out of band, as
7831            // with the fused lm_head path.
7832            return vec![0.0; self.hidden_size];
7833        }
7834        // Gemma-3n runs its own stack (4 AltUp replicas don't fit this
7835        // loop); `hidden` is the extended embedding from embed_single.
7836        if let Some(b) = &self.g3n {
7837            let _ = (task_mask, upto);
7838            return crate::g3n::g3n_forward(
7839                &b.0,
7840                &b.1,
7841                hidden,
7842                position,
7843                &mut self.kv_cache.layers,
7844                self.num_heads,
7845                self.num_kv_heads,
7846                self.head_dim,
7847                self.pool.as_deref(),
7848            );
7849        }
7850        let mut h = hidden.to_vec();
7851        // Split borrows: copy scalars / clone handles so the per-layer
7852        // cfg does not hold `&self` while the KV cache is `&mut`.
7853        let (nh, _nkv, _hd, hs, _rd, eps) = (
7854            self.num_heads,
7855            self.num_kv_heads,
7856            self.head_dim,
7857            self.hidden_size,
7858            self.rotary_dim,
7859            self.rms_eps,
7860        );
7861        let pool = self.pool.clone();
7862        // Opt-in wgpu token-graph attention (discrete Vulkan/DX12): the whole
7863        // attention sub-block runs resident in one submit. Off by default.
7864        // Whole-token wgpu graph: eligibility + arbitration.
7865        //  - explicit CMF_GPU_WGPU_GRAPH forces it on/off;
7866        //  - discrete adapters (4090: decode 76 -> 137 tok/s) and GDN
7867        //    hybrids (recurrent state device-resident, no CPU twin to
7868        //    race) TRUST it;
7869        //  - integrated/mobile adapters RACE it against the normal path
7870        //    at generation granularity (gpu::graph_race_*) — tiled
7871        //    mobile GPUs can turn the ~300-dispatch graph into seconds
7872        //    per token, while a fast phone GPU keeps its win.
7873        let graph_env = std::env::var("CMF_GPU_WGPU_GRAPH").ok();
7874        let graph_on = match graph_env.as_deref() {
7875            Some("0") => false,
7876            Some("prefill") => false, // decode keeps the per-op path
7877            Some(_) => true,
7878            // Unset: same discrete-only default as every other graph
7879            // site. "Is the GPU on" used to stand in here — which made
7880            // the 0.2 tok/s whole-token graph race-eligible on mobile
7881            // adapters and cost 12-14× on first tokens (cmfmobile
7882            // TUNING.md); integrated GPUs keep the per-op probe path.
7883            None => crate::gpu::wgpu_graph_default(),
7884        };
7885        let graph_trusted =
7886            graph_env.is_some() || crate::gpu::wgpu_graph_default() || self.gdn_cfg.is_some();
7887        let race_eligible = graph_on
7888            && upto.is_none()
7889            && task_mask.is_none()
7890            && from == 0
7891            && !crate::gpu::graph_unsupported();
7892        let mut tail_start = 0usize;
7893        if race_eligible && crate::gpu::graph_race_use_graph(graph_trusted) {
7894            let t_graph = std::time::Instant::now();
7895            let mut lg = Vec::new();
7896            let mut gl = 0usize;
7897            let built = self.try_token_graph_wgpu(hidden, position, &mut lg, &mut gl);
7898            // Past the transient guards (o1 still collecting, a softcap)
7899            // a refusal is about the weights and will never change —
7900            // remember it instead of walking every layer again next
7901            // token.
7902            if built.is_none() && !self.o1_active() && self.attn_softcap == 0.0 {
7903                crate::gpu::graph_mark_unsupported();
7904            }
7905            graph_note(built.is_some());
7906            if let Some(hh) = built {
7907                let dur = t_graph.elapsed();
7908                if std::env::var("CMF_GRAPH_PROF").is_ok() {
7909                    eprintln!("graph-call: {:.2} ms total", dur.as_secs_f64() * 1000.0);
7910                }
7911                if gl > 0 && gl < self.num_layers {
7912                    // Device prefix: the graph ran layers 0..gl and handed
7913                    // back the boundary hidden — the loop below owns the
7914                    // tail. The prefix layers' KV/state advanced on the
7915                    // device; the tail's advances on the host below. One
7916                    // boundary crossing per token.
7917                    h = hh;
7918                    tail_start = gl;
7919                } else if graph_trusted || !crate::gpu::graph_race_first_token_hopeless(dur) {
7920                    if !graph_trusted {
7921                        crate::gpu::graph_race_record(true, dur);
7922                    }
7923                    if !lg.is_empty() {
7924                        // Graph produced logits (final-norm + lm_head folded in) —
7925                        // pad/cap to vocab and hand them to the sampler directly.
7926                        lg.resize(self.vocab_size, 0.0);
7927                        if let Some(c) = self.final_softcap {
7928                            for l in lg.iter_mut() {
7929                                *l = c * (*l / c).tanh();
7930                            }
7931                        }
7932                        self.graph_logits = Some(lg);
7933                    }
7934                    return hh;
7935                }
7936                // Hopeless first graph token: discard it and fall through
7937                // to the normal path. Safe exactly here — the prompt KV is
7938                // still CPU-owned (chunked prefill), so recomputing this
7939                // position is exact; the mirror's extra row is never read
7940                // (the race just settled on the normal path).
7941            }
7942        }
7943        // KIMI-LINEAR HAS NO SPLIT BUG. The 2.6× reported from the
7944        // model rotation (12.2 tok/s on one card against 4.6 on two)
7945        // was a single measurement of a model whose arm arbitration is
7946        // borderline, and it did not survive repetition. Three runs an
7947        // arm, same binary, back to back:
7948        //   probe on : 1 GPU 9.5 / 5.7 / 5.9   2 GPU 7.8 / 13.0 / 13.3
7949        //   pinned   : 1 GPU 5.6 / 5.3 / 5.2   2 GPU 3.5 / 4.2 / 3.4
7950        // With the arms pinned the split costs about 1.45×, which is
7951        // what a layer split costs. With the probe free, TWO CARDS RUN
7952        // FASTER — because for this model the CPU arm wins some op
7953        // classes and the probe finds that.
7954        //
7955        // Two things do stand, and both are measured. The token graph
7956        // builds NOTHING here (`covered 0 of 14 layers [0..14)`), so
7957        // every layer walks per-op on either arm — that is where the
7958        // headroom is, not in the split. And this model's benchmark is
7959        // unusable without `CMF_GPU_PROBE=0`: the arbitration alone
7960        // moves it by more than 2×.
7961        //
7962        // Span runs (network split): the graph covers exactly [from..=upto]
7963        // — one submit per SEGMENT per token. No race: its state is global
7964        // and calibrated on full stacks, so spans take the graph only where
7965        // it is trusted by default (discrete adapters / CMF_GPU_WGPU_GRAPH).
7966        let span = from > 0 || upto.is_some();
7967        if span && graph_on && task_mask.is_none() && graph_trusted {
7968            let upto_excl = upto.map_or(self.num_layers, |u| u + 1);
7969            let mut lg = Vec::new();
7970            let mut gl = 0usize;
7971            let span_res =
7972                self.try_token_graph_wgpu_span(hidden, position, &mut lg, from, upto_excl, &mut gl);
7973            graph_note(span_res.is_some() && gl == upto_excl - from);
7974            if std::env::var("CMF_GPU_DEBUG").is_ok() {
7975                // How much of the span the graph actually covered. A
7976                // prefix of nothing means every layer walks per-op and
7977                // the split's extra cost is elsewhere.
7978                static SEEN: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
7979                if SEEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 4 {
7980                    eprintln!(
7981                        "span graph: covered {gl} of {} layers [{from}..{upto_excl}) res={}",
7982                        upto_excl - from,
7983                        span_res.is_some()
7984                    );
7985                }
7986            }
7987            if let Some(hh) = span_res {
7988                if gl == upto_excl - from {
7989                    if !lg.is_empty() {
7990                        lg.resize(self.vocab_size, 0.0);
7991                        if let Some(c) = self.final_softcap {
7992                            for l in lg.iter_mut() {
7993                                *l = c * (*l / c).tanh();
7994                            }
7995                        }
7996                        self.graph_logits = Some(lg);
7997                    }
7998                    crate::gpu::set_layer(-1);
7999                    return hh;
8000                }
8001                // Partial device prefix of the span: CPU owns the tail.
8002                h = hh;
8003                tail_start = from + gl;
8004            }
8005        }
8006        let t_race_cpu = (race_eligible && !graph_trusted).then(std::time::Instant::now);
8007
8008        #[cfg(target_os = "macos")]
8009        let mut gpu_skip_until = 0usize;
8010        for li in tail_start.max(from)..self.num_layers {
8011            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU (CMF_GPU_LAYERS)
8012            if let Some(u) = upto {
8013                if li > u {
8014                    break;
8015                }
8016            }
8017            if let Some(mask) = task_mask {
8018                if !mask.layer_alive(li) {
8019                    continue; // dead layer: residual pass-through
8020                }
8021            }
8022            // Whole-block q1 token graph: a run of consecutive q1
8023            // layers — GDN and full attention — executes with one sync
8024            // per CPU attend instead of per op (macOS/Metal).
8025            #[cfg(target_os = "macos")]
8026            {
8027                if li < gpu_skip_until {
8028                    continue;
8029                }
8030                if task_mask.is_none() {
8031                    let end = self.q1_graph_gpu(li, upto, position, &mut h);
8032                    if end > li {
8033                        gpu_skip_until = end;
8034                        // Looped Transformer: the graph stopped at a loop
8035                        // boundary — apply final norm before the next iteration.
8036                        if self.is_loop_end(end - 1) && end < self.num_layers {
8037                            h = inference::rms_norm(
8038                                &h,
8039                                &self.weights.final_norm,
8040                                self.rms_eps,
8041                                self.norm_style,
8042                            );
8043                        }
8044                        continue;
8045                    }
8046                }
8047            }
8048
8049            let lw = &self.weights.layers[self.phys_layer(li)];
8050            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
8051                if tp.parse::<usize>().ok() == Some(position) {
8052                    let n: f32 = h.iter().map(|x| x * x).sum::<f32>().sqrt();
8053                    eprintln!(
8054                        "TRACE pos {position} layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
8055                        h[0], h[1]
8056                    );
8057                }
8058            }
8059            // Norm into the pipeline scratch — the returning rms_norm
8060            // allocated twice per layer per token (roadmap §3 P0).
8061            inference::rms_norm_into(
8062                &h,
8063                &lw.input_norm,
8064                self.rms_eps,
8065                self.norm_style,
8066                &mut self.ws.n1,
8067            );
8068
8069            let attn_out = match &lw.attn {
8070                AttnKind::Mla(w) => {
8071                    let inv_freq_l = self.layer_inv_freq(li);
8072                    let rs = self.layer_rope_scale(li);
8073                    let eps = self.rms_eps;
8074                    let pool = self.pool.clone();
8075                    mla_attention(
8076                        w,
8077                        &self.ws.n1,
8078                        &mut self.kv_cache.layers[li],
8079                        position,
8080                        &inv_freq_l,
8081                        rs,
8082                        eps,
8083                        pool.as_deref(),
8084                    )
8085                }
8086                AttnKind::Linear(w) => {
8087                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
8088                    vmf_phase_forward(
8089                        &self.ws.n1,
8090                        w,
8091                        &cfg,
8092                        &mut self.kv_cache.layers[li].linear_state,
8093                        self.pool.as_deref(),
8094                    )
8095                }
8096                AttnKind::Kda(w) => {
8097                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
8098                    crate::linear_core::kda_forward(
8099                        &self.ws.n1,
8100                        w,
8101                        &cfg,
8102                        &mut self.kv_cache.layers[li].linear_state,
8103                        self.pool.as_deref(),
8104                    )
8105                }
8106                AttnKind::LinearGdn(w) => {
8107                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
8108                    gdn_forward(
8109                        &self.ws.n1,
8110                        w,
8111                        &cfg,
8112                        &mut self.kv_cache.layers[li].linear_state,
8113                        self.pool.as_deref(),
8114                    )
8115                }
8116                AttnKind::ShortConv(w) => {
8117                    let cfg = self
8118                        .short_conv_cfg
8119                        .expect("short-conv layer without short_conv_cfg");
8120                    short_conv_forward(
8121                        &self.ws.n1,
8122                        w,
8123                        &cfg,
8124                        &mut self.kv_cache.layers[li].linear_state,
8125                        self.pool.as_deref(),
8126                    )
8127                }
8128                AttnKind::Full {
8129                    wq,
8130                    wk,
8131                    wv,
8132                    wo,
8133                    q_norm,
8134                    k_norm,
8135                    output_gate,
8136                    softplus_gate,
8137                    bias,
8138                } if self.kv_cache.layers[li].o1_sealed() => {
8139                    // O(1) override: decode on the sealed Nyström state
8140                    // instead of the growing KV cache.
8141                    let inv_freq_l = self.layer_inv_freq(li);
8142                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8143                    let cfg = QwenAttnCfg {
8144                        num_heads: self.layer_num_heads(li),
8145                        num_kv_heads: nkv_l,
8146                        head_dim: hd_l,
8147                        hidden_size: hs,
8148                        position,
8149                        inv_freq: &inv_freq_l,
8150                        rotary_dim: rd_l,
8151                        scale: self.attn_scale,
8152                        softcap: self.attn_softcap,
8153                        window: None,
8154                        v_norm: self.attn_v_norm,
8155                        q_norm: q_norm.as_deref(),
8156                        k_norm: k_norm.as_deref(),
8157                        output_gate: *output_gate,
8158                        softplus_gate: softplus_gate
8159                            .as_ref()
8160                            .map(|(gate, per_head)| (gate, *per_head)),
8161                        rope_scale: self.layer_rope_scale(li),
8162                        bias: bias
8163                            .as_ref()
8164                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
8165                        rms_eps: eps,
8166                        norm_style: self.norm_style,
8167                        pool: pool.as_deref(),
8168                    };
8169                    attention::qwen_attention_nystrom(
8170                        &self.ws.n1,
8171                        wq,
8172                        wk,
8173                        wv,
8174                        wo,
8175                        &mut self.kv_cache.layers[li],
8176                        &cfg,
8177                    )
8178                }
8179                AttnKind::Full {
8180                    wq,
8181                    wk,
8182                    wv,
8183                    wo,
8184                    q_norm,
8185                    k_norm,
8186                    output_gate,
8187                    softplus_gate,
8188                    bias,
8189                } => 'attn: {
8190                    // wgpu token-graph attention (opt-in): whole sub-block in
8191                    // one submit, device K/V mirror. q1 only, no gate/bias/mask.
8192                    if graph_on
8193                        && !*output_gate
8194                        && softplus_gate.is_none()
8195                        && self.attention_heads_per_layer.is_none()
8196                        && bias.is_none()
8197                        && task_mask.is_none()
8198                    {
8199                        let inv_freq_l = self.layer_inv_freq(li);
8200                        let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8201                        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
8202                        if let (Some((gm, qi)), Some((_, ki)), Some((_, vi)), Some((_, oi))) = (
8203                            wq.mapped_q1(),
8204                            wk.mapped_q1(),
8205                            wv.mapped_q1(),
8206                            wo.mapped_q1(),
8207                        ) {
8208                            let gm = gm.clone();
8209                            let mut out = vec![0f32; hs];
8210                            let cache = &self.kv_cache.layers[li];
8211                            if crate::gpu::attn_dropin(
8212                                &gm,
8213                                self.graph_kv_id,
8214                                li,
8215                                &self.ws.n1,
8216                                qi,
8217                                ki,
8218                                vi,
8219                                oi,
8220                                q_norm.as_deref(),
8221                                k_norm.as_deref(),
8222                                &inv_freq_l,
8223                                nh,
8224                                nkv_l,
8225                                hd_l,
8226                                rd_l,
8227                                hs,
8228                                position,
8229                                self.kv_cache.max_seq_len,
8230                                gemma,
8231                                eps as f32,
8232                                cache.k_heads(),
8233                                cache.v_heads(),
8234                                &mut out,
8235                            ) {
8236                                break 'attn out;
8237                            }
8238                        }
8239                    }
8240                    let masked = task_mask
8241                        .map(|m| m.head_flags(li, self.num_heads).iter().any(|&a| !a))
8242                        .unwrap_or(false);
8243                    let f32_view = (wq.as_f32(), wk.as_f32(), wv.as_f32(), wo.as_f32());
8244                    match (masked, f32_view) {
8245                        // Historical masked path (f32 slices; the loader
8246                        // keeps masked models in f32).
8247                        (true, (Some(q), Some(k), Some(v), Some(o))) => {
8248                            let active_heads = task_mask.unwrap().head_flags(li, self.num_heads);
8249                            attention::multi_head_attention(
8250                                &self.ws.n1,
8251                                q,
8252                                k,
8253                                v,
8254                                o,
8255                                &mut self.kv_cache.layers[li],
8256                                self.num_heads,
8257                                self.num_kv_heads,
8258                                self.head_dim,
8259                                self.hidden_size,
8260                                position,
8261                                &active_heads,
8262                                &self.inv_freq,
8263                            )
8264                        }
8265                        (masked, _) => {
8266                            if masked {
8267                                tracing::warn!(
8268                                    "layer {li}: head mask on quantized weights not \
8269                                     supported yet — executing dense"
8270                                );
8271                            }
8272                            let inv_freq_l = self.layer_inv_freq(li);
8273                            let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8274                            let cfg = QwenAttnCfg {
8275                                num_heads: self.layer_num_heads(li),
8276                                num_kv_heads: nkv_l,
8277                                head_dim: hd_l,
8278                                hidden_size: hs,
8279                                position,
8280                                inv_freq: &inv_freq_l,
8281                                rotary_dim: rd_l,
8282                                scale: self.attn_scale,
8283                                softcap: self.attn_softcap,
8284                                window: self.layer_window(li),
8285                                v_norm: self.attn_v_norm,
8286                                q_norm: q_norm.as_deref(),
8287                                k_norm: k_norm.as_deref(),
8288                                output_gate: *output_gate,
8289                                softplus_gate: softplus_gate
8290                                    .as_ref()
8291                                    .map(|(gate, per_head)| (gate, *per_head)),
8292                                rope_scale: self.layer_rope_scale(li),
8293                                bias: bias
8294                                    .as_ref()
8295                                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
8296                                rms_eps: eps,
8297                                norm_style: self.norm_style,
8298                                pool: pool.as_deref(),
8299                            };
8300                            attention::qwen_attention(
8301                                &self.ws.n1,
8302                                wq,
8303                                wk,
8304                                wv,
8305                                wo,
8306                                &mut self.kv_cache.layers[li],
8307                                &cfg,
8308                            )
8309                        }
8310                    }
8311                }
8312            };
8313            // Gemma sandwich norm: normalize the attention branch before
8314            // it joins the residual stream.
8315            let attn_out = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
8316                Some(w) => inference::rms_norm(&attn_out, w, self.rms_eps, self.norm_style),
8317                None => attn_out,
8318            };
8319            let lw = &self.weights.layers[self.phys_layer(li)];
8320            inference::add_rmsnorm_fused_into(
8321                &mut h,
8322                &attn_out,
8323                &lw.post_norm,
8324                self.rms_eps,
8325                self.norm_style,
8326                &mut self.ws.p1,
8327            );
8328            let mut attn_out = attn_out;
8329            attention::recycle_buf(&mut attn_out);
8330            let post_normed = &self.ws.p1;
8331
8332            let ffn_masked = task_mask
8333                .map(|m| m.ffn_active_count(li) < self.intermediate_size)
8334                .unwrap_or(false);
8335            // One masked dense CONTRACT, dispatched by cost. The
8336            // activation-zeroing arm (the batched sweep's, validated
8337            // against the replica to 0.8%) computes the FULL fused FFN
8338            // and zeroes the dead — right whenever most neurons live.
8339            // The sparse arm reads ONLY active rows and down columns —
8340            // per-row dots are slower per element than the fused kernel,
8341            // so it pays only once the mask is deep enough. The 0.5
8342            // crossover is first-principles (fused kernels run ~2x the
8343            // per-row dot throughput); a shallow specialist (95% alive)
8344            // stays fused, a --target-sparsity bake flips arms on its
8345            // own weight.
8346            let ffn_out = match (ffn_masked, &lw.ffn) {
8347                (true, FfnKind::Dense(d)) => {
8348                    let tm = task_mask.unwrap();
8349                    let alive = tm.ffn_active_count(li);
8350                    let deep = alive * 2 <= self.intermediate_size;
8351                    if deep && d.down_proj.sparse_col_ok() {
8352                        let active = tm.ffn_active_indices(li);
8353                        sparse_ffn_quant(
8354                            d,
8355                            post_normed,
8356                            &active,
8357                            self.hidden_size,
8358                            self.pool.as_deref(),
8359                        )
8360                    } else if deep
8361                        && let (Some(g), Some(u), Some(dn)) = (
8362                            d.gate_proj.as_f32(),
8363                            d.up_proj.as_f32(),
8364                            d.down_proj.as_f32(),
8365                        )
8366                    {
8367                        let active = tm.ffn_active_indices(li);
8368                        inference::sparse_ffn_forward(
8369                            post_normed,
8370                            g,
8371                            u,
8372                            dn,
8373                            self.hidden_size,
8374                            self.intermediate_size,
8375                            &active,
8376                            self.pool.as_deref(),
8377                        )
8378                    } else {
8379                        let row = tm.ffn_masks.get(li).map(|v| v.as_slice());
8380                        dense_ffn_batch(d, post_normed, 1, self.pool.as_deref(), row)
8381                    }
8382                }
8383                (true, FfnKind::Moe(m)) => {
8384                    // MoE is sparse by expert selection; a task mask
8385                    // narrows the ROUTABLE set via its expert fields
8386                    // (spec §5) when it carries them.
8387                    let allowed = task_mask.and_then(|tm| tm.expert_flags(li, m.experts.len()));
8388                    ffn_forward(
8389                        &lw.ffn,
8390                        post_normed,
8391                        self.pool.as_deref(),
8392                        allowed.as_deref(),
8393                    )
8394                }
8395                (true, FfnKind::DenseMoe(dm)) => dense_moe_ffn(
8396                    dm,
8397                    post_normed,
8398                    &h,
8399                    self.rms_eps,
8400                    self.norm_style,
8401                    self.pool.as_deref(),
8402                ),
8403                (false, _) => match &lw.ffn {
8404                    FfnKind::DenseMoe(dm) => dense_moe_ffn(
8405                        dm,
8406                        post_normed,
8407                        &h,
8408                        self.rms_eps,
8409                        self.norm_style,
8410                        self.pool.as_deref(),
8411                    ),
8412                    _ => {
8413                        let allowed = match (&lw.ffn, task_mask) {
8414                            (FfnKind::Moe(m), Some(tm)) => tm.expert_flags(li, m.experts.len()),
8415                            _ => None,
8416                        };
8417                        ffn_forward(
8418                            &lw.ffn,
8419                            post_normed,
8420                            self.pool.as_deref(),
8421                            allowed.as_deref(),
8422                        )
8423                    }
8424                },
8425            };
8426            let ffn_out = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
8427                Some(w) => inference::rms_norm(&ffn_out, w, self.rms_eps, self.norm_style),
8428                None => ffn_out,
8429            };
8430            for (i, &f) in ffn_out.iter().enumerate() {
8431                h[i] += f;
8432            }
8433            let mut ffn_out = ffn_out;
8434            attention::recycle_buf(&mut ffn_out);
8435
8436            // Gemma-4: the layer output is scaled by a learned scalar.
8437            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
8438                for v in h.iter_mut() {
8439                    *v *= sc;
8440                }
8441            }
8442
8443            // Looped Transformer: apply final norm at the end of each loop iteration.
8444            // Nanbeige 4.2: after layer 21 (virtual), apply norm before looping back to layer 0.
8445            if self.is_loop_end(li) && li + 1 < self.num_layers {
8446                h = inference::rms_norm(
8447                    &h,
8448                    &self.weights.final_norm,
8449                    self.rms_eps,
8450                    self.norm_style,
8451                );
8452            }
8453
8454            // Dynamic routing φ capture (on-policy, fireball-style): the
8455            // EMA of the post-residual hidden at the router's phi_layer,
8456            // updated as the context evolves during decode.
8457            if self.dyn_phi_layer == Some(li) {
8458                self.update_dyn_phi(&h);
8459            }
8460        }
8461        crate::gpu::set_layer(-1); // layers done — lm_head outside layer-split
8462        if let Some(t) = t_race_cpu {
8463            crate::gpu::graph_race_record(false, t.elapsed());
8464        }
8465
8466        h
8467    }
8468
8469    /// EMA of φ at the router layer (rolling, weight 0.2 = ~5-token
8470    /// horizon). First observation seeds it exactly.
8471    fn update_dyn_phi(&mut self, h: &[f32]) {
8472        const A: f32 = 0.2;
8473        if self.dyn_phi_ema.len() != h.len() {
8474            self.dyn_phi_ema = vec![0.0; h.len()];
8475            self.dyn_phi_seen = 0;
8476        }
8477        if self.dyn_phi_seen == 0 {
8478            self.dyn_phi_ema.copy_from_slice(h);
8479        } else {
8480            for (e, &v) in self.dyn_phi_ema.iter_mut().zip(h) {
8481                *e = (1.0 - A) * *e + A * v;
8482            }
8483        }
8484        self.dyn_phi_seen += 1;
8485    }
8486
8487    /// Current router φ (EMA at phi_layer); empty until first capture.
8488    pub fn dyn_phi(&self) -> &[f32] {
8489        &self.dyn_phi_ema
8490    }
8491
8492    /// Enable/disable φ capture at the router layer, reset the EMA.
8493    pub fn set_dyn_phi_layer(&mut self, layer: Option<usize>) {
8494        self.dyn_phi_layer = layer;
8495        self.dyn_phi_ema.clear();
8496        self.dyn_phi_seen = 0;
8497    }
8498
8499    /// Skills eligible for dynamic switching: (index, id, phi_layer).
8500    pub fn dynamic_skills(&self) -> Vec<(usize, String, usize)> {
8501        let Some(model) = &self.model else {
8502            return Vec::new();
8503        };
8504        model
8505            .header
8506            .skills
8507            .iter()
8508            .enumerate()
8509            .filter_map(|(i, sk)| {
8510                let ok = matches!(self.dyn_skill_layers.get(i), Some(Some(_)));
8511                let sel = sk.selection.as_ref()?;
8512                (ok).then(|| (i, sk.id.clone(), sel.phi_layer))
8513            })
8514            .collect()
8515    }
8516
8517    /// Index of the currently overlaid skill (None = backbone).
8518    pub fn active_skill(&self) -> Option<usize> {
8519        self.dyn_active
8520    }
8521
8522    /// Enable dynamic per-token skill routing: build the hysteresis
8523    /// router from the container's routable skills, start φ capture at
8524    /// their (shared) phi_layer. Returns the number of routable skills
8525    /// (0 = nothing to route; router stays off). Idempotent.
8526    pub fn enable_dynamic_routing(&mut self) -> usize {
8527        use crate::swarm::{DynRouter, RoutableSkill};
8528        let Some(model) = self.model.clone() else {
8529            return 0;
8530        };
8531        // A blend materialized f32 working tensors into the layers; there
8532        // is no single skill index to revert from → refuse (honest).
8533        if self.dyn_blend_loaded {
8534            tracing::warn!("dynamic routing unavailable on a blend-loaded pipeline");
8535            return 0;
8536        }
8537        // A statically-overlaid skill that is NOT FFN-eligible can't be
8538        // cheaply reverted at generation start → refuse rather than
8539        // silently keep it overlaid.
8540        if let Some(a) = self.dyn_active {
8541            if !matches!(self.dyn_skill_layers.get(a), Some(Some(_))) {
8542                tracing::warn!("loaded skill is not FFN-eligible — dynamic routing unavailable");
8543                return 0;
8544            }
8545        }
8546        let hidden = self.hidden_size;
8547        let mut skills = Vec::new();
8548        for (idx, id, _phi) in self.dynamic_skills() {
8549            if let Some(sel) = model.header.skills[idx].selection.as_ref() {
8550                if let Some(rs) = RoutableSkill::from_descriptor(idx, id, sel, hidden) {
8551                    skills.push(rs);
8552                }
8553            }
8554        }
8555        if skills.is_empty() {
8556            return 0;
8557        }
8558        // Skills should share a phi_layer; warn (not fail) if they don't.
8559        let phi = skills[0].phi_layer;
8560        if skills.iter().any(|s| s.phi_layer != phi) {
8561            tracing::warn!("routable skills disagree on phi_layer; using {phi}");
8562        }
8563        let n = skills.len();
8564        self.set_dyn_phi_layer(Some(phi));
8565        self.dyn_router = Some(DynRouter::new(skills));
8566        n
8567    }
8568
8569    /// Human-readable switch log from the last dynamic-routed generation.
8570    pub fn route_switches(&self) -> Vec<(usize, Option<String>, Option<String>)> {
8571        self.dyn_router
8572            .as_ref()
8573            .map(|r| r.switches.clone())
8574            .unwrap_or_default()
8575    }
8576
8577    /// LM head: hidden → logits [vocab_size]. The dominant matvec of
8578    /// every decode step — row-parallel on the worker pool.
8579    fn lm_head_forward(&self, hidden: &[f32]) -> Vec<f32> {
8580        let rows = self.weights.lm_head.rows();
8581        let mut logits = attention::take_buf(rows.min(self.vocab_size));
8582        self.weights
8583            .lm_head
8584            .matvec(hidden, &mut logits, self.pool.as_deref());
8585        logits.resize(self.vocab_size, 0.0);
8586        if let Some(m) = self.logit_multiplier {
8587            for l in logits.iter_mut() {
8588                *l *= m;
8589            }
8590        }
8591        if let Some(c) = self.final_softcap {
8592            for l in logits.iter_mut() {
8593                *l = c * (*l / c).tanh();
8594            }
8595        }
8596        logits
8597    }
8598
8599    /// Prefill `ids` and return the next-token logits — what the model
8600    /// would predict next, WITHOUT committing to generation (introspection
8601    /// for `cortiq explain`). Clears and repopulates the KV cache; leaves
8602    /// the active overlay untouched.
8603    pub fn prefill_next_logits(&mut self, ids: &[u32], task_mask: Option<&TaskMask>) -> Vec<f32> {
8604        self.kv_cache.clear();
8605        self.kv_history.clear();
8606        let mut hidden = vec![0.0f32; self.hidden_size];
8607        for (pos, &id) in ids.iter().enumerate() {
8608            let emb = self.embed_single(id);
8609            hidden = self.forward_layers(&emb, pos, task_mask);
8610        }
8611        inference::rms_norm_into(
8612            &hidden,
8613            &self.weights.final_norm,
8614            self.rms_eps,
8615            self.norm_style,
8616            &mut self.ws.n1,
8617        );
8618        self.lm_head_forward(&self.ws.n1)
8619    }
8620}
8621
8622/// Convenience: deterministic tiny pipeline for tests.
8623pub fn create_test_pipeline(
8624    hidden_size: usize,
8625    intermediate_size: usize,
8626    num_heads: usize,
8627    num_kv_heads: usize,
8628    head_dim: usize,
8629    num_layers: usize,
8630    vocab_size: usize,
8631) -> Pipeline {
8632    // Small pseudo-random weights: constant weights make attention
8633    // degenerate and hide indexing bugs.
8634    let synth = |n: usize, salt: usize| -> Vec<f32> {
8635        (0..n)
8636            .map(|i| (((i * 31 + salt * 17 + 7) % 97) as f32 / 97.0 - 0.5) * 0.2)
8637            .collect()
8638    };
8639    let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
8640        QTensor::from_f32(synth(rows * cols, salt), rows, cols)
8641    };
8642    let layer_weights: Vec<LayerWeights> = (0..num_layers)
8643        .map(|li| LayerWeights {
8644            input_norm: vec![1.0; hidden_size],
8645            post_norm: vec![1.0; hidden_size],
8646            attn_out_norm: None,
8647            ffn_out_norm: None,
8648            layer_scale: None,
8649            ffn: FfnKind::Dense(DenseFfn {
8650                gate_proj: qt(intermediate_size, hidden_size, li * 10 + 5),
8651                up_proj: qt(intermediate_size, hidden_size, li * 10 + 6),
8652                down_proj: qt(hidden_size, intermediate_size, li * 10 + 7),
8653                act: Act::Silu,
8654            }),
8655            attn: AttnKind::Full {
8656                bias: None,
8657                wq: qt(num_heads * head_dim, hidden_size, li * 10 + 1),
8658                wk: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 2),
8659                wv: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 3),
8660                wo: qt(hidden_size, num_heads * head_dim, li * 10 + 4),
8661                q_norm: None,
8662                k_norm: None,
8663                output_gate: false,
8664                softplus_gate: None,
8665            },
8666        })
8667        .collect();
8668
8669    Pipeline::new(
8670        Tokenizer::byte_level(),
8671        PipelineWeights {
8672            embed_tokens: qt(vocab_size, hidden_size, 100),
8673            layers: layer_weights,
8674            lm_head: qt(vocab_size, hidden_size, 200),
8675            final_norm: vec![1.0; hidden_size],
8676        },
8677        hidden_size,
8678        intermediate_size,
8679        num_heads,
8680        num_kv_heads,
8681        head_dim,
8682        num_layers,
8683        num_layers, // physical_layers = num_layers (non-looped)
8684        false,      // loop_final_norm
8685        vocab_size,
8686        1e-6,
8687        10_000.0,
8688        NormStyle::Qwen,
8689        4096,
8690        SamplerConfig {
8691            seed: Some(42),
8692            ..Default::default()
8693        },
8694    )
8695}
8696
8697/// Batched dense-FFN: gate/up/down via matmat (element-wise the same
8698/// math as b × dense_ffn — the same dot kernels).
8699/// One mask bit, LSB-first per byte — `TaskMask::ffn_active_indices`'s
8700/// convention.
8701#[inline]
8702fn mask_bit(row: &[u8], j: usize) -> bool {
8703    (row.get(j >> 3).copied().unwrap_or(0) >> (j & 7)) & 1 != 0
8704}
8705
8706/// Zero the CLOSED neurons' activations in a [rows × inter] panel — the
8707/// masked-inference fast path's whole trick: full fused quant compute,
8708/// then the mask lands on the ACTIVATIONS, which is arithmetically the
8709/// pruned network without touching a quantized weight byte. Whole open
8710/// bytes (0xFF = 8 open neurons) skip in one test.
8711fn zero_masked_cols(g: &mut [f32], rows: usize, inter: usize, row: &[u8]) {
8712    for r in 0..rows {
8713        let base = r * inter;
8714        for (bi, &byte) in row.iter().enumerate() {
8715            if byte == 0xFF {
8716                continue;
8717            }
8718            let j0 = bi * 8;
8719            for bit in 0..8 {
8720                let j = j0 + bit;
8721                if j < inter && byte & (1 << bit) == 0 {
8722                    g[base + j] = 0.0;
8723                }
8724            }
8725        }
8726    }
8727}
8728
8729fn dense_ffn_batch(
8730    d: &DenseFfn,
8731    xs: &[f32],
8732    b: usize,
8733    pool: Option<&Pool>,
8734    mask_row: Option<&[u8]>,
8735) -> Vec<f32> {
8736    let inter = d.gate_proj.rows();
8737    let hidden = d.down_proj.rows();
8738    // Fused on-device SwiGLU when the device is in play: three separate
8739    // `matmat` calls are three round trips per layer, and the gate/up
8740    // panels (b × inter — 22 MB each at a 512-token chunk) cross the bus
8741    // twice for nothing. The kernel already existed for the image DiT;
8742    // the LLM prefill was simply never wired to it. A task mask needs the
8743    // activations on the host between the halves, so it keeps the CPU
8744    // arm below.
8745    if mask_row.is_none()
8746        && d.act == Act::Silu
8747        && b >= 32
8748        && crate::gpu::enabled_here()
8749        && !crate::gpu::mm_killed()
8750    {
8751        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
8752            d.gate_proj.mapped_q4t(),
8753            d.up_proj.mapped_q4t(),
8754            d.down_proj.mapped_q4t(),
8755        ) {
8756            let mut out = vec![0.0f32; b * hidden];
8757            if crate::gpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
8758                return out;
8759            }
8760        }
8761        // The q4tp twin (same kernel family, scale from the row ladder) —
8762        // the DiT has run it in production since the pipeline containers;
8763        // the LLM prefill was simply never wired to it, so a q4tp model's
8764        // prefill panels stayed on the CPU.
8765        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
8766            d.gate_proj.mapped_q4tp(),
8767            d.up_proj.mapped_q4tp(),
8768            d.down_proj.mapped_q4tp(),
8769        ) {
8770            let mut out = vec![0.0f32; b * hidden];
8771            if crate::gpu::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
8772                return out;
8773            }
8774        }
8775    }
8776    let mut g = vec![0.0f32; b * inter];
8777    d.gate_proj.matmat(xs, b, &mut g, pool);
8778    let mut u = vec![0.0f32; b * inter];
8779    d.up_proj.matmat(xs, b, &mut u, pool);
8780    for i in 0..b * inter {
8781        g[i] = d.act.combine(g[i], u[i]);
8782    }
8783    if let Some(row) = mask_row {
8784        zero_masked_cols(&mut g, b, inter, row);
8785    }
8786    let mut out = vec![0.0f32; b * hidden];
8787    d.down_proj.matmat(&g, b, &mut out, pool);
8788    out
8789}
8790
8791/// Batched MoE-FFN: router batched, positions are GROUPED by expert —
8792/// an expert's weights are read once for all its positions in the chunk
8793/// (the main prefill-GEMM win on MoE: 960MB/token of 35B experts).
8794/// Accumulate per-channel activation energy for `CMF_RMS_TRACE`.
8795fn accumulate_act(m: &MoeFfn, xs: &[f32], b: usize) {
8796    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8797    static DUMP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8798    let on = *ON.get_or_init(|| std::env::var("CMF_RMS_TRACE").is_ok());
8799    let dump = *DUMP.get_or_init(|| std::env::var("CMF_ACT_DUMP").is_ok());
8800    if (!on && !dump) || b == 0 {
8801        return;
8802    }
8803    let hidden = xs.len() / b;
8804    if on {
8805        let mut acc = m.act_sq.borrow_mut();
8806        if acc.len() < hidden {
8807            acc.resize(hidden, 0.0);
8808        }
8809        for t in 0..b {
8810            let row = &xs[t * hidden..(t + 1) * hidden];
8811            for (a, &v) in acc.iter_mut().zip(row) {
8812                *a += (v as f64) * (v as f64);
8813            }
8814        }
8815    }
8816    if dump {
8817        // Cap the capture: the covariance needs a few thousand rows, and a
8818        // whole prefill of every layer would be gigabytes for no extra rank.
8819        let cap: usize = std::env::var("CMF_ACT_DUMP_ROWS")
8820            .ok()
8821            .and_then(|v| v.parse().ok())
8822            .unwrap_or(4096);
8823        let mut rows = m.act_rows.borrow_mut();
8824        if rows.len() < cap * hidden {
8825            let take = b.min((cap * hidden - rows.len()) / hidden.max(1));
8826            rows.extend_from_slice(&xs[..take * hidden]);
8827        }
8828    }
8829}
8830
8831/// Send-able cursor over a Vec-of-Vecs: each pool worker writes only its
8832/// own slots (disjoint by construction in the caller).
8833#[derive(Clone, Copy)]
8834struct SendVecs(*mut Vec<f32>);
8835unsafe impl Send for SendVecs {}
8836unsafe impl Sync for SendVecs {}
8837impl SendVecs {
8838    #[inline]
8839    fn at(self, i: usize) -> *mut Vec<f32> {
8840        unsafe { self.0.add(i) }
8841    }
8842}
8843
8844fn moe_ffn_batch(
8845    m: &MoeFfn,
8846    xs: &[f32],
8847    b: usize,
8848    hidden: usize,
8849    pool: Option<&Pool>,
8850    allowed: Option<&[bool]>,
8851) -> Vec<f32> {
8852    accumulate_act(m, xs, b);
8853    let ne = m.experts.len();
8854    let mut logits = vec![0.0f32; b * ne];
8855    m.router.matmat(xs, b, &mut logits, pool);
8856
8857    // Assignments: expert → [(position, weight)] — same routing as
8858    // moe_ffn, per position (see `moe_route`).
8859    let mut assign: Vec<Vec<(usize, f32)>> = vec![Vec::new(); ne];
8860    {
8861        let mut st = m.stats.borrow_mut();
8862        if st.len() < ne {
8863            st.resize(ne, 0);
8864        }
8865        for bi in 0..b {
8866            let (idx, p, wsum) = moe_route(&logits[bi * ne..(bi + 1) * ne], m, allowed);
8867            for &e in &idx {
8868                st[e] += 1;
8869                assign[e].push((bi, p[e] / wsum));
8870            }
8871        }
8872    }
8873
8874    let mut out = vec![0.0f32; b * hidden];
8875    let cols = m.experts[0].gate_proj.cols();
8876    let run_expert = |d: &DenseFfn, list: &[(usize, f32)], out: &mut [f32]| {
8877        let sb = list.len();
8878        let mut sub = vec![0.0f32; sb * cols];
8879        for (k, &(bi, _)) in list.iter().enumerate() {
8880            sub[k * cols..(k + 1) * cols].copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
8881        }
8882        let eo = dense_ffn_batch(d, &sub, sb, pool, None);
8883        for (k, &(bi, w)) in list.iter().enumerate() {
8884            for i in 0..hidden {
8885                out[bi * hidden + i] += w * eo[k * hidden + i];
8886            }
8887        }
8888    };
8889    // Routed experts: the panels are TINY (b·top_k spread over every
8890    // expert — a few positions each), so a pool dispatch per expert is
8891    // pure barrier cost. Invert the parallelism: workers take WHOLE
8892    // experts (serial math inside), then one deterministic scatter in
8893    // expert order — the exact accumulation order the serial loop had.
8894    let active: Vec<usize> = (0..ne).filter(|&e| !assign[e].is_empty()).collect();
8895    if pool.is_some() && active.len() >= 8 {
8896        let mut panels: Vec<Vec<f32>> = vec![Vec::new(); active.len()];
8897        {
8898            let panel_ptr = SendVecs(panels.as_mut_ptr());
8899            // Capture only the expert table: `m` itself carries RefCell
8900            // stats and must not cross the pool boundary.
8901            let experts = &m.experts;
8902            let (active_r, assign_r) = (&active, &assign);
8903            let run = |start: usize, end: usize| {
8904                for ai in start..end {
8905                    let e = active_r[ai];
8906                    let list = &assign_r[e];
8907                    let sb = list.len();
8908                    let mut sub = vec![0.0f32; sb * cols];
8909                    for (k, &(bi, _)) in list.iter().enumerate() {
8910                        sub[k * cols..(k + 1) * cols]
8911                            .copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
8912                    }
8913                    // SAFETY: each worker owns a disjoint panels[ai].
8914                    unsafe {
8915                        *panel_ptr.at(ai) = dense_ffn_batch(&experts[e], &sub, sb, None, None);
8916                    }
8917                }
8918            };
8919            match pool {
8920                Some(p) => p.run_rows(active.len(), &run),
8921                None => run(0, active.len()),
8922            }
8923        }
8924        for (ai, &e) in active.iter().enumerate() {
8925            for (k, &(bi, w)) in assign[e].iter().enumerate() {
8926                let eo = &panels[ai][k * hidden..(k + 1) * hidden];
8927                for i in 0..hidden {
8928                    out[bi * hidden + i] += w * eo[i];
8929                }
8930            }
8931        }
8932    } else {
8933        for &e in &active {
8934            run_expert(&m.experts[e], &assign[e], &mut out);
8935        }
8936    }
8937    if let Some((se, gate)) = &m.shared {
8938        let all: Vec<(usize, f32)> = if let Some(gate) = gate {
8939            let mut gl = vec![0.0f32; b];
8940            gate.matmat(xs, b, &mut gl, pool);
8941            (0..b)
8942                .map(|bi| (bi, 1.0 / (1.0 + (-gl[bi]).exp())))
8943                .collect()
8944        } else {
8945            (0..b).map(|bi| (bi, 1.0)).collect()
8946        };
8947        run_expert(se, &all, &mut out);
8948    }
8949    out
8950}
8951
8952thread_local! {
8953    /// gate/up activation scratch for the dense FFN paths (single uses
8954    /// two slots, the fused pair all four) — these were fresh
8955    /// intermediate-size Vecs on every layer of every token.
8956    static FFN_SCRATCH: std::cell::RefCell<[Vec<f32>; 4]> =
8957        const { std::cell::RefCell::new([Vec::new(), Vec::new(), Vec::new(), Vec::new()]) };
8958}
8959
8960/// Dense SwiGLU FFN through QTensor matvecs (any storage).
8961fn dense_ffn(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
8962    // Whole-FFN GPU submit (этап 4.2 increment): gate → silu·up → down
8963    // chained in ONE command buffer with the intermediate activations
8964    // resident on the device — 3 per-op polls become 1 per layer. The
8965    // moe_block backend already implements exactly this chain; a dense
8966    // FFN is one expert with weight 1. Runtime probe: the chain still
8967    // pays one submit+poll per layer — alternate it against the pure-CPU
8968    // FFN and keep whichever is faster on this machine.
8969    // q1 FFNs offload at any practical size: the q1 CPU kernel is
8970    // compute-bound, so the UMA threshold logic does not apply — the
8971    // probe measures and decides either way.
8972    if crate::gpu::enabled_here()
8973        && (d.gate_proj.rows() >= crate::gpu::min_rows() || d.gate_proj.is_q1())
8974    {
8975        let arm = if d.gate_proj.is_q1() && crate::gpu::q1_force() {
8976            crate::gpu::ProbeArm::Gpu
8977        } else {
8978            crate::gpu::probe_arm(crate::gpu::OpClass::Ffn)
8979        };
8980        match arm {
8981            crate::gpu::ProbeArm::Gpu => {
8982                let t0 = std::time::Instant::now();
8983                if let Some(out) = dense_ffn_gpu(d, x, pool) {
8984                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
8985                    return out;
8986                }
8987            }
8988            crate::gpu::ProbeArm::CpuTimed => {
8989                let t0 = std::time::Instant::now();
8990                let out = crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
8991                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
8992                return out;
8993            }
8994            crate::gpu::ProbeArm::Cpu => {
8995                return crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
8996            }
8997        }
8998    }
8999    dense_ffn_cpu(d, x, pool)
9000}
9001
9002/// The pure-CPU dense-FFN body (also the fallback of every GPU refusal).
9003fn dense_ffn_cpu(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
9004    let inter = d.gate_proj.rows();
9005    FFN_SCRATCH.with(|s| {
9006        let mut s = s.borrow_mut();
9007        let [g, u, ..] = &mut *s;
9008        g.resize(inter, 0.0);
9009        // Fused gate+up+silu: one dispatch, no separate silu pass.
9010        // Falls back to matvec_many + silu loop for unsupported dtypes.
9011        if d.act == Act::Silu && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool) {
9012            // g now holds silu(gate)·up directly.
9013        } else {
9014            u.resize(inter, 0.0);
9015            // Multi-matrix job: gate+up under one pool dispatch.
9016            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
9017            for i in 0..inter {
9018                g[i] = d.act.combine(g[i], u[i]);
9019            }
9020        }
9021        // DTG-MA bake probe (Patent 2): accumulate this layer's
9022        // per-neuron activation mass while a probe pass is active.
9023        FFN_PROBE.with(|pr| {
9024            if let Some(acc) = pr.borrow_mut().as_mut() {
9025                let li = crate::gpu::cur_layer();
9026                if li >= 0 {
9027                    if let Some(row) = acc.get_mut(li as usize) {
9028                        for (a, &v) in row.iter_mut().zip(g.iter()) {
9029                            *a += (v as f64).abs();
9030                        }
9031                    }
9032                }
9033            }
9034        });
9035        let mut out = attention::take_buf(d.down_proj.rows());
9036        d.down_proj.matvec(g, &mut out, pool);
9037        out
9038    })
9039}
9040
9041thread_local! {
9042    /// DTG-MA activation probe: per-layer per-neuron Σ|silu(g)·u|
9043    /// accumulator, alive only during `Pipeline::probe_ffn_mass`.
9044    static FFN_PROBE: std::cell::RefCell<Option<Vec<Vec<f64>>>> =
9045        const { std::cell::RefCell::new(None) };
9046}
9047
9048/// `dense_ffn_cpu` with a per-visit mask landing on the activations —
9049/// the masked-inference fast path's decode arm. Full fused quant
9050/// compute, closed neurons zeroed before down: arithmetically the
9051/// pruned network, no dequant, no weight bytes touched.
9052fn dense_ffn_masked(d: &DenseFfn, x: &[f32], pool: Option<&Pool>, mask_row: &[u8]) -> Vec<f32> {
9053    let inter = d.gate_proj.rows();
9054    FFN_SCRATCH.with(|s| {
9055        let mut s = s.borrow_mut();
9056        let [g, u, ..] = &mut *s;
9057        g.resize(inter, 0.0);
9058        if d.act == Act::Silu && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool) {
9059            // g holds silu(gate)·up.
9060        } else {
9061            u.resize(inter, 0.0);
9062            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
9063            for i in 0..inter {
9064                g[i] = d.act.combine(g[i], u[i]);
9065            }
9066        }
9067        zero_masked_cols(g, 1, inter, mask_row);
9068        let mut out = attention::take_buf(d.down_proj.rows());
9069        d.down_proj.matvec(g, &mut out, pool);
9070        out
9071    })
9072}
9073
9074/// Dense FFN as one GPU submission via the MoE block path (single
9075/// expert, weight 1.0): gate → silu·up → down chained in one command
9076/// buffer, intermediate activations device-resident. None → weights
9077/// not q8-mapped in the primary shard / over the VRAM budget / backend
9078/// refusal → honest CPU path.
9079fn dense_ffn_gpu(d: &DenseFfn, x: &[f32], _pool: Option<&Pool>) -> Option<Vec<f32>> {
9080    // The GPU block hardcodes SiLU; GeLU FFNs (Gemma) stay on CPU.
9081    if d.act != Act::Silu {
9082        return None;
9083    }
9084    // Threshold: tiny FFNs are not worth a submission (q1 excepted —
9085    // see the caller's gate).
9086    if d.gate_proj.rows() < crate::gpu::min_rows() && !d.gate_proj.is_q1() {
9087        return None;
9088    }
9089    let mut jobs: Vec<crate::gpu::MoeJob> = Vec::with_capacity(1);
9090    let mut model_ref = None;
9091    moe_push_job(d, x, 1.0, &mut jobs, &mut model_ref)?;
9092    let model = model_ref?;
9093    let hidden = jobs[0].down.1;
9094    let mut out = attention::take_buf(hidden);
9095    if crate::gpu::moe_block(&model, &jobs, &mut out) {
9096        Some(out)
9097    } else {
9098        let mut out = out;
9099        attention::recycle_buf(&mut out);
9100        None
9101    }
9102}
9103
9104/// q8-mapped primary-shard tensor parts for a GPU job: q8_2f carries
9105/// its column field, q8_row runs with empty col slices (the backend
9106/// skips the multiply). Shared by the MoE block and the dense-FFN
9107/// single-job path.
9108#[allow(clippy::type_complexity)]
9109#[allow(clippy::type_complexity)]
9110pub(crate) fn moe_parts(
9111    t: &QTensor,
9112) -> Option<(
9113    &std::sync::Arc<cortiq_core::CmfModel>,
9114    usize,
9115    usize,
9116    usize,
9117    &[f32],
9118    &[f32],
9119    bool,
9120    bool,
9121    bool,
9122)> {
9123    match t {
9124        QTensor::Mapped {
9125            model,
9126            idx,
9127            dtype: dt @ (cortiq_core::TensorDtype::Q8_2f | cortiq_core::TensorDtype::Q8Row),
9128            rows,
9129            cols,
9130            row_scale,
9131            col_field,
9132            ..
9133        } if (*dt == cortiq_core::TensorDtype::Q8Row) || !col_field.is_empty() => Some((
9134            model, *idx, *rows, *cols, row_scale, col_field, false, false, false,
9135        )),
9136        // q1: tile-embedded scales — empty rs/col slices, raw xs.
9137        QTensor::Mapped {
9138            model,
9139            idx,
9140            dtype: cortiq_core::TensorDtype::Q1,
9141            rows,
9142            cols,
9143            ..
9144        } => Some((
9145            model,
9146            *idx,
9147            *rows,
9148            *cols,
9149            &[][..],
9150            &[][..],
9151            true,
9152            false,
9153            false,
9154        )),
9155        // q4_tiled: 18-byte tiles with embedded f16 scales — raw xs.
9156        QTensor::Mapped {
9157            model,
9158            idx,
9159            dtype: cortiq_core::TensorDtype::Q4Tiled,
9160            rows,
9161            cols,
9162            ..
9163        } => Some((
9164            model,
9165            *idx,
9166            *rows,
9167            *cols,
9168            &[][..],
9169            &[][..],
9170            false,
9171            true,
9172            false,
9173        )),
9174        // q4tp: same raw-xs contract, different stride and scale plane.
9175        QTensor::Mapped {
9176            model,
9177            idx,
9178            dtype: cortiq_core::TensorDtype::Q4TiledP,
9179            rows,
9180            cols,
9181            ..
9182        } => Some((
9183            model,
9184            *idx,
9185            *rows,
9186            *cols,
9187            &[][..],
9188            &[][..],
9189            false,
9190            true,
9191            false,
9192        )),
9193        // q2tp: the 2-bit expert plane of the mixed profile — q4 family
9194        // for stride bookkeeping, flagged q2 so the trio validation can
9195        // demand a q4tp down.
9196        QTensor::Mapped {
9197            model,
9198            idx,
9199            dtype: cortiq_core::TensorDtype::Q2TiledP,
9200            rows,
9201            cols,
9202            ..
9203        } => Some((
9204            model,
9205            *idx,
9206            *rows,
9207            *cols,
9208            &[][..],
9209            &[][..],
9210            false,
9211            true,
9212            true,
9213        )),
9214        _ => None,
9215    }
9216}
9217
9218/// Map a softmax-router MoE onto the Metal token graph's contract:
9219/// f32 router, gated shared expert, experts uniformly q4tp (or the
9220/// mixed profile: q2tp gate/up over a q4tp down). Sigmoid/bias/τ
9221/// routers, masks, per-expert scales and Gemma's router-input norm
9222/// refuse here — those semantics stay on the CPU path.
9223#[cfg(target_os = "macos")]
9224fn metal_moe_graph_parts(m: &MoeFfn, hidden: usize) -> Option<crate::gpu::GpuMoe<'_>> {
9225    if m.router_sigmoid
9226        || m.router_input_norm
9227        || m.expert_bias.is_some()
9228        || m.route_tau.is_some()
9229        || m.mask.is_some()
9230        || m.per_expert_scale.is_some()
9231        || m.experts.is_empty()
9232        || m.top_k == 0
9233    {
9234        return None;
9235    }
9236    // The select kernel hard-codes the gated shared expert; an
9237    // ungated one would need its own weight-1 slot.
9238    let (sh, sg) = match &m.shared {
9239        Some((sh, Some(sg))) => (sh, sg),
9240        _ => return None,
9241    };
9242    let (rf, rr, rc) = m.router.f32_parts()?;
9243    if rr != m.experts.len() || rc != hidden {
9244        return None;
9245    }
9246    let (sf, sr, sc) = sg.f32_parts()?;
9247    if sr * sc != hidden {
9248        return None;
9249    }
9250    let inter = m.experts[0].gate_proj.rows();
9251    // The first expert's gate decides the profile; every trio (shared
9252    // included) must agree — the jobs ladder flips ONE kernel for all.
9253    let gu_q2 = m.experts[0].gate_proj.mapped_q2tp().is_some();
9254    let trio = |e: &DenseFfn| -> Option<(usize, usize, usize)> {
9255        if e.act != Act::Silu
9256            || e.gate_proj.rows() != inter
9257            || e.gate_proj.cols() != hidden
9258            || e.up_proj.rows() != inter
9259            || e.up_proj.cols() != hidden
9260            || e.down_proj.rows() != hidden
9261            || e.down_proj.cols() != inter
9262        {
9263            return None;
9264        }
9265        let pick = |t: &QTensor| -> Option<usize> {
9266            if gu_q2 {
9267                t.mapped_q2tp().map(|(_, i)| i)
9268            } else {
9269                t.mapped_q4tp().map(|(_, i)| i)
9270            }
9271        };
9272        Some((
9273            pick(&e.gate_proj)?,
9274            pick(&e.up_proj)?,
9275            e.down_proj.mapped_q4tp().map(|(_, i)| i)?,
9276        ))
9277    };
9278    let experts = m.experts.iter().map(trio).collect::<Option<Vec<_>>>()?;
9279    let shared = trio(sh)?;
9280    Some(crate::gpu::GpuMoe {
9281        router: rf,
9282        sgate: sf,
9283        experts,
9284        shared,
9285        n_exp: m.experts.len(),
9286        top_k: m.top_k,
9287        inter,
9288        norm_topk: m.norm_topk_prob,
9289        route_scale: m.routed_scaling,
9290        gu_q2,
9291    })
9292}
9293
9294/// Build one gate/up/down GPU job from three tensors. `moe_push_job` is the
9295/// DenseFfn-shaped caller; architectures that keep their experts in their own
9296/// structs (DeepSeek-V4) come here directly.
9297pub(crate) fn moe_push_job_parts<'a>(
9298    gate: &'a QTensor,
9299    up: &'a QTensor,
9300    down: &'a QTensor,
9301    x: &[f32],
9302    w: f32,
9303    swiglu_limit: f32,
9304    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
9305    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
9306) -> Option<()> {
9307    use crate::qtensor::prescale;
9308    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(gate)?;
9309    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(up)?;
9310    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(down)?;
9311    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
9312        return None; // mixed-dtype trio — honest CPU path
9313    }
9314    // The 2-bit profile is gate/up q2tp over a PLAIN q4tp down; any other
9315    // 2-bit arrangement stays on the CPU.
9316    if gq2 && (dq2 || !dq4 || down.mapped_q4tp().is_none()) {
9317        return None;
9318    }
9319    if !gq2 && dq2 {
9320        return None;
9321    }
9322    model_ref.get_or_insert_with(|| gm.clone());
9323    let dt = |cf: &[f32]| {
9324        if cf.is_empty() {
9325            cortiq_core::TensorDtype::Q8Row
9326        } else {
9327            cortiq_core::TensorDtype::Q8_2f
9328        }
9329    };
9330    jobs.push(crate::gpu::MoeJob {
9331        gate: (gi, gr, gc, grs),
9332        up: (ui, ur, uc, urs),
9333        down: (di, dr, dc, drs),
9334        xs_gate: prescale(x, gcf, dt(gcf)).into_owned(),
9335        xs_up: prescale(x, ucf, dt(ucf)).into_owned(),
9336        down_col: dcf,
9337        w,
9338        q1: gq1,
9339        q4t: gq4 && !gq2 && gate.mapped_q4tp().is_none(),
9340        q4tp: gq4 && (gq2 || gate.mapped_q4tp().is_some()),
9341        gu_q2: gq2,
9342        swiglu_limit,
9343    });
9344    Some(())
9345}
9346
9347/// Build one gate/up/down GPU job (see `moe_parts`).
9348fn moe_push_job<'a>(
9349    d: &'a DenseFfn,
9350    x: &[f32],
9351    w: f32,
9352    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
9353    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
9354) -> Option<()> {
9355    use crate::qtensor::prescale;
9356    if d.act != Act::Silu {
9357        return None; // GPU block hardcodes SiLU
9358    }
9359    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(&d.gate_proj)?;
9360    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(&d.up_proj)?;
9361    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(&d.down_proj)?;
9362    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
9363        return None; // mixed-dtype trio — honest CPU path
9364    }
9365    if gq2 && (dq2 || !dq4 || d.down_proj.mapped_q4tp().is_none()) {
9366        return None;
9367    }
9368    if !gq2 && dq2 {
9369        return None;
9370    }
9371    model_ref.get_or_insert_with(|| gm.clone());
9372    let gdt = if gcf.is_empty() {
9373        cortiq_core::TensorDtype::Q8Row
9374    } else {
9375        cortiq_core::TensorDtype::Q8_2f
9376    };
9377    let udt = if ucf.is_empty() {
9378        cortiq_core::TensorDtype::Q8Row
9379    } else {
9380        cortiq_core::TensorDtype::Q8_2f
9381    };
9382    jobs.push(crate::gpu::MoeJob {
9383        gate: (gi, gr, gc, grs),
9384        up: (ui, ur, uc, urs),
9385        down: (di, dr, dc, drs),
9386        xs_gate: prescale(x, gcf, gdt).into_owned(),
9387        xs_up: prescale(x, ucf, udt).into_owned(),
9388        down_col: dcf,
9389        w,
9390        q1: gq1,
9391        q4t: gq4 && !gq2 && d.gate_proj.mapped_q4tp().is_none(),
9392        q4tp: gq4 && (gq2 || d.gate_proj.mapped_q4tp().is_some()),
9393        gu_q2: gq2,
9394        swiglu_limit: 0.0,
9395    });
9396    Some(())
9397}
9398
9399/// Sparse dense-FFN directly on QUANTIZED weights (mask × mmap): reads
9400/// ONLY the active neurons' gate/up rows and down columns from the mmap
9401/// — no full-matrix dequant, no f32 model copy. This is what lets a
9402/// masked big model run at quantized RSS (the historical mask path
9403/// forced the whole model to f32). Semantics identical to the f32
9404/// sparse path within quant tolerance.
9405fn sparse_ffn_quant(
9406    d: &DenseFfn,
9407    x: &[f32],
9408    active: &[u16],
9409    hidden: usize,
9410    pool: Option<&Pool>,
9411) -> Vec<f32> {
9412    let n = active.len();
9413    let inter = d.gate_proj.rows();
9414    let mut act = vec![0.0f32; n];
9415    // Scratch is needed if EITHER projection is group-packed (q4/vbit);
9416    // gate/up normally share a dtype but sizing on both is robust.
9417    let need_scratch = !(d.gate_proj.sparse_col_ok() && d.up_proj.sparse_col_ok());
9418    let compute = |ai: usize| -> f32 {
9419        let idx = active[ai] as usize;
9420        if idx >= inter {
9421            return 0.0; // defensive parity with the f32 sparse path
9422        }
9423        let mut s = if need_scratch {
9424            vec![0.0f32; hidden]
9425        } else {
9426            Vec::new()
9427        };
9428        let gate = d.gate_proj.row_dot(idx, x, &mut s);
9429        let up = d.up_proj.row_dot(idx, x, &mut s);
9430        d.act.combine(gate, up)
9431    };
9432    match pool {
9433        Some(p) if n >= 256 => {
9434            let ptr = SendMut(act.as_mut_ptr());
9435            p.run(&|widx, nw| {
9436                let chunk = n.div_ceil(nw);
9437                let (s, e) = (widx * chunk, ((widx + 1) * chunk).min(n));
9438                for ai in s..e {
9439                    unsafe { *ptr.at(ai) = compute(ai) };
9440                }
9441            });
9442        }
9443        _ => {
9444            for (ai, a) in act.iter_mut().enumerate() {
9445                *a = compute(ai);
9446            }
9447        }
9448    }
9449    // Scatter through active down columns (reads only those columns).
9450    let mut out = vec![0.0f32; hidden];
9451    for (ai, &idx) in active.iter().enumerate() {
9452        let w = act[ai];
9453        if w.abs() >= 1e-12 && (idx as usize) < inter {
9454            d.down_proj.add_col_scaled(idx as usize, w, &mut out);
9455        }
9456    }
9457    out
9458}
9459
9460/// Test-only re-export of the private sparse-quant FFN (mask × mmap gate).
9461#[doc(hidden)]
9462pub fn sparse_ffn_quant_for_test(
9463    d: &DenseFfn,
9464    x: &[f32],
9465    active: &[u16],
9466    hidden: usize,
9467) -> Vec<f32> {
9468    sparse_ffn_quant(d, x, active, hidden, None)
9469}
9470
9471/// Dequantize a DenseFfn's three matrices to f32 (transient; only the
9472/// q4/vbit-masked fallback uses it — the memory-lean path is
9473/// sparse_ffn_quant). Reuses row_f32 row-by-row.
9474fn dequant_dense_f32(d: &DenseFfn) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
9475    let deq = |t: &QTensor| -> Vec<f32> {
9476        let (rows, cols) = (t.rows(), t.cols());
9477        let mut out = vec![0.0f32; rows * cols];
9478        for r in 0..rows {
9479            t.row_f32(r, &mut out[r * cols..(r + 1) * cols]);
9480        }
9481        out
9482    };
9483    (deq(&d.gate_proj), deq(&d.up_proj), deq(&d.down_proj))
9484}
9485
9486/// Pointer wrapper for the worker-pool scatter (same pattern as qtensor).
9487struct SendMut(*mut f32);
9488unsafe impl Send for SendMut {}
9489unsafe impl Sync for SendMut {}
9490impl SendMut {
9491    #[inline]
9492    // Deliberate unsynchronized scatter: pool workers write disjoint indices
9493    // in parallel, so returning `&mut` from `&self` is intentional here.
9494    #[allow(clippy::mut_from_ref)]
9495    unsafe fn at(&self, i: usize) -> &mut f32 {
9496        unsafe { &mut *self.0.add(i) }
9497    }
9498}
9499
9500/// Router → (selected experts in torch.topk order, per-expert score
9501/// vector, normalizer). The final weight of expert `e` is `p[e] / wsum`.
9502///
9503/// Two regimes share this. Qwen: softmax over ALL experts, top-k of the
9504/// probabilities, optional renorm — `router_sigmoid=false`, no bias,
9505/// scale 1 → bit-identical to the historical path. LFM2-MoE /
9506/// DeepSeek-V3 `noaux_tc`: per-expert sigmoid scores, an optional
9507/// selection bias (top-k CHOICE only; weights stay unbiased), a 1e-6 renorm
9508/// floor and a routed scale.
9509fn moe_route(logits: &[f32], m: &MoeFfn, allowed: Option<&[bool]>) -> (Vec<usize>, Vec<f32>, f32) {
9510    let ne = logits.len();
9511    let p: Vec<f32> = if m.router_sigmoid {
9512        logits.iter().map(|&l| 1.0 / (1.0 + (-l).exp())).collect()
9513    } else {
9514        let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
9515        let mut e: Vec<f32> = logits.iter().map(|&l| (l - mx).exp()).collect();
9516        let s: f32 = e.iter().sum();
9517        for v in &mut e {
9518            *v /= s;
9519        }
9520        e
9521    };
9522    // Expert restriction: the static env mask (CMF_MOE_MASK) AND the
9523    // active task mask's expert fields (spec §5) both narrow the
9524    // candidate set; selection happens over the admitted experts only.
9525    // With norm_topk the kept weights renormalize below; without it
9526    // the excluded mass is honestly dropped.
9527    let admit = |e: usize| {
9528        m.mask.as_ref().is_none_or(|mk| mk[e])
9529            && allowed.is_none_or(|a| a.get(e).copied().unwrap_or(false))
9530    };
9531    let mut idx: Vec<usize> = (0..ne).filter(|&e| admit(e)).collect();
9532    // Descending by selection score, lower index wins ties (torch.topk).
9533    match &m.expert_bias {
9534        Some(b) => idx.sort_unstable_by(|&x, &y| {
9535            (p[y] + b[y])
9536                .partial_cmp(&(p[x] + b[x]))
9537                .unwrap()
9538                .then(x.cmp(&y))
9539        }),
9540        None => idx.sort_unstable_by(|&x, &y| p[y].partial_cmp(&p[x]).unwrap().then(x.cmp(&y))),
9541    }
9542    idx.truncate(m.top_k);
9543    // Adaptive τ-routing: trim the tail experts once the kept mass is
9544    // enough. wsum below renormalizes over the KEPT set, so the output
9545    // stays a proper weighted average.
9546    if let Some(tau) = m.route_tau {
9547        let total: f32 = idx.iter().map(|&e| p[e]).sum();
9548        if total > 0.0 {
9549            let mut acc = 0.0f32;
9550            let mut keep = idx.len();
9551            for (i, &e) in idx.iter().enumerate() {
9552                acc += p[e];
9553                if acc >= tau * total {
9554                    keep = i + 1;
9555                    break;
9556                }
9557            }
9558            idx.truncate(keep);
9559        }
9560    }
9561    let wsum: f32 = if m.norm_topk_prob {
9562        let s: f32 = idx.iter().map(|&e| p[e]).sum();
9563        // LFM2 floors the denom (matches HF `+ 1e-6`); the softmax path's
9564        // probs already sum near 1, so it stays exactly as before.
9565        (if m.router_sigmoid { s + 1e-6 } else { s }) / m.routed_scaling
9566    } else {
9567        1.0 / m.routed_scaling
9568    };
9569    (idx, p, wsum)
9570}
9571
9572/// MoE FFN: router → top-k experts (see `moe_route`). Only selected
9573/// experts' pages are touched in mmap.
9574fn moe_ffn(m: &MoeFfn, x: &[f32], pool: Option<&Pool>, allowed: Option<&[bool]>) -> Vec<f32> {
9575    accumulate_act(m, x, 1);
9576    let ne = m.experts.len();
9577    let mut logits = vec![0.0f32; ne];
9578    m.router.matvec(x, &mut logits, pool);
9579    let (idx, p, wsum) = moe_route(&logits, m, allowed);
9580    {
9581        let mut st = m.stats.borrow_mut();
9582        if st.len() < ne {
9583            st.resize(ne, 0);
9584        }
9585        for &e in &idx {
9586            st[e] += 1;
9587        }
9588    }
9589    // D5: the whole layer MoE block in one GPU command buffer (experts — the
9590    // same mmap via a no-copy buffer; intermediate activations on the GPU).
9591    // Same Ffn probe class as the dense chain: one submit per layer
9592    // either wins on this driver stack or it doesn't.
9593    if crate::gpu::enabled_here() {
9594        match crate::gpu::probe_arm(crate::gpu::OpClass::Ffn) {
9595            crate::gpu::ProbeArm::Gpu => {
9596                let t0 = std::time::Instant::now();
9597                if let Some(out) = moe_ffn_gpu(m, x, &idx, &p, wsum, pool) {
9598                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
9599                    return out;
9600                }
9601            }
9602            crate::gpu::ProbeArm::CpuTimed => {
9603                let t0 = std::time::Instant::now();
9604                let out = crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
9605                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
9606                return out;
9607            }
9608            crate::gpu::ProbeArm::Cpu => {
9609                return crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
9610            }
9611        }
9612    }
9613    moe_ffn_cpu(m, x, &idx, &p, wsum, pool)
9614}
9615
9616/// One-shot report of whether the whole-token wgpu graph actually formed.
9617/// A refusal silently reverts to the per-op path, which is how a model can
9618/// look "GPU-accelerated" while every layer walks the host.
9619fn graph_note(built: bool) {
9620    use std::sync::atomic::{AtomicBool, Ordering};
9621    if built {
9622        GRAPH_TOK_OK.fetch_add(1, Ordering::Relaxed);
9623    } else {
9624        GRAPH_TOK_MISS.fetch_add(1, Ordering::Relaxed);
9625    }
9626    static SAID: AtomicBool = AtomicBool::new(false);
9627    if !SAID.swap(true, Ordering::Relaxed) {
9628        if built {
9629            tracing::info!("wgpu whole-token graph: ACTIVE");
9630        } else {
9631            tracing::warn!("wgpu whole-token graph refused — per-op path");
9632        }
9633    }
9634}
9635
9636/// Whole-token graph outcomes, process-wide: a benchmark that claims a
9637/// GPU number while MISS climbs is measuring the CPU — the honest-bench
9638/// contract makes that an error, not a footnote.
9639pub static GRAPH_TOK_OK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
9640pub static GRAPH_TOK_MISS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
9641
9642/// `CMF_MOE_BATCH=0` restores the per-expert serial loop — the A/B lever
9643/// for the batched kernel, and how its bit-identity is checked.
9644fn moe_batch_enabled() -> bool {
9645    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
9646    *ON.get_or_init(|| std::env::var("CMF_MOE_BATCH").as_deref() != Ok("0"))
9647}
9648
9649/// Two-dispatch CPU MoE: every routed expert (and the shared one) fused
9650/// into one gate/up/SiLU dispatch and one down dispatch, instead of two
9651/// pool barriers per expert. Bit-identical to the serial loop below —
9652/// see `moe_gate_up_many` / `moe_down_many`. `None` = the batched kernel
9653/// does not cover this layer, walk the serial path.
9654fn moe_ffn_cpu_batched(
9655    m: &MoeFfn,
9656    x: &[f32],
9657    idx: &[usize],
9658    p: &[f32],
9659    wsum: f32,
9660    pool: Option<&Pool>,
9661) -> Option<Vec<f32>> {
9662    if idx.is_empty() || !moe_batch_enabled() {
9663        return None;
9664    }
9665    // The bake probe reads per-neuron activation mass out of the
9666    // single-expert path; batching would skip it. Rare and offline —
9667    // hand those runs to the serial loop.
9668    if FFN_PROBE.with(|pr| pr.borrow().is_some()) {
9669        return None;
9670    }
9671    let n = idx.len() + usize::from(m.shared.is_some());
9672    let mut pairs = Vec::with_capacity(n);
9673    let mut downs = Vec::with_capacity(n);
9674    let mut ws = Vec::with_capacity(n);
9675    for &e in idx {
9676        let d = &m.experts[e];
9677        if d.act != Act::Silu {
9678            return None;
9679        }
9680        pairs.push((&d.gate_proj, &d.up_proj));
9681        downs.push(&d.down_proj);
9682        ws.push(p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]));
9683    }
9684    // The shared expert goes last, matching the serial loop's order —
9685    // the f32 accumulation order is part of the bit-identity claim.
9686    if let Some((se, gate)) = &m.shared {
9687        if se.act != Act::Silu {
9688            return None;
9689        }
9690        let g = gate.as_ref().map_or(1.0, |gate| {
9691            let mut gl = [0.0f32; 1];
9692            gate.matvec(x, &mut gl, pool);
9693            1.0 / (1.0 + (-gl[0]).exp())
9694        });
9695        pairs.push((&se.gate_proj, &se.up_proj));
9696        downs.push(&se.down_proj);
9697        ws.push(g);
9698    }
9699    let inter = pairs[0].0.rows();
9700    let mut gs: Vec<Vec<f32>> = (0..pairs.len()).map(|_| vec![0f32; inter]).collect();
9701    if !QTensor::moe_gate_up_many(&pairs, x, &mut gs, pool) {
9702        return None;
9703    }
9704    let mut out = attention::take_buf(x.len());
9705    if !QTensor::moe_down_many(&downs, &gs, &ws, &mut out, pool) {
9706        attention::recycle_buf(&mut out);
9707        return None;
9708    }
9709    Some(out)
9710}
9711
9712/// The pure-CPU MoE expert loop (also the fallback of every GPU refusal).
9713fn moe_ffn_cpu(
9714    m: &MoeFfn,
9715    x: &[f32],
9716    idx: &[usize],
9717    p: &[f32],
9718    wsum: f32,
9719    pool: Option<&Pool>,
9720) -> Vec<f32> {
9721    if let Some(out) = moe_ffn_cpu_batched(m, x, idx, p, wsum, pool) {
9722        return out;
9723    }
9724    let mut out = attention::take_buf(x.len());
9725    for &e in idx {
9726        let mut eo = dense_ffn(&m.experts[e], x, pool);
9727        let w = p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]);
9728        for i in 0..out.len() {
9729            out[i] += w * eo[i];
9730        }
9731        attention::recycle_buf(&mut eo);
9732    }
9733    if let Some((se, gate)) = &m.shared {
9734        let mut so = dense_ffn(se, x, pool);
9735        let g = gate.as_ref().map_or(1.0, |gate| {
9736            let mut gl = [0.0f32; 1];
9737            gate.matvec(x, &mut gl, pool);
9738            1.0 / (1.0 + (-gl[0]).exp())
9739        });
9740        for i in 0..out.len() {
9741            out[i] += g * so[i];
9742        }
9743        attention::recycle_buf(&mut so);
9744    }
9745    out
9746}
9747
9748/// DeepSeek-V2 MLA forward, expand-to-MHA form (see `AttnKind::Mla`):
9749/// per token the latent expands to every head's K/V and the ordinary
9750/// cache + grouped attend do the rest. K head layout is [rope | nope]
9751/// (rotary_dim = qk_rope rotates the shared rope key and each q head's
9752/// prefix); V rows are zero-padded to the K head_dim inside the cache
9753/// and the pad is sliced off before O. Born importance is not
9754/// accumulated for MLA yet (no eviction interplay).
9755#[allow(clippy::too_many_arguments)]
9756fn mla_attention(
9757    w: &MlaWeights,
9758    normed: &[f32],
9759    cache: &mut crate::kv_cache::LayerKvCache,
9760    position: usize,
9761    inv_freq: &[f32],
9762    rope_scale: f32,
9763    eps: f64,
9764    pool: Option<&Pool>,
9765) -> Vec<f32> {
9766    let (nh, dr, dn, dv, lora) = (w.nh, w.qk_rope, w.qk_nope, w.v_dim, w.lora);
9767    let hd = dr + dn;
9768    let mut q = vec![0.0f32; nh * hd];
9769    match (&w.q_a, &w.q_a_norm) {
9770        (Some(qa), Some(qn)) => {
9771            let mut t = vec![0.0f32; qa.rows()];
9772            qa.matvec(normed, &mut t, pool);
9773            let tn = inference::rms_norm(&t, qn, eps, NormStyle::Qwen);
9774            w.q_proj.matvec(&tn, &mut q, pool);
9775        }
9776        _ => w.q_proj.matvec(normed, &mut q, pool),
9777    }
9778    let mut ca = vec![0.0f32; lora + dr];
9779    w.kv_a.matvec(normed, &mut ca, pool);
9780    let (c_lat, k_rope) = ca.split_at_mut(lora);
9781    let latn = inference::rms_norm(c_lat, &w.kv_a_norm, eps, NormStyle::Qwen);
9782    let mut kvb = vec![0.0f32; nh * (dn + dv)];
9783    w.kv_b.matvec(&latn, &mut kvb, pool);
9784    if !w.nope {
9785        attention::rope_rotate_scaled(k_rope, position, inv_freq, rope_scale);
9786    }
9787    for h in 0..nh {
9788        if !w.nope {
9789            attention::rope_rotate_scaled(
9790                &mut q[h * hd..h * hd + dr],
9791                position,
9792                inv_freq,
9793                rope_scale,
9794            );
9795        }
9796    }
9797    let mut k = vec![0.0f32; nh * hd];
9798    let mut v = vec![0.0f32; nh * hd];
9799    for h in 0..nh {
9800        k[h * hd..h * hd + dr].copy_from_slice(k_rope);
9801        k[h * hd + dr..(h + 1) * hd].copy_from_slice(&kvb[h * (dn + dv)..h * (dn + dv) + dn]);
9802        v[h * hd..h * hd + dv].copy_from_slice(&kvb[h * (dn + dv) + dn..(h + 1) * (dn + dv)]);
9803    }
9804    cache.append(&k, &v, &vec![true; nh]);
9805    let (ao, mut imp) = attention::attend_all_heads(&q, cache, nh, 1, hd, w.scale, None, 0.0);
9806    attention::recycle_buf(&mut imp);
9807    let mut ov = vec![0.0f32; nh * dv];
9808    for h in 0..nh {
9809        ov[h * dv..(h + 1) * dv].copy_from_slice(&ao[h * hd..h * hd + dv]);
9810    }
9811    let mut out = vec![0.0f32; w.o_proj.rows()];
9812    w.o_proj.matvec(&ov, &mut out, pool);
9813    out
9814}
9815
9816/// Gemma-4 dual-branch FFN (spec: see `FfnKind::DenseMoe`). The dense
9817/// branch reads the pre-FFN-normed activation; the router and the
9818/// expert branch read the RAW residual — the router through a
9819/// scale-less rms norm (its constant gain is folded into the weights),
9820/// the experts through `pre_norm_2`. CPU path; GPU graphs refuse the
9821/// layer kind honestly.
9822fn dense_moe_ffn(
9823    dm: &DenseMoeFfn,
9824    x_normed: &[f32],
9825    h_raw: &[f32],
9826    eps: f64,
9827    norm_style: NormStyle,
9828    pool: Option<&Pool>,
9829) -> Vec<f32> {
9830    let mut d = dense_ffn(&dm.dense, x_normed, pool);
9831    d = inference::rms_norm(&d, &dm.post_norm_1, eps, norm_style);
9832    let m = &dm.moe;
9833    let ne = m.experts.len();
9834    let mut logits = vec![0.0f32; ne];
9835    if m.router_input_norm {
9836        let ss: f32 = h_raw.iter().map(|v| v * v).sum::<f32>() / h_raw.len() as f32;
9837        let inv = 1.0 / (ss + eps as f32).sqrt();
9838        let xr: Vec<f32> = h_raw.iter().map(|v| v * inv).collect();
9839        m.router.matvec(&xr, &mut logits, pool);
9840    } else {
9841        m.router.matvec(h_raw, &mut logits, pool);
9842    }
9843    let (idx, p, wsum) = moe_route(&logits, m, None);
9844    {
9845        let mut st = m.stats.borrow_mut();
9846        if st.len() < ne {
9847            st.resize(ne, 0);
9848        }
9849        for &e in &idx {
9850            st[e] += 1;
9851        }
9852    }
9853    let x2 = inference::rms_norm(h_raw, &dm.pre_norm_2, eps, norm_style);
9854    let mo = moe_ffn_cpu(m, &x2, &idx, &p, wsum, pool);
9855    let mo = inference::rms_norm(&mo, &dm.post_norm_2, eps, norm_style);
9856    for (di, mi) in d.iter_mut().zip(&mo) {
9857        *di += mi;
9858    }
9859    d
9860}
9861
9862/// Building the MoE-layer GPU jobs: all selected experts (+shared) must
9863/// be q8_2f-Mapped from the primary mapping; otherwise None → CPU path.
9864/// One-shot report of why the MoE GPU block refused. A silent `?` here
9865/// sends every expert to the CPU with nothing in the logs to say so —
9866/// which is exactly how a q4tp MoE model looked "GPU-accelerated" while
9867/// running entirely on the host.
9868fn moe_gpu_refused(why: &'static str) {
9869    use std::sync::atomic::{AtomicBool, Ordering};
9870    static SAID: AtomicBool = AtomicBool::new(false);
9871    if !SAID.swap(true, Ordering::Relaxed) {
9872        tracing::warn!("MoE GPU block refused ({why}) — experts run on the CPU");
9873    }
9874}
9875
9876fn moe_ffn_gpu(
9877    m: &MoeFfn,
9878    x: &[f32],
9879    idx: &[usize],
9880    p: &[f32],
9881    wsum: f32,
9882    pool: Option<&Pool>,
9883) -> Option<Vec<f32>> {
9884    use crate::gpu::MoeJob;
9885
9886    let mut jobs: Vec<MoeJob> = Vec::with_capacity(idx.len() + 1);
9887    let mut model_ref = None;
9888    for &e in idx {
9889        if moe_push_job(&m.experts[e], x, p[e] / wsum, &mut jobs, &mut model_ref).is_none() {
9890            moe_gpu_refused("push_job(expert)");
9891            return None;
9892        }
9893    }
9894    if let Some((se, gate)) = &m.shared {
9895        let g = gate.as_ref().map_or(1.0, |gate| {
9896            let mut gl = [0.0f32; 1];
9897            gate.matvec(x, &mut gl, pool);
9898            1.0 / (1.0 + (-gl[0]).exp())
9899        });
9900        if moe_push_job(se, x, g, &mut jobs, &mut model_ref).is_none() {
9901            moe_gpu_refused("push_job(shared)");
9902            return None;
9903        }
9904    }
9905    let Some(model) = model_ref else {
9906        moe_gpu_refused("no model_ref");
9907        return None;
9908    };
9909    let hidden = jobs[0].down.1;
9910    let mut out = vec![0.0f32; hidden];
9911    if crate::gpu::moe_block(&model, &jobs, &mut out) {
9912        Some(out)
9913    } else {
9914        moe_gpu_refused("gpu::moe_block");
9915        None
9916    }
9917}
9918
9919/// Single-position FFN dispatch.
9920fn ffn_forward(
9921    ffn: &FfnKind,
9922    x: &[f32],
9923    pool: Option<&Pool>,
9924    experts_allowed: Option<&[bool]>,
9925) -> Vec<f32> {
9926    match ffn {
9927        FfnKind::Dense(d) => dense_ffn(d, x, pool),
9928        FfnKind::Moe(m) => moe_ffn(m, x, pool, experts_allowed),
9929        // Dual-branch layers need the raw residual — their callers
9930        // dispatch dense_moe_ffn directly; the auxiliary paths that land
9931        // here (MTP draft, o1 replay) do not co-occur with gemma-4 MoE.
9932        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
9933    }
9934}
9935
9936/// Fused two-position FFN: gate/up/down streamed once (dense). MoE
9937/// falls back to two singles — expert sets differ per position, there
9938/// is nothing to fuse.
9939fn ffn_forward_pair(
9940    ffn: &FfnKind,
9941    x1: &[f32],
9942    x2: &[f32],
9943    pool: Option<&Pool>,
9944    experts_allowed: Option<&[bool]>,
9945) -> (Vec<f32>, Vec<f32>) {
9946    let d = match ffn {
9947        FfnKind::Dense(d) => d,
9948        FfnKind::Moe(m) => {
9949            return (
9950                moe_ffn(m, x1, pool, experts_allowed),
9951                moe_ffn(m, x2, pool, experts_allowed),
9952            );
9953        }
9954        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
9955    };
9956    let inter = d.gate_proj.rows();
9957    FFN_SCRATCH.with(|s| {
9958        let mut s = s.borrow_mut();
9959        let [g1, g2, u1, u2] = &mut *s;
9960        g1.resize(inter, 0.0);
9961        g2.resize(inter, 0.0);
9962        u1.resize(inter, 0.0);
9963        u2.resize(inter, 0.0);
9964        // Multi-matrix pair job: gate+up under one pool dispatch
9965        // (o1s = lane-1 outputs across tensors, o2s = lane-2).
9966        QTensor::matvec2_many(
9967            [&d.gate_proj, &d.up_proj],
9968            x1,
9969            x2,
9970            [g1.as_mut_slice(), u1.as_mut_slice()],
9971            [g2.as_mut_slice(), u2.as_mut_slice()],
9972            pool,
9973        );
9974        for i in 0..inter {
9975            g1[i] = d.act.combine(g1[i], u1[i]);
9976            g2[i] = d.act.combine(g2[i], u2[i]);
9977        }
9978        let mut o1 = attention::take_buf(d.down_proj.rows());
9979        let mut o2 = attention::take_buf(d.down_proj.rows());
9980        d.down_proj.matvec2(g1, g2, &mut o1, &mut o2, pool);
9981        (o1, o2)
9982    })
9983}
9984
9985#[cfg(test)]
9986mod tests {
9987
9988    #[test]
9989    fn cancel_flag_stops_generation() {
9990        let mut p = create_test_pipeline(16, 32, 2, 2, 8, 2, 32);
9991        // Set before the call: the prefill loops honour it, the run
9992        // returns immediately with the cancelled reason and no tokens.
9993        p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
9994        let r = p.generate_from_ids(&[1, 2, 3], 8, None, None).unwrap();
9995        assert_eq!(r.finish_reason, "cancelled");
9996        assert!(
9997            r.token_ids.is_empty(),
9998            "no tokens after cancel: {:?}",
9999            r.token_ids
10000        );
10001        // Flag auto-cleared: the next call generates normally.
10002        let r2 = p.generate_from_ids(&[1, 2, 3], 4, None, None).unwrap();
10003        assert_ne!(r2.finish_reason, "cancelled");
10004    }
10005    use super::*;
10006
10007    /// sparse_ffn_quant must equal a dense FFN where inactive neurons are
10008    /// zeroed (mask × mmap correctness). On F32 tensors this is EXACT —
10009    /// it validates the row_dot / add_col_scaled / scatter indexing, the
10010    /// bug-prone part. The q8 branches reuse the golden-tested linear
10011    /// scale, structurally identical to the matvec kernels.
10012    #[test]
10013    fn sparse_ffn_quant_equals_dense_with_inactive_zeroed() {
10014        let (hidden, inter) = (16usize, 40usize);
10015        let synth = |n: usize, salt: usize| -> Vec<f32> {
10016            (0..n)
10017                .map(|i| (((i * 37 + salt * 11 + 3) % 101) as f32 / 101.0 - 0.5) * 0.4)
10018                .collect()
10019        };
10020        let d = DenseFfn {
10021            gate_proj: QTensor::from_f32(synth(inter * hidden, 1), inter, hidden),
10022            up_proj: QTensor::from_f32(synth(inter * hidden, 2), inter, hidden),
10023            down_proj: QTensor::from_f32(synth(hidden * inter, 3), hidden, inter),
10024            act: Act::Silu,
10025        };
10026        let x = synth(hidden, 9);
10027        // Active = every 3rd neuron.
10028        let active: Vec<u16> = (0..inter as u16).filter(|i| i % 3 == 0).collect();
10029
10030        let sparse = sparse_ffn_quant(&d, &x, &active, hidden, None);
10031
10032        // Reference: full dense FFN but g[i]=0 for inactive neurons.
10033        let mut g = vec![0.0f32; inter];
10034        d.gate_proj.matvec(&x, &mut g, None);
10035        let mut u = vec![0.0f32; inter];
10036        d.up_proj.matvec(&x, &mut u, None);
10037        let act_set: std::collections::HashSet<u16> = active.iter().copied().collect();
10038        for i in 0..inter {
10039            g[i] = if act_set.contains(&(i as u16)) {
10040                inference::silu(g[i]) * u[i]
10041            } else {
10042                0.0
10043            };
10044        }
10045        let mut reference = vec![0.0f32; hidden];
10046        d.down_proj.matvec(&g, &mut reference, None);
10047
10048        let max_d = sparse
10049            .iter()
10050            .zip(&reference)
10051            .map(|(a, b)| (a - b).abs())
10052            .fold(0.0f32, f32::max);
10053        assert!(max_d < 1e-5, "sparse != dense-zeroed: max|Δ| = {max_d}");
10054    }
10055
10056    /// Attach a synthetic MTP head (same structure as a main layer).
10057    fn attach_test_mtp(p: &mut Pipeline) {
10058        let (h, inter, heads, kv, hd) = (
10059            p.hidden_size,
10060            p.intermediate_size,
10061            p.num_heads,
10062            p.num_kv_heads,
10063            p.head_dim,
10064        );
10065        let synth = |n: usize, salt: usize| -> Vec<f32> {
10066            (0..n)
10067                .map(|i| (((i * 29 + salt * 23 + 5) % 101) as f32 / 101.0 - 0.5) * 0.2)
10068                .collect()
10069        };
10070        let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
10071            QTensor::from_f32(synth(rows * cols, salt), rows, cols)
10072        };
10073        p.mtp = Some(MtpModule {
10074            enorm: vec![1.0; h],
10075            hnorm: vec![1.0; h],
10076            eh_proj: qt(h, 2 * h, 301),
10077            layer: LayerWeights {
10078                input_norm: vec![1.0; h],
10079                post_norm: vec![1.0; h],
10080                attn_out_norm: None,
10081                ffn_out_norm: None,
10082                layer_scale: None,
10083                ffn: FfnKind::Dense(DenseFfn {
10084                    gate_proj: qt(inter, h, 315),
10085                    up_proj: qt(inter, h, 316),
10086                    down_proj: qt(h, inter, 317),
10087                    act: Act::Silu,
10088                }),
10089                attn: AttnKind::Full {
10090                    bias: None,
10091                    wq: qt(heads * hd, h, 311),
10092                    wk: qt(kv * hd, h, 312),
10093                    wv: qt(kv * hd, h, 313),
10094                    wo: qt(h, heads * hd, 314),
10095                    q_norm: None,
10096                    k_norm: None,
10097                    output_gate: false,
10098                    softplus_gate: None,
10099                },
10100            },
10101            final_norm: vec![1.0; h],
10102            kv: crate::kv_cache::LayerKvCache::new(kv, hd),
10103        });
10104    }
10105
10106    #[test]
10107    fn speculative_equals_vanilla_greedy() {
10108        // Speculative decode and the wgpu token graph are mutually
10109        // exclusive; a leaked CMF_GPU=wgpu from a parallel gpu test
10110        // would silently disable drafting. Pin the graph off.
10111        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
10112        let run = |spec: bool| {
10113            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10114            p.sampler_config.temperature = 0.0;
10115            attach_test_mtp(&mut p);
10116            p.speculative = spec;
10117            let r = p.generate("abcdef", 12, None, None).unwrap();
10118            (r.token_ids, r.mtp_drafted, r.mtp_accepted)
10119        };
10120        let (vanilla, d0, _) = run(false);
10121        let (spec, d1, a1) = run(true);
10122        assert_eq!(d0, 0, "vanilla path must not draft");
10123        assert!(d1 > 0, "speculative path must draft");
10124        assert_eq!(
10125            vanilla, spec,
10126            "speculative must reproduce the exact greedy sequence (accepted {a1}/{d1})"
10127        );
10128    }
10129
10130    #[test]
10131    fn speculative_accepts_constant_oracle() {
10132        // See speculative_equals_vanilla_greedy: pin the wgpu graph off.
10133        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
10134        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10135        p.sampler_config.temperature = 0.0;
10136        p.sampler_config.repetition_penalty = 1.0;
10137        // Constant lm_head → every logit equal → both the main model and
10138        // the draft head argmax to token 0: acceptance must be 100%.
10139        p.weights.lm_head = QTensor::from_f32(vec![0.01; 64 * 8], 64, 8);
10140        attach_test_mtp(&mut p);
10141        p.speculative = true;
10142        let r = p.generate("abcd", 10, None, None).unwrap();
10143        assert!(r.mtp_drafted > 0);
10144        assert_eq!(
10145            r.mtp_accepted, r.mtp_drafted,
10146            "constant logits → every draft accepted"
10147        );
10148        // Ties resolve to the same token in both the main and draft
10149        // heads — the sequence is one repeated token.
10150        assert!(r.token_ids.windows(2).all(|w| w[0] == w[1]));
10151    }
10152
10153    #[test]
10154    fn empty_prompt_is_an_error_not_a_panic() {
10155        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
10156        let r = p.generate("", 4, None, None);
10157        assert!(r.is_err(), "empty prompt must be a clean error");
10158    }
10159
10160    #[test]
10161    fn every_token_enters_kv_exactly_once() {
10162        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10163        // Greedy so no RNG variance; byte tokenizer → 3 prompt tokens.
10164        p.sampler_config.temperature = 0.0;
10165        let r = p.generate("abc", 2, None, None).unwrap();
10166        assert_eq!(r.prompt_tokens, 3);
10167        // prompt(3) + first sampled token forwarded before second logits:
10168        // step0 samples from prefill hidden (no extra forward), then
10169        // forwards t1 → cache 4; step1 samples, loop ends (max_tokens).
10170        assert_eq!(
10171            p.kv_cache.seq_len(),
10172            3 + r.tokens_generated - 1,
10173            "each token must be cached exactly once (v1 cached the last prompt token twice)"
10174        );
10175    }
10176
10177    #[test]
10178    fn generation_is_reproducible_with_seed() {
10179        let run = || {
10180            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10181            p.generate("hello", 8, None, None).unwrap().token_ids
10182        };
10183        assert_eq!(run(), run());
10184    }
10185
10186    #[test]
10187    fn resetting_sampler_restarts_the_seeded_stream() {
10188        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10189        let config = SamplerConfig {
10190            seed: Some(1234),
10191            ..SamplerConfig::default()
10192        };
10193        p.set_sampler_config(config.clone());
10194        let first = p.generate("hello", 8, None, None).unwrap().token_ids;
10195        p.set_sampler_config(config);
10196        let second = p.generate("hello", 8, None, None).unwrap().token_ids;
10197        assert_eq!(first, second);
10198    }
10199
10200    #[test]
10201    fn eviction_bounds_the_cache() {
10202        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
10203        p.kv_cache.max_seq_len = 6;
10204        p.sampler_config.temperature = 0.0;
10205        let _ = p.generate("abcd", 12, None, None).unwrap();
10206        assert!(
10207            p.kv_cache.seq_len() <= 6 + 1,
10208            "cache must stay bounded by max_seq_len (got {})",
10209            p.kv_cache.seq_len()
10210        );
10211    }
10212
10213    #[test]
10214    fn confidence_matches_tokens_and_is_a_probability() {
10215        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10216        p.sampler_config.temperature = 0.0;
10217        p.sampler_config.repetition_penalty = 1.0;
10218        let r = p.generate("abcd", 10, None, None).unwrap();
10219        assert_eq!(
10220            r.token_confidence.len(),
10221            r.token_ids.len(),
10222            "one confidence per emitted token"
10223        );
10224        for &c in &r.token_confidence {
10225            assert!((0.0..=1.0).contains(&c), "confidence out of [0,1]: {c}");
10226        }
10227        // top1_prob is a valid softmax probability.
10228        let logits = [1.0f32, 3.0, 0.5, 3.0];
10229        let p0 = top1_prob_t(&logits, 1, 1.0);
10230        let p1 = top1_prob_t(&logits, 3, 1.0);
10231        assert!((p0 - p1).abs() < 1e-6, "equal logits → equal prob");
10232        assert!(p0 > 0.0 && p0 < 1.0);
10233        // Calibration temperature > 1 softens an over-confident peak.
10234        let sharp = top1_prob_t(&logits, 1, 1.0);
10235        let soft = top1_prob_t(&logits, 1, 2.0);
10236        assert!(soft < sharp, "higher temperature lowers peak confidence");
10237    }
10238
10239    #[test]
10240    fn trace_is_opt_in_and_parallels_the_output() {
10241        // Off by default: the runtime is silent unless observation asked.
10242        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10243        p.sampler_config.temperature = 0.0;
10244        p.sampler_config.repetition_penalty = 1.0;
10245        let r = p.generate("abcd", 10, None, None).unwrap();
10246        assert!(r.traces.is_empty(), "trace must be empty unless enabled");
10247
10248        // On: exactly one row per emitted token, aligned with the output.
10249        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10250        p.sampler_config.temperature = 0.0;
10251        p.sampler_config.repetition_penalty = 1.0;
10252        p.set_trace(true);
10253        let r = p.generate("abcd", 10, None, None).unwrap();
10254        assert_eq!(r.traces.len(), r.token_ids.len(), "one trace row per token");
10255        for (i, tr) in r.traces.iter().enumerate() {
10256            assert_eq!(tr.t, i, "trace index is sequential");
10257            assert_eq!(tr.token_id, r.token_ids[i], "trace token_id matches output");
10258            assert_eq!(
10259                tr.confidence, r.token_confidence[i],
10260                "trace confidence matches the confidence channel"
10261            );
10262            // No dynamic router in this pipeline → no skill, no coherence.
10263            assert!(tr.active_skill.is_none() && tr.recon.is_none() && !tr.switched);
10264        }
10265    }
10266
10267    #[test]
10268    fn explain_prefill_logits_match_greedy_first_token() {
10269        // `cortiq explain` shows the next-token distribution from
10270        // prefill_next_logits; its argmax must equal what greedy generate
10271        // actually emits first — otherwise explain would lie.
10272        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10273        p.sampler_config.temperature = 0.0;
10274        p.sampler_config.repetition_penalty = 1.0;
10275        let ids = p.tokenizer.encode("abcd");
10276        let logits = p.prefill_next_logits(&ids, None);
10277        let argmax = logits
10278            .iter()
10279            .enumerate()
10280            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
10281            .unwrap()
10282            .0 as u32;
10283        let r = p.generate("abcd", 1, None, None).unwrap();
10284        assert_eq!(
10285            argmax, r.token_ids[0],
10286            "explain preview must match greedy emit"
10287        );
10288    }
10289
10290    #[test]
10291    fn laguna_shared_expert_is_unconditionally_added() {
10292        let matrix = |values: Vec<f32>| QTensor::from_f32(values, 2, 2);
10293        let identity = || matrix(vec![1.0, 0.0, 0.0, 1.0]);
10294        let zero_dense = || DenseFfn {
10295            gate_proj: matrix(vec![0.0; 4]),
10296            up_proj: matrix(vec![0.0; 4]),
10297            down_proj: matrix(vec![0.0; 4]),
10298            act: Act::Silu,
10299        };
10300        let shared = DenseFfn {
10301            gate_proj: identity(),
10302            up_proj: identity(),
10303            down_proj: identity(),
10304            act: Act::Silu,
10305        };
10306        let x = [1.0, 2.0];
10307        let expected = dense_ffn(&shared, &x, None);
10308        let moe = MoeFfn {
10309            router: QTensor::from_f32(vec![0.0, 0.0], 1, 2),
10310            experts: vec![zero_dense()],
10311            top_k: 1,
10312            norm_topk_prob: true,
10313            router_sigmoid: true,
10314            expert_bias: None,
10315            routed_scaling: 1.0,
10316            route_tau: None,
10317            shared: Some((shared, None)),
10318            stats: std::cell::RefCell::new(Vec::new()),
10319            act_sq: std::cell::RefCell::new(Vec::new()),
10320            act_rows: std::cell::RefCell::new(Vec::new()),
10321            mask: None,
10322            per_expert_scale: None,
10323            router_input_norm: false,
10324        };
10325        let actual = moe_ffn_cpu(&moe, &x, &[0], &[0.0], 1.0, None);
10326        for (actual, expected) in actual.iter().zip(expected) {
10327            assert!((actual - expected).abs() < 1e-6);
10328        }
10329    }
10330}