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    /// Cortiq Embryo hierarchical head: cluster matrix [C, hidden]. The
262    /// flat logits h·Eᵀ are turned into the two-level log-probabilities
263    /// log softmax_c(h·Cᵀ)[c(v)] + log softmax_{s∈c(v)}(h·E_c(v)ᵀ)[v].
264    pub head_clusters: Option<std::sync::Arc<Vec<f32>>>,
265    /// Gemma-2 attention-logit soft-capping (0.0 = off).
266    pub attn_softcap: f32,
267    /// Compute per-token Born confidence (a full-vocab softmax each
268    /// token). On by default; `bench --core` turns it off to match
269    /// llama-bench's core timing.
270    confidence_on: bool,
271}
272
273#[cfg(target_os = "macos")]
274impl Drop for Pipeline {
275    fn drop(&mut self) {
276        crate::gpu::kv_mirror_drop(self.graph_kv_id);
277    }
278}
279
280/// Model weights. Matrices are `QTensor` (owned f32 for small models
281/// and tests — bit-identical to the historical paths — or quantized
282/// bytes zero-copy from the CMF mmap for big models). 1-D norms are
283/// always small and stay f32.
284pub struct PipelineWeights {
285    /// Embedding table: [vocab_size, hidden_size]
286    pub embed_tokens: QTensor,
287    /// Per-layer weights
288    pub layers: Vec<LayerWeights>,
289    /// LM head: [vocab_size, hidden_size]
290    pub lm_head: QTensor,
291    /// Final norm: [hidden_size]
292    pub final_norm: Vec<f32>,
293}
294
295/// One transformer layer: shared norms + MLP, attention by kind.
296pub struct LayerWeights {
297    pub input_norm: Vec<f32>,
298    /// The pre-FFN norm (`post_attention_layernorm` classically;
299    /// `pre_feedforward_layernorm` on Gemma-2/3 sandwich layers).
300    pub post_norm: Vec<f32>,
301    /// Gemma-2/3 sandwich: norm applied to the ATTENTION OUTPUT before
302    /// its residual add (`post_attention_layernorm` there).
303    pub attn_out_norm: Option<Vec<f32>>,
304    /// Gemma-4: the whole layer output is multiplied by this scalar.
305    pub layer_scale: Option<f32>,
306    /// Gemma-2/3 sandwich: norm applied to the FFN OUTPUT before its
307    /// residual add (`post_feedforward_layernorm`).
308    pub ffn_out_norm: Option<Vec<f32>>,
309    pub ffn: FfnKind,
310    pub attn: AttnKind,
311}
312
313/// FFN gate activation: SiLU (SwiGLU family) or tanh-GELU (Gemma's
314/// GeGLU). A property of the model, carried on every FFN triple.
315#[derive(Clone, Copy, PartialEq, Debug, Default)]
316pub enum Act {
317    #[default]
318    Silu,
319    GeluTanh,
320    /// Kimi-K3 SituAndMul: BOTH halves transform —
321    /// a = β·tanh(g/β)·σ(g), up' = linβ·tanh(u/linβ) (linβ>0), out = a·up'.
322    Situ {
323        beta: f32,
324        linear_beta: f32,
325    },
326}
327
328impl Act {
329    pub fn from_arch(name: &str) -> Self {
330        if name == "gelu_tanh" {
331            Self::GeluTanh
332        } else {
333            Self::Silu
334        }
335    }
336
337    /// Arch-driven constructor (activation name + situ betas).
338    pub fn from_arch_full(arch: &cortiq_core::ModelArch) -> Self {
339        match arch.hidden_act.as_str() {
340            "situ" => Self::Situ {
341                beta: arch.activation_situ_beta.unwrap_or(1.0) as f32,
342                linear_beta: arch.activation_situ_linear_beta.unwrap_or(0.0) as f32,
343            },
344            other => Self::from_arch(other),
345        }
346    }
347
348    #[inline]
349    pub fn apply(self, x: f32) -> f32 {
350        match self {
351            Self::Silu => inference::silu(x),
352            Self::GeluTanh => inference::gelu_tanh(x),
353            Self::Situ { beta, .. } => beta * (x / beta).tanh() * (1.0 / (1.0 + (-x).exp())),
354        }
355    }
356
357    /// Gated combine — the FFN contract. Situ transforms the UP half
358    /// too, so callers must use this instead of apply(g)·u.
359    #[inline]
360    pub fn combine(self, g: f32, u: f32) -> f32 {
361        match self {
362            Self::Situ { linear_beta, .. } if linear_beta > 0.0 => {
363                self.apply(g) * (linear_beta * (u / linear_beta).tanh())
364            }
365            _ => self.apply(g) * u,
366        }
367    }
368}
369
370/// Dense gated triple — the FFN of a dense layer or of one expert.
371pub struct DenseFfn {
372    pub gate_proj: QTensor,
373    pub up_proj: QTensor,
374    pub down_proj: QTensor,
375    /// Gate activation (SiLU default; Gemma: tanh-GELU).
376    pub act: Act,
377}
378
379/// FFN operator of a layer, decided by tensor presence at load time
380/// (router `mlp.gate.weight` in the directory = MoE layer).
381pub enum FfnKind {
382    Dense(DenseFfn),
383    /// Mixture-of-Experts (Qwen2-MoE / Qwen3-MoE): softmax over ALL
384    /// expert logits → top-k, optional renorm; experts stay quantized
385    /// in mmap — only the selected ones are touched per token.
386    Moe(MoeFfn),
387    /// Gemma-4 MoE: a dense MLP branch AND a routed-expert branch in
388    /// the SAME layer, each with its own norm sandwich. The dense
389    /// branch reads the pre-FFN-normed input; the expert branch (and
390    /// the router) read the RAW residual through `pre_norm_2`:
391    ///   d = post_norm_1(dense(x̂));  m = post_norm_2(Σwₑ·FFNₑ(pre_norm_2(h)))
392    ///   ffn_out = d + m   (the caller's ffn_out_norm + residual follow)
393    DenseMoe(Box<DenseMoeFfn>),
394}
395
396/// Gemma-4 dual-branch FFN (see `FfnKind::DenseMoe`).
397pub struct DenseMoeFfn {
398    pub dense: DenseFfn,
399    pub moe: MoeFfn,
400    /// post_feedforward_layernorm_1 — dense-branch output norm.
401    pub post_norm_1: Vec<f32>,
402    /// pre_feedforward_layernorm_2 — expert-branch input norm (applied
403    /// to the RAW residual, not the pre-FFN-normed activation).
404    pub pre_norm_2: Vec<f32>,
405    /// post_feedforward_layernorm_2 — expert-branch output norm.
406    pub post_norm_2: Vec<f32>,
407}
408
409pub struct MoeFfn {
410    /// Router `mlp.gate.weight` [num_experts, hidden].
411    pub router: QTensor,
412    pub experts: Vec<DenseFfn>,
413    pub top_k: usize,
414    pub norm_topk_prob: bool,
415    /// Router scores per-expert with a sigmoid (LFM2-MoE / DeepSeek-V3
416    /// `noaux_tc`) instead of a softmax over all experts (Qwen).
417    pub router_sigmoid: bool,
418    /// Per-expert selection bias `mlp.expert_bias` [num_experts]
419    /// (LFM2-MoE): added to the sigmoid scores for the top-k CHOICE only;
420    /// the gathered weights use the unbiased scores. None = no bias.
421    pub expert_bias: Option<Vec<f32>>,
422    /// Top-k weights are multiplied by this after the optional renorm
423    /// (LFM2-MoE `routed_scaling_factor`; 1.0 = off).
424    pub routed_scaling: f32,
425    /// Adaptive routing (CMF_MOE_TAU, opt-in): keep the smallest
426    /// prefix of the top-k whose renormalized mass reaches τ —
427    /// confident tokens touch 1–2 experts, flat ones keep all k.
428    /// MoE decode is memory-bound, so skipped experts are skipped
429    /// weight traffic. None = classic fixed top-k (bit-identical).
430    pub route_tau: Option<f32>,
431    /// Always-on shared expert. Qwen2-MoE carries an additional sigmoid
432    /// gate; Laguna adds the shared expert unconditionally (`None`).
433    pub shared: Option<(DenseFfn, Option<QTensor>)>,
434    /// Expert-selection counters (truncated Fisher B-field of claim 12:
435    /// routing frequency during calibration). Filled by every forward,
436    /// read by the CLI via CMF_MOE_STATS. RefCell: decode is single-threaded.
437    pub stats: std::cell::RefCell<Vec<u64>>,
438    /// Per-CHANNEL sum of squares of this FFN's input, accumulated over a
439    /// calibration run (`CMF_RMS_TRACE`). These are the RMS activation
440    /// traces AWNP needs: raw weight magnitude says every channel matters
441    /// equally, and the question AWNP asks is whether the ACTIVATIONS
442    /// disagree. Off unless the env var is set — an f64 add per channel
443    /// per token is cheap, but not free.
444    pub act_sq: std::cell::RefCell<Vec<f64>>,
445    /// Raw FFN-input rows captured for the layers named by `CMF_ACT_DUMP`
446    /// (`"9,19"`). AWNP is nullspace PROJECTION: after dropping channels the
447    /// survivors are refitted to absorb what was removed, and how much they
448    /// can absorb depends on the activation COVARIANCE, not on per-channel
449    /// RMS. Per-channel numbers can only bound the cost from above.
450    pub act_rows: std::cell::RefCell<Vec<f32>>,
451    /// Task mask over routed experts (DTG-MA over MoE, claim-12 B-field
452    /// applied): `false` experts are excluded from selection, the
453    /// softmax renormalizes over the allowed set. Built by the loader
454    /// from CMF_MOE_MASK=<stats.json> + CMF_MOE_MASK_COVER. None = all.
455    pub mask: Option<Vec<bool>>,
456    /// Gemma-4: per-expert weight scale applied AFTER the top-k renorm
457    /// (`router.per_expert_scale`). None = 1.0 everywhere.
458    pub per_expert_scale: Option<Vec<f32>>,
459    /// Gemma-4: the router reads a SCALE-LESS rms-norm of its input
460    /// (the constant gain router.scale·√hidden is folded into the
461    /// router weights at convert time).
462    pub router_input_norm: bool,
463    /// Cortiq Embryo: resonance routing (P1) — the "logits" are
464    /// bias_e − ‖(x−μ_e) − U_eᵀU_e(x−μ_e)‖², argmax = the expert whose
465    /// descriptor reconstructs the input best. `router` is a placeholder.
466    pub resonance: Option<Resonance>,
467}
468
469/// Per-expert resonance descriptors of one MoE layer (`mlp.desc.*`).
470pub struct Resonance {
471    /// [E, hidden]
472    pub mu: Vec<f32>,
473    /// [E, k, hidden] orthonormal directions (k may be 0)
474    pub u: Vec<f32>,
475    pub k: usize,
476    /// [E] selection bias (loss-free balancing, trained online)
477    pub bias: Vec<f32>,
478}
479
480impl Resonance {
481    /// Routing scores for one input row (higher = better).
482    pub fn scores(&self, x: &[f32], out: &mut [f32]) {
483        let h = x.len();
484        let ne = out.len();
485        for e in 0..ne {
486            let mu = &self.mu[e * h..(e + 1) * h];
487            let mut d2 = 0.0f32;
488            for j in 0..h {
489                let d = x[j] - mu[j];
490                d2 += d * d;
491            }
492            let mut proj = 0.0f32;
493            for i in 0..self.k {
494                let u = &self.u[(e * self.k + i) * h..(e * self.k + i + 1) * h];
495                let mut p = 0.0f32;
496                for j in 0..h {
497                    p += (x[j] - mu[j]) * u[j];
498                }
499                proj += p * p;
500            }
501            out[e] = self.bias.get(e).copied().unwrap_or(0.0) - (d2 - proj);
502        }
503    }
504}
505
506/// Attention operator of a layer. Extension point: new operators are
507/// new variants here + a forward in their own module.
508pub enum AttnKind {
509    /// GQA softmax attention (+ optional Qwen3.5 qk-norm / output gate).
510    Full {
511        wq: QTensor,
512        wk: QTensor,
513        wv: QTensor,
514        wo: QTensor,
515        q_norm: Option<Vec<f32>>,
516        k_norm: Option<Vec<f32>>,
517        output_gate: bool,
518        /// Laguna: a separate softplus projection applied to the attention
519        /// output before O. The bool means one scalar per head (broadcast
520        /// across head_dim); false means one scalar per element.
521        softplus_gate: Option<(QTensor, bool)>,
522        /// Qwen2-family projection biases (q, k, v).
523        bias: Option<(Vec<f32>, Vec<f32>, Vec<f32>)>,
524    },
525    /// Canonical linear core (VMF phase attention).
526    Linear(VmfPhaseWeights),
527    /// Faithful vendor linear operator (Qwen3.5 GatedDeltaNet).
528    LinearGdn(GdnWeights),
529    /// LFM2 gated short-convolution mixer (no KV cache; conv ring state
530    /// lives in the layer's `linear_state`).
531    ShortConv(ShortConvWeights),
532    /// DeepSeek-V2 Multi-head Latent Attention. v1 executes it as
533    /// expand-to-MHA: the latent is projected per token, K/V expand to
534    /// every head and live in the ordinary cache (K head layout
535    /// [rope | nope] so the standard partial rotary covers the shared
536    /// rope key; V rows are zero-padded to the K head_dim and the pad
537    /// is sliced off before O). Latent-resident cache is a later
538    /// optimization, not a semantic change.
539    Mla(Box<MlaWeights>),
540    /// Kimi Delta Attention (Kimi Linear / Kimi-K3): per-channel decayed
541    /// delta rule, separate q/k/v short convs, sigmoid-gated output norm.
542    /// State lives in the layer's `linear_state` (no KV cache).
543    Kda(Box<crate::linear_core::KdaWeights>),
544}
545
546/// DeepSeek-V2 MLA projections (see `AttnKind::Mla`).
547pub struct MlaWeights {
548    /// `[nh·(rope+nope), hidden]` (or `[…, q_lora]` when compressed) —
549    /// the converter permutes each head rope-first so rotary_dim =
550    /// qk_rope works unchanged.
551    pub q_proj: QTensor,
552    /// Compressed q (K3/V3 class): x → q_a `[q_lora, hidden]` →
553    /// rms(q_a_norm) → q_proj (= q_b). None = direct q (V2-Lite).
554    pub q_a: Option<QTensor>,
555    pub q_a_norm: Option<Vec<f32>>,
556    /// `kv_a_proj_with_mqa` `[lora + rope, hidden]` (latent first).
557    pub kv_a: QTensor,
558    /// RMS-norm weights over the latent (`kv_a_layernorm`, [lora]).
559    pub kv_a_norm: Vec<f32>,
560    /// `[nh·(nope+v), lora]` — per head [k_nope | v].
561    pub kv_b: QTensor,
562    /// `[hidden, nh·v]`.
563    pub o_proj: QTensor,
564    pub nh: usize,
565    pub qk_rope: usize,
566    pub qk_nope: usize,
567    pub v_dim: usize,
568    pub lora: usize,
569    /// Softmax scale (1/√(rope+nope), YaRN-mscale-corrected at load).
570    pub scale: f32,
571    /// Kimi Linear NoPE: skip the rotary entirely (layout unchanged).
572    pub nope: bool,
573}
574
575/// Multi-token-prediction head (DeepSeek/Qwen style, spec §2.1):
576/// `x = eh_proj·[enorm(embed(next)); hnorm(hidden)]` → one transformer
577/// block over its own KV → shared lm_head. Drafts the token after next;
578/// the main model verifies, so output is exact — MTP only buys speed.
579pub struct MtpModule {
580    pub enorm: Vec<f32>,
581    pub hnorm: Vec<f32>,
582    /// [hidden, 2·hidden]
583    pub eh_proj: QTensor,
584    pub layer: LayerWeights,
585    pub final_norm: Vec<f32>,
586    pub kv: crate::kv_cache::LayerKvCache,
587}
588
589/// A Metal verify graph after its sync: what the commit needs — the
590/// graph (per-layer replay scratch), the GDN layers in encode order (their
591/// CPU states receive the replay), and the attention layers with the CPU
592/// row count they were encoded against (the accepted rows are pulled from
593/// the mirror from there).
594/// One item of the Metal rows-graph plan.
595#[cfg(target_os = "macos")]
596enum MetalRowsItem<'a> {
597    Gdn {
598        run: Vec<crate::gpu_metal::GdnGpuLayer<'a>>,
599        first: usize,
600    },
601    Attn {
602        l: crate::gpu_metal::AttnGpuLayer<'a>,
603        li: usize,
604        q_norm: Option<&'a [f32]>,
605        k_norm: Option<&'a [f32]>,
606        output_gate: bool,
607    },
608}
609
610#[cfg(target_os = "macos")]
611struct MetalVerifyPending {
612    graph: crate::gpu_metal::VerifyGraph,
613    gdn_layers: Vec<usize>,
614    attn_layers: Vec<(usize, usize)>,
615}
616
617/// The speculation trial's phases (see the decode loop): four timed
618/// speculative rounds, eight timed plain tokens, then the faster arm
619/// until a re-check.
620#[derive(Clone, Copy)]
621enum SpecTrial {
622    Spec {
623        t0: std::time::Instant,
624        gen0: usize,
625        rounds: usize,
626    },
627    Plain {
628        t0: std::time::Instant,
629        gen0: usize,
630    },
631    Decided {
632        spec: bool,
633        recheck_at: usize,
634    },
635}
636
637/// The speculation monitor: exponential averages of a round's wall time
638/// and of the tokens it produced, and the plain token's wall time — the
639/// three numbers the keep/stop rule needs. A round pays when
640/// `tokens_per_round · plain_ms > round_ms · 1.03`. The one-shot trial
641/// (four rounds against eight tokens) mis-called prose: the first rounds
642/// after a prompt are formulaic and accept well, the body does not (an
643/// essay measured 39 against a plain 44.8 with the trial saying
644/// "speculate"), so the rule now runs on EVERY round and stops after four
645/// consecutive losing rounds; a stopped speculation is retried 128 tokens
646/// later.
647#[derive(Default, Clone, Copy)]
648struct SpecMon {
649    round_ms: f64,
650    tokens: f64,
651    plain_ms: f64,
652    n: u32,
653    fails: u32,
654}
655
656impl SpecMon {
657    fn round(&mut self, dt_ms: f64, produced: usize) {
658        self.n += 1;
659        if self.n == 1 {
660            return; // round 1 pays the batch scratch and the draft mirror
661        }
662        let a = if self.n == 2 { 1.0 } else { 0.3 };
663        self.round_ms += a * (dt_ms - self.round_ms);
664        self.tokens += a * (produced as f64 - self.tokens);
665    }
666    fn pays(&self) -> bool {
667        self.plain_ms > 0.0 && self.tokens * self.plain_ms > self.round_ms * 1.03
668    }
669}
670
671/// Result of a generation call.
672pub struct GenerateResult {
673    pub text: String,
674    pub token_ids: Vec<u32>,
675    pub prompt_tokens: usize,
676    pub tokens_generated: usize,
677    pub finish_reason: String,
678    /// Speculative-decode stats (0/0 when MTP is absent or inactive).
679    pub mtp_drafted: usize,
680    pub mtp_accepted: usize,
681    /// Per-generated-token confidence = softmax probability of the token
682    /// that was actually emitted (Born mass on the chosen state). High =
683    /// the model was sure; low = it was guessing. Same length as the
684    /// generated slice of `token_ids`.
685    pub token_confidence: Vec<f32>,
686    /// Structured per-token telemetry (B4 channel). Empty unless
687    /// `set_trace(true)`; otherwise same length as the generated slice.
688    pub traces: Vec<TokenTrace>,
689}
690
691/// One row of the structured telemetry trace (B4): the model's internal
692/// routing state at the moment a token was emitted. Every field is a
693/// quantity the runtime already computes — nothing is inferred or
694/// estimated (anti-principle: only measured bytes).
695#[derive(Clone, Debug)]
696pub struct TokenTrace {
697    /// 0-based index within the generated slice.
698    pub t: usize,
699    /// The emitted token id.
700    pub token_id: u32,
701    /// Born mass on the emitted token (softmax prob) — how sure the model was.
702    pub confidence: f32,
703    /// Skill in force while this token was generated (None = backbone).
704    pub active_skill: Option<String>,
705    /// Recon error E = ‖r−BBᵀr‖²/‖φ‖² at the last routing eval — coherence
706    /// with the active skill's subspace (low = coherent). None = no router
707    /// or not yet evaluated.
708    pub recon: Option<f32>,
709    /// The router changed the active skill right after this token (a
710    /// domain boundary crossed under the hysteresis barrier).
711    pub switched: bool,
712}
713
714/// Calibrated softmax probability of `id` under `logits` (the Born mass on
715/// the emitted token) — the confidence signal, cheap from logits already
716/// computed for sampling. `temp` is the calibration temperature (B1):
717/// softmax(logits / temp); 1.0 = raw.
718#[cfg_attr(not(test), allow(dead_code))]
719fn top1_prob_t(logits: &[f32], id: u32, temp: f32) -> f32 {
720    let t = if temp > 1e-3 { temp } else { 1.0 };
721    let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
722    let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
723    if sum > 0.0 {
724        (((logits[id as usize] - max) / t).exp()) / sum
725    } else {
726        0.0
727    }
728}
729
730/// prefill-GEMM enabled? (CMF_PREFILL=seq — emergency fallback to the
731/// sequential path.)
732fn prefill_batched() -> bool {
733    std::env::var("CMF_PREFILL")
734        .map(|v| v != "seq")
735        .unwrap_or(true)
736}
737
738/// Input to the layer-major batched span walk: token ids (embeds itself,
739/// full-stack and coordinator prefill) or ready boundary hiddens (the
740/// network worker's side of a split).
741#[derive(Clone, Copy)]
742enum PrefillIn<'a> {
743    Ids(&'a [u32]),
744    Hidden(&'a [f32]),
745}
746
747/// The batched prefill walks `weights.layers`. Architectures that load
748/// their own stack (gemma-3n's AltUp replicas, DeepSeek-V4's hyper-
749/// connections) leave that empty and must go position by position — asking
750/// otherwise indexes an empty vector, which is a panic rather than a
751/// fallback. Every call site goes through here so the next such
752/// architecture is one line, not four.
753impl Pipeline {
754    fn can_prefill_batched(&self) -> bool {
755        prefill_batched() && !self.weights.layers.is_empty()
756    }
757}
758
759/// Prefill chunk (positions per batched pass). On macOS the AMX GEMM
760/// path wants tall panels — M=48 starves the matrix units (ggml uses
761/// ubatch 512); elsewhere the historical 48 stays. CMF_PREFILL_CHUNK
762/// overrides. Pub: the network split MUST chunk identically to the
763/// local path — panel width reorders float accumulation, so a different
764/// chunk is a different (equally valid) generation.
765pub fn prefill_chunk() -> usize {
766    if let Some(n) = std::env::var("CMF_PREFILL_CHUNK")
767        .ok()
768        .and_then(|v| v.parse::<usize>().ok())
769    {
770        return n.max(1);
771    }
772    if cfg!(target_os = "macos") {
773        512
774    } else if cfg!(target_arch = "aarch64") {
775        // Mobile: big enough to feed the batched attend (gate b ≥ 32)
776        // and the blocked SDOT GEMM without the memory of 512.
777        256
778    } else {
779        48
780    }
781}
782
783/// Callback for streaming tokens. Return `false` to cancel.
784pub type TokenCallback = Box<dyn FnMut(&str) -> bool + Send>;
785
786impl Pipeline {
787    /// Map a virtual layer index to its physical weight index.
788    /// Looped Transformer (Nanbeige 4.2): 22 physical layers × 2 loops = 44 virtual;
789    /// virtual layer 23 maps back to physical layer 1 (23 % 22 = 1).
790    #[inline]
791    pub fn phys_layer(&self, virtual_idx: usize) -> usize {
792        virtual_idx % self.physical_layers
793    }
794
795    /// True when `virtual_idx` is the last layer of a loop iteration
796    /// (used for loop_final_norm insertion).
797    #[inline]
798    pub fn is_loop_end(&self, virtual_idx: usize) -> bool {
799        self.loop_final_norm && (virtual_idx + 1) % self.physical_layers == 0
800    }
801
802    /// Build a pipeline from parts (used by the loader and tests).
803    #[allow(clippy::too_many_arguments)]
804
805    /// Whole-block q1 token graph on the GPU (macOS/Metal): the run of
806    /// consecutive q1 layers — GDN *and* full attention — starting at
807    /// `start` executes as few command buffers as the CPU truly needs.
808    /// Hidden stays device-resident across every layer; the only syncs
809    /// are before each CPU attend (it needs q/k/v and owns the KV
810    /// cache) and the final hidden readback. Recurrent states
811    /// round-trip through shared memory (the CPU stays their owner, so
812    /// every other path remains coherent). Returns the first layer
813    /// index NOT covered (== `start` → refused, caller falls through
814    /// to the per-layer CPU path).
815    /// Should prefill run position-by-position through the GPU token
816    /// graph instead of the batched CPU chunk-GEMM? True for q1 GDN
817    /// hybrids on native Metal: their chunk prefill is walled by the
818    /// sequential scalar recurrence, so the graph's decode rate wins.
819    /// NOT for Looped Transformers, despite the per-chunk loop_final_norm
820    /// sync: the chunk-GEMM amortizes each weight over the whole chunk,
821    /// which the per-position graph cannot (Nanbeige 4.2 on M4, 512-token
822    /// prompt: 85 tok/s chunked vs 14 through the graph).
823    #[cfg(target_os = "macos")]
824    fn graph_prefill_preferred(&self) -> bool {
825        if !crate::gpu::enabled_here()
826            || !crate::gpu::q1_force()
827            || std::env::var("CMF_GPU_BLOCK")
828                .map(|v| v == "0")
829                .unwrap_or(false)
830            // CMF_PREFILL_GRAPH=0: the chunked prefill (GEMM projections,
831            // CPU recurrence) instead of the per-position token graph.
832            || std::env::var("CMF_PREFILL_GRAPH").as_deref() == Ok("0")
833        {
834            return false;
835        }
836        self.weights
837            .layers
838            .iter()
839            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(w) if w.in_proj_qkv.is_q1()))
840    }
841
842    #[cfg(not(target_os = "macos"))]
843    fn graph_prefill_preferred(&self) -> bool {
844        // Discrete-GPU wgpu whole-token graph: GDN layers carry recurrent state
845        // (conv ring + delta-rule S) resident on the GPU. A batched CPU prefill
846        // builds that state on the CPU only, leaving the GPU buffers zeroed at
847        // decode → garbage. Route GDN-hybrid prefill through the graph one
848        // position at a time so the resident state is seeded exactly as decode
849        // will read it. Pure-attention models keep the batched CPU prefill (its
850        // KV mirror re-syncs from the CPU cache, so no seeding gap).
851        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Prefill);
852        if !graph_on || !crate::gpu::enabled_here() {
853            return false;
854        }
855        // O(1) needs the CPU prefill: the q-trace that seals the Nyström
856        // skeleton is recorded there and nowhere else. The GDN half of
857        // the hybrid loses nothing — the graph's first decode creates
858        // its (ring, S) entries seeded from `cpu_state`, the same
859        // handoff every graph run relies on when the entry is fresh.
860        // Without this line the two designs collide on hybrids and o1
861        // never becomes graph-portable: prefill through the graph
862        // records no trace, so views stay None forever.
863        if self.o1_active() {
864            return false;
865        }
866        self.weights
867            .layers
868            .iter()
869            .any(|lw| matches!(&lw.attn, AttnKind::LinearGdn(_)))
870    }
871
872    #[cfg(target_os = "macos")]
873    fn q1_graph_gpu(
874        &mut self,
875        start: usize,
876        upto: Option<usize>,
877        position: usize,
878        h: &mut [f32],
879    ) -> usize {
880        let _mt0 = std::time::Instant::now(); // CMF_METAL_HOSTPROF
881        use crate::gpu::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, GraphDims, MetalFfn, TokenGraph};
882        if self.attn_softcap > 0.0 // capped scores: no graph kernel — CPU path
883            || !crate::gpu::enabled_here()
884            || !crate::gpu::q1_force()
885            || std::env::var("CMF_GPU_BLOCK")
886                .map(|v| v == "0")
887                .unwrap_or(false)
888        {
889            if std::env::var("CMF_GRAPH_DBG").is_ok() {
890                eprintln!(
891                    "block-graph: front gate (softcap={} enabled_here={} q1_force={})",
892                    self.attn_softcap > 0.0,
893                    crate::gpu::enabled_here(),
894                    crate::gpu::q1_force(),
895                );
896            }
897            return start;
898        }
899        // The graph encodes SiLU FFN, 1/√hd attention scores and
900        // full-context attend with no branch norms — Gemma-style archs
901        // (sliding window, scale override, sandwich norms, GeLU) fall
902        // back to the CPU path.
903        if self.swa.is_some()
904            || self.global_attn.is_some()
905            || self.attention_heads_per_layer.is_some()
906            || self.attn_v_norm
907            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
908            || self.weights.layers.iter().any(|lw| {
909                lw.attn_out_norm.is_some()
910                    || lw.ffn_out_norm.is_some()
911                    || lw.layer_scale.is_some()
912                    || matches!(&lw.ffn, FfnKind::Dense(d) if d.act != Act::Silu)
913            })
914        {
915            if std::env::var("CMF_GRAPH_DBG").is_ok() {
916                eprintln!(
917                    "block-graph: arch ineligible (swa={} gattn={} hpl={} vnorm={} scale_delta={:.2e})",
918                    self.swa.is_some(),
919                    self.global_attn.is_some(),
920                    self.attention_heads_per_layer.is_some(),
921                    self.attn_v_norm,
922                    (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs(),
923                );
924            }
925            return start;
926        }
927        // Looped Transformer: the graph covers ALL loop iterations;
928        // encode_loop_norm is inserted on-device at each boundary.
929        let limit = upto
930            .map(|u| u + 1)
931            .unwrap_or(self.num_layers)
932            .min(self.num_layers);
933
934        enum Item<'a> {
935            Gdn {
936                run: Vec<GdnGpuLayer<'a>>,
937                first: usize,
938            },
939            Attn {
940                l: AttnGpuLayer<'a>,
941                li: usize,
942                q_norm: Option<&'a [f32]>,
943                k_norm: Option<&'a [f32]>,
944                output_gate: bool,
945                bias: Option<(&'a [f32], &'a [f32], &'a [f32])>,
946                /// Attend on the device too (no sync): F32 KV, no
947                /// o1/bias, dims inside the kernels' contract.
948                full_gpu: bool,
949            },
950        }
951
952        // Device-attend KERNEL contract, shared by every Full layer. The
953        // hd>128 default-off POLICY is applied after the scan: it was
954        // measured on dense models, and a MoE plan inverts it — with the
955        // experts on device each CPU-attend sandwich costs a
956        // commit+wait, ~30 submits/token (W2 on M4: 14.7 tok/s
957        // sandwiched vs 27.1 device-attend vs 18.8 pure CPU).
958        let attend_mode = std::env::var("CMF_GPU_ATTEND").unwrap_or_else(|_| "auto".into());
959        let attend_contract = attend_mode != "0"
960            && attend_mode != "off"
961            && self.head_dim % 4 == 0
962            && self.head_dim <= 256
963            && self.rotary_dim >= 2
964            && self.rotary_dim <= self.head_dim
965            && (self.rotary_dim / 2) % 32 == 0
966            && self.num_kv_heads > 0
967            && self.num_heads % self.num_kv_heads == 0;
968
969        let mut plan: Vec<Item> = Vec::new();
970        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
971        // Break-reason diagnostics ride the same env as the plan summary.
972        let block_diag = std::env::var("CMF_GRAPH_DBG").is_ok();
973        let mut scan = start;
974        while scan < limit {
975            let lw = &self.weights.layers[self.phys_layer(scan)];
976            let ffn = match &lw.ffn {
977                FfnKind::Dense(d) => {
978                    let (Some(g), Some(u), Some(dn)) = (
979                        d.gate_proj.q1_parts(),
980                        d.up_proj.q1_parts(),
981                        d.down_proj.q1_parts(),
982                    ) else {
983                        if block_diag {
984                            eprintln!(
985                                "block-graph: L{scan} FFN trio not graph-mappable — run ends"
986                            );
987                        }
988                        break;
989                    };
990                    MetalFfn::Dense {
991                        gate: g,
992                        up: u,
993                        down: dn,
994                    }
995                }
996                FfnKind::Moe(m) => {
997                    let Some(moe) = metal_moe_graph_parts(m, self.hidden_size) else {
998                        if block_diag {
999                            eprintln!(
1000                                "block-graph: L{scan} MoE outside the graph contract — run ends"
1001                            );
1002                        }
1003                        break;
1004                    };
1005                    if let QTensor::Mapped { model, .. } = &m.experts[0].gate_proj {
1006                        model_ref.get_or_insert_with(|| model.clone());
1007                    }
1008                    MetalFfn::Moe(moe)
1009                }
1010                _ => {
1011                    if block_diag {
1012                        eprintln!("block-graph: L{scan} non-graph FFN — run ends");
1013                    }
1014                    break;
1015                }
1016            };
1017            match &lw.attn {
1018                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
1019                    let parts = (
1020                        w.in_proj_qkv.q1_parts(),
1021                        w.in_proj_z.q1_parts(),
1022                        w.in_proj_a.f32_parts(),
1023                        w.in_proj_b.f32_parts(),
1024                        w.out_proj.q1_parts(),
1025                    );
1026                    let (Some(qkv), Some(z), Some(a), Some(b), Some(out)) = parts else {
1027                        if block_diag {
1028                            eprintln!(
1029                                "block-graph: L{scan} GDN parts refused (qkv={} z={} a_f32={} b_f32={} out={})",
1030                                w.in_proj_qkv.q1_parts().is_some(),
1031                                w.in_proj_z.q1_parts().is_some(),
1032                                w.in_proj_a.f32_parts().is_some(),
1033                                w.in_proj_b.f32_parts().is_some(),
1034                                w.out_proj.q1_parts().is_some(),
1035                            );
1036                        }
1037                        break;
1038                    };
1039                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
1040                        model_ref.get_or_insert_with(|| model.clone());
1041                    }
1042                    let gl = GdnGpuLayer {
1043                        attn_norm: &lw.input_norm,
1044                        post_norm: &lw.post_norm,
1045                        qkv,
1046                        z,
1047                        a,
1048                        b,
1049                        out,
1050                        ffn,
1051                        conv1d: &w.conv1d,
1052                        a_log: &w.a_log,
1053                        dt_bias: &w.dt_bias,
1054                        gnorm: &w.norm,
1055                    };
1056                    match plan.last_mut() {
1057                        Some(Item::Gdn { run, .. }) => run.push(gl),
1058                        _ => plan.push(Item::Gdn {
1059                            run: vec![gl],
1060                            first: scan,
1061                        }),
1062                    }
1063                }
1064                AttnKind::Full {
1065                    wq,
1066                    wk,
1067                    wv,
1068                    wo,
1069                    q_norm,
1070                    k_norm,
1071                    output_gate,
1072                    softplus_gate: None,
1073                    bias,
1074                } if !self.kv_cache.layers[scan].o1_sealed()
1075                    // Sealed o1 stays plannable when the Metal o1 port
1076                    // is on: full_gpu attends through the device state,
1077                    // and any refusal falls to the sandwich, whose CPU
1078                    // core routes sealed layers through the nystrom step.
1079                    || std::env::var("CMF_O1_METAL").as_deref() == Ok("1") =>
1080                {
1081                    let parts = (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts());
1082                    let (Some(pq), Some(pk), Some(pv), Some(po)) = parts else {
1083                        break;
1084                    };
1085                    if let QTensor::Mapped { model, .. } = wq {
1086                        model_ref.get_or_insert_with(|| model.clone());
1087                    }
1088                    let cache = &self.kv_cache.layers[scan];
1089                    // O(1) layer on Metal: the device attends through the
1090                    // sealed Nystrom state (opt-in while the port proves
1091                    // itself). Unsealed -> sandwich path = the CPU o1 step.
1092                    let o1_metal = cache.o1.is_some()
1093                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1")
1094                        && cache.o1_views().is_some();
1095                    let full_gpu = attend_contract
1096                        && cache.mode == crate::kv_cache::KvMode::F32
1097                        && (cache.o1.is_none() || o1_metal)
1098                        && bias.is_none()
1099                        && pq.1 == self.num_heads * self.head_dim * (1 + *output_gate as usize)
1100                        && pk.1 == self.num_kv_heads * self.head_dim
1101                        && pv.1 == self.num_kv_heads * self.head_dim
1102                        && po.2 == self.num_heads * self.head_dim;
1103                    plan.push(Item::Attn {
1104                        l: AttnGpuLayer {
1105                            attn_norm: &lw.input_norm,
1106                            post_norm: &lw.post_norm,
1107                            wq: pq,
1108                            wk: pk,
1109                            wv: pv,
1110                            wo: po,
1111                            ffn,
1112                        },
1113                        li: scan,
1114                        q_norm: q_norm.as_deref(),
1115                        k_norm: k_norm.as_deref(),
1116                        output_gate: *output_gate,
1117                        bias: bias
1118                            .as_ref()
1119                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
1120                        full_gpu,
1121                    });
1122                }
1123                _ => break,
1124            }
1125            scan += 1;
1126        }
1127        let Some(model) = model_ref else {
1128            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1129                eprintln!("q1-graph: no model ref (start {start}, scanned to {scan})");
1130            }
1131            return start;
1132        };
1133        if plan.is_empty() {
1134            if std::env::var("CMF_GRAPH_DBG").is_ok() {
1135                eprintln!("q1-graph: empty plan at layer {start}");
1136            }
1137            return start;
1138        }
1139        let has_moe = plan.iter().any(|it| match it {
1140            Item::Gdn { run, .. } => run.iter().any(|l| matches!(l.ffn, MetalFfn::Moe(_))),
1141            Item::Attn { l, .. } => matches!(l.ffn, MetalFfn::Moe(_)),
1142        });
1143        let has_gdn = plan.iter().any(|it| matches!(it, Item::Gdn { .. }));
1144        let dev_attend = attend_contract
1145            && (self.head_dim <= 128
1146                || has_moe
1147                // A GDN hybrid attends on a quarter of its layers: the
1148                // hd>128 caution was measured on pure-dense models where
1149                // gqa_attend dominates, and on Qwen3.8-27B (hd 256, 48
1150                // GDN + 16 attn) the sandwich costs 2x the whole decode
1151                // (1.2 vs 2.21 tok/s measured before the arena fix).
1152                || (self.head_dim <= 256 && has_gdn)
1153                || attend_mode == "force"
1154                || attend_mode == "256");
1155        if !dev_attend {
1156            for it in &mut plan {
1157                if let Item::Attn { li, full_gpu, .. } = it {
1158                    // The hd>128 policy is about gqa_attend; an o1 layer
1159                    // attends through its own kernel set.
1160                    let keep_o1 = self.kv_cache.layers[*li].o1.is_some()
1161                        && std::env::var("CMF_O1_METAL").as_deref() == Ok("1");
1162                    if !keep_o1 {
1163                        *full_gpu = false;
1164                    }
1165                }
1166            }
1167        }
1168        if std::env::var("CMF_GRAPH_DBG").is_ok() {
1169            use std::sync::atomic::{AtomicBool, Ordering};
1170            static SAID: AtomicBool = AtomicBool::new(false);
1171            if !SAID.swap(true, Ordering::Relaxed) {
1172                let fg = plan
1173                    .iter()
1174                    .filter(|it| matches!(it, Item::Attn { full_gpu: true, .. }))
1175                    .count();
1176                let att = plan
1177                    .iter()
1178                    .filter(|it| matches!(it, Item::Attn { .. }))
1179                    .count();
1180                eprintln!(
1181                    "q1-graph: plan of {} items from layer {start} to {scan} | dev_attend={dev_attend} full_gpu {fg}/{att} | hd={} rd={} nkv={} nh={}",
1182                    plan.len(),
1183                    self.head_dim,
1184                    self.rotary_dim,
1185                    self.num_kv_heads,
1186                    self.num_heads,
1187                );
1188            }
1189        }
1190        let dims = GraphDims {
1191            hidden: self.hidden_size,
1192            eps: self.rms_eps as f32,
1193            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1194        };
1195        let Some(mut graph) = TokenGraph::new(&model, dims, h) else {
1196            return start;
1197        };
1198        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
1199            nv: cfg.num_v_heads,
1200            nk: cfg.num_k_heads,
1201            dk: cfg.key_head_dim,
1202            dv: cfg.value_head_dim,
1203            kk: cfg.conv_kernel,
1204            hidden: self.hidden_size,
1205            inter: self.intermediate_size,
1206            c_dim: cfg.conv_dim(),
1207            eps: cfg.rms_eps as f32,
1208            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
1209        });
1210        // Validate the whole plan BEFORE encoding anything: after the
1211        // first sync a refused layer would leave the token
1212        // half-executed, so truncate to the provably encodable prefix.
1213        let mut valid = 0usize;
1214        let mut end = start;
1215        crate::gpu::stageprof(1, _mt0.elapsed()); // конец планирования
1216        if std::env::var("CMF_PLAN_DUMP").is_ok() {
1217            static ONCE: std::sync::Once = std::sync::Once::new();
1218            ONCE.call_once(|| {
1219                for it in &plan {
1220                    match it {
1221                        Item::Gdn { first, run } => {
1222                            eprintln!("plan: Gdn first={first} len={}", run.len())
1223                        }
1224                        Item::Attn { li, full_gpu, .. } => {
1225                            eprintln!("plan: Attn li={li} full_gpu={full_gpu}")
1226                        }
1227                    }
1228                }
1229            });
1230        }
1231        for item in &plan {
1232            let ok = match item {
1233                Item::Gdn { run, .. } => gcfg
1234                    .as_ref()
1235                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
1236                    .unwrap_or(false),
1237                Item::Attn { l, .. } => graph.attn_ok(l),
1238            };
1239            if !ok {
1240                if block_diag {
1241                    eprintln!(
1242                        "block-graph: plan item {} ({}) failed graph preflight",
1243                        valid,
1244                        match item {
1245                            Item::Gdn { run, first } => format!("GDN run L{first}+{}", run.len()),
1246                            Item::Attn { li, .. } => format!("Attn L{li}"),
1247                        }
1248                    );
1249                }
1250                break;
1251            }
1252            valid += 1;
1253            end += match item {
1254                Item::Gdn { run, .. } => run.len(),
1255                Item::Attn { .. } => 1,
1256            };
1257        }
1258        plan.truncate(valid);
1259        if plan.is_empty() {
1260            return start;
1261        }
1262
1263        let inv_freq = self.inv_freq.clone();
1264        let pool = self.pool.clone();
1265        let (nh, nkv, hd, hs, rd, eps) = (
1266            self.num_heads,
1267            self.num_kv_heads,
1268            self.head_dim,
1269            self.hidden_size,
1270            self.rotary_dim,
1271            self.rms_eps,
1272        );
1273        let norm_style = self.norm_style;
1274        let gemma = norm_style == cortiq_core::NormStyle::Gemma;
1275        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
1276        let kv_id = self.graph_kv_id;
1277        // GDN runs whose states await readback after the next sync
1278        // (device-attended layers add no sync, so several may stack).
1279        let mut pending: Vec<(usize, usize)> = Vec::new();
1280        // Device-attended layers: their K/V/imp are pulled from the
1281        // mirror after the final sync.
1282        let mut dev_attn: Vec<usize> = Vec::new();
1283        for item in &plan {
1284            let _xt0 = std::time::Instant::now();
1285            let _xkind: u32 = match item {
1286                Item::Gdn { .. } => 2,
1287                Item::Attn { .. } => 3,
1288            };
1289            // Looped Transformer: insert on-device norm at loop boundaries.
1290            if self.loop_final_norm {
1291                let item_start = match item {
1292                    Item::Gdn { first, .. } => *first,
1293                    Item::Attn { li, .. } => *li,
1294                };
1295                if item_start > start && self.is_loop_end(item_start - 1) {
1296                    graph.encode_loop_norm(&self.weights.final_norm);
1297                }
1298            }
1299            match item {
1300                Item::Gdn { run, first } => {
1301                    for l in &mut self.kv_cache.layers[*first..*first + run.len()] {
1302                        if l.linear_state.len() != want {
1303                            l.linear_state = vec![0f32; want];
1304                        }
1305                    }
1306                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
1307                        .iter()
1308                        .map(|l| l.linear_state.as_slice())
1309                        .collect();
1310                    let _ig = std::time::Instant::now();
1311                    if !graph.encode_gdn_run(run, &ro, gcfg.as_ref().unwrap()) {
1312                        // Unreachable: the plan was validated above.
1313                        tracing::error!("q1 graph: GDN run refused after validation");
1314                        return start;
1315                    }
1316                    // Early commit: the GPU starts the run while the
1317                    // CPU encodes the next layer (nothing to wait on).
1318                    graph.commit_kind = 2;
1319                    graph.commit();
1320                    crate::gpu::stageprof(0, _ig.elapsed());
1321                    pending.push((*first, run.len()));
1322                }
1323                Item::Attn {
1324                    l,
1325                    li,
1326                    q_norm,
1327                    k_norm,
1328                    output_gate,
1329                    bias,
1330                    full_gpu,
1331                } => {
1332                    let _ia = std::time::Instant::now();
1333                    // ── Fully device-resident attention: no sync at all.
1334                    if *full_gpu {
1335                        let cache = &self.kv_cache.layers[*li];
1336                        let o1p = if cache.o1.is_some() {
1337                            match cache.o1_views() {
1338                                Some(views) => Some(crate::gpu::O1AttnParams {
1339                                    views,
1340                                    epoch: self.o1_epoch,
1341                                }),
1342                                // Sealed state gone mid-run: sandwich.
1343                                None => None,
1344                            }
1345                        } else {
1346                            None
1347                        };
1348                        let o1_layer = cache.o1.is_some();
1349                        if o1_layer && o1p.is_none() {
1350                            // fall to the sandwich (CPU o1 step)
1351                        }
1352                        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
1353                        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
1354                        let cpu_stored = if o1_layer { 0 } else { cpu_k[0].len() / hd };
1355                        let p = crate::gpu::AttnDeviceParams {
1356                            kv_id,
1357                            layer: *li,
1358                            nh,
1359                            nkv,
1360                            hd,
1361                            rd,
1362                            position,
1363                            eps: eps as f32,
1364                            gemma,
1365                            output_gate: *output_gate,
1366                            q_norm: *q_norm,
1367                            k_norm: *k_norm,
1368                            inv_freq: &inv_freq,
1369                            cpu_k,
1370                            cpu_v,
1371                            cpu_stored,
1372                            o1: o1p,
1373                        };
1374                        let o1_bad = o1_layer && p.o1.is_none();
1375                        if !o1_bad && graph.attn_device_ok(l, &p) && graph.encode_attn_device(l, &p)
1376                        {
1377                            // o1 layers leave no mirror row to pull.
1378                            if p.o1.is_none() {
1379                                dev_attn.push(*li);
1380                            }
1381                            graph.commit_kind = 3;
1382                            graph.commit();
1383                            // The footer below is skipped by `continue`:
1384                            // account the device-attn item here or its
1385                            // cost hides from the stage profile entirely.
1386                            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1387                            continue;
1388                        }
1389                        // Mirror refused (nothing encoded) → sandwich.
1390                    }
1391                    graph.encode_attn_prefix(l);
1392                    graph.sync();
1393                    if !pending.is_empty() {
1394                        let idxs: Vec<usize> =
1395                            pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1396                        let mut outs: Vec<&mut [f32]> = self
1397                            .kv_cache
1398                            .layers
1399                            .iter_mut()
1400                            .enumerate()
1401                            .filter(|(i, _)| idxs.binary_search(i).is_ok())
1402                            .map(|(_, s)| s.linear_state.as_mut_slice())
1403                            .collect();
1404                        graph.read_states(&mut outs);
1405                    }
1406                    let mut q_raw = attention::take_buf(l.wq.1);
1407                    let mut k = attention::take_buf(l.wk.1);
1408                    let mut v = attention::take_buf(l.wv.1);
1409                    graph.read_qkv(&mut q_raw, &mut k, &mut v);
1410                    let cfg = QwenAttnCfg {
1411                        num_heads: nh,
1412                        num_kv_heads: nkv,
1413                        head_dim: hd,
1414                        hidden_size: hs,
1415                        position,
1416                        inv_freq: &inv_freq,
1417                        rotary_dim: rd,
1418                        scale: self.attn_scale,
1419                        softcap: self.attn_softcap,
1420                        window: None,
1421                        v_norm: false,
1422                        q_norm: *q_norm,
1423                        k_norm: *k_norm,
1424                        output_gate: *output_gate,
1425                        softplus_gate: None,
1426                        rope_scale: 1.0,
1427                        bias: *bias,
1428                        rms_eps: eps,
1429                        norm_style,
1430                        pool: pool.as_deref(),
1431                    };
1432                    // CMF_ATTN_ORACLE=1: diff the device attend against
1433                    // this CPU attend on identical inputs (bring-up).
1434                    let oracle = std::env::var("CMF_ATTN_ORACLE").as_deref() == Ok("1")
1435                        || std::env::var("CMF_ATTN_DUMP").is_ok();
1436                    let _ = full_gpu;
1437                    let oracle_in = oracle.then(|| (q_raw.clone(), k.clone(), v.clone()));
1438                    let mut ao = attention::qwen_attention_core(
1439                        q_raw,
1440                        k,
1441                        v,
1442                        &mut self.kv_cache.layers[*li],
1443                        &cfg,
1444                    );
1445                    // CMF_ATTN_DUMP=<dir>: this token's rope'd Q and the layer's whole
1446                    // K/V cache as raw f32 (offline attention-statistics probes:
1447                    // block bounds, mass concentration). Needs CMF_GPU_ATTEND=0.
1448                    if let Ok(dir) = std::env::var("CMF_ATTN_DUMP") {
1449                        if let Some((qr0, k0, v0)) = oracle_in.clone() {
1450                            let (cq, _cg, _ck, _cv) = attention::finish_projection_debug(qr0, k0, v0, &cfg, position);
1451                            let cache = &self.kv_cache.layers[*li];
1452                            let n = cache.head_keys(0).len() / hd;
1453                            let mut bytes: Vec<u8> = Vec::new();
1454                            for v in [nh as u32, nkv as u32, hd as u32, n as u32, position as u32] {
1455                                bytes.extend_from_slice(&v.to_le_bytes());
1456                            }
1457                            for v in &cq {
1458                                bytes.extend_from_slice(&v.to_le_bytes());
1459                            }
1460                            for g in 0..nkv {
1461                                for v in cache.head_keys(g) {
1462                                    bytes.extend_from_slice(&v.to_le_bytes());
1463                                }
1464                            }
1465                            for g in 0..nkv {
1466                                for v in cache.head_values(g) {
1467                                    bytes.extend_from_slice(&v.to_le_bytes());
1468                                }
1469                            }
1470                            let _ = std::fs::write(format!("{dir}/L{li}_pos{position}.bin"), &bytes);
1471                        }
1472                    }
1473                    if let Some((qr0, k0, v0)) = oracle_in.filter(|_| std::env::var("CMF_ATTN_ORACLE").as_deref() == Ok("1")) {
1474                        let (cq, _cg, ck, cv) = attention::finish_projection_debug(qr0, k0, v0, &cfg, position);
1475                        let mut h_now = vec![0f32; hs];
1476                        graph.read_h(&mut h_now);
1477                        let cache = &self.kv_cache.layers[*li];
1478                        let n_after = cache.head_keys(0).len() / hd;
1479                        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| &cache.head_keys(g)[..(n_after - 1) * hd]).collect();
1480                        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| &cache.head_values(g)[..(n_after - 1) * hd]).collect();
1481                        let p = crate::gpu::AttnDeviceParams {
1482                            kv_id,
1483                            layer: *li,
1484                            nh,
1485                            nkv,
1486                            hd,
1487                            rd,
1488                            position,
1489                            eps: eps as f32,
1490                            gemma,
1491                            output_gate: *output_gate,
1492                            q_norm: *q_norm,
1493                            k_norm: *k_norm,
1494                            inv_freq: &inv_freq,
1495                            cpu_k,
1496                            cpu_v,
1497                            cpu_stored: n_after - 1,
1498                            o1: None,
1499                        };
1500                        if let Some((dq, dk, dv, dao)) = graph.debug_attn_device(l, &p, &h_now) {
1501                            let md = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
1502                            let nn = |a: &[f32]| a.iter().map(|x| x * x).sum::<f32>().sqrt();
1503                            eprintln!(
1504                                "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}",
1505                                nn(&cq), md(&cq, &dq), nn(&ck), md(&ck, &dk), nn(&cv), md(&cv, &dv), nn(&ao), md(&ao, &dao)
1506                            );
1507                        } else {
1508                            eprintln!("attn-oracle L{li}: device probe declined");
1509                        }
1510                    }
1511                    graph.encode_attn_suffix(l, &ao);
1512                    // Early commit: the GPU starts O+FFN while the CPU
1513                    // encodes the following GDN run / attention prefix.
1514                    graph.commit();
1515                    attention::recycle_buf(&mut ao);
1516                }
1517            }
1518
1519            crate::gpu::stageprof(_xkind, _xt0.elapsed());
1520        }
1521        // Ride the final norm + lm_head in the same command buffer when
1522        // this run reaches the model's end and the caller wants logits:
1523        // the separate per-op lm_head submit (a full round trip) folds
1524        // into the sync that already happens here.
1525        let mut lm_rows = None;
1526        if self.graph_want_logits
1527            && upto.is_none()
1528            && end == self.num_layers
1529            && std::env::var("CMF_GPU_LMHEAD")
1530                .map(|v| v != "0")
1531                .unwrap_or(true)
1532        {
1533            if let Some(lm) = self.weights.lm_head.q1_parts() {
1534                if graph.lm_head_ok(lm) {
1535                    graph.encode_lm_head(&self.weights.final_norm, lm);
1536                    lm_rows = Some(lm.1);
1537                }
1538            }
1539        }
1540        let _sy0 = std::time::Instant::now();
1541        graph.sync();
1542        let _rs0 = std::time::Instant::now();
1543        if !pending.is_empty() {
1544            let idxs: Vec<usize> = pending.drain(..).flat_map(|(f, n)| f..f + n).collect();
1545            let mut outs: Vec<&mut [f32]> = self
1546                .kv_cache
1547                .layers
1548                .iter_mut()
1549                .enumerate()
1550                .filter(|(i, _)| idxs.binary_search(i).is_ok())
1551                .map(|(_, s)| s.linear_state.as_mut_slice())
1552                .collect();
1553            graph.read_states(&mut outs);
1554        }
1555        if std::env::var("CMF_GRAPH_HOSTPROF").as_deref() == Ok("1") {
1556            use std::sync::atomic::{AtomicU64, Ordering};
1557            static SY: AtomicU64 = AtomicU64::new(0);
1558            static RS: AtomicU64 = AtomicU64::new(0);
1559            static N: AtomicU64 = AtomicU64::new(0);
1560            SY.fetch_add((_rs0 - _sy0).as_nanos() as u64, Ordering::Relaxed);
1561            RS.fetch_add(_rs0.elapsed().as_nanos() as u64, Ordering::Relaxed);
1562            let n = N.fetch_add(1, Ordering::Relaxed) + 1;
1563            if n % 100 == 0 {
1564                eprintln!(
1565                    "postprof: sync-wait {:.1} ms/ток | read_states {:.1} ms/ток ({n})",
1566                    SY.load(Ordering::Relaxed) as f64 / n as f64 / 1e6,
1567                    RS.load(Ordering::Relaxed) as f64 / n as f64 / 1e6
1568                );
1569            }
1570        }
1571        if let Some(rows) = lm_rows {
1572            crate::gpu::hostprof_encode_done(_mt0);
1573            let mut lg = attention::take_buf(rows.min(self.vocab_size));
1574            graph.read_logits(&mut lg);
1575            crate::gpu::hostprof_total(_mt0);
1576            lg.resize(self.vocab_size, 0.0);
1577            if let Some(c) = self.final_softcap {
1578                for l in lg.iter_mut() {
1579                    *l = c * (*l / c).tanh();
1580                }
1581            }
1582            self.graph_logits = Some(lg);
1583        }
1584        graph.finish(h);
1585        // Device-attended layers: replay the CPU bookkeeping — append
1586        // the mirror's new K/V row (rope'd on the GPU) into the owner
1587        // cache, then bank this token's Born-importance mass.
1588        for li in dev_attn {
1589            let mut krow = attention::take_buf(nkv * hd);
1590            let mut vrow = attention::take_buf(nkv * hd);
1591            if crate::gpu::kv_mirror_read_last(kv_id, li, nkv, hd, &mut krow, &mut vrow) {
1592                let cache = &mut self.kv_cache.layers[li];
1593                cache.append(&krow, &vrow, &[]);
1594                let n = cache.seq_len;
1595                let mut imp = attention::take_buf(n);
1596                crate::gpu::kv_mirror_take_imp(kv_id, li, &mut imp);
1597                cache.accumulate_imp(&imp);
1598                attention::recycle_buf(&mut imp);
1599            }
1600            attention::recycle_buf(&mut krow);
1601            attention::recycle_buf(&mut vrow);
1602        }
1603        end
1604    }
1605
1606    pub fn new(
1607        tokenizer: Tokenizer,
1608        weights: PipelineWeights,
1609        hidden_size: usize,
1610        intermediate_size: usize,
1611        num_heads: usize,
1612        num_kv_heads: usize,
1613        head_dim: usize,
1614        num_layers: usize,
1615        physical_layers: usize,
1616        loop_final_norm: bool,
1617        vocab_size: usize,
1618        rms_eps: f64,
1619        rope_base: f32,
1620        norm_style: NormStyle,
1621        max_seq_len: usize,
1622        sampler_config: SamplerConfig,
1623    ) -> Self {
1624        let rng = match sampler_config.seed {
1625            Some(s) => SplitMix64::new(s),
1626            None => SplitMix64::from_entropy(),
1627        };
1628        let inv_freq = std::sync::Arc::new(attention::rope_inv_freq(head_dim, rope_base));
1629        let pool = Pool::from_env();
1630        if let Some(p) = &pool {
1631            tracing::info!("worker pool: {} threads", p.n_workers());
1632        }
1633        Self {
1634            gpu_plan: None,
1635            tokenizer: std::sync::Arc::new(tokenizer),
1636            kv_cache: KvCache::new(num_layers, num_kv_heads, head_dim, max_seq_len),
1637            sampler_config,
1638            weights,
1639            hidden_size,
1640            intermediate_size,
1641            num_heads,
1642            num_kv_heads,
1643            head_dim,
1644            num_layers,
1645            physical_layers,
1646            loop_final_norm,
1647            vocab_size,
1648            rms_eps,
1649            rope_base,
1650            norm_style,
1651            rotary_dim: head_dim,
1652            attention_heads_per_layer: None,
1653            vmf_cfg: None,
1654            gdn_cfg: None,
1655            kda_cfg: None,
1656            g3n: None,
1657            dsv4: None,
1658            dsv4_mtp: Vec::new(),
1659            dspark: None,
1660            dspark_pending: Vec::new(),
1661            dspark_hist: Vec::new(),
1662            dspark_real: Vec::new(),
1663            dspark_trunk_picks: Vec::new(),
1664            dspark_exp: Vec::new(),
1665            dspark_draft_ns: 0,
1666            logit_multiplier: None,
1667            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1668            kv_history: Vec::new(),
1669            short_conv_cfg: None,
1670            mtp: None,
1671            speculative: std::env::var("CMF_MTP").map(|v| v != "0").unwrap_or(true),
1672            rng,
1673            sampler_scratch: SamplerScratch::default(),
1674            spec_forced: None,
1675            spec_q: Vec::new(),
1676            spec_p: Vec::new(),
1677            spec_res: Vec::new(),
1678            spec_qs: Vec::new(),
1679            spec_ps: Vec::new(),
1680            spec_ress: Vec::new(),
1681            mtp_graph_mode: None,
1682            #[cfg(target_os = "macos")]
1683            metal_verify: None,
1684            inv_freq,
1685            ws: ForwardScratch::new(hidden_size),
1686            pool,
1687            model: None,
1688            dyn_force_f32: false,
1689            dyn_skill_layers: Vec::new(),
1690            dyn_active: None,
1691            dyn_blend_loaded: false,
1692            dyn_phi_layer: None,
1693            dyn_phi_ema: Vec::new(),
1694            dyn_phi_seen: 0,
1695            dyn_router: None,
1696            o1_cfg: None,
1697            o1_epoch: 0,
1698            o1_flags: Vec::new(),
1699            trace: false,
1700            calib_temp: 1.0,
1701            confidence_on: true,
1702            embed_multiplier: 1.0,
1703            attn_scale: 1.0 / (head_dim as f32).sqrt(),
1704            swa: None,
1705            sliding_layers: None,
1706            inv_freq_local: None,
1707            rotary_dim_local: None,
1708            rope_scale: 1.0,
1709            rope_scale_local: 1.0,
1710            global_attn: None,
1711            inv_freq_global: None,
1712            attn_v_norm: false,
1713            final_softcap: None,
1714            head_clusters: None,
1715            attn_softcap: 0.0,
1716            graph_want_logits: false,
1717            graph_logits: None,
1718            graph_kv_id: {
1719                static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1720                NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1721            },
1722        }
1723    }
1724
1725    /// Enable/disable per-layer O(1) Nyström attention. Only Full
1726    /// layers are eligible (a linear layer keeps its own operator).
1727    /// Applies to generation (`generate*`/`forward_ids`): the prompt
1728    /// pass stays exact, the seal happens once after prefill, decode
1729    /// runs on the O(1) state. Teacher-forced scoring (`ppl_ids`)
1730    /// intentionally stays exact.
1731    pub fn set_o1(&mut self, cfg: Option<crate::nystrom::O1Cfg>) {
1732        self.o1_flags = match &cfg {
1733            Some(c) => {
1734                let mut flags = c.layer_flags(self.num_layers);
1735                for (li, f) in flags.iter_mut().enumerate() {
1736                    if *f
1737                        && !matches!(
1738                            self.weights.layers[self.phys_layer(li)].attn,
1739                            AttnKind::Full { .. }
1740                        )
1741                    {
1742                        *f = false;
1743                    }
1744                }
1745                flags
1746            }
1747            None => Vec::new(),
1748        };
1749        if let Some(c) = &cfg {
1750            let n = self.o1_flags.iter().filter(|&&f| f).count();
1751            tracing::info!(
1752                "o1 nystrom attention: {n}/{} layer(s), m={} w={} sink={} rect={:?}",
1753                self.num_layers,
1754                c.m,
1755                c.w,
1756                c.sink,
1757                c.rect
1758            );
1759        }
1760        self.o1_cfg = cfg;
1761    }
1762
1763    /// True when at least one layer runs the O(1) kernel.
1764    pub fn o1_active(&self) -> bool {
1765        self.o1_cfg.is_some() && self.o1_flags.iter().any(|&f| f)
1766    }
1767
1768    /// Arm query collection on the o1 layers (fresh prompt pass).
1769    /// Reset the o1 layers to Collecting for a fresh sequence. Pub for the
1770    /// network split: each side runs the o1 lifecycle over ITS OWN layers
1771    /// (begin before prefill, seal at the prefill barrier).
1772    pub fn o1_begin(&mut self) {
1773        if let Some(c) = &self.o1_cfg {
1774            let (m, w, sink, rect) = (c.m, c.w, c.sink, c.rect);
1775            for (li, &f) in self.o1_flags.iter().enumerate() {
1776                if f {
1777                    self.kv_cache.layers[li].o1_begin(m, w, sink, rect);
1778                }
1779            }
1780        }
1781    }
1782
1783    /// Freeze landmarks + skeleton state after the prompt pass and drop
1784    /// the o1 layers' full KV; decode then runs `step()` per token.
1785    /// Pub for the network split (see `o1_begin`).
1786    pub fn o1_seal(&mut self) {
1787        self.o1_epoch = self.o1_epoch.wrapping_add(1);
1788        if self.o1_cfg.is_none() {
1789            return;
1790        }
1791        for li in 0..self.num_layers {
1792            if self.o1_flags.get(li).copied().unwrap_or(false) {
1793                self.kv_cache.layers[li].o1_seal(self.num_heads);
1794            }
1795        }
1796    }
1797
1798    /// Enable/disable the structured per-token telemetry trace (B4).
1799    pub fn set_trace(&mut self, on: bool) {
1800        self.trace = on;
1801    }
1802
1803    /// Replace all request-scoped sampler options and reset the random stream.
1804    /// This is required for deterministic `seed` semantics in pooled servers.
1805    pub fn set_sampler_config(&mut self, config: SamplerConfig) {
1806        self.rng = match config.seed {
1807            Some(seed) => SplitMix64::new(seed),
1808            None => SplitMix64::from_entropy(),
1809        };
1810        self.sampler_config = config;
1811    }
1812
1813    /// Toggle the per-token Born-confidence reduction (a full-vocab
1814    /// softmax each token). `bench --core` turns it off so the timed
1815    /// loop matches llama-bench's core contract; the result's
1816    /// `confidence` vec is empty while off.
1817    pub fn set_confidence(&mut self, on: bool) {
1818        self.confidence_on = on;
1819    }
1820
1821    /// Set the confidence-calibration temperature (B1). Values ≤0 are
1822    /// clamped to raw (1.0).
1823    pub fn set_calib_temp(&mut self, t: f32) {
1824        self.calib_temp = if t > 1e-3 { t } else { 1.0 };
1825    }
1826
1827    /// The active calibration temperature (1.0 = raw Born mass).
1828    pub fn calib_temp(&self) -> f32 {
1829        self.calib_temp
1830    }
1831
1832    /// Partial rotary (Qwen3.5): rotate only the first `rotary_dim` dims;
1833    /// the frequency table is rebuilt over the rotary dims.
1834    pub fn set_rotary(&mut self, rotary_dim: usize, base: f32) {
1835        self.rotary_dim = rotary_dim.min(self.head_dim);
1836        self.inv_freq = std::sync::Arc::new(attention::rope_inv_freq(self.rotary_dim, base));
1837    }
1838
1839    fn attn_cfg(&self, position: usize) -> QwenAttnCfg<'_> {
1840        QwenAttnCfg {
1841            num_heads: self.num_heads,
1842            num_kv_heads: self.num_kv_heads,
1843            head_dim: self.head_dim,
1844            hidden_size: self.hidden_size,
1845            position,
1846            inv_freq: &self.inv_freq,
1847            rotary_dim: self.rotary_dim,
1848            scale: self.attn_scale,
1849            softcap: self.attn_softcap,
1850            window: None,
1851            v_norm: false,
1852            q_norm: None,
1853            k_norm: None,
1854            output_gate: false,
1855            softplus_gate: None,
1856            rope_scale: self.rope_scale,
1857            bias: None,
1858            rms_eps: self.rms_eps,
1859            norm_style: self.norm_style,
1860            pool: self.pool.as_deref(),
1861        }
1862    }
1863
1864    /// Generate text from a plain-text prompt. Streams tokens via `on_token`.
1865    pub fn generate(
1866        &mut self,
1867        prompt: &str,
1868        max_tokens: usize,
1869        task_mask: Option<&TaskMask>,
1870        on_token: Option<TokenCallback>,
1871    ) -> Result<GenerateResult, String> {
1872        let input_ids = self.tokenizer.with_bos(self.tokenizer.encode(prompt));
1873        self.generate_from_ids(&input_ids, max_tokens, task_mask, on_token)
1874    }
1875
1876    /// Generate from prepared token ids (e.g. a chat template).
1877    ///
1878    /// With an MTP head, greedy generation without a task mask takes the
1879    /// speculative path: the MTP module drafts the token after next and
1880    /// the main model verifies both in one fused two-position forward
1881    /// (weights streamed once). The output is EXACTLY the vanilla greedy
1882    /// sequence — a rejected draft is rolled back — MTP only buys speed.
1883    pub fn generate_from_ids(
1884        &mut self,
1885        input_ids: &[u32],
1886        max_tokens: usize,
1887        task_mask: Option<&TaskMask>,
1888        mut on_token: Option<TokenCallback>,
1889    ) -> Result<GenerateResult, String> {
1890        if std::env::var("CMF_TRACE_H").is_ok() {
1891            eprintln!("input_ids: {input_ids:?}");
1892        }
1893        if input_ids.is_empty() {
1894            return Err("empty prompt: nothing to generate from".to_string());
1895        }
1896
1897        // Cross-turn KV reuse: a chat app resends the whole history
1898        // every turn; when the new ids strictly EXTEND what the cache
1899        // already holds, prefill only the tail — turn latency stays
1900        // proportional to the new text instead of the whole session.
1901        // Extension-only (no rollback), so it is exact for every layer
1902        // kind including recurrent state; MTP/o1/task-mask runs keep
1903        // the fresh-sequence path. CMF_KV_REUSE=0 disables.
1904        let reuse_from = {
1905            let on = !std::env::var("CMF_KV_REUSE").is_ok_and(|v| v == "0");
1906            let h = &self.kv_history;
1907            if on
1908                && task_mask.is_none()
1909                && self.mtp.is_none()
1910                && self.o1_cfg.is_none()
1911                && !h.is_empty()
1912                && h.len() < input_ids.len()
1913                && input_ids[..h.len()] == h[..]
1914            {
1915                h.len()
1916            } else {
1917                0
1918            }
1919        };
1920        if reuse_from == 0 {
1921            // Fresh sequence — the cache holds absolute positions.
1922            self.kv_cache.clear();
1923            self.kv_history.clear();
1924            crate::gpu::graph_kv_reset(self.graph_kv_id);
1925        } else if std::env::var("CMF_PREFILL_PROF").is_ok() {
1926            eprintln!(
1927                "kv-reuse: {} of {} prompt positions already cached",
1928                reuse_from,
1929                input_ids.len()
1930            );
1931        }
1932        crate::gpu::graph_race_begin_generation();
1933        self.o1_begin();
1934
1935        // Speculative decode is off under o1: a rejected draft can't be
1936        // rolled back out of the far accumulators / ring window (the
1937        // Nyström insertion is irreversible by design).
1938        // The wgpu token graph owns a device K/V mirror that speculative
1939        // rollback would desync — the two are mutually exclusive.
1940        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
1941        // Graph speculative decode (`CMF_GRAPH_SPEC=1`): the MTP head
1942        // drafts, ONE batched graph submit verifies the whole chain.
1943        //
1944        // It now PAYS on Qwen3.6-27B / RTX 5090 — 51.1 tok/s against a
1945        // plain 49.4 at k=3, medians of three, 89% of drafts accepted,
1946        // and the greedy continuation is byte-identical to the plain
1947        // path. That took the batch matvec sharing its nibble unpack
1948        // across the batch (`CMF_MV_BK=2`); before it, the same round
1949        // measured 43.6, an 11% LOSS, which is what the earlier note
1950        // here described.
1951        //
1952        // Still opt-in. One model's win is not a default: the verify
1953        // rides `gdn_spec_restore` and a batched frame whose numerics
1954        // are the batch kernels', and that has to be shown on more than
1955        // one architecture before every greedy decode takes it.
1956        // Greedy (with or without penalties) verifies by argmax equality.
1957        // Sampling (temperature > 0) can go through speculative SAMPLING —
1958        // draft from the MTP head's own post-chain distribution, accept
1959        // with min(1, p/q), correct from max(0, p − q); the emitted stream
1960        // is distributed exactly as the plain sampler's — but it is
1961        // OPT-IN (`CMF_GRAPH_SPEC_SAMPLE=1`): measured on Qwen3.8-27B /
1962        // RTX 5090 at the instruct row (0.7 / 0.80 / 20 / presence 1.5)
1963        // it decoded 19-22 tok/s against a plain 40 — nine post-chain
1964        // distributions a round plus a lower acceptance than greedy's,
1965        // against a verify that costs 2.7 single tokens. The greedy arms
1966        // pay +10%; the sampling arm needs a cheaper verify first.
1967        let spec_sampling_ok = self.sampler_config.temperature < 1e-6
1968            || std::env::var("CMF_GRAPH_SPEC_SAMPLE").as_deref() == Ok("1");
1969        // ON by default for greedy on the wgpu graph: with the draft on
1970        // the graph and the verify bit-exact, it measured 58.7 tok/s
1971        // against a plain 48.1 on Qwen3.8-27B q4tp / RTX 5090 (k=4) and
1972        // 51.1 against 49.4 on Qwen3.6-27B, and a round that stops
1973        // paying turns itself off below (acceptance watchdog).
1974        // `CMF_GRAPH_SPEC=0` disables; `=1` was the old opt-in spelling.
1975        // …but only where the batched verify has its register-blocked
1976        // kernel: q4tp dense FFNs (graph kind 6). q4t and q8_2f verify
1977        // through tile GEMMs today and measured a LOSS (q8_2f 22 against
1978        // 29 tok/s), the 2-bit plane the same; those stay opt-in
1979        // (`CMF_GRAPH_SPEC=1`).
1980        // …at least in nine dense FFNs of ten: a healed file carries its
1981        // last two layers at q8_2f, and two tile-GEMM verifies among 64 do
1982        // not change the arithmetic (measured: the healed q4tp file
1983        // decodes at the plain file's rate and would otherwise sit out).
1984        let (mut dense_n, mut dense_q4tp) = (0usize, 0usize);
1985        for lw in &self.weights.layers {
1986            if let FfnKind::Dense(d) = &lw.ffn {
1987                dense_n += 1;
1988                if matches!(d.gate_proj.graph_weight(), Some((_, _, 6, _)))
1989                    && matches!(d.up_proj.graph_weight(), Some((_, _, 6, _)))
1990                    && matches!(d.down_proj.graph_weight(), Some((_, _, 6, _)))
1991                {
1992                    dense_q4tp += 1;
1993                }
1994            }
1995        }
1996        let spec_default_ok = dense_n == 0 || dense_q4tp * 10 >= dense_n * 9;
1997        // Penalties break the draft head's agreement with the trunk (a
1998        // 1.1 repetition penalty measured 2 of 16 accepted): not by
1999        // default there either.
2000        let penalized = self.sampler_config.repetition_penalty != 1.0
2001            || self.sampler_config.presence_penalty != 0.0
2002            || !self.sampler_config.suppress_tokens.is_empty();
2003        // …and not on wgpu-over-Metal: the batched verify graph there
2004        // returned 0 accepted drafts and garbage text on a GDN hybrid
2005        // (16.08, Qwen3.5-0.8B) while Vulkan is bit-exact; the Mac's
2006        // default backend is native Metal without a batch graph anyway.
2007        #[cfg(feature = "gpu")]
2008        let metal_wgpu = graph_on && crate::gpu_wgpu::wgpu_backend_is_metal();
2009        #[cfg(not(feature = "gpu"))]
2010        let metal_wgpu = false;
2011        let spec_env = std::env::var("CMF_GRAPH_SPEC").ok();
2012        let spec_wanted = match spec_env.as_deref() {
2013            Some("0") => false,
2014            Some(_) => {
2015                if metal_wgpu {
2016                    tracing::warn!(
2017                        "CMF_GRAPH_SPEC forced on wgpu/Metal: the batched verify graph is not \
2018                         verified on this backend (garbage measured on Qwen3.5-0.8B)"
2019                    );
2020                }
2021                true
2022            }
2023            None => spec_default_ok && !penalized && !metal_wgpu,
2024        };
2025        // Native Metal: the b-row verify graph (`try_batch_graph_metal`)
2026        // stands where the wgpu batch graph stands on discrete cards.
2027        #[cfg(target_os = "macos")]
2028        let metal_graph = crate::gpu::q1_force()
2029            && crate::gpu::enabled_here()
2030            && std::env::var("CMF_GPU_BLOCK").map(|v| v != "0").unwrap_or(true);
2031        #[cfg(not(target_os = "macos"))]
2032        let metal_graph = false;
2033        let graph_spec = self.speculative
2034            && (graph_on || metal_graph)
2035            && self.mtp.is_some()
2036            && task_mask.is_none()
2037            && !self.o1_active()
2038            && spec_sampling_ok
2039            && spec_wanted;
2040        // GDN hybrids sit the fused-pair speculation out by default: the
2041        // recurrence is sequential, so the pair lane cannot parallelize
2042        // (the bench's own Pair line reads fused 1.28x TWO singles on the
2043        // 35B) and the draft's full-vocab head rides on top — measured 2x
2044        // SLOWER end to end (16.1 vs 32.4 tok/s on the 48-core stand).
2045        // CMF_MTP=1 forces it back for study.
2046        let pair_pays = self.gdn_cfg.is_none() || std::env::var("CMF_MTP").as_deref() == Ok("1");
2047        let spec_active = self.speculative
2048            && self.mtp.is_some()
2049            && task_mask.is_none()
2050            && !self.o1_active()
2051            && ((!graph_on && pair_pays && self.sampler_config.temperature < 1e-6) || graph_spec);
2052        // The MTP module is detached during generation so its mutable
2053        // state does not fight the borrow on `self`.
2054        let mut mtp = if spec_active { self.mtp.take() } else { None };
2055        if std::env::var("CMF_MTP_CHAIN_PROBE").is_ok() {
2056            eprintln!(
2057                "mtp-probe gate: spec_active={spec_active} mtp={} speculative={} graph_on={graph_on} temp_ok={}",
2058                mtp.is_some(),
2059                self.speculative,
2060                self.sampler_config.temperature < 1e-6,
2061            );
2062        }
2063        if let Some(m) = &mut mtp {
2064            m.kv.clear();
2065            // The MTP block's own device mirror starts over with its cache.
2066            crate::gpu::graph_kv_reset(self.mtp_kv_id());
2067            self.mtp_graph_mode = None;
2068        }
2069        // Dynamic router detached during decode (same borrow trick as MTP).
2070        // Speculative decode and dynamic routing are mutually exclusive
2071        // for now — the fused-pair path doesn't carry per-token φ.
2072        let mut router = if mtp.is_none() {
2073            self.dyn_router.take()
2074        } else {
2075            None
2076        };
2077        if let Some(r) = &mut router {
2078            r.reset(); // active=backbone, matching a fresh overlay
2079            self.dyn_phi_seen = 0; // fresh φ EMA per generation
2080            let _ = self.set_active_skill(None);
2081        }
2082
2083        let mut all_ids = input_ids.to_vec();
2084        let mut generated = 0usize;
2085        let mut finish_reason = "max_tokens".to_string();
2086        let mut drafted = 0usize;
2087        let mut accepted = 0usize;
2088        let mut confidence: Vec<f32> = Vec::new();
2089        let trace_on = self.trace;
2090        let calib_temp = self.calib_temp;
2091        let mut traces: Vec<TokenTrace> = Vec::new();
2092
2093        // ── Prefill: forward each prompt token once, KEEP the last hidden.
2094        //    Dense prefill runs in fused pairs (weights streamed once per
2095        //    two positions — bit-identical to sequential, proven by the
2096        //    pair tests). With MTP: warm the draft head on
2097        //    (hidden_p, token_{p+1}) pairs.
2098        let mut hidden = vec![0.0f32; self.hidden_size];
2099        let mut pos = reuse_from;
2100        // lm_head-in-graph is only sound when the very next logits
2101        // consumer is this loop's own (MTP and skill routing interleave
2102        // other forwards / can swap lm_head between forward and sample).
2103        // CMF_GPU_LMHEAD=0 keeps lm_head off the graph: the token reads back
2104        // the 8 KB hidden instead of ~1 MB of logits, and the head runs on
2105        // the host. A probe for how much of the graph's fixed per-token cost
2106        // is the logits readback (the layer sweep puts that fixed part at
2107        // 3.88 ms of an 18.5 ms frame).
2108        let fuse_lm = mtp.is_none()
2109            && router.is_none()
2110            && std::env::var("CMF_GPU_LMHEAD").as_deref() != Ok("0");
2111        self.graph_logits = None;
2112        self.graph_want_logits = false;
2113        let _tpf = std::time::Instant::now();
2114        let batch_k = std::env::var("CMF_BATCH_K")
2115            .ok()
2116            .and_then(|v| v.parse::<usize>().ok())
2117            .unwrap_or(0);
2118        // DeepSeek-V4 owns a separate hyper-connection stack. Route it
2119        // before the generic prefill choices: those correctly reject an
2120        // empty `weights.layers`, but their final per-position fallback used
2121        // to consume the whole prompt before `dsv4::forward_chunk` could see
2122        // it. The batch implementation therefore existed without a live
2123        // production entry point.
2124        //
2125        // Bounded chunks preserve cancellation responsiveness. Only the
2126        // prompt's final chunk asks for logits; every earlier head projection
2127        // would produce 129 280 values that no caller reads.
2128        while self.dsv4.is_some()
2129            && mtp.is_none()
2130            && pos < input_ids.len()
2131            && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2132        {
2133            let end = (pos + prefill_chunk()).min(input_ids.len());
2134            let ids: Vec<u32> = input_ids[pos..end].to_vec();
2135            let mut lg = Vec::new();
2136            if let Some(b) = &mut self.dsv4 {
2137                let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
2138                crate::dsv4::forward_chunk(
2139                    g,
2140                    layers,
2141                    &cfg,
2142                    st,
2143                    &ids,
2144                    pos,
2145                    &self.inv_freq,
2146                    self.pool.as_deref(),
2147                    &mut lg,
2148                    end == input_ids.len(),
2149                );
2150            }
2151            if end == input_ids.len() {
2152                self.graph_logits = Some(lg);
2153            }
2154            pos = end;
2155            hidden = vec![0.0; self.hidden_size];
2156        }
2157        // With dynamic routing, prefill sequentially so the φ hook fires
2158        // over the PROMPT — the router enters decode with a warm φ (the
2159        // fused-pair path skips the per-layer φ capture). o1 layers
2160        // collect their query trace in both the single and pair paths.
2161        let dyn_prefill = router.is_some();
2162        // q1 hybrids on Metal: the per-position GPU token graph beats
2163        // the CPU chunk-GEMM (whose wall is the sequential scalar GDN
2164        // recurrence), so prefill goes position-by-position through the
2165        // same graph as decode. Pure-attention models keep the batched
2166        // path — there the chunk-GEMM amortization wins.
2167        let graph_prefill = self.graph_prefill_preferred();
2168        // Native Metal, q4tp GDN hybrids: the prompt through the b-row
2169        // rows graph — projections as GEMMs over up to 512 positions, the
2170        // GDN recurrence in registers on the device, K/V rows appended by
2171        // the chunk — instead of one token-graph submit per position (the
2172        // 27B: 8 tok/s → GEMM-bound). The MTP warm-up rows come out of one
2173        // batched run of the block per chunk. Any refusal leaves the rest
2174        // of the prompt to the sequential paths below.
2175        #[cfg(target_os = "macos")]
2176        if task_mask.is_none()
2177            && !dyn_prefill
2178            && crate::gpu::q1_force()
2179            && crate::gpu::enabled_here()
2180            && self.gdn_cfg.is_some()
2181            && self.g3n.is_none()
2182            && input_ids.len() > 8
2183            && std::env::var("CMF_MTP_CHAIN_PROBE").is_err()
2184            && std::env::var("CMF_METAL_PREFILL").as_deref() != Ok("0")
2185        {
2186            let chunk: usize = std::env::var("CMF_METAL_PREFILL_CHUNK")
2187                .ok()
2188                .and_then(|v| v.parse().ok())
2189                .filter(|&v| (16..=512).contains(&v))
2190                .unwrap_or(256);
2191            let hs = self.hidden_size;
2192            let _tp = std::time::Instant::now();
2193            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2194                let end = (pos + chunk).min(input_ids.len());
2195                let Some(hb) = self.prefill_batch_metal(&input_ids[pos..end], pos) else {
2196                    break;
2197                };
2198                if let Some(m) = &mut mtp {
2199                    let n_pairs = if end < input_ids.len() { end - pos } else { end - pos - 1 };
2200                    if n_pairs > 0 {
2201                        let pairs: Vec<(&[f32], u32)> = (0..n_pairs)
2202                            .map(|j| (&hb[j * hs..(j + 1) * hs], input_ids[pos + j + 1]))
2203                            .collect();
2204                        if !self.mtp_warm_batch_metal(m, &pairs, pos) {
2205                            for (j, (h, t)) in pairs.iter().enumerate() {
2206                                let h = h.to_vec();
2207                                let _ = self.mtp_step(m, &h, *t, pos + j);
2208                            }
2209                        }
2210                    }
2211                }
2212                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2213                pos = end;
2214            }
2215            if std::env::var("CMF_PREFILL_PROF").is_ok() {
2216                eprintln!(
2217                    "metal-prefill: {} of {} tokens in {:.1} ms",
2218                    pos,
2219                    input_ids.len(),
2220                    _tp.elapsed().as_secs_f64() * 1e3
2221                );
2222            }
2223        }
2224        if task_mask.is_none()
2225            && !dyn_prefill
2226            && !graph_prefill
2227            && self.can_prefill_batched()
2228            && self.g3n.is_none()
2229            && input_ids.len() > 2
2230        {
2231            // Production prefill = the same chunked prefill-GEMM that
2232            // bench/PPL measure (roadmap §3 P0: generation used to warm
2233            // the prompt with the slower pair path — the published
2234            // prefill number didn't match real TTFT). MTP warm-up reads
2235            // each position's hidden straight from the chunk result.
2236            let chunk = prefill_chunk();
2237            let hs = self.hidden_size;
2238            while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2239                let end = (pos + chunk).min(input_ids.len());
2240                let hb = self.prefill_batch(&input_ids[pos..end], pos);
2241                if let Some(m) = &mut mtp {
2242                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2243                        .ok()
2244                        .and_then(|v| v.parse().ok())
2245                        .unwrap_or(0);
2246                    for p in pos..end {
2247                        if p + 1 < input_ids.len() {
2248                            if probe >= 1 && p + 2 < input_ids.len() {
2249                                // Teacher-forced chain acceptance (see the
2250                                // tail loop's twin): the warm-up row stays,
2251                                // the chain's rows roll back.
2252                                let (d1, mut hx) = self.mtp_step_h(
2253                                    m,
2254                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2255                                    input_ids[p + 1],
2256                                    p,
2257                                );
2258                                let mut ok = d1 == input_ids[p + 2];
2259                                Self::chain_probe_note(0, ok);
2260                                let mut d_prev = d1;
2261                                let mut extra = 0usize;
2262                                for j in 1..probe {
2263                                    if p + 2 + j >= input_ids.len() {
2264                                        break;
2265                                    }
2266                                    let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, p + 1 + j);
2267                                    extra += 1;
2268                                    ok = ok && dj == input_ids[p + 2 + j];
2269                                    Self::chain_probe_note(j, ok);
2270                                    d_prev = dj;
2271                                    hx = hj;
2272                                }
2273                                m.kv.truncate_last(extra);
2274                            } else {
2275                                let _ = self.mtp_step(
2276                                    m,
2277                                    &hb[(p - pos) * hs..(p - pos + 1) * hs],
2278                                    input_ids[p + 1],
2279                                    p,
2280                                );
2281                            }
2282                        }
2283                    }
2284                }
2285                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
2286                pos = end;
2287            }
2288        }
2289        let pair_off = std::env::var("CMF_PAIR").is_ok_and(|v| v == "0");
2290        if task_mask.is_none()
2291            && !dyn_prefill
2292            && !graph_prefill
2293            && !pair_off
2294            && self.pair_supported()
2295        {
2296            while pos + 1 < input_ids.len()
2297                && !self.cancel.load(std::sync::atomic::Ordering::Relaxed)
2298            {
2299                let e1 = self.embed_single(input_ids[pos]);
2300                let e2 = self.embed_single(input_ids[pos + 1]);
2301                let (h1, h2) = self.forward_pair(&e1, &e2, pos);
2302                // Both prefill tokens are real → commit lane-2 states.
2303                self.commit_linear_scratch();
2304                if let Some(m) = &mut mtp {
2305                    let _ = self.mtp_step(m, &h1, input_ids[pos + 1], pos);
2306                    if pos + 2 < input_ids.len() {
2307                        let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2308                            .ok()
2309                            .and_then(|v| v.parse().ok())
2310                            .unwrap_or(0);
2311                        if probe >= 1 && pos + 3 < input_ids.len() {
2312                            // Same teacher-forced chain table as the tail
2313                            // loop below, fed from the pair path that owns
2314                            // most prefill positions.
2315                            let (d1, mut hx) = self.mtp_step_h(m, &h2, input_ids[pos + 2], pos + 1);
2316                            let mut ok = d1 == input_ids[pos + 3];
2317                            Self::chain_probe_note(0, ok);
2318                            let mut d_prev = d1;
2319                            let mut extra = 0usize;
2320                            for j in 1..probe {
2321                                if pos + 3 + j >= input_ids.len() {
2322                                    break;
2323                                }
2324                                let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 2 + j);
2325                                extra += 1;
2326                                ok = ok && dj == input_ids[pos + 3 + j];
2327                                Self::chain_probe_note(j, ok);
2328                                d_prev = dj;
2329                                hx = hj;
2330                            }
2331                            m.kv.truncate_last(extra);
2332                        } else {
2333                            let _ = self.mtp_step(m, &h2, input_ids[pos + 2], pos + 1);
2334                        }
2335                    }
2336                }
2337                hidden = h2;
2338                pos += 2;
2339            }
2340        }
2341        // Batched GPU prefill for the wgpu decode graph (GDN hybrids): K prompt
2342        // positions per submit — projections/FFN as GEMMs (weight once per K),
2343        // attention/GDN looped inside — instead of one whole-graph submit per
2344        // position. Falls through to the per-position graph on any refusal.
2345        // Batched prefill is opt-in (CMF_BATCH_K>0). Default 0 = per-position
2346        // graph prefill. (Steady-state decode is provably identical either way —
2347        // token-graph submit and lm_head both unchanged — so this only trades
2348        // prefill wall.)
2349        if batch_k > 0
2350            && graph_prefill
2351            && task_mask.is_none()
2352            && !self.o1_active()
2353            && mtp.is_none()
2354            && !dyn_prefill
2355            && pos + 1 < input_ids.len()
2356        {
2357            let hs = self.hidden_size;
2358            let chunk = batch_k;
2359            while pos < input_ids.len() {
2360                let end = (pos + chunk).min(input_ids.len());
2361                let bk = end - pos;
2362                let mut hiddens = vec![0f32; bk * hs];
2363                for (j, &id) in input_ids[pos..end].iter().enumerate() {
2364                    hiddens[j * hs..(j + 1) * hs].copy_from_slice(&self.embed_single(id));
2365                }
2366                let positions: Vec<usize> = (pos..end).collect();
2367                let t_chunk = std::time::Instant::now();
2368                let ok_b = self.try_batch_graph_wgpu(&mut hiddens, &positions, bk, None);
2369                if std::env::var("CMF_GRAPH_PROF").is_ok() {
2370                    let ms = t_chunk.elapsed().as_secs_f64() * 1000.0;
2371                    eprintln!(
2372                        "batch-chunk: k={bk} ok={ok_b} {ms:.1} ms ({:.1} tok/s)",
2373                        bk as f64 / (ms / 1000.0)
2374                    );
2375                }
2376                {
2377                    use std::sync::atomic::{AtomicBool, Ordering};
2378                    static SAID: AtomicBool = AtomicBool::new(false);
2379                    if !SAID.swap(true, Ordering::Relaxed) {
2380                        if ok_b {
2381                            tracing::info!("batched prefill: ACTIVE (k={bk})");
2382                        } else {
2383                            tracing::warn!("batched prefill declined — per-position graph");
2384                        }
2385                    }
2386                }
2387                if ok_b {
2388                    hidden.copy_from_slice(&hiddens[(bk - 1) * hs..]);
2389                    pos = end;
2390                } else {
2391                    break; // unsupported → per-position graph handles the rest
2392                }
2393            }
2394        }
2395        while pos < input_ids.len() && !self.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2396            self.graph_want_logits = fuse_lm && pos + 1 == input_ids.len();
2397            hidden = self.forward_layers(&self.embed_single(input_ids[pos]), pos, task_mask);
2398            if let Some(m) = &mut mtp {
2399                if pos + 1 < input_ids.len() {
2400                    // `CMF_MTP_CHAIN_PROBE=k`: teacher-forced acceptance of a
2401                    // CHAINED draft — iterate the head on its own hidden k
2402                    // deep and score every depth against the prompt's real
2403                    // continuation. The economics of a k-token speculative
2404                    // round stand or fall on this table.
2405                    let probe: usize = std::env::var("CMF_MTP_CHAIN_PROBE")
2406                        .ok()
2407                        .and_then(|v| v.parse().ok())
2408                        .unwrap_or(0);
2409                    if probe >= 1 && pos + 2 < input_ids.len() {
2410                        let (d1, mut hx) = self.mtp_step_h(m, &hidden, input_ids[pos + 1], pos);
2411                        let mut ok = d1 == input_ids[pos + 2];
2412                        Self::chain_probe_note(0, ok);
2413                        let mut d_prev = d1;
2414                        let mut extra = 0usize;
2415                        for j in 1..probe {
2416                            if pos + 2 + j >= input_ids.len() {
2417                                break;
2418                            }
2419                            let (dj, hj) = self.mtp_step_h(m, &hx, d_prev, pos + 1 + j);
2420                            extra += 1;
2421                            ok = ok && dj == input_ids[pos + 2 + j];
2422                            Self::chain_probe_note(j, ok);
2423                            d_prev = dj;
2424                            hx = hj;
2425                        }
2426                        // The chain's rows are speculation, not the prompt —
2427                        // keep only the warmup row the plain path would add.
2428                        m.kv.truncate_last(extra);
2429                    } else {
2430                        let _ = self.mtp_step(m, &hidden, input_ids[pos + 1], pos);
2431                    }
2432                }
2433            }
2434            pos += 1;
2435        }
2436        if std::env::var("CMF_PREFILL_PROF").is_ok() {
2437            eprintln!(
2438                "prefill: {} tokens in {:.1} ms (batch_k={batch_k})",
2439                input_ids.len(),
2440                _tpf.elapsed().as_secs_f64() * 1000.0
2441            );
2442        }
2443        // Cancelled mid-prefill: the cache holds a partial prompt —
2444        // drop the reuse history and return an empty generation.
2445        if self
2446            .cancel
2447            .swap(false, std::sync::atomic::Ordering::Relaxed)
2448        {
2449            self.kv_history.clear();
2450            if let Some(m) = mtp {
2451                self.mtp = Some(m);
2452            }
2453            return Ok(GenerateResult {
2454                text: String::new(),
2455                token_ids: Vec::new(),
2456                prompt_tokens: input_ids.len(),
2457                tokens_generated: 0,
2458                finish_reason: "cancelled".to_string(),
2459                mtp_drafted: 0,
2460                mtp_accepted: 0,
2461                token_confidence: Vec::new(),
2462                traces: Vec::new(),
2463            });
2464        }
2465
2466        // Prompt absorbed → freeze the o1 layers' skeletons; from here
2467        // every decode step on those layers is O(W + m·dv + m²).
2468        self.o1_seal();
2469
2470        // Commit one token: push, check EOS, stream. Returns false = stop.
2471        macro_rules! commit {
2472            ($id:expr) => {{
2473                all_ids.push($id);
2474                generated += 1;
2475                if self.tokenizer.is_eos($id) {
2476                    finish_reason = "stop".to_string();
2477                    false
2478                } else {
2479                    let token_text = self.tokenizer.decode_token($id);
2480                    let mut go = true;
2481                    if let Some(ref mut cb) = on_token {
2482                        if !cb(&token_text) {
2483                            finish_reason = "cancelled".to_string();
2484                            go = false;
2485                        }
2486                    }
2487                    go
2488                }
2489            }};
2490        }
2491
2492        // Speculation is decided by MEASUREMENT, not by an acceptance
2493        // model. A k=4 round costs ~3.8 plain tokens on the 5090 (draft
2494        // 6.6 + verify 66.6 + commit 4.8 ms against a 20.6 ms token), so it
2495        // pays only when the head lands ~2.8 of 4 — predictable text (code,
2496        // structured output) does, free prose often does not, and the
2497        // ratio at which the two cross depends on the card and the context
2498        // depth. So: four speculative rounds timed, then eight plain
2499        // tokens timed, and the faster arm runs until a re-check 256
2500        // tokens later (context growth moves the balance). The trial
2501        // costs at most a few tokens of the slower arm per 256.
2502        let mut spec_trial = SpecTrial::Spec {
2503            t0: std::time::Instant::now(),
2504            gen0: generated,
2505            rounds: 0,
2506        };
2507        let mut spec_mon = SpecMon::default();
2508        let mut spec_watchdog_off = false;
2509        // ── Decode ──
2510        let mut next_pos = input_ids.len();
2511        'decode: while generated < max_tokens {
2512            if self
2513                .cancel
2514                .swap(false, std::sync::atomic::Ordering::Relaxed)
2515            {
2516                finish_reason = "cancelled".to_string();
2517                break 'decode;
2518            }
2519            // A rejected speculative draft already drew this position's
2520            // token from the residual distribution (graph_spec_step); it
2521            // is committed as-is — sampling again from the row's logits
2522            // would bias the stream toward the target's mode.
2523            let forced = self.spec_forced.take();
2524            let mut logits = match (forced, self.graph_logits.take()) {
2525                (Some(_), _) => Vec::new(),
2526                (None, Some(lg)) => lg,
2527                (None, None) => {
2528                    inference::rms_norm_into(
2529                        &hidden,
2530                        &self.weights.final_norm,
2531                        self.rms_eps,
2532                        self.norm_style,
2533                        &mut self.ws.n1,
2534                    );
2535                    self.lm_head_forward(&self.ws.n1)
2536                }
2537            };
2538            // CMF_LOGIT_DUMP=<path>: the first decode step's hidden + logits
2539            // as raw f32 (hidden first) — cross-backend numerics diffing.
2540            if generated
2541                == std::env::var("CMF_LOGIT_DUMP_STEP")
2542                    .ok()
2543                    .and_then(|v| v.parse().ok())
2544                    .unwrap_or(0)
2545            {
2546                if let Ok(path) = std::env::var("CMF_LOGIT_DUMP") {
2547                    let mut bytes: Vec<u8> = Vec::with_capacity((hidden.len() + logits.len()) * 4);
2548                    for v in hidden.iter().chain(logits.iter()) {
2549                        bytes.extend_from_slice(&v.to_le_bytes());
2550                    }
2551                    let _ = std::fs::write(&path, &bytes);
2552                }
2553            }
2554            let t_next = match forced {
2555                Some(c) => c,
2556                None => sampler::sample_with_scratch_pool(
2557                    &logits,
2558                    &self.sampler_config,
2559                    &all_ids,
2560                    &mut self.rng,
2561                    &mut self.sampler_scratch,
2562                    self.pool.as_deref(),
2563                ),
2564            };
2565            if self.confidence_on {
2566                confidence.push(if logits.is_empty() {
2567                    0.0
2568                } else {
2569                    sampler::top1_prob_pool(
2570                        self.pool.as_deref(),
2571                        &mut self.sampler_scratch,
2572                        &logits,
2573                        t_next,
2574                        calib_temp,
2575                    )
2576                });
2577            }
2578            if !logits.is_empty() {
2579                attention::recycle_buf(&mut logits);
2580            }
2581            if trace_on {
2582                // active_skill = the overlay in force while this token was
2583                // generated; recon/switched are filled after the post-emit
2584                // routing eval below (freshest coherence for this token).
2585                let skill = router.as_ref().and_then(|r| r.active_id());
2586                traces.push(TokenTrace {
2587                    t: generated,
2588                    token_id: t_next,
2589                    confidence: confidence.last().copied().unwrap_or(0.0),
2590                    active_skill: skill,
2591                    recon: None,
2592                    switched: false,
2593                });
2594            }
2595            if !commit!(t_next) {
2596                break 'decode;
2597            }
2598            if generated >= max_tokens {
2599                break 'decode;
2600            }
2601
2602            if self.kv_cache.needs_eviction() {
2603                // Say it ONCE, loudly: past this point the model keeps
2604                // talking but has lost half its context, and on a GDN
2605                // hybrid the graph's device state goes stale on top. The
2606                // Qwen3.8 bring-up spent a day reading this cliff as
2607                // three different model bugs.
2608                static SAID: std::sync::Once = std::sync::Once::new();
2609                SAID.call_once(|| {
2610                    tracing::warn!(
2611                        "KV cache full at {} positions — evicting half; quality \
2612                         will degrade. Raise CMF_MAX_SEQ.",
2613                        self.kv_cache.max_seq_len,
2614                    );
2615                });
2616                let keep = (self.kv_cache.max_seq_len / 2).max(1);
2617                self.kv_cache.evict(keep);
2618            }
2619
2620            // Advance the speculation trial: plain-phase accounting and
2621            // the periodic re-check happen here, on every token.
2622            if graph_spec {
2623                match spec_trial {
2624                    SpecTrial::Plain { t0, gen0 } if generated >= gen0 + 8 => {
2625                        spec_mon.plain_ms =
2626                            t0.elapsed().as_secs_f64() * 1e3 / (generated - gen0) as f64;
2627                        let keep = spec_mon.pays();
2628                        tracing::info!(
2629                            "speculation trial: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
2630                            spec_mon.tokens,
2631                            spec_mon.round_ms,
2632                            spec_mon.plain_ms,
2633                            if keep { "speculating" } else { "plain" }
2634                        );
2635                        spec_mon.fails = 0;
2636                        spec_trial = SpecTrial::Decided {
2637                            spec: keep,
2638                            recheck_at: if keep { usize::MAX } else { generated + 128 },
2639                        };
2640                    }
2641                    SpecTrial::Decided { recheck_at, .. } if generated >= recheck_at => {
2642                        spec_mon.n = 0;
2643                        spec_trial = SpecTrial::Spec {
2644                            t0: std::time::Instant::now(),
2645                            gen0: generated,
2646                            rounds: 0,
2647                        };
2648                    }
2649                    _ => {}
2650                }
2651                spec_watchdog_off = matches!(
2652                    spec_trial,
2653                    SpecTrial::Plain { .. } | SpecTrial::Decided { spec: false, .. }
2654                );
2655            }
2656            match &mut mtp {
2657                // ── Graph speculation: chain-draft, batch-verify on device ──
2658                #[cfg(feature = "gpu")]
2659                Some(m)
2660                    if graph_spec
2661                        && !spec_watchdog_off
2662                        && generated + 1 < max_tokens
2663                        && next_pos > 0 =>
2664                {
2665                    let t_round = std::time::Instant::now();
2666                    if let Some((extra, n_pos, new_h)) = self.graph_spec_step(
2667                        m,
2668                        &hidden,
2669                        t_next,
2670                        next_pos,
2671                        &mut drafted,
2672                        &mut accepted,
2673                        &mut all_ids,
2674                    ) {
2675                        next_pos = n_pos;
2676                        hidden = new_h;
2677                        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
2678                            eprintln!(
2679                                "spec-round wall {:.1} ms → {} tokens",
2680                                t_round.elapsed().as_secs_f64() * 1e3,
2681                                extra.len() + 1
2682                            );
2683                        }
2684                        // One speculative round done: the monitor counts it
2685                        // (round 1 untimed — it pays the batch scratch and
2686                        // the draft mirror), and the trial advances.
2687                        spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, extra.len() + 1);
2688                        // the round's tokens land in `generated` below; the
2689                        // plain phase must start counting AFTER them
2690                        spec_trial = Self::spec_trial_round(
2691                            spec_trial,
2692                            &mut spec_mon,
2693                            generated + extra.len() + 1,
2694                        );
2695                        let mut stopped = false;
2696                        for &id in &extra {
2697                            if self.confidence_on {
2698                                confidence.push(0.0);
2699                            }
2700                            if !commit!(id) {
2701                                stopped = true;
2702                                break;
2703                            }
2704                        }
2705                        if stopped {
2706                            break 'decode;
2707                        }
2708                        continue 'decode;
2709                    }
2710                    // Declined (batch graph refused): plain forward below —
2711                    // and a round that produced one token for the trial's
2712                    // ledger, so a graph that keeps refusing is measured out
2713                    // like a head that keeps missing (it was spinning
2714                    // forever on a file whose batch graph declines).
2715                    // A declined round is not a cheap one-token round — it
2716                    // is a verify that does not exist for this file (a
2717                    // healed q8_2f tail measured 760 drafts, 0 accepted, 33
2718                    // against 48.8 tok/s while the monitor called the draft
2719                    // alone "paying"). Count it as the losing streak in one.
2720                    spec_mon.round(t_round.elapsed().as_secs_f64() * 1e3, 1);
2721                    spec_mon.tokens = 0.0;
2722                    spec_mon.fails = 3;
2723                    spec_trial = Self::spec_trial_round(spec_trial, &mut spec_mon, generated + 1);
2724                    hidden = self.forward_layers(&self.embed_single(t_next), next_pos, task_mask);
2725                    next_pos += 1;
2726                    continue 'decode;
2727                }
2728                // ── Speculative: draft t+2, verify in a fused pair ──
2729                Some(m) if !graph_spec && generated + 1 < max_tokens => {
2730                    let draft = self.mtp_step(m, &hidden, t_next, next_pos - 1);
2731                    drafted += 1;
2732                    let emb1 = self.embed_single(t_next);
2733                    let emb2 = self.embed_single(draft);
2734                    let (h1, h2) = self.forward_pair(&emb1, &emb2, next_pos);
2735
2736                    inference::rms_norm_into(
2737                        &h1,
2738                        &self.weights.final_norm,
2739                        self.rms_eps,
2740                        self.norm_style,
2741                        &mut self.ws.n1,
2742                    );
2743                    let mut logits1 = self.lm_head_forward(&self.ws.n1);
2744                    let t_after = sampler::sample_with_scratch_pool(
2745                        &logits1,
2746                        &self.sampler_config,
2747                        &all_ids,
2748                        &mut self.rng,
2749                        &mut self.sampler_scratch,
2750                        self.pool.as_deref(),
2751                    );
2752                    if self.confidence_on {
2753                        confidence.push(sampler::top1_prob_pool(
2754                            self.pool.as_deref(),
2755                            &mut self.sampler_scratch,
2756                            &logits1,
2757                            t_after,
2758                            calib_temp,
2759                        ));
2760                    }
2761                    attention::recycle_buf(&mut logits1);
2762                    if trace_on {
2763                        // Speculative decode is mutually exclusive with
2764                        // dynamic routing (router is None here) — no skill.
2765                        traces.push(TokenTrace {
2766                            t: generated,
2767                            token_id: t_after,
2768                            confidence: confidence.last().copied().unwrap_or(0.0),
2769                            active_skill: None,
2770                            recon: None,
2771                            switched: false,
2772                        });
2773                    }
2774                    let stop = !commit!(t_after);
2775
2776                    if t_after == draft {
2777                        accepted += 1;
2778                        self.commit_linear_scratch();
2779                        let _ = self.mtp_step(m, &h1, t_after, next_pos);
2780                        hidden = h2;
2781                        next_pos += 2;
2782                    } else {
2783                        // The draft lane is wrong: roll its KV entry back.
2784                        for layer in &mut self.kv_cache.layers {
2785                            layer.truncate_last(1);
2786                        }
2787                        if !stop {
2788                            let _ = self.mtp_step(m, &h1, t_after, next_pos);
2789                            hidden = self.forward_layers(
2790                                &self.embed_single(t_after),
2791                                next_pos + 1,
2792                                None,
2793                            );
2794                        }
2795                        next_pos += 2;
2796                    }
2797                    if stop {
2798                        break 'decode;
2799                    }
2800                }
2801                // ── Vanilla: forward the sampled token ──
2802                _ => {
2803                    // ── DeepSeek-V4 speculative decode (CMF_DSV4_SPEC=1):
2804                    // draft five on the card, verify batched, commit the
2805                    // accepted prefix. Greedy only; a rejected token's state
2806                    // is restored and replayed, so output equals the walk. ──
2807                    #[cfg(feature = "gpu")]
2808                    if Self::dsv4_spec_on() && self.dsv4.is_some() {
2809                        static SAID: std::sync::Once = std::sync::Once::new();
2810                        SAID.call_once(|| {
2811                            eprintln!(
2812                                "dsv4-spec гейт: mtp={} mask={} router={} trace={} temp={} rep={} ",
2813                                !self.dsv4_mtp.is_empty(),
2814                                task_mask.is_none(),
2815                                router.is_none(),
2816                                !trace_on,
2817                                self.sampler_config.temperature < 1e-6,
2818                                self.sampler_config.repetition_penalty == 1.0,
2819                            );
2820                        });
2821                    }
2822                    #[cfg(feature = "gpu")]
2823                    if Self::dsv4_spec_on()
2824                        && self.dsv4.is_some()
2825                        && !self.dsv4_mtp.is_empty()
2826                        && task_mask.is_none()
2827                        && router.is_none()
2828                        && !trace_on
2829                        && self.sampler_config.temperature < 1e-6
2830                        && self.sampler_config.repetition_penalty == 1.0
2831                        && generated + 1 < max_tokens
2832                        && all_ids.len() >= 2
2833                    {
2834                        let tip_token = all_ids[all_ids.len() - 2];
2835                        if let Some((extra, n_pos)) = self.dsv4_spec_step(
2836                            tip_token,
2837                            t_next,
2838                            next_pos,
2839                            &mut drafted,
2840                            &mut accepted,
2841                        ) {
2842                            next_pos = n_pos;
2843                            let mut stopped = false;
2844                            for &id in &extra {
2845                                if self.confidence_on {
2846                                    confidence.push(0.0);
2847                                }
2848                                if !commit!(id) {
2849                                    stopped = true;
2850                                    break;
2851                                }
2852                            }
2853                            if stopped {
2854                                break 'decode;
2855                            }
2856                            continue 'decode;
2857                        }
2858                    }
2859                    self.graph_want_logits = fuse_lm;
2860                    // Greedy burst (CMF_MULTISTEP, default 8, 1 = off): while
2861                    // nothing observes per-token state — pure argmax sampling,
2862                    // no router/trace/confidence/mask — decode k tokens per
2863                    // submit and commit them wholesale. The trailing normal
2864                    // forward leaves logits for the loop top, as always.
2865                    let mut t_fwd = t_next;
2866                    let pure_greedy = self.sampler_config.temperature < 1e-6
2867                        && self.sampler_config.repetition_penalty == 1.0
2868                        && self.sampler_config.suppress_tokens.is_empty();
2869                    // Off by default: at every k the burst measured at or
2870                    // below the plain path on this graph shape (k=1 loses
2871                    // the argmax dispatches vs a 1 MB readback, k>=8 loses
2872                    // inter-step drains vs the saved sync). Experimental.
2873                    let burst_k = std::env::var("CMF_MULTISTEP")
2874                        .ok()
2875                        .and_then(|v| v.parse::<usize>().ok())
2876                        .unwrap_or(0);
2877                    if pure_greedy
2878                        && burst_k >= 1
2879                        && fuse_lm
2880                        && task_mask.is_none()
2881                        && router.is_none()
2882                        && !trace_on
2883                        && !self.confidence_on
2884                    {
2885                        let mut stopped = false;
2886                        loop {
2887                            let room = max_tokens.saturating_sub(generated);
2888                            if room <= 2 {
2889                                break;
2890                            }
2891                            let k = burst_k.min(room - 1);
2892                            if k < 1 {
2893                                break;
2894                            }
2895                            let Some(ids) = self.try_multi_burst(t_fwd, next_pos, k) else {
2896                                break;
2897                            };
2898                            next_pos += k;
2899                            for &id in &ids {
2900                                if !commit!(id) {
2901                                    stopped = true;
2902                                    break;
2903                                }
2904                            }
2905                            if stopped {
2906                                break;
2907                            }
2908                            t_fwd = *ids.last().unwrap();
2909                        }
2910                        if stopped {
2911                            break 'decode;
2912                        }
2913                    }
2914                    hidden = self.forward_layers(&self.embed_single(t_fwd), next_pos, task_mask);
2915                    next_pos += 1;
2916                    // Dynamic routing: the forward updated φ; ask the
2917                    // router whether to switch skills before the next token.
2918                    if let Some(r) = &mut router {
2919                        let phi = self.dyn_phi_ema.clone();
2920                        let decision = r.step(&phi, generated);
2921                        if let Some(new_active) = decision {
2922                            let _ = self.set_active_skill(new_active);
2923                        }
2924                        // Backfill this token's coherence + switch flag from
2925                        // the just-run eval (freshest measured values).
2926                        if trace_on {
2927                            if let Some(last) = traces.last_mut() {
2928                                let e = r.last_best_e();
2929                                last.recon = e.is_finite().then_some(e);
2930                                last.switched = decision.is_some();
2931                            }
2932                        }
2933                    }
2934                }
2935            }
2936        }
2937
2938        self.graph_want_logits = false;
2939        self.graph_logits = None;
2940        // Restore backbone overlay and re-attach the router for reuse.
2941        if router.is_some() {
2942            let _ = self.set_active_skill(None);
2943        }
2944        self.dyn_router = router.or(self.dyn_router.take());
2945        self.mtp = mtp.or(self.mtp.take());
2946
2947        let output_ids = &all_ids[input_ids.len()..];
2948        // Forwarded = prompt + all generated but the LAST sampled token
2949        // (emitted without being fed back). Exact only without MTP —
2950        // reuse is gated off when MTP is active.
2951        let forwarded = input_ids.len() + output_ids.len().saturating_sub(1);
2952        self.kv_history = all_ids[..forwarded.min(all_ids.len())].to_vec();
2953        confidence.truncate(output_ids.len()); // guard against any overshoot
2954        traces.truncate(output_ids.len());
2955        Ok(GenerateResult {
2956            text: self.tokenizer.decode(output_ids),
2957            token_ids: output_ids.to_vec(),
2958            prompt_tokens: input_ids.len(),
2959            tokens_generated: generated,
2960            finish_reason,
2961            mtp_drafted: drafted,
2962            mtp_accepted: accepted,
2963            token_confidence: confidence,
2964            traces,
2965        })
2966    }
2967
2968    /// One MTP step: feed `(hidden_p, token_{p+1})` into the draft head,
2969    /// advance its KV cache at position `p`, return the drafted token
2970    /// for position `p+2`.
2971    fn mtp_step(
2972        &mut self,
2973        m: &mut MtpModule,
2974        hidden: &[f32],
2975        next_token: u32,
2976        position: usize,
2977    ) -> u32 {
2978        self.mtp_step_h(m, hidden, next_token, position).0
2979    }
2980
2981    /// Tally for `CMF_MTP_CHAIN_PROBE`: per depth, how often the CHAIN is
2982    /// still an exact prefix of the real continuation. Printed every 128
2983    /// depth-0 samples so a killed run still shows its table.
2984    fn chain_probe_note(depth: usize, prefix_ok: bool) {
2985        use std::sync::Mutex;
2986        static T: Mutex<Vec<(u64, u64)>> = Mutex::new(Vec::new());
2987        let mut t = T.lock().unwrap();
2988        if t.len() <= depth {
2989            t.resize(depth + 1, (0, 0));
2990        }
2991        t[depth].0 += 1;
2992        t[depth].1 += prefix_ok as u64;
2993        if depth == 0 && t[0].0 % 128 == 0 {
2994            let line: Vec<String> = t
2995                .iter()
2996                .enumerate()
2997                .map(|(d, (n, k))| {
2998                    format!(
2999                        "d{}={:.0}%({n})",
3000                        d + 1,
3001                        100.0 * *k as f64 / (*n).max(1) as f64
3002                    )
3003                })
3004                .collect();
3005            eprintln!("mtp-chain: {}", line.join(" "));
3006        }
3007    }
3008
3009    /// `mtp_step` that also hands back the block's own output hidden — the
3010    /// state a CHAINED draft feeds the next step, the way a multi-token
3011    /// speculative round iterates the head on itself.
3012    /// One MTP block step from (trunk hidden, token): the head's LOGITS
3013    /// and the block's own hidden for chaining. The draft is argmax of the
3014    /// logits on the greedy path and a draw from their post-chain
3015    /// distribution on the sampling path.
3016    fn mtp_step_hl(
3017        &mut self,
3018        m: &mut MtpModule,
3019        hidden: &[f32],
3020        next_token: u32,
3021        position: usize,
3022    ) -> (Vec<f32>, Vec<f32>) {
3023        // The graph arm: the MTP block as a one-layer token graph with the
3024        // head fused — device attention over the block's own KV mirror,
3025        // one submit for block + head, hidden and logits back together.
3026        // Decided once per generation (see `mtp_graph_mode`).
3027        #[cfg(target_os = "macos")]
3028        if self.mtp_graph_mode != Some(false) && crate::gpu::q1_force() {
3029            if let Some(r) = self.mtp_step_metal(m, hidden, next_token, position, true) {
3030                self.mtp_graph_mode = Some(true);
3031                return r;
3032            }
3033            self.mtp_graph_mode = Some(false);
3034        }
3035        #[cfg(feature = "gpu")]
3036        if self.mtp_graph_mode != Some(false) {
3037            if let Some(r) = self.mtp_step_graph(m, hidden, next_token, position) {
3038                self.mtp_graph_mode = Some(true);
3039                return r;
3040            }
3041            if self.mtp_graph_mode == Some(true) {
3042                // The graph carried this generation's MTP KV and just
3043                // declined — the CPU cache is not current. A draft from
3044                // stale attention is still only a draft (verify decides),
3045                // but say so once.
3046                tracing::warn!("mtp graph declined mid-run — draft falls to the per-op path");
3047            }
3048            self.mtp_graph_mode = Some(false);
3049        }
3050        // fc concat order is [enorm(embed); hnorm(hidden)] — EMBEDDING
3051        // FIRST. Verified by the oracle (converter/mtp_oracle.py):
3052        // [emb;hid] → 45.8% acceptance, [hid;emb] → 0.00%.
3053        let e = self.embed_single(next_token);
3054        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3055        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3056        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3057        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3058        let mut x = vec![0.0f32; self.hidden_size];
3059        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3060
3061        // One standard transformer block over the MTP's own cache.
3062        let lw = &m.layer;
3063        inference::rms_norm_into(
3064            &x,
3065            &lw.input_norm,
3066            self.rms_eps,
3067            self.norm_style,
3068            &mut self.ws.n1,
3069        );
3070        let attn = match &lw.attn {
3071            // MLA models carry no MTP head; this path cannot see them.
3072            AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
3073            AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
3074            AttnKind::Full {
3075                wq,
3076                wk,
3077                wv,
3078                wo,
3079                q_norm,
3080                k_norm,
3081                output_gate,
3082                softplus_gate,
3083                bias,
3084            } => {
3085                let mut cfg = self.attn_cfg(position);
3086                cfg.q_norm = q_norm.as_deref();
3087                cfg.k_norm = k_norm.as_deref();
3088                cfg.output_gate = *output_gate;
3089                cfg.softplus_gate = softplus_gate
3090                    .as_ref()
3091                    .map(|(gate, per_head)| (gate, *per_head));
3092                cfg.bias = bias
3093                    .as_ref()
3094                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
3095                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
3096            }
3097            AttnKind::Linear(_) | AttnKind::LinearGdn(_) | AttnKind::ShortConv(_) => {
3098                unreachable!("MTP block is full attention")
3099            }
3100        };
3101        for (i, &a) in attn.iter().enumerate() {
3102            x[i] += a;
3103        }
3104        inference::rms_norm_into(
3105            &x,
3106            &lw.post_norm,
3107            self.rms_eps,
3108            self.norm_style,
3109            &mut self.ws.p1,
3110        );
3111        let ffn = ffn_forward(&lw.ffn, &self.ws.p1, self.pool.as_deref(), None);
3112        for (i, &f) in ffn.iter().enumerate() {
3113            x[i] += f;
3114        }
3115
3116        inference::rms_norm_into(
3117            &x,
3118            &m.final_norm,
3119            self.rms_eps,
3120            self.norm_style,
3121            &mut self.ws.n1,
3122        );
3123        let lg = self.lm_head_forward(&self.ws.n1);
3124        (lg, x)
3125    }
3126
3127    /// `mtp_step_hl` reduced to the greedy draft: argmax of the head.
3128    fn mtp_step_h(
3129        &mut self,
3130        m: &mut MtpModule,
3131        hidden: &[f32],
3132        next_token: u32,
3133        position: usize,
3134    ) -> (u32, Vec<f32>) {
3135        let (mut lg, x) = self.mtp_step_hl(m, hidden, next_token, position);
3136        let draft = sampler::argmax(&lg);
3137        attention::recycle_buf(&mut lg);
3138        (draft, x)
3139    }
3140
3141    /// One speculative round for the trial: rounds 1..5 of a `Spec` phase
3142    /// advance it (the monitor already averaged this round); after five,
3143    /// the plain phase runs (once — a known plain rate decides at once);
3144    /// a decided speculation keeps re-checking the rule every round and
3145    /// stops after four losing rounds in a row.
3146    fn spec_trial_round(trial: SpecTrial, mon: &mut SpecMon, generated: usize) -> SpecTrial {
3147        match trial {
3148            SpecTrial::Spec { t0, gen0, rounds } => {
3149                let rounds = rounds + 1;
3150                if rounds >= 5 {
3151                    if mon.plain_ms > 0.0 {
3152                        let keep = mon.pays();
3153                        mon.fails = 0;
3154                        tracing::info!(
3155                            "speculation re-check: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok — {}",
3156                            mon.tokens,
3157                            mon.round_ms,
3158                            mon.plain_ms,
3159                            if keep { "speculating" } else { "plain" }
3160                        );
3161                        SpecTrial::Decided {
3162                            spec: keep,
3163                            recheck_at: if keep { usize::MAX } else { generated + 128 },
3164                        }
3165                    } else {
3166                        SpecTrial::Plain {
3167                            t0: std::time::Instant::now(),
3168                            gen0: generated,
3169                        }
3170                    }
3171                } else {
3172                    SpecTrial::Spec { t0, gen0, rounds }
3173                }
3174            }
3175            SpecTrial::Decided { spec: true, .. } => {
3176                if mon.pays() {
3177                    mon.fails = 0;
3178                    trial
3179                } else {
3180                    mon.fails += 1;
3181                    if mon.fails >= 4 {
3182                        tracing::info!(
3183                            "speculation stopped: {:.2} tok/round in {:.1} ms vs plain {:.1} ms/tok",
3184                            mon.tokens,
3185                            mon.round_ms,
3186                            mon.plain_ms
3187                        );
3188                        SpecTrial::Decided {
3189                            spec: false,
3190                            recheck_at: generated + 128,
3191                        }
3192                    } else {
3193                        trial
3194                    }
3195                }
3196            }
3197            other => other,
3198        }
3199    }
3200
3201    /// The MTP block's device-mirror id: the trunk's id with a high bit,
3202    /// so the (kv_id, layer) mirror keys never collide.
3203    fn mtp_kv_id(&self) -> u64 {
3204        self.graph_kv_id | (1u64 << 40)
3205    }
3206
3207    /// The MTP block's mirror layer index: 0 — its own kv_id keeps it
3208    /// apart from the trunk, and the BATCH graph (the warm-up path) keys
3209    /// its mirrors at layer 0 with no base of its own, so the draft's
3210    /// token graph must key the same slot.
3211    const MTP_LAYER_BASE: usize = 0;
3212
3213    /// The block's input from (trunk hidden, token): eh_proj · [enorm(e);
3214    /// hnorm(h)] — the same arithmetic the per-op path starts with.
3215    fn mtp_block_input(&mut self, m: &MtpModule, hidden: &[f32], next_token: u32) -> Vec<f32> {
3216        let e = self.embed_single(next_token);
3217        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3218        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3219        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3220        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3221        let mut x = vec![0.0f32; self.hidden_size];
3222        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3223        x
3224    }
3225
3226    /// Is the MTP block graphable at all (device up, full attention
3227    /// without softplus, dense FFN)? The plan itself is built per call.
3228    #[cfg(feature = "gpu")]
3229    fn mtp_graph_ok(&self, m: &MtpModule) -> bool {
3230        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0") {
3231            return false;
3232        }
3233        if !crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode)
3234            || !crate::gpu::enabled_here()
3235            || self.attn_softcap > 0.0
3236            || self.attention_heads_per_layer.is_some()
3237        {
3238            return false;
3239        }
3240        matches!(
3241            &m.layer.attn,
3242            AttnKind::Full {
3243                softplus_gate: None,
3244                ..
3245            }
3246        ) && matches!(&m.layer.ffn, FfnKind::Dense(_))
3247    }
3248
3249    /// One MTP block step on the wgpu token graph: block + fused head in
3250    /// one submit, the block hidden and the logits read back together.
3251    /// None = the graph cannot take this block (softplus gate, non-dense
3252    /// FFN, unquantized head, no device) — the caller keeps the per-op
3253    /// path for the whole generation.
3254    #[cfg(feature = "gpu")]
3255    fn mtp_step_graph(
3256        &mut self,
3257        m: &mut MtpModule,
3258        hidden: &[f32],
3259        next_token: u32,
3260        position: usize,
3261    ) -> Option<(Vec<f32>, Vec<f32>)> {
3262        if !self.mtp_graph_ok(m) {
3263            return None;
3264        }
3265        let lw = &m.layer;
3266        let AttnKind::Full {
3267            wq,
3268            wk,
3269            wv,
3270            wo,
3271            q_norm,
3272            k_norm,
3273            output_gate,
3274            softplus_gate,
3275            bias,
3276        } = &lw.attn
3277        else {
3278            return None;
3279        };
3280        if softplus_gate.is_some() {
3281            return None;
3282        }
3283        let FfnKind::Dense(d) = &lw.ffn else {
3284            return None;
3285        };
3286        // The block's input first: it borrows `self` mutably (embed scratch,
3287        // pool), the plan below borrows the weights immutably.
3288        let mut x = self.mtp_block_input(m, hidden, next_token);
3289        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
3290            let (_, i, kind, rs) = t.graph_weight()?;
3291            Some(crate::gpu::GraphW {
3292                idx: i,
3293                kind,
3294                row_scale: rs,
3295                data: &[],
3296            })
3297        }
3298        let (model, _, _, _) = wq.graph_weight()?;
3299        let model = model.clone();
3300        let (lm_gw, lm_rows) = {
3301            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
3302            (
3303                crate::gpu::GraphW {
3304                    idx: i,
3305                    kind,
3306                    row_scale: rs,
3307                    data: &[],
3308                },
3309                self.weights.lm_head.rows(),
3310            )
3311        };
3312        let layer = crate::gpu::GraphLayer {
3313            input_norm: &lw.input_norm,
3314            attn: crate::gpu::GraphAttn::Full {
3315                wq: gw(wq)?,
3316                wk: gw(wk)?,
3317                wv: gw(wv)?,
3318                wo: gw(wo)?,
3319                q_norm: q_norm.as_deref(),
3320                k_norm: k_norm.as_deref(),
3321                bias: bias
3322                    .as_ref()
3323                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
3324                output_gate: *output_gate,
3325                cpu_k: m.kv.k_heads(),
3326                cpu_v: m.kv.v_heads(),
3327            },
3328            post_norm: &lw.post_norm,
3329            ffn: crate::gpu::GraphFfn::Dense {
3330                gate: gw(&d.gate_proj)?,
3331                up: gw(&d.up_proj)?,
3332                down: gw(&d.down_proj)?,
3333            },
3334        };
3335        let nh = self.num_heads;
3336        let (nkv, hd, rd) = self.layer_geom(0);
3337        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
3338        let mut logits = Vec::new();
3339        let ok = crate::gpu::forward_token_graph(
3340            &model,
3341            self.mtp_kv_id(),
3342            std::slice::from_ref(&layer),
3343            &[None],
3344            self.o1_epoch,
3345            &self.inv_freq,
3346            &mut x,
3347            nh,
3348            nkv,
3349            hd,
3350            rd,
3351            self.hidden_size,
3352            self.intermediate_size,
3353            position,
3354            self.kv_cache.max_seq_len,
3355            gemma,
3356            self.rms_eps as f32,
3357            Some((&lm_gw, lm_rows)),
3358            &m.final_norm,
3359            &mut logits,
3360            &[],
3361            1,
3362            None,
3363            None,
3364            None,
3365            Self::MTP_LAYER_BASE,
3366            true,
3367        );
3368        if !ok {
3369            return None;
3370        }
3371        logits.resize(self.vocab_size, 0.0);
3372        Some((logits, x))
3373    }
3374
3375    /// The warm-ups of one speculative round on the device: every accepted
3376    /// (hidden, token) pair as ONE batched graph run over the MTP block
3377    /// (no head) — its kv_append lands the pairs in the block's mirror.
3378    /// `pairs` are consecutive positions from `first_pos`. False = the
3379    /// batch graph declined; the caller warms one by one on the token
3380    /// graph (prefix mode) instead.
3381    #[cfg(feature = "gpu")]
3382    fn mtp_warm_graph(
3383        &mut self,
3384        m: &mut MtpModule,
3385        pairs: &[(&[f32], u32)],
3386        first_pos: usize,
3387    ) -> bool {
3388        if pairs.is_empty() || !self.mtp_graph_ok(m) {
3389            return pairs.is_empty();
3390        }
3391        let hs = self.hidden_size;
3392        // Block inputs for every pair (eh_proj on the per-op path, one
3393        // matvec each — the plan's own prologue).
3394        let mut hiddens = Vec::with_capacity(pairs.len() * hs);
3395        for (h, t) in pairs {
3396            hiddens.extend_from_slice(&self.mtp_block_input(m, h, *t));
3397        }
3398        let lw = &m.layer;
3399        let AttnKind::Full {
3400            wq,
3401            wk,
3402            wv,
3403            wo,
3404            q_norm,
3405            k_norm,
3406            output_gate,
3407            bias,
3408            ..
3409        } = &lw.attn
3410        else {
3411            return false;
3412        };
3413        let FfnKind::Dense(d) = &lw.ffn else {
3414            return false;
3415        };
3416        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
3417            let (_, i, kind, rs) = t.graph_weight()?;
3418            Some(crate::gpu::GraphW {
3419                idx: i,
3420                kind,
3421                row_scale: rs,
3422                data: &[],
3423            })
3424        }
3425        let Some((model, _, _, _)) = wq.graph_weight() else {
3426            return false;
3427        };
3428        let model = model.clone();
3429        let (Some(gwq), Some(gwk), Some(gwv), Some(gwo), Some(gg), Some(gu), Some(gd)) = (
3430            gw(wq),
3431            gw(wk),
3432            gw(wv),
3433            gw(wo),
3434            gw(&d.gate_proj),
3435            gw(&d.up_proj),
3436            gw(&d.down_proj),
3437        ) else {
3438            return false;
3439        };
3440        let layer = crate::gpu::GraphLayer {
3441            input_norm: &lw.input_norm,
3442            attn: crate::gpu::GraphAttn::Full {
3443                wq: gwq,
3444                wk: gwk,
3445                wv: gwv,
3446                wo: gwo,
3447                q_norm: q_norm.as_deref(),
3448                k_norm: k_norm.as_deref(),
3449                bias: bias
3450                    .as_ref()
3451                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
3452                output_gate: *output_gate,
3453                cpu_k: m.kv.k_heads(),
3454                cpu_v: m.kv.v_heads(),
3455            },
3456            post_norm: &lw.post_norm,
3457            ffn: crate::gpu::GraphFfn::Dense {
3458                gate: gg,
3459                up: gu,
3460                down: gd,
3461            },
3462        };
3463        let positions: Vec<usize> = (first_pos..first_pos + pairs.len()).collect();
3464        let nh = self.num_heads;
3465        let (nkv, hd, rd) = self.layer_geom(0);
3466        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
3467        crate::gpu::forward_batch_graph(
3468            &model,
3469            self.mtp_kv_id(),
3470            std::slice::from_ref(&layer),
3471            &self.inv_freq,
3472            &mut hiddens,
3473            nh,
3474            nkv,
3475            hd,
3476            rd,
3477            hs,
3478            self.intermediate_size,
3479            &positions,
3480            self.kv_cache.max_seq_len,
3481            gemma,
3482            self.rms_eps as f32,
3483            pairs.len(),
3484            None,
3485        )
3486    }
3487
3488    /// The MTP block alone — advance its KV with a (hidden, token) pair the
3489    /// verify just proved, without paying the head. What keeps the draft's
3490    /// attention context warm between speculative rounds.
3491    fn mtp_warm(&mut self, m: &mut MtpModule, hidden: &[f32], next_token: u32, position: usize) {
3492        let e = self.embed_single(next_token);
3493        let mut cat = vec![0.0f32; 2 * self.hidden_size];
3494        let (cat_e, cat_h) = cat.split_at_mut(self.hidden_size);
3495        inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
3496        inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
3497        let mut x = vec![0.0f32; self.hidden_size];
3498        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
3499        inference::rms_norm_into(
3500            &x,
3501            &m.layer.input_norm,
3502            self.rms_eps,
3503            self.norm_style,
3504            &mut self.ws.n1,
3505        );
3506        let attn = match &m.layer.attn {
3507            AttnKind::Full {
3508                wq,
3509                wk,
3510                wv,
3511                wo,
3512                q_norm,
3513                k_norm,
3514                output_gate,
3515                softplus_gate,
3516                bias,
3517            } => {
3518                let mut cfg = self.attn_cfg(position);
3519                cfg.q_norm = q_norm.as_deref();
3520                cfg.k_norm = k_norm.as_deref();
3521                cfg.output_gate = *output_gate;
3522                cfg.softplus_gate = softplus_gate.as_ref().map(|(g, p)| (g, *p));
3523                cfg.bias = bias
3524                    .as_ref()
3525                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
3526                attention::qwen_attention(&self.ws.n1, wq, wk, wv, wo, &mut m.kv, &cfg)
3527            }
3528            _ => return,
3529        };
3530        let _ = attn;
3531    }
3532
3533    /// Speculative decode ON the wgpu whole-token graph: draft k with the
3534    /// MTP head, verify all of them plus the tip in ONE batched graph
3535    /// submit whose tail folds the head, commit the accepted prefix and
3536    /// roll the GDN state back to the last real position. Greedy only —
3537    /// output equals the plain graph's token for token, the way the DSV4
3538    /// verify equals the walk.
3539    #[cfg(feature = "gpu")]
3540    #[allow(clippy::too_many_arguments)]
3541    fn graph_spec_step(
3542        &mut self,
3543        m: &mut MtpModule,
3544        hidden: &[f32],
3545        t_next: u32,
3546        next_pos: usize,
3547        drafted: &mut usize,
3548        accepted: &mut usize,
3549        // The committed stream (prompt + generated so far, `t_next`
3550        // included): the sampler chain's penalties read it, and the
3551        // sampling arm extends it with the drafts position by position.
3552        all_ids: &mut Vec<u32>,
3553    ) -> Option<(Vec<u32>, usize, Vec<f32>)> {
3554        // 3 is the measured optimum on Qwen3.6-27B / RTX 5090 (medians
3555        // of three, greedy): 51.1 tok/s against a plain 49.4, where k=2
3556        // gives 46.1, k=4 50.0, k=5 47.4, k=6 45.2. Acceptance is 89-91%
3557        // throughout — what turns the curve over is the verify, which
3558        // costs ~7.4 ms per extra position, and the draft ~3 ms a step.
3559        // 4 since the draft moved onto the graph (Qwen3.8-27B / 5090:
3560        // k=3 51.2, k=4 51.8 with the per-op draft; the graph draft
3561        // halves the draft cost, so the extra draft is cheaper still).
3562        // 5 with the int8 verify (the default: measured 76.5 against
3563        // k=4's 72-74 and k=6's 74 on the 5090), 4 with the f32 one.
3564        #[cfg(target_os = "macos")]
3565        let metal_native = crate::gpu::q1_force();
3566        #[cfg(not(target_os = "macos"))]
3567        let metal_native = false;
3568        #[cfg(feature = "gpu")]
3569        let k_default = if metal_native {
3570            // the Metal verify's GEMM tile is 8 rows wide and flat in b:
3571            // seven drafts + the tip fill it for free
3572            7
3573        } else if crate::gpu_wgpu::verify_i8_on() {
3574            5
3575        } else {
3576            4
3577        };
3578        #[cfg(not(feature = "gpu"))]
3579        let k_default = 4;
3580        let k_spec: usize = std::env::var("CMF_GRAPH_SPEC_K")
3581            .ok()
3582            .and_then(|v| v.parse().ok())
3583            .filter(|&v| (1..=8).contains(&v))
3584            .unwrap_or(k_default);
3585        if next_pos == 0 {
3586            return None;
3587        }
3588        let t_round = std::time::Instant::now();
3589        // Submissions per phase — and they say where the round's money is.
3590        // Qwen3.6-27B on an RTX 5090, k=3:
3591        //
3592        //   draft   9.3 ms / 12 submissions   (four per MTP step)
3593        //   verify 52.8 ms /  1               (the batched graph)
3594        //   commit  5.4 ms /  6               (two per warm)
3595        //
3596        // The verify is already one submit. The draft's own work is 834 MB
3597        // a step — 0.8 ms at this card's measured 1056 GB/s — against 3.1
3598        // ms measured, so ~0.58 ms of every step is round trip, not
3599        // arithmetic, and the same holds for the warms. Eighteen round
3600        // trips a round at roughly half a millisecond each is ~11 ms of a
3601        // 68 ms round: fusing the MTP block into ONE submit the way the
3602        // trunk already is projects to ~64 tok/s against today's 50.9.
3603        // That is the largest measured item left on this path.
3604        let subs = || crate::gpu_wgpu::SUBMITS.load(std::sync::atomic::Ordering::Relaxed);
3605        let sub0 = subs();
3606        // Greedy without penalties verifies by argmax equality (bit-exact
3607        // against the plain path). Anything else is speculative SAMPLING:
3608        // each draft is a DRAW from the MTP head's post-chain distribution
3609        // q_j, kept for the accept test; the verify's rows give p_j.
3610        let cfg = self.sampler_config.clone();
3611        let penalized = !(cfg.repetition_penalty == 1.0
3612            && cfg.presence_penalty == 0.0
3613            && cfg.suppress_tokens.is_empty());
3614        // Three verify regimes: plain greedy (argmax of the raw rows),
3615        // greedy WITH penalties (argmax of the penalized rows — a single
3616        // pass each, no distributions), and sampling (draw / accept /
3617        // correct on post-chain distributions).
3618        let greedy_pen = cfg.temperature < 1e-6 && penalized;
3619        let sampling = cfg.temperature >= 1e-6;
3620        // Sampling with a top-k goes through the SPARSE chain: the dense
3621        // one builds nine 248k-float distributions a round (four drafts,
3622        // five verify rows) and measured 19-22 tok/s against a plain 40 —
3623        // the host, not the card. Sparse, the same nine cost tens of
3624        // microseconds each.
3625        let sparse = sampling && sampler::sparse_ok(&cfg);
3626        let base_len = all_ids.len();
3627        if sampling && !sparse && self.spec_q.len() < k_spec {
3628            self.spec_q.resize_with(k_spec, Vec::new);
3629        }
3630        if sparse && self.spec_qs.len() < k_spec {
3631            self.spec_qs.resize_with(k_spec, Vec::new);
3632        }
3633        // Draft the chain: first from the trunk's tip hidden, then the head
3634        // iterating on itself. Rows land in the MTP KV; the chain rows past
3635        // the first are speculation over speculative state and roll back
3636        // below, replaced by verified pairs.
3637        let mut drafts = Vec::with_capacity(k_spec);
3638        let mut hx = hidden.to_vec();
3639        // CMF_SPEC_DBG=1: draft 0 through BOTH MTP arms (graph and per-op)
3640        // from the same inputs — are the arms the difference, or the inputs?
3641        let spec_dbg = std::env::var("CMF_SPEC_DBG").is_ok();
3642        for j in 0..k_spec {
3643            let tok_in = if j == 0 { t_next } else { drafts[j - 1] };
3644            let mut dbg_ref: Option<(Vec<f32>, Vec<f32>)> = None;
3645            if spec_dbg {
3646                let saved = self.mtp_graph_mode;
3647                self.mtp_graph_mode = Some(false);
3648                let r = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
3649                self.mtp_graph_mode = saved;
3650                m.kv.truncate_last(1);
3651                dbg_ref = Some(r);
3652            }
3653            let (mut lg, hj) = self.mtp_step_hl(m, &hx, tok_in, next_pos - 1 + j);
3654            if let Some((lg_cpu, h_cpu)) = dbg_ref {
3655                let n = |v: &[f32]| v.iter().map(|x| x * x).sum::<f32>().sqrt();
3656                let dl = lg.iter().zip(&lg_cpu).fold(0f32, |m, (a, b)| m.max((a - b).abs()));
3657                let dh = hj.iter().zip(&h_cpu).fold(0f32, |m, (a, b)| m.max((a - b).abs()));
3658                eprintln!(
3659                    "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 {}",
3660                    next_pos - 1 + j,
3661                    sampler::argmax(&lg_cpu),
3662                    sampler::argmax(&lg),
3663                    n(&h_cpu),
3664                    n(&hj),
3665                    m.kv.seq_len
3666                );
3667            }
3668            let dj = if sparse {
3669                let mut q = std::mem::take(&mut self.spec_qs[j]);
3670                let ok = sampler::sparse_distribution_into(
3671                    &lg,
3672                    &cfg,
3673                    all_ids,
3674                    &mut self.sampler_scratch,
3675                    self.pool.as_deref(),
3676                    &mut q,
3677                );
3678                let d = if ok {
3679                    sampler::draw_sparse(&q, &mut self.rng)
3680                } else {
3681                    // everything filtered: the dense chain's greedy fallback
3682                    let t = sampler::argmax(&lg);
3683                    q.clear();
3684                    q.push((t, 1.0));
3685                    t
3686                };
3687                self.spec_qs[j] = q;
3688                all_ids.push(d);
3689                d
3690            } else if sampling {
3691                let mut q = std::mem::take(&mut self.spec_q[j]);
3692                sampler::distribution_into(
3693                    &lg,
3694                    &cfg,
3695                    all_ids,
3696                    &mut self.sampler_scratch,
3697                    self.pool.as_deref(),
3698                    &mut q,
3699                );
3700                let d = sampler::draw(&q, &mut self.rng);
3701                self.spec_q[j] = q;
3702                all_ids.push(d); // the next draft's penalties see this one
3703                d
3704            } else if greedy_pen {
3705                let d = sampler::argmax_penalized(
3706                    &lg,
3707                    &cfg,
3708                    all_ids,
3709                    &mut self.sampler_scratch,
3710                    self.pool.as_deref(),
3711                );
3712                all_ids.push(d);
3713                d
3714            } else {
3715                sampler::argmax(&lg)
3716            };
3717            attention::recycle_buf(&mut lg);
3718            drafts.push(dj);
3719            hx = hj;
3720        }
3721        all_ids.truncate(base_len);
3722        *drafted += k_spec;
3723        let t_draft = t_round.elapsed();
3724        let sub_draft = subs();
3725        // Verify batch: [t_next, d1 .. d_{k-1}] at next_pos.. — every row's
3726        // logits come back from the graph's own head.
3727        let b = k_spec + 1;
3728        let mut hiddens = vec![0.0f32; b * self.hidden_size];
3729        for (i, &t) in std::iter::once(&t_next).chain(drafts.iter()).enumerate() {
3730            let e = self.embed_single(t);
3731            hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&e);
3732        }
3733        let positions: Vec<usize> = (next_pos..next_pos + b).collect();
3734        let (lm_gw, lm_rows) = {
3735            let (_, i, kind, rs) = self.weights.lm_head.graph_weight()?;
3736            (
3737                crate::gpu::GraphW {
3738                    idx: i,
3739                    kind,
3740                    row_scale: rs,
3741                    data: &[],
3742                },
3743                self.weights.lm_head.rows(),
3744            )
3745        };
3746        let mut logits = Vec::new();
3747        let final_norm = self.weights.final_norm.clone();
3748        #[cfg(target_os = "macos")]
3749        let ok = if metal_native {
3750            let lm = self.weights.lm_head.q1_parts()?;
3751            self.try_batch_graph_metal(&mut hiddens, &positions, b, Some((lm, &final_norm, &mut logits)))
3752        } else {
3753            self.try_batch_graph_wgpu(
3754                &mut hiddens,
3755                &positions,
3756                b,
3757                Some(crate::gpu::SpecTail {
3758                    lm: lm_gw,
3759                    lm_rows,
3760                    final_norm: &final_norm,
3761                    logits_out: &mut logits,
3762                }),
3763            )
3764        };
3765        #[cfg(not(target_os = "macos"))]
3766        let ok = self.try_batch_graph_wgpu(
3767            &mut hiddens,
3768            &positions,
3769            b,
3770            Some(crate::gpu::SpecTail {
3771                lm: lm_gw,
3772                lm_rows,
3773                final_norm: &final_norm,
3774                logits_out: &mut logits,
3775            }),
3776        );
3777        if !ok {
3778            // Roll the draft rows back out of the MTP cache and decline —
3779            // the caller runs the plain path, nothing has changed.
3780            m.kv.truncate_last(k_spec);
3781            return None;
3782        }
3783        // `CMF_METAL_VERIFY_CHECK=1`: run the same b tokens through the
3784        // plain per-token path and compare each row's argmax + logits with
3785        // the verify's — the bring-up oracle for the batched graph. The
3786        // plain forwards mutate the CPU state; it is snapshotted and put
3787        // back, and the K/V mirrors re-pointed, before the round goes on.
3788        #[cfg(target_os = "macos")]
3789        if metal_native && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("1") {
3790            let snap: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3791            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
3792            let toks: Vec<u32> = std::iter::once(t_next).chain(drafts.iter().copied()).collect();
3793            let want_save = self.graph_want_logits;
3794            self.graph_want_logits = false;
3795            for (i, &t) in toks.iter().enumerate() {
3796                let hi = self.forward_layers(&self.embed_single(t), next_pos + i, None);
3797                let _ = self.graph_logits.take();
3798                // CMF_SPEC_PLAIN_HIDDEN=1: the next round drafts from the
3799                // plain path's hidden instead of the verify's (an experiment
3800                // on the chain's sensitivity to the half-GEMM noise)
3801                if std::env::var("CMF_SPEC_PLAIN_HIDDEN").as_deref() == Ok("1") {
3802                    hiddens[i * self.hidden_size..(i + 1) * self.hidden_size].copy_from_slice(&hi);
3803                }
3804                let ref_lg = self.logits_from_hidden(&hi);
3805                let row = &logits[i * lm_rows..(i + 1) * lm_rows];
3806                let ra = sampler::argmax(&ref_lg);
3807                let va = sampler::argmax(row);
3808                let mut md = 0f32;
3809                let mut rms = 0f64;
3810                for j in 0..lm_rows.min(ref_lg.len()) {
3811                    let d = (ref_lg[j] - row[j]).abs();
3812                    md = md.max(d);
3813                    rms += (d as f64) * (d as f64);
3814                }
3815                let mut hd = 0f32;
3816                for j in 0..self.hidden_size {
3817                    hd = hd.max((hi[j] - hiddens[i * self.hidden_size + j]).abs());
3818                }
3819                eprintln!(
3820                    "verify-check row {i} tok {t} pos {}: ref argmax {ra} verify argmax {va} {} | max|dlogit| {md:.3} rms {:.4} | max|dhidden| {hd:.4}",
3821                    next_pos + i,
3822                    if ra == va { "OK" } else { "MISMATCH" },
3823                    (rms / lm_rows as f64).sqrt()
3824                );
3825            }
3826            self.graph_want_logits = want_save;
3827            // restore IN PLACE: the pending verify graph wraps these very
3828            // allocations (zero-copy) — replacing the Vec would strand it
3829            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
3830                if l.linear_state.len() == st.len() {
3831                    l.linear_state.copy_from_slice(&st);
3832                } else {
3833                    l.linear_state = st;
3834                }
3835            }
3836            for (li, (l, n0)) in self.kv_cache.layers.iter_mut().zip(attn_lens).enumerate() {
3837                let extra = l.seq_len.saturating_sub(n0);
3838                if extra > 0 {
3839                    l.truncate_last(extra);
3840                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, n0);
3841                }
3842            }
3843        }
3844        let t_verify = t_round.elapsed();
3845        let sub_verify = subs();
3846        // Acceptance. Greedy: row i's argmax is the trunk's token after
3847        // input i. Sampling: accept draft i with min(1, p_i/q_i), and on
3848        // the first rejection draw the correction from max(0, p_i − q_i)
3849        // — that token is committed by the loop top as-is (spec_forced).
3850        let mut a = 0usize;
3851        let mut forced: Option<u32> = None;
3852        let ids: Vec<u32> = if sparse {
3853            let mut p = std::mem::take(&mut self.spec_ps);
3854            let mut res = std::mem::take(&mut self.spec_ress);
3855            while a < k_spec {
3856                let ok = sampler::sparse_distribution_into(
3857                    &logits[a * lm_rows..(a + 1) * lm_rows],
3858                    &cfg,
3859                    all_ids,
3860                    &mut self.sampler_scratch,
3861                    self.pool.as_deref(),
3862                    &mut p,
3863                );
3864                if !ok {
3865                    let t = sampler::argmax(&logits[a * lm_rows..(a + 1) * lm_rows]);
3866                    p.clear();
3867                    p.push((t, 1.0));
3868                }
3869                match sampler::spec_accept_or_correct_sparse(
3870                    &p,
3871                    &self.spec_qs[a],
3872                    drafts[a],
3873                    &mut self.rng,
3874                    &mut res,
3875                ) {
3876                    None => {
3877                        all_ids.push(drafts[a]);
3878                        a += 1;
3879                    }
3880                    Some(c) => {
3881                        forced = Some(c);
3882                        break;
3883                    }
3884                }
3885            }
3886            all_ids.truncate(base_len);
3887            self.spec_ps = p;
3888            self.spec_ress = res;
3889            drafts.clone()
3890        } else if sampling {
3891            let mut p = std::mem::take(&mut self.spec_p);
3892            let mut res = std::mem::take(&mut self.spec_res);
3893            while a < k_spec {
3894                sampler::distribution_into(
3895                    &logits[a * lm_rows..(a + 1) * lm_rows],
3896                    &cfg,
3897                    all_ids,
3898                    &mut self.sampler_scratch,
3899                    self.pool.as_deref(),
3900                    &mut p,
3901                );
3902                match sampler::spec_accept_or_correct(
3903                    &p,
3904                    &self.spec_q[a],
3905                    drafts[a],
3906                    &mut self.rng,
3907                    &mut res,
3908                    self.pool.as_deref(),
3909                ) {
3910                    None => {
3911                        all_ids.push(drafts[a]);
3912                        a += 1;
3913                    }
3914                    Some(c) => {
3915                        forced = Some(c);
3916                        break;
3917                    }
3918                }
3919            }
3920            all_ids.truncate(base_len);
3921            self.spec_p = p;
3922            self.spec_res = res;
3923            // the accepted drafts ARE the verified tokens after inputs 0..a
3924            drafts.clone()
3925        } else if greedy_pen {
3926            // Row i's penalized argmax, penalties over the stream that
3927            // includes the accepted drafts before it — the plain loop's
3928            // exact arithmetic, one pass per row, no working copy.
3929            let mut ids: Vec<u32> = Vec::with_capacity(b);
3930            for i in 0..b {
3931                let t = sampler::argmax_penalized(
3932                    &logits[i * lm_rows..(i + 1) * lm_rows],
3933                    &cfg,
3934                    all_ids,
3935                    &mut self.sampler_scratch,
3936                    self.pool.as_deref(),
3937                );
3938                ids.push(t);
3939                if i < k_spec && t == drafts[i] {
3940                    all_ids.push(t);
3941                } else {
3942                    break;
3943                }
3944            }
3945            all_ids.truncate(base_len);
3946            while a < k_spec && a < ids.len() && ids[a] == drafts[a] {
3947                a += 1;
3948            }
3949            // rows past the first mismatch were never scored; the loop
3950            // top re-samples the last verified row itself.
3951            ids
3952        } else {
3953            let ids: Vec<u32> = (0..b)
3954                .map(|i| sampler::argmax(&logits[i * lm_rows..(i + 1) * lm_rows]))
3955                .collect();
3956            while a < k_spec && ids[a] == drafts[a] {
3957                a += 1;
3958            }
3959            ids
3960        };
3961        if spec_dbg {
3962            eprintln!("spec-dbg round: t_next {t_next} drafts {:?} verified {:?} accepted {a}", drafts, ids);
3963        }
3964        // CMF_METAL_VERIFY_CHECK=2: the commit oracle — plain-forward the
3965        // a+1 accepted tokens from a snapshot, then diff the replayed GDN
3966        // states and the appended K/V rows against that.
3967        #[cfg(target_os = "macos")]
3968        let commit_ref: Option<(Vec<Vec<f32>>, Vec<(usize, Vec<f32>, Vec<f32>)>)> = if metal_native
3969            && std::env::var("CMF_METAL_VERIFY_CHECK").as_deref() == Ok("2")
3970        {
3971            let snap: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3972            let attn_lens: Vec<usize> = self.kv_cache.layers.iter().map(|l| l.seq_len).collect();
3973            let toks: Vec<u32> = std::iter::once(t_next).chain(drafts.iter().copied()).collect();
3974            let want_save = self.graph_want_logits;
3975            self.graph_want_logits = false;
3976            for (i, &t) in toks.iter().take(a + 1).enumerate() {
3977                let _ = self.forward_layers(&self.embed_single(t), next_pos + i, None);
3978                let _ = self.graph_logits.take();
3979            }
3980            self.graph_want_logits = want_save;
3981            let plain_states: Vec<Vec<f32>> = self.kv_cache.layers.iter().map(|l| l.linear_state.clone()).collect();
3982            let (nkv, hd) = (self.num_kv_heads, self.head_dim);
3983            let mut rows = Vec::new();
3984            for (li, (l, n0)) in self.kv_cache.layers.iter_mut().zip(attn_lens.iter()).enumerate() {
3985                let extra = l.seq_len.saturating_sub(*n0);
3986                if extra > 0 {
3987                    let mut kk = Vec::new();
3988                    let mut vv = Vec::new();
3989                    for g in 0..nkv {
3990                        kk.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
3991                        vv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
3992                    }
3993                    rows.push((li, kk, vv));
3994                    l.truncate_last(extra);
3995                    crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, li, *n0);
3996                }
3997            }
3998            for (l, st) in self.kv_cache.layers.iter_mut().zip(snap) {
3999                if l.linear_state.len() == st.len() {
4000                    l.linear_state.copy_from_slice(&st);
4001                } else {
4002                    l.linear_state = st;
4003                }
4004            }
4005            Some((plain_states, rows))
4006        } else {
4007            None
4008        };
4009        // a fully-accepted round needs no restore: every input was real.
4010        #[cfg(target_os = "macos")]
4011        if metal_native {
4012            // the Metal verify never wrote its states: the commit replays the
4013            // accepted prefix into the CPU owners and appends the K/V rows
4014            self.metal_verify_commit(a);
4015            if let Some((plain_states, rows)) = commit_ref {
4016                crate::gpu_metal::queue_fence();
4017                let (nkv, hd) = (self.num_kv_heads, self.head_dim);
4018                let mut worst_s = 0f32;
4019                let mut worst_li = 0usize;
4020                for (li, (l, ps)) in self.kv_cache.layers.iter().zip(&plain_states).enumerate() {
4021                    if l.linear_state.len() != ps.len() || ps.is_empty() {
4022                        continue;
4023                    }
4024                    let d = l.linear_state.iter().zip(ps).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
4025                    let n = ps.iter().fold(0f32, |m, y| m.max(y.abs()));
4026                    let rel = d / n.max(1e-6);
4027                    if rel > worst_s {
4028                        worst_s = rel;
4029                        worst_li = li;
4030                    }
4031                }
4032                let mut worst_k = 0f32;
4033                for (li, kk, vv) in &rows {
4034                    let l = &self.kv_cache.layers[*li];
4035                    let n0 = l.seq_len - (kk.len() / (nkv * hd));
4036                    let mut ck = Vec::new();
4037                    let mut cv = Vec::new();
4038                    for g in 0..nkv {
4039                        ck.extend_from_slice(&l.head_keys(g)[n0 * hd..]);
4040                        cv.extend_from_slice(&l.head_values(g)[n0 * hd..]);
4041                    }
4042                    if ck.len() == kk.len() {
4043                        let dk = ck.iter().zip(kk).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
4044                        let dv = cv.iter().zip(vv).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
4045                        worst_k = worst_k.max(dk).max(dv);
4046                    } else {
4047                        eprintln!("commit-check L{li}: kv row count mismatch {} vs {}", ck.len(), kk.len());
4048                    }
4049                }
4050                eprintln!(
4051                    "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}"
4052                );
4053            }
4054        } else if a + 1 < b {
4055            crate::gpu::gdn_spec_restore(self.graph_kv_id, a);
4056        }
4057        #[cfg(not(target_os = "macos"))]
4058        if a + 1 < b {
4059            crate::gpu::gdn_spec_restore(self.graph_kv_id, a);
4060        }
4061        *accepted += a;
4062        // MTP cache: keep the first draft row (its inputs were real), drop
4063        // the chain's, then append the verified pairs the round produced.
4064        // Each of those is a whole MTP block on the per-op path and they
4065        // cost 5.8 ms of a 69 ms round at k=3 — a third of what the
4066        // round's own draft costs. PRICED, and they earn it: skipping
4067        // them (`CMF_SPEC_WARM=0`) drops acceptance from 89% to 81% at
4068        // k=3 and 85% to 74% at k=4, and the tok/s goes nowhere at k=3
4069        // (50.3 against 50.5) and backwards at k=4 (48.1 against 50.1).
4070        // The knob stays so the next person can re-price it after the
4071        // warms are batched instead of assuming either way.
4072        m.kv.truncate_last(k_spec.saturating_sub(1));
4073        #[cfg(target_os = "macos")]
4074        if metal_native && self.mtp_graph_mode == Some(true) {
4075            // the mirror rows below the cut are the CPU rows: re-point,
4076            // no re-upload
4077            crate::gpu_metal::kv_mirror_set_stored(self.mtp_kv_id(), Self::MTP_LAYER_BASE, m.kv.seq_len);
4078        }
4079        let warm_off = std::env::var("CMF_SPEC_WARM").is_ok_and(|v| v == "0");
4080        if !warm_off && a > 0 {
4081            // Graph arm: all accepted pairs in ONE batched run over the
4082            // MTP block; the token graph one by one if the batch declines.
4083            let mut warmed = false;
4084            #[cfg(target_os = "macos")]
4085            if metal_native && self.mtp_graph_mode == Some(true) {
4086                // all accepted pairs in ONE b-row graph run over the MTP
4087                // block (its input projection folded in); one by one on
4088                // the token graph if that declines
4089                let pairs: Vec<(&[f32], u32)> = (0..a)
4090                    .map(|j| (&hiddens[j * self.hidden_size..(j + 1) * self.hidden_size], ids[j]))
4091                    .collect();
4092                warmed = self.mtp_warm_batch_metal(m, &pairs, next_pos);
4093                if !warmed {
4094                    warmed = true;
4095                    for j in 0..a {
4096                        let row = hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec();
4097                        if self.mtp_step_metal(m, &row, ids[j], next_pos + j, false).is_none() {
4098                            warmed = false;
4099                            break;
4100                        }
4101                    }
4102                }
4103            }
4104            if !warmed && self.mtp_graph_mode == Some(true) && !metal_native {
4105                let rows: Vec<Vec<f32>> = (0..a)
4106                    .map(|j| hiddens[j * self.hidden_size..(j + 1) * self.hidden_size].to_vec())
4107                    .collect();
4108                let pairs: Vec<(&[f32], u32)> = rows
4109                    .iter()
4110                    .zip(ids.iter())
4111                    .map(|(r, &t)| (r.as_slice(), t))
4112                    .collect();
4113                warmed = self.mtp_warm_graph(m, &pairs, next_pos);
4114                if !warmed {
4115                    // Prefix-mode token graph per pair (kv_append inside).
4116                    warmed = true;
4117                    for j in 0..a {
4118                        if self
4119                            .mtp_step_graph(m, &rows[j], ids[j], next_pos + j)
4120                            .is_none()
4121                        {
4122                            warmed = false;
4123                            break;
4124                        }
4125                    }
4126                }
4127            }
4128            if !warmed {
4129                for j in 0..a {
4130                    let row = &hiddens[j * self.hidden_size..(j + 1) * self.hidden_size];
4131                    let row = row.to_vec();
4132                    self.mtp_warm(m, &row, ids[j], next_pos + j);
4133                }
4134            }
4135        }
4136        // The sampler's contract: logits of the LAST verified position —
4137        // unless a rejected draft already drew the correction, in which
4138        // case the loop top commits that token and samples nothing.
4139        if let Some(c) = forced {
4140            self.spec_forced = Some(c);
4141            self.graph_logits = None;
4142        } else {
4143            let mut row = logits[a * lm_rows..(a + 1) * lm_rows].to_vec();
4144            row.resize(self.vocab_size, 0.0);
4145            if let Some(c) = self.final_softcap {
4146                for l in row.iter_mut() {
4147                    *l = c * (*l / c).tanh();
4148                }
4149            }
4150            self.graph_logits = Some(row);
4151        }
4152        let new_hidden = hiddens[a * self.hidden_size..(a + 1) * self.hidden_size].to_vec();
4153        // Three phases, not two. The round's wall clock was 4 ms longer
4154        // than draft+verify and the difference had nowhere to be seen:
4155        // the accepted prefix re-runs the MTP block once per token to
4156        // keep the draft head's attention cache warm, and the GDN state
4157        // rolls back on any rejection. Both live here, after the verify.
4158        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
4159            let end = subs();
4160            eprintln!(
4161                "spec-round: draft {:.1} ms/{} sub | verify {:.1} ms/{} sub | \
4162                 commit {:.1} ms/{} sub (accepted {a} of {k_spec})",
4163                t_draft.as_secs_f64() * 1e3,
4164                sub_draft - sub0,
4165                (t_verify - t_draft).as_secs_f64() * 1e3,
4166                sub_verify - sub_draft,
4167                (t_round.elapsed() - t_verify).as_secs_f64() * 1e3,
4168                end - sub_verify,
4169            );
4170        }
4171        Some((drafts[..a].to_vec(), next_pos + a + 1, new_hidden))
4172    }
4173
4174    /// Micro-benchmark: two single-position forwards vs one fused pair
4175    /// from the current cache state (KV rewound after each probe).
4176    /// Returns (two_singles_ms, fused_pair_ms) per probe, or the (0, 0)
4177    /// sentinel when this model has no pair path to measure — the same
4178    /// answer the o1 arm gives, and the bench prints it the same way.
4179    /// (An architecture that loads its own layers leaves `weights.layers`
4180    /// empty; walking it here was an index panic, found by `bench` on
4181    /// deepseek_v4.)
4182    pub fn measure_pair_fusion(&mut self, iters: usize) -> (f64, f64) {
4183        if !self.pair_supported() {
4184            return (0.0, 0.0);
4185        }
4186        let emb1 = self.embed_single(1);
4187        let emb2 = self.embed_single(2);
4188        let pos = self.kv_cache.seq_len();
4189
4190        let t0 = std::time::Instant::now();
4191        for _ in 0..iters {
4192            let _ = self.forward_layers(&emb1, pos, None);
4193            let _ = self.forward_layers(&emb2, pos + 1, None);
4194            for l in &mut self.kv_cache.layers {
4195                l.truncate_last(2);
4196            }
4197        }
4198        let singles_ms = t0.elapsed().as_secs_f64() * 1000.0 / iters as f64;
4199
4200        let t1 = std::time::Instant::now();
4201        for _ in 0..iters {
4202            let _ = self.forward_pair(&emb1, &emb2, pos);
4203            for l in &mut self.kv_cache.layers {
4204                l.truncate_last(2);
4205            }
4206        }
4207        let pair_ms = t1.elapsed().as_secs_f64() * 1000.0 / iters as f64;
4208        (singles_ms, pair_ms)
4209    }
4210
4211    /// Fused two-position forward: weight rows are streamed from memory
4212    /// once per layer for both positions. Full layers → fused GQA pair;
4213    /// linear layers → vmf_phase pair (lane 2 state is tentative in the
4214    /// per-layer scratch until the draft is accepted).
4215    /// Whether the fused two-position path covers every layer kind in
4216    /// this model. MLA and KDA run per position (their pair arms are
4217    /// unreachable); the seq prefill falls back to singles for them.
4218    fn pair_supported(&self) -> bool {
4219        // An EMPTY layer stack means the architecture loaded its own and
4220        // this path has nothing to walk. Checking that directly, rather
4221        // than naming each such architecture, is what makes the guard hold
4222        // for the next one: `any()` over no layers is false, so a
4223        // feature-by-feature test says "supported" for a model that has no
4224        // layers here at all.
4225        !self.weights.layers.is_empty()
4226            && self.g3n.is_none()
4227            && !self
4228                .weights
4229                .layers
4230                .iter()
4231                .any(|lw| matches!(&lw.attn, AttnKind::Mla(_) | AttnKind::Kda(_)))
4232    }
4233
4234    fn forward_pair(
4235        &mut self,
4236        emb1: &[f32],
4237        emb2: &[f32],
4238        position: usize,
4239    ) -> (Vec<f32>, Vec<f32>) {
4240        let mut h1 = emb1.to_vec();
4241        let mut h2 = emb2.to_vec();
4242        let (_nkv, _hd, hs, _rd, eps) = (
4243            self.num_kv_heads,
4244            self.head_dim,
4245            self.hidden_size,
4246            self.rotary_dim,
4247            self.rms_eps,
4248        );
4249        let pool = self.pool.clone();
4250
4251        for li in 0..self.num_layers {
4252            let lw = &self.weights.layers[self.phys_layer(li)];
4253            // Norms into pipeline scratch (4 allocs/layer on the MTP
4254            // decode hot path before this).
4255            inference::rms_norm_into(
4256                &h1,
4257                &lw.input_norm,
4258                self.rms_eps,
4259                self.norm_style,
4260                &mut self.ws.n1,
4261            );
4262            inference::rms_norm_into(
4263                &h2,
4264                &lw.input_norm,
4265                self.rms_eps,
4266                self.norm_style,
4267                &mut self.ws.n2,
4268            );
4269
4270            let (a1, a2) = match &lw.attn {
4271                AttnKind::Mla(_) => unreachable!("MLA has no MTP/pair path"),
4272                AttnKind::Kda(_) => unreachable!("KDA has no MTP/pair path"),
4273                AttnKind::Linear(w) => {
4274                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
4275                    let layer = &mut self.kv_cache.layers[li];
4276                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4277                    vmf_phase_pair(
4278                        &self.ws.n1,
4279                        &self.ws.n2,
4280                        w,
4281                        &cfg,
4282                        state,
4283                        scratch,
4284                        self.pool.as_deref(),
4285                    )
4286                }
4287                AttnKind::LinearGdn(w) => {
4288                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
4289                    let layer = &mut self.kv_cache.layers[li];
4290                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4291                    gdn_pair(
4292                        &self.ws.n1,
4293                        &self.ws.n2,
4294                        w,
4295                        &cfg,
4296                        state,
4297                        scratch,
4298                        self.pool.as_deref(),
4299                    )
4300                }
4301                AttnKind::ShortConv(w) => {
4302                    let cfg = self
4303                        .short_conv_cfg
4304                        .expect("short-conv layer without short_conv_cfg");
4305                    let layer = &mut self.kv_cache.layers[li];
4306                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
4307                    short_conv_pair(
4308                        &self.ws.n1,
4309                        &self.ws.n2,
4310                        w,
4311                        &cfg,
4312                        state,
4313                        scratch,
4314                        self.pool.as_deref(),
4315                    )
4316                }
4317                AttnKind::Full {
4318                    wq,
4319                    wk,
4320                    wv,
4321                    wo,
4322                    q_norm,
4323                    k_norm,
4324                    output_gate,
4325                    softplus_gate,
4326                    bias,
4327                } => {
4328                    let inv_freq_l = self.layer_inv_freq(li);
4329                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
4330                    let cfg = QwenAttnCfg {
4331                        num_heads: self.layer_num_heads(li),
4332                        num_kv_heads: nkv_l,
4333                        head_dim: hd_l,
4334                        hidden_size: hs,
4335                        position,
4336                        inv_freq: &inv_freq_l,
4337                        rotary_dim: rd_l,
4338                        scale: self.attn_scale,
4339                        softcap: self.attn_softcap,
4340                        window: self.layer_window(li),
4341                        v_norm: self.attn_v_norm,
4342                        q_norm: q_norm.as_deref(),
4343                        k_norm: k_norm.as_deref(),
4344                        output_gate: *output_gate,
4345                        softplus_gate: softplus_gate
4346                            .as_ref()
4347                            .map(|(gate, per_head)| (gate, *per_head)),
4348                        rope_scale: self.layer_rope_scale(li),
4349                        bias: bias
4350                            .as_ref()
4351                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
4352                        rms_eps: eps,
4353                        norm_style: self.norm_style,
4354                        pool: pool.as_deref(),
4355                    };
4356                    attention::qwen_attention_pair(
4357                        &self.ws.n1,
4358                        &self.ws.n2,
4359                        wq,
4360                        wk,
4361                        wv,
4362                        wo,
4363                        &mut self.kv_cache.layers[li],
4364                        &cfg,
4365                    )
4366                }
4367            };
4368            let (a1, a2) = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
4369                Some(w) => (
4370                    inference::rms_norm(&a1, w, self.rms_eps, self.norm_style),
4371                    inference::rms_norm(&a2, w, self.rms_eps, self.norm_style),
4372                ),
4373                None => (a1, a2),
4374            };
4375            for i in 0..self.hidden_size {
4376                h1[i] += a1[i];
4377                h2[i] += a2[i];
4378            }
4379            let (mut a1, mut a2) = (a1, a2);
4380            attention::recycle_buf(&mut a1);
4381            attention::recycle_buf(&mut a2);
4382
4383            let lw = &self.weights.layers[self.phys_layer(li)];
4384            inference::rms_norm_into(
4385                &h1,
4386                &lw.post_norm,
4387                self.rms_eps,
4388                self.norm_style,
4389                &mut self.ws.p1,
4390            );
4391            inference::rms_norm_into(
4392                &h2,
4393                &lw.post_norm,
4394                self.rms_eps,
4395                self.norm_style,
4396                &mut self.ws.p2,
4397            );
4398            let (f1, f2) = match &lw.ffn {
4399                // Dual-branch layers need the raw residuals — run the
4400                // two positions through the same fn decode uses.
4401                FfnKind::DenseMoe(dm) => (
4402                    dense_moe_ffn(
4403                        dm,
4404                        &self.ws.p1,
4405                        &h1,
4406                        self.rms_eps,
4407                        self.norm_style,
4408                        self.pool.as_deref(),
4409                    ),
4410                    dense_moe_ffn(
4411                        dm,
4412                        &self.ws.p2,
4413                        &h2,
4414                        self.rms_eps,
4415                        self.norm_style,
4416                        self.pool.as_deref(),
4417                    ),
4418                ),
4419                _ => ffn_forward_pair(
4420                    &lw.ffn,
4421                    &self.ws.p1,
4422                    &self.ws.p2,
4423                    self.pool.as_deref(),
4424                    None,
4425                ),
4426            };
4427            let (f1, f2) = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
4428                Some(w) => (
4429                    inference::rms_norm(&f1, w, self.rms_eps, self.norm_style),
4430                    inference::rms_norm(&f2, w, self.rms_eps, self.norm_style),
4431                ),
4432                None => (f1, f2),
4433            };
4434            for i in 0..self.hidden_size {
4435                h1[i] += f1[i];
4436                h2[i] += f2[i];
4437            }
4438            let (mut f1, mut f2) = (f1, f2);
4439            attention::recycle_buf(&mut f1);
4440            attention::recycle_buf(&mut f2);
4441            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
4442                for i in 0..self.hidden_size {
4443                    h1[i] *= sc;
4444                    h2[i] *= sc;
4445                }
4446            }
4447            // Looped Transformer: apply final norm at the end of each loop iteration.
4448            if self.is_loop_end(li) && li + 1 < self.num_layers {
4449                h1 = inference::rms_norm(
4450                    &h1,
4451                    &self.weights.final_norm,
4452                    self.rms_eps,
4453                    self.norm_style,
4454                );
4455                h2 = inference::rms_norm(
4456                    &h2,
4457                    &self.weights.final_norm,
4458                    self.rms_eps,
4459                    self.norm_style,
4460                );
4461            }
4462        }
4463        (h1, h2)
4464    }
4465
4466    /// Commit lane-2 linear states after an accepted draft.
4467    fn commit_linear_scratch(&mut self) {
4468        for layer in &mut self.kv_cache.layers {
4469            if !layer.linear_scratch.is_empty() {
4470                std::mem::swap(&mut layer.linear_state, &mut layer.linear_scratch);
4471                layer.linear_scratch.clear();
4472            }
4473        }
4474    }
4475
4476    /// Forward a full id sequence from a fresh cache and return the
4477    /// logits after the last position (golden-parity harness, bench).
4478    pub fn forward_ids(
4479        &mut self,
4480        ids: &[u32],
4481        task_mask: Option<&TaskMask>,
4482    ) -> Result<Vec<f32>, String> {
4483        if ids.is_empty() {
4484            return Err("empty id sequence".to_string());
4485        }
4486        self.kv_cache.clear();
4487        self.kv_history.clear();
4488        self.o1_begin();
4489        let mut hidden = vec![0.0f32; self.hidden_size];
4490        let mut pos = 0usize;
4491        // Same routing predicate generation uses. Two reasons it must be
4492        // the same one: (1) a GDN hybrid's recurrent state is GPU-
4493        // resident, and a batched CPU prefill would build it on the host
4494        // only — decode then reads buffers the prefill never wrote;
4495        // (2) bench times THIS function and calls the result "prefill",
4496        // so a different path here reports a number production never
4497        // sees (W2 on 2×5090: 8.7 tok/s reported against 125 real).
4498        if self.can_prefill_batched() && !self.graph_prefill_preferred() && ids.len() > 2 {
4499            // prefill-GEMM in chunks; only the last position's hidden is
4500            // needed. (o1-compatible: the batch path attends per position
4501            // through qwen_attention, which carries the collection hook.)
4502            let chunk = prefill_chunk();
4503            let hs = self.hidden_size;
4504            while pos < ids.len() {
4505                let end = (pos + chunk).min(ids.len());
4506                let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
4507                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
4508                pos = end;
4509            }
4510        }
4511        // Same guards as generation's prefill — INCLUDING the graph one.
4512        // The CPU pair walk was intercepting positions that the resident
4513        // token graph would have run itself: on a GDN hybrid over wgpu
4514        // that is 89 ms of host forward against 7 ms of device submit,
4515        // and it made prefill look 12× slower than it is (W2 on an RTX
4516        // 5090, ctx 512: 11.2 tok/s with the walk, 136.6 without).
4517        // CMF_PAIR=0 opts out; a model whose layers live outside
4518        // `weights.layers` has no pair walk to take.
4519        if task_mask.is_none()
4520            && !self.graph_prefill_preferred()
4521            && !std::env::var("CMF_PAIR").is_ok_and(|v| v == "0")
4522            && self.pair_supported()
4523        {
4524            while pos + 1 < ids.len() {
4525                let e1 = self.embed_single(ids[pos]);
4526                let e2 = self.embed_single(ids[pos + 1]);
4527                let (_, h2) = self.forward_pair(&e1, &e2, pos);
4528                self.commit_linear_scratch();
4529                hidden = h2;
4530                pos += 2;
4531            }
4532        }
4533        while pos < ids.len() {
4534            hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
4535            pos += 1;
4536        }
4537        // Harness contract: after forward_ids the cache is decode-ready —
4538        // under o1 that means sealed (bench measures the seal as part of
4539        // prefill, honestly).
4540        self.o1_seal();
4541        let normed = inference::rms_norm(
4542            &hidden,
4543            &self.weights.final_norm,
4544            self.rms_eps,
4545            self.norm_style,
4546        );
4547        Ok(self.lm_head_forward(&normed))
4548    }
4549
4550    /// Teacher-forced perplexity over a token sequence (phase-C gate:
4551    /// honest quant comparisons instead of prompt vibes).
4552    ///
4553    /// Attention is EXACT even on a model whose layers are flagged for
4554    /// the O(1) kernel — scoring the backbone is the default on purpose
4555    /// (it is the yardstick). `nll_ids_o1` scores the CONVERTED model.
4556    pub fn ppl_ids(&mut self, ids: &[u32]) -> f64 {
4557        let (nll, cnt) = self.nll_ids_from(ids, 0);
4558        (nll / cnt.max(1) as f64).exp()
4559    }
4560
4561    /// DTG-MA calibration pass (Patent 2): run `ids` through the model
4562    /// (CPU path, per position) and return each layer's per-neuron
4563    /// activation mass Σ|silu(gate)·up| — the statistic the task-guided
4564    /// FFN mask is derived from.
4565    pub fn probe_ffn_mass(&mut self, ids: &[u32]) -> Vec<Vec<f64>> {
4566        self.kv_cache.clear();
4567        self.kv_history.clear();
4568        FFN_PROBE.with(|p| {
4569            *p.borrow_mut() = Some(vec![vec![0f64; self.intermediate_size]; self.num_layers]);
4570        });
4571        crate::gpu::cpu_scope(|| {
4572            for (pos, &id) in ids.iter().enumerate() {
4573                let emb = self.embed_single(id);
4574                let _ = self.forward_layers(&emb, pos, None);
4575            }
4576        });
4577        self.kv_cache.clear();
4578        self.kv_history.clear();
4579        FFN_PROBE
4580            .with(|p| p.borrow_mut().take())
4581            .unwrap_or_default()
4582    }
4583
4584    /// Teacher-forced PPL with a task mask active (sparse execution) —
4585    /// the quality gate for a DTG-MA-masked skill. Sequential per
4586    /// position: the batched prefill path is dense-only.
4587    pub fn ppl_ids_masked(&mut self, ids: &[u32], mask: &TaskMask) -> f64 {
4588        self.kv_cache.clear();
4589        self.kv_history.clear();
4590        let mut nll = 0f64;
4591        let mut cnt = 0usize;
4592        let mut hidden = vec![0f32; self.hidden_size];
4593        for (pos, &id) in ids.iter().enumerate() {
4594            if pos > 0 {
4595                inference::rms_norm_into(
4596                    &hidden,
4597                    &self.weights.final_norm,
4598                    self.rms_eps,
4599                    self.norm_style,
4600                    &mut self.ws.n1,
4601                );
4602                let mut logits = self.lm_head_forward(&self.ws.n1);
4603                let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
4604                let sum: f64 = logits.iter().map(|&v| ((v - max) as f64).exp()).sum();
4605                let p = ((logits[id as usize] - max) as f64).exp() / sum.max(1e-300);
4606                nll -= p.max(1e-300).ln();
4607                cnt += 1;
4608                attention::recycle_buf(&mut logits);
4609            }
4610            let emb = self.embed_single(id);
4611            hidden = self.forward_layers(&emb, pos, Some(mask));
4612        }
4613        self.kv_cache.clear();
4614        self.kv_history.clear();
4615        (nll / cnt.max(1) as f64).exp()
4616    }
4617
4618    /// Teacher-forced NLL sum + scored-token count over positions
4619    /// `start..len-1`, attention EXACT. Positions below `start` still
4620    /// run — they are the context — they are just not scored, so this
4621    /// pairs with `nll_ids_o1(ids, start)` over the very same tokens.
4622    ///
4623    /// Returning (nll, cnt) rather than a ppl is what lets a windowed
4624    /// caller combine windows before the exp, so every scored token
4625    /// weighs the same regardless of how the windows are cut.
4626    /// `nll_ids_from` with a task mask held active at every position.
4627    ///
4628    /// The batched prefill path does not thread masks, so this walks the
4629    /// per-position forward — slower, but it scores the file exactly the
4630    /// way `run --task` will serve it, which is the point of the gate
4631    /// that calls it. With `None` it defers to the fast path.
4632    /// Masked scoring rides the SAME batched sweep as unmasked scoring —
4633    /// the masked-inference fast path: `prefill_batch_masked` lands the
4634    /// per-visit FFN rows on the activations inside the fused arms. The
4635    /// per-position loop below remains only as the no-batch fallback.
4636    pub fn nll_ids_masked(
4637        &mut self,
4638        ids: &[u32],
4639        start: usize,
4640        task_mask: Option<&TaskMask>,
4641    ) -> (f64, usize) {
4642        self.nll_ids_inner(ids, start, task_mask)
4643    }
4644
4645    pub fn nll_ids_from(&mut self, ids: &[u32], start: usize) -> (f64, usize) {
4646        self.nll_ids_inner(ids, start, None)
4647    }
4648
4649    fn nll_ids_inner(
4650        &mut self,
4651        ids: &[u32],
4652        start: usize,
4653        task_mask: Option<&TaskMask>,
4654    ) -> (f64, usize) {
4655        self.kv_cache.clear();
4656        self.kv_history.clear();
4657        let mut nll = 0f64;
4658        let mut cnt = 0usize;
4659        if self.can_prefill_batched() {
4660            // prefill-GEMM: layer-major position chunks, lm_head batched
4661            // (254MB lm_head read once per chunk, not per position).
4662            // The layer chunk is large (grouping positions by MoE experts
4663            // wins with size), lm_head in sub-blocks (logit buffer
4664            // 32×vocab ≈ 32MB instead of 128×).
4665            const CHUNK: usize = 128;
4666            const LM_SUB: usize = 32;
4667            let n = ids.len().saturating_sub(1);
4668            let hs = self.hidden_size;
4669            let rows = self.weights.lm_head.rows();
4670            let mut pos = 0usize;
4671            while pos < n {
4672                let end = (pos + CHUNK).min(n);
4673                let bsz = end - pos;
4674                let hb = self.prefill_batch_masked(&ids[pos..end], pos, task_mask);
4675                let mut k0 = 0usize;
4676                while k0 < bsz {
4677                    let k1 = (k0 + LM_SUB).min(bsz);
4678                    let sb = k1 - k0;
4679                    // Sub-block entirely below the scored range: the KV
4680                    // it just built is all this pass needed from it.
4681                    if pos + k1 <= start {
4682                        k0 = k1;
4683                        continue;
4684                    }
4685                    let mut normed = vec![0.0f32; sb * hs];
4686                    for k in 0..sb {
4687                        let r = inference::rms_norm(
4688                            &hb[(k0 + k) * hs..(k0 + k + 1) * hs],
4689                            &self.weights.final_norm,
4690                            self.rms_eps,
4691                            self.norm_style,
4692                        );
4693                        normed[k * hs..(k + 1) * hs].copy_from_slice(&r);
4694                    }
4695                    let mut logits = vec![0.0f32; sb * rows];
4696                    self.weights
4697                        .lm_head
4698                        .matmat(&normed, sb, &mut logits, self.pool.as_deref());
4699                    for k in 0..sb {
4700                        if pos + k0 + k < start {
4701                            continue;
4702                        }
4703                        let lg = &mut logits[k * rows..k * rows + self.vocab_size.min(rows)];
4704                        if let Some(mu) = self.logit_multiplier {
4705                            for v in lg.iter_mut() {
4706                                *v *= mu;
4707                            }
4708                        }
4709                        // Gemma-class final-logit soft-capping: the
4710                        // decode paths apply it; scoring must too, or
4711                        // the uncapped softmax misprices every token.
4712                        if let Some(c) = self.final_softcap {
4713                            for v in lg.iter_mut() {
4714                                *v = c * (*v / c).tanh();
4715                            }
4716                        }
4717                        // Cortiq Embryo hierarchical head: same correction
4718                        // the decode path applies (lm_head_forward).
4719                        if let Some(cm) = self.head_clusters.clone() {
4720                            self.hierarchical_head_logprobs(&normed[k * hs..(k + 1) * hs], &cm, lg);
4721                        }
4722                        let lg = &logits[k * rows..k * rows + self.vocab_size.min(rows)];
4723                        let target = ids[pos + k0 + k + 1] as usize;
4724                        let max = lg.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4725                        let lse: f64 = lg
4726                            .iter()
4727                            .map(|&v| ((v - max) as f64).exp())
4728                            .sum::<f64>()
4729                            .ln()
4730                            + max as f64;
4731                        nll += lse - lg[target] as f64;
4732                        cnt += 1;
4733                        if std::env::var("CMF_PPL_TRACE").is_ok() {
4734                            let top = lg
4735                                .iter()
4736                                .enumerate()
4737                                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4738                                .map(|(i, _)| i)
4739                                .unwrap_or(0);
4740                            eprintln!(
4741                                "BTRACE pos {} target {} nll {:.4} top {} lg_t {:.3} lg_top {:.3}",
4742                                pos + k0 + k,
4743                                target,
4744                                lse - lg[target] as f64,
4745                                top,
4746                                lg[target],
4747                                lg[top]
4748                            );
4749                        }
4750                    }
4751                    k0 = k1;
4752                }
4753                pos = end;
4754            }
4755            self.kv_cache.clear();
4756            self.kv_history.clear();
4757            return (nll, cnt);
4758        }
4759        for pos in 0..ids.len().saturating_sub(1) {
4760            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
4761            // Architectures whose head lives inside their own stack return
4762            // the logits out of band and a zero hidden — DeepSeek-V4 folds
4763            // its hyper-connection copies between the last layer and the
4764            // norm, so it cannot hand back a vector this loop could use.
4765            // Scoring the zeros gave a perplexity of exactly the vocabulary
4766            // size, which is a uniform distribution reported as a
4767            // measurement. `generate` already reads this channel.
4768            let out_of_band = self.graph_logits.take();
4769            if pos < start {
4770                continue;
4771            }
4772            let logits = match out_of_band {
4773                Some(lg) => lg,
4774                None => {
4775                    let normed = inference::rms_norm(
4776                        &hidden,
4777                        &self.weights.final_norm,
4778                        self.rms_eps,
4779                        self.norm_style,
4780                    );
4781                    // lm_head_forward applies the final-logit softcap itself
4782                    // — capping again here double-squashed gemma-class
4783                    // logits (tanh∘tanh) and reported a flattered ppl.
4784                    self.lm_head_forward(&normed)
4785                }
4786            };
4787            let target = ids[pos + 1] as usize;
4788            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4789            let lse: f64 = logits
4790                .iter()
4791                .map(|&v| ((v - max) as f64).exp())
4792                .sum::<f64>()
4793                .ln()
4794                + max as f64;
4795            let tok_nll = lse - logits[target] as f64;
4796            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4797                let top = logits
4798                    .iter()
4799                    .enumerate()
4800                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4801                    .map(|(i, _)| i)
4802                    .unwrap_or(0);
4803                eprintln!(
4804                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4805                    logits[target], logits[top]
4806                );
4807            }
4808            nll += tok_nll;
4809            cnt += 1;
4810        }
4811        self.kv_cache.clear();
4812        self.kv_history.clear();
4813        (nll, cnt)
4814    }
4815
4816    /// Teacher-forced NLL of the CONVERTED model: the O(1) Nyström path
4817    /// is ACTIVE over the scored positions. Returns (nll sum, scored
4818    /// count) over `prefill..len-1`.
4819    ///
4820    /// Runtime discipline, deliberately NOT the matrix probe's: the
4821    /// first `prefill` tokens run the exact prompt pass — that pass is
4822    /// what freezes the landmarks and M — and every scored position then
4823    /// goes through `NystromState::step()`, the same code decode runs.
4824    /// So the landmarks are PREFILL-frozen (what ships), not
4825    /// full-sequence oracles (what the published probe measured), and
4826    /// every scored row carries a real far field rather than sitting
4827    /// inside the exact window.
4828    ///
4829    /// Pair with `nll_ids_from(ids, prefill)` for the exact baseline
4830    /// over the identical token set — that ratio is the honest one.
4831    pub fn nll_ids_o1(&mut self, ids: &[u32], prefill: usize) -> (f64, usize) {
4832        self.kv_cache.clear();
4833        self.kv_history.clear();
4834        self.o1_begin();
4835        let n = ids.len().saturating_sub(1);
4836        let p = prefill.min(n);
4837        // Exact prompt pass over ids[..p]: the seal consumes its q/k/v.
4838        let mut pos = 0usize;
4839        if self.can_prefill_batched() {
4840            const CHUNK: usize = 128;
4841            while pos < p {
4842                let end = (pos + CHUNK).min(p);
4843                let _ = self.prefill_batch(&ids[pos..end], pos);
4844                pos = end;
4845            }
4846        } else {
4847            while pos < p {
4848                let _ = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4849                pos += 1;
4850            }
4851        }
4852        self.o1_seal();
4853
4854        let mut nll = 0f64;
4855        let mut cnt = 0usize;
4856        for pos in p..n {
4857            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4858            let normed = inference::rms_norm(
4859                &hidden,
4860                &self.weights.final_norm,
4861                self.rms_eps,
4862                self.norm_style,
4863            );
4864            // lm_head_forward applies the final-logit softcap itself —
4865            // capping again here double-squashed gemma-class logits
4866            // (tanh∘tanh) and reported a flattered ppl.
4867            let logits = self.lm_head_forward(&normed);
4868            let target = ids[pos + 1] as usize;
4869            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4870            let lse: f64 = logits
4871                .iter()
4872                .map(|&v| ((v - max) as f64).exp())
4873                .sum::<f64>()
4874                .ln()
4875                + max as f64;
4876            let tok_nll = lse - logits[target] as f64;
4877            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4878                let top = logits
4879                    .iter()
4880                    .enumerate()
4881                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4882                    .map(|(i, _)| i)
4883                    .unwrap_or(0);
4884                eprintln!(
4885                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4886                    logits[target], logits[top]
4887                );
4888            }
4889            nll += tok_nll;
4890            cnt += 1;
4891        }
4892        self.kv_cache.clear();
4893        self.kv_history.clear();
4894        (nll, cnt)
4895    }
4896
4897    /// Teacher-forced calibration data (B1): for each position, whether the
4898    /// argmax equals the actual next token, and the top-1 softmax prob
4899    /// (Born mass) under EACH temperature in `temps` — all from ONE forward
4900    /// pass (argmax/correctness are temperature-invariant; only p_max
4901    /// reshapes). Feeds `cortiq calibrate` (reliability/ECE + temperature
4902    /// fit): is the model's confidence a true property, or does it need a
4903    /// measured scaling?
4904    pub fn calib_ids(&mut self, ids: &[u32], temps: &[f32]) -> (Vec<bool>, Vec<Vec<f32>>) {
4905        self.kv_cache.clear();
4906        self.kv_history.clear();
4907        let n = ids.len().saturating_sub(1);
4908        let mut correct = Vec::with_capacity(n);
4909        let mut pmax = Vec::with_capacity(n);
4910        for pos in 0..n {
4911            let emb = self.embed_single(ids[pos]);
4912            let hidden = self.forward_layers(&emb, pos, None);
4913            let normed = inference::rms_norm(
4914                &hidden,
4915                &self.weights.final_norm,
4916                self.rms_eps,
4917                self.norm_style,
4918            );
4919            // lm_head_forward applies the final-logit softcap itself —
4920            // capping again here double-squashed gemma-class logits
4921            // (tanh∘tanh) and reported a flattered ppl.
4922            let logits = self.lm_head_forward(&normed);
4923            let target = ids[pos + 1] as usize;
4924            let (mut amax, mut mval) = (0usize, f32::NEG_INFINITY);
4925            for (i, &v) in logits.iter().enumerate() {
4926                if v > mval {
4927                    mval = v;
4928                    amax = i;
4929                }
4930            }
4931            correct.push(amax == target);
4932            let row: Vec<f32> = temps
4933                .iter()
4934                .map(|&t| {
4935                    let tt = t.max(1e-3);
4936                    let s: f32 = logits.iter().map(|&v| ((v - mval) / tt).exp()).sum();
4937                    1.0 / s.max(1e-12) // numerator at the max is exp(0)=1
4938                })
4939                .collect();
4940            pmax.push(row);
4941        }
4942        self.kv_cache.clear();
4943        self.kv_history.clear();
4944        (correct, pmax)
4945    }
4946
4947    /// Teacher-forced PPL with the dynamic router driving per-window
4948    /// skill switches (VMF experiment №2 measurement). Sequential (φ
4949    /// must update per token), returns (ppl, switch_count). The router
4950    /// must be enabled (`enable_dynamic_routing`); else this equals
4951    /// plain `ppl_ids`. The active skill when scoring token t shapes the
4952    /// logits for t+1 — on-policy over the held-out text itself.
4953    pub fn ppl_ids_dynamic(&mut self, ids: &[u32]) -> (f64, usize) {
4954        let mut router = match self.dyn_router.take() {
4955            Some(r) => r,
4956            None => return (self.ppl_ids(ids), 0),
4957        };
4958        router.reset();
4959        self.dyn_phi_seen = 0;
4960        let _ = self.set_active_skill(None);
4961
4962        self.kv_cache.clear();
4963
4964        self.kv_history.clear();
4965        let mut nll = 0f64;
4966        let mut cnt = 0usize;
4967        for pos in 0..ids.len().saturating_sub(1) {
4968            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
4969            let normed = inference::rms_norm(
4970                &hidden,
4971                &self.weights.final_norm,
4972                self.rms_eps,
4973                self.norm_style,
4974            );
4975            // lm_head_forward applies the final-logit softcap itself —
4976            // capping again here double-squashed gemma-class logits
4977            // (tanh∘tanh) and reported a flattered ppl.
4978            let logits = self.lm_head_forward(&normed);
4979            let target = ids[pos + 1] as usize;
4980            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
4981            let lse: f64 = logits
4982                .iter()
4983                .map(|&v| ((v - max) as f64).exp())
4984                .sum::<f64>()
4985                .ln()
4986                + max as f64;
4987            let tok_nll = lse - logits[target] as f64;
4988            if std::env::var("CMF_PPL_TRACE").is_ok() && pos < 48 {
4989                let top = logits
4990                    .iter()
4991                    .enumerate()
4992                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
4993                    .map(|(i, _)| i)
4994                    .unwrap_or(0);
4995                eprintln!(
4996                    "pos {pos:3} tgt {target:6} nll {tok_nll:7.3} | top1 {top:6} lg[t]={:.2} lg[top]={:.2}",
4997                    logits[target], logits[top]
4998                );
4999            }
5000            nll += tok_nll;
5001            cnt += 1;
5002            // Route on the evolving φ (drives the NEXT token's skill).
5003            let phi = self.dyn_phi_ema.clone();
5004            if let Some(new_active) = router.step(&phi, pos) {
5005                let _ = self.set_active_skill(new_active);
5006            }
5007        }
5008        let switches = router.switches.len();
5009        let _ = self.set_active_skill(None);
5010        self.dyn_router = Some(router);
5011        self.kv_cache.clear();
5012        self.kv_history.clear();
5013        ((nll / cnt.max(1) as f64).exp(), switches)
5014    }
5015
5016    /// Routing probe φ (spec §9): mean-pooled hidden after `layer`.
5017    pub fn probe_phi(&mut self, ids: &[u32], layer: usize) -> Vec<f32> {
5018        self.kv_cache.clear();
5019        self.kv_history.clear();
5020        let mut acc = vec![0f32; self.hidden_size];
5021        for (pos, &id) in ids.iter().enumerate() {
5022            let h = self.forward_layers_upto(&self.embed_single(id), pos, None, Some(layer));
5023            for (a, v) in acc.iter_mut().zip(&h) {
5024                *a += v;
5025            }
5026        }
5027        let n = ids.len().max(1) as f32;
5028        for a in acc.iter_mut() {
5029            *a /= n;
5030        }
5031        self.kv_cache.clear();
5032        self.kv_history.clear();
5033        acc
5034    }
5035
5036    /// Layer-major batched prefill (prefill-GEMM): full-attention —
5037    /// per-position with the existing operators (KV grows naturally,
5038    /// causality preserved), GDN projections / FFN / MoE — batched
5039    /// (a weight row is read from DRAM once per chunk, not per
5040    /// position). Returns the hidden of all positions [b × hidden].
5041    fn prefill_batch(&mut self, ids: &[u32], start_pos: usize) -> Vec<f32> {
5042        self.prefill_batch_masked(ids, start_pos, None)
5043    }
5044
5045    /// `prefill_batch` with a task mask honored on the dense-FFN panels
5046    /// (the masked-inference fast path: full fused compute, mask lands on
5047    /// the activations). The whole-chunk GPU graph is skipped for masked
5048    /// layers by the callers' arms; the per-GEMM device paths stay in
5049    /// play because the zeroing happens on the host between them.
5050    fn prefill_batch_masked(
5051        &mut self,
5052        ids: &[u32],
5053        start_pos: usize,
5054        task_mask: Option<&TaskMask>,
5055    ) -> Vec<f32> {
5056        self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, usize::MAX)
5057    }
5058
5059    /// The layer-major batched walk over a layer span [from..upto_excl):
5060    /// the whole prefill machinery (chunk graph, batched attends, GEMM
5061    /// panels) for a PARTIAL stack — the network split's prefill rides
5062    /// the same canon as the local one. Input is token ids (embeds
5063    /// itself, coordinator side) or ready boundary hiddens (worker side).
5064    fn prefill_batch_span(
5065        &mut self,
5066        input: PrefillIn<'_>,
5067        start_pos: usize,
5068        task_mask: Option<&TaskMask>,
5069        from: usize,
5070        upto_excl: usize,
5071    ) -> Vec<f32> {
5072        let hs = self.hidden_size;
5073        let b = match input {
5074            PrefillIn::Ids(ids) => ids.len(),
5075            PrefillIn::Hidden(hb) => hb.len() / hs,
5076        };
5077        let upto_excl = upto_excl.min(self.num_layers);
5078        // The CPU embed is deferred: when the chunk graph takes the run
5079        // from layer 0 it gathers the embeddings on the device instead.
5080        // A hidden input is ready by definition.
5081        let mut h: Vec<f32>;
5082        let mut h_ready;
5083        match input {
5084            PrefillIn::Ids(_) => {
5085                h = vec![0.0; b * hs];
5086                h_ready = false;
5087            }
5088            PrefillIn::Hidden(hb) => {
5089                h = hb.to_vec();
5090                h_ready = true;
5091            }
5092        }
5093        let fill_h = |h: &mut Vec<f32>, me: &Self| {
5094            if let PrefillIn::Ids(ids) = input {
5095                for (bi, &id) in ids.iter().enumerate() {
5096                    let e = me.embed_single(id);
5097                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&e);
5098                }
5099                if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
5100                    if let Some(t) = tp.parse::<usize>().ok() {
5101                        if t >= start_pos && t < start_pos + ids.len() {
5102                            let bi = t - start_pos;
5103                            let row = &h[bi * hs..(bi + 1) * hs];
5104                            let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
5105                            eprintln!(
5106                                "BATCH pos {t} embed: id {} |h| = {n:.6} h0 {:.6} h1 {:.6} | b={} start={start_pos} ids[..8]={:?}",
5107                                ids[bi], row[0], row[1], ids.len(), &ids[..ids.len().min(8)]
5108                            );
5109                        }
5110                    }
5111                }
5112            }
5113        };
5114        let (_nkv, _hd, _rd, eps) = (
5115            self.num_kv_heads,
5116            self.head_dim,
5117            self.rotary_dim,
5118            self.rms_eps,
5119        );
5120        let pool = self.pool.clone();
5121        let norm_style = self.norm_style;
5122
5123        #[cfg(target_os = "macos")]
5124        let mut chunk_skip_until = 0usize;
5125        for li in from..upto_excl {
5126            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU
5127            // GPU chunk graph (default-on under CMF_GPU=1): a run of
5128            // consecutive eligible layers for the whole chunk in ONE
5129            // Metal submission — norm, QKV, RoPE with fused mirror
5130            // append, causal attend, O, FFN, hidden device-resident
5131            // across the run. Any refusal falls through to the CPU path.
5132            #[cfg(target_os = "macos")]
5133            if task_mask.is_none() {
5134                if li < chunk_skip_until {
5135                    continue;
5136                }
5137                // Device-side embedding needs a q8_row embedding matrix;
5138                // with any other layout the CPU fills `h` first and the
5139                // graph starts from a ready hidden (refusing the whole
5140                // run over the embedding alone kept q4t models — the
5141                // whole Nanbeige/Bonsai class — on the CPU prefill).
5142                if !h_ready && li == 0 && self.weights.embed_tokens.q8_row_parts().is_none() {
5143                    fill_h(&mut h, self);
5144                    h_ready = true;
5145                }
5146                let ids_for_embed = match input {
5147                    PrefillIn::Ids(ids) => (!h_ready && li == 0).then_some(ids),
5148                    PrefillIn::Hidden(_) => None,
5149                };
5150                let end = self.chunk_run_gpu(li, &mut h, b, start_pos, ids_for_embed, upto_excl);
5151                if end > li {
5152                    h_ready = true;
5153                    chunk_skip_until = end;
5154                    // Looped Transformer: the graph stopped at a loop
5155                    // boundary — apply final norm before the next iteration.
5156                    if self.is_loop_end(end - 1) && end < self.num_layers {
5157                        for bi in 0..b {
5158                            let normed = inference::rms_norm(
5159                                &h[bi * hs..(bi + 1) * hs],
5160                                &self.weights.final_norm,
5161                                eps,
5162                                norm_style,
5163                            );
5164                            h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
5165                        }
5166                    }
5167                    continue;
5168                }
5169            }
5170            if !h_ready {
5171                fill_h(&mut h, self);
5172                h_ready = true;
5173            }
5174            let lw = &self.weights.layers[self.phys_layer(li)];
5175            // ── attention ──
5176            match &lw.attn {
5177                AttnKind::Kda(w) => {
5178                    // Projections batched, recurrence sequential.
5179                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
5180                    let mut normed = vec![0.0f32; b * 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[bi * hs..(bi + 1) * hs],
5188                        );
5189                    }
5190                    let attn = crate::linear_core::kda_forward_batch(
5191                        &normed,
5192                        b,
5193                        w,
5194                        &cfg,
5195                        &mut self.kv_cache.layers[li].linear_state,
5196                        pool.as_deref(),
5197                    );
5198                    for (dst, &a) in h.iter_mut().zip(&attn) {
5199                        *dst += a;
5200                    }
5201                }
5202                AttnKind::LinearGdn(w) => {
5203                    // Projections batched, recurrence sequential.
5204                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
5205                    let mut normed = vec![0.0f32; b * hs];
5206                    for bi in 0..b {
5207                        let r = inference::rms_norm(
5208                            &h[bi * hs..(bi + 1) * hs],
5209                            &lw.input_norm,
5210                            eps,
5211                            norm_style,
5212                        );
5213                        normed[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5214                    }
5215                    let attn = crate::linear_core::gdn_forward_batch(
5216                        &normed,
5217                        b,
5218                        w,
5219                        &cfg,
5220                        &mut self.kv_cache.layers[li].linear_state,
5221                        pool.as_deref(),
5222                    );
5223                    for (dst, &a) in h.iter_mut().zip(&attn) {
5224                        *dst += a;
5225                    }
5226                }
5227                AttnKind::ShortConv(w) => {
5228                    // Projections batched over the chunk; the conv walks the
5229                    // contiguous positions in order (same ring as decode).
5230                    let cfg = self
5231                        .short_conv_cfg
5232                        .expect("short-conv layer without short_conv_cfg");
5233                    let mut normed = vec![0.0f32; b * hs];
5234                    for bi in 0..b {
5235                        inference::rms_norm_into(
5236                            &h[bi * hs..(bi + 1) * hs],
5237                            &lw.input_norm,
5238                            eps,
5239                            norm_style,
5240                            &mut normed[bi * hs..(bi + 1) * hs],
5241                        );
5242                    }
5243                    let attn = short_conv_forward_batch(
5244                        &normed,
5245                        b,
5246                        w,
5247                        &cfg,
5248                        &mut self.kv_cache.layers[li].linear_state,
5249                        pool.as_deref(),
5250                    );
5251                    for (dst, &a) in h.iter_mut().zip(&attn) {
5252                        *dst += a;
5253                    }
5254                }
5255                AttnKind::Mla(w) => {
5256                    // Per-position prefill (correctness first; latent
5257                    // batching is a later optimization).
5258                    let inv_freq_l = self.layer_inv_freq(li);
5259                    let rs = self.layer_rope_scale(li);
5260                    let mut normed = vec![0.0f32; hs];
5261                    for bi in 0..b {
5262                        inference::rms_norm_into(
5263                            &h[bi * hs..(bi + 1) * hs],
5264                            &lw.input_norm,
5265                            eps,
5266                            norm_style,
5267                            &mut normed,
5268                        );
5269                        let ao = mla_attention(
5270                            w,
5271                            &normed,
5272                            &mut self.kv_cache.layers[li],
5273                            start_pos + bi,
5274                            &inv_freq_l,
5275                            rs,
5276                            eps,
5277                            pool.as_deref(),
5278                        );
5279                        for (dst, &a) in h[bi * hs..(bi + 1) * hs].iter_mut().zip(&ao) {
5280                            *dst += a;
5281                        }
5282                    }
5283                }
5284                AttnKind::Full {
5285                    wq,
5286                    wk,
5287                    wv,
5288                    wo,
5289                    q_norm,
5290                    k_norm,
5291                    output_gate,
5292                    softplus_gate,
5293                    bias,
5294                } => {
5295                    // Chunk-GEMM QKV/O; per-position causal attention
5296                    // inside (roadmap §3 P0 — full-attention prefill no
5297                    // longer re-reads the projection weights b times).
5298                    let mut normed = vec![0.0f32; b * hs];
5299                    for bi in 0..b {
5300                        inference::rms_norm_into(
5301                            &h[bi * hs..(bi + 1) * hs],
5302                            &lw.input_norm,
5303                            eps,
5304                            norm_style,
5305                            &mut normed[bi * hs..(bi + 1) * hs],
5306                        );
5307                    }
5308                    let inv_freq_l = self.layer_inv_freq(li);
5309                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
5310                    let cfg = QwenAttnCfg {
5311                        num_heads: self.layer_num_heads(li),
5312                        num_kv_heads: nkv_l,
5313                        head_dim: hd_l,
5314                        hidden_size: hs,
5315                        position: start_pos,
5316                        inv_freq: &inv_freq_l,
5317                        rotary_dim: rd_l,
5318                        scale: self.attn_scale,
5319                        softcap: self.attn_softcap,
5320                        window: self.layer_window(li),
5321                        v_norm: self.attn_v_norm,
5322                        q_norm: q_norm.as_deref(),
5323                        k_norm: k_norm.as_deref(),
5324                        output_gate: *output_gate,
5325                        softplus_gate: softplus_gate
5326                            .as_ref()
5327                            .map(|(gate, per_head)| (gate, *per_head)),
5328                        rope_scale: self.layer_rope_scale(li),
5329                        bias: bias
5330                            .as_ref()
5331                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
5332                        rms_eps: eps,
5333                        norm_style,
5334                        pool: pool.as_deref(),
5335                    };
5336                    let mut attn = attention::qwen_attention_batch(
5337                        &normed,
5338                        b,
5339                        wq,
5340                        wk,
5341                        wv,
5342                        wo,
5343                        &mut self.kv_cache.layers[li],
5344                        &cfg,
5345                    );
5346                    if let Some(w) = &lw.attn_out_norm {
5347                        for bi in 0..b {
5348                            inference::rms_norm_into(
5349                                &attn[bi * hs..(bi + 1) * hs],
5350                                w,
5351                                eps,
5352                                norm_style,
5353                                &mut normed[bi * hs..(bi + 1) * hs],
5354                            );
5355                        }
5356                        attn.copy_from_slice(&normed);
5357                    }
5358                    for (dst, &a) in h.iter_mut().zip(&attn) {
5359                        *dst += a;
5360                    }
5361                }
5362                AttnKind::Linear(w) => {
5363                    for bi in 0..b {
5364                        let normed = inference::rms_norm(
5365                            &h[bi * hs..(bi + 1) * hs],
5366                            &lw.input_norm,
5367                            eps,
5368                            norm_style,
5369                        );
5370                        vmf_phase_forward(
5371                            &normed,
5372                            w,
5373                            &self.vmf_cfg.expect("linear layer without vmf_cfg"),
5374                            &mut self.kv_cache.layers[li].linear_state,
5375                            pool.as_deref(),
5376                        )
5377                        .iter()
5378                        .enumerate()
5379                        .for_each(|(i, &a)| h[bi * hs + i] += a);
5380                    }
5381                }
5382            }
5383
5384            // ── FFN batched ──
5385            let lw = &self.weights.layers[self.phys_layer(li)];
5386            let mut post = vec![0.0f32; b * hs];
5387            for bi in 0..b {
5388                let r =
5389                    inference::rms_norm(&h[bi * hs..(bi + 1) * hs], &lw.post_norm, eps, norm_style);
5390                post[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5391            }
5392            // A restrictive per-visit FFN row lands on the activations
5393            // inside the dense arm; an all-open row costs nothing.
5394            let mask_row = task_mask
5395                .filter(|m| m.ffn_active_count(li) < self.intermediate_size)
5396                .and_then(|m| m.ffn_masks.get(li))
5397                .map(|v| v.as_slice());
5398            let mut ffn = match &lw.ffn {
5399                FfnKind::Dense(d) => dense_ffn_batch(d, &post, b, pool.as_deref(), mask_row),
5400                FfnKind::Moe(m) => moe_ffn_batch(m, &post, b, hs, pool.as_deref(), None),
5401                // Dual-branch layers run per position (the expert branch
5402                // reads the raw residual — nothing to batch yet).
5403                FfnKind::DenseMoe(dm) => {
5404                    let mut out = vec![0.0f32; b * hs];
5405                    for bi in 0..b {
5406                        let r = dense_moe_ffn(
5407                            dm,
5408                            &post[bi * hs..(bi + 1) * hs],
5409                            &h[bi * hs..(bi + 1) * hs],
5410                            eps,
5411                            norm_style,
5412                            pool.as_deref(),
5413                        );
5414                        out[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
5415                    }
5416                    out
5417                }
5418            };
5419            if let Some(w) = &lw.ffn_out_norm {
5420                for bi in 0..b {
5421                    inference::rms_norm_into(
5422                        &ffn[bi * hs..(bi + 1) * hs],
5423                        w,
5424                        eps,
5425                        norm_style,
5426                        &mut post[bi * hs..(bi + 1) * hs],
5427                    );
5428                }
5429                ffn.copy_from_slice(&post);
5430            }
5431            for (dst, &f) in h.iter_mut().zip(&ffn) {
5432                *dst += f;
5433            }
5434            if let Some(sc) = lw.layer_scale {
5435                for v in h.iter_mut() {
5436                    *v *= sc;
5437                }
5438            }
5439            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
5440                if let Some(t) = tp.parse::<usize>().ok() {
5441                    if t >= start_pos && t < start_pos + b {
5442                        let bi = t - start_pos;
5443                        let row = &h[bi * hs..(bi + 1) * hs];
5444                        let n: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
5445                        eprintln!(
5446                            "BATCH pos {t} after layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
5447                            row[0], row[1]
5448                        );
5449                    }
5450                }
5451            }
5452            // CMF_DEBUG_LAYERS=1: per-layer hidden-state health of the
5453            // LAST prompt position — the knife for "which layer type
5454            // breaks first" on a new architecture.
5455            if std::env::var("CMF_DEBUG_LAYERS").is_ok() {
5456                let row = &h[(b - 1) * hs..b * hs];
5457                let rms =
5458                    (row.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / hs as f64).sqrt();
5459                let mx = row.iter().fold(0f32, |m, &v| m.max(v.abs()));
5460                eprintln!(
5461                    "layer {li:>3} {:>10} ffn={:<5} rms={rms:>12.4} max={mx:>12.4}",
5462                    match &self.weights.layers[self.phys_layer(li)].attn {
5463                        AttnKind::LinearGdn(_) => "gdn",
5464                        AttnKind::Linear(_) => "vmf",
5465                        AttnKind::ShortConv(_) => "conv",
5466                        _ => "attn",
5467                    },
5468                    match &lw.ffn {
5469                        FfnKind::Moe(_) => "moe",
5470                        FfnKind::Dense(_) => "dense",
5471                        FfnKind::DenseMoe(_) => "dense+moe",
5472                    },
5473                );
5474            }
5475            // Looped Transformer: apply final norm at the end of each loop iteration.
5476            if self.is_loop_end(li) && li + 1 < self.num_layers {
5477                for bi in 0..b {
5478                    let normed = inference::rms_norm(
5479                        &h[bi * hs..(bi + 1) * hs],
5480                        &self.weights.final_norm,
5481                        eps,
5482                        norm_style,
5483                    );
5484                    h[bi * hs..(bi + 1) * hs].copy_from_slice(&normed);
5485                }
5486            }
5487            if std::env::var("CMF_TRACE_H").is_ok() {
5488                let n = h[..hs].iter().map(|v| v.abs()).sum::<f32>() / hs as f32;
5489                let mx = h[..hs].iter().fold(0.0f32, |a, &v| a.max(v.abs()));
5490                eprintln!(
5491                    "layer {li}: mean|h|={n:.4} max|h|={mx:.2} scale={:?}",
5492                    lw.layer_scale
5493                );
5494            }
5495        }
5496        crate::gpu::set_layer(-1); // lm_head/final ops outside layer-split
5497        h
5498    }
5499
5500    /// Embed a single token.
5501    fn embed_single(&self, id: u32) -> Vec<f32> {
5502        let mut out = vec![0.0f32; self.hidden_size];
5503        if (id as usize) < self.weights.embed_tokens.rows() {
5504            self.weights.embed_tokens.row_f32(id as usize, &mut out);
5505        }
5506        if self.embed_multiplier != 1.0 {
5507            for v in out.iter_mut() {
5508                *v *= self.embed_multiplier;
5509            }
5510        }
5511        // DeepSeek-V4's hash layers route by TOKEN ID, so the id has to
5512        // reach the forward. It rides in slot 0 (the forward re-reads the
5513        // real embedding itself from the table).
5514        if self.dsv4.is_some() {
5515            let mut v = vec![0.0f32; self.hidden_size.max(1)];
5516            v[0] = id as f32;
5517            return v;
5518        }
5519        // Gemma-3n: the per-layer-embedding half needs the token ID, so
5520        // it rides appended to the embedding; the g3n forward splits it.
5521        if let Some(b) = &self.g3n {
5522            return b.0.extend_embedding(id, &out, self.pool.as_deref());
5523        }
5524        out
5525    }
5526
5527    /// A run of consecutive prefill layers on the GPU for the whole
5528    /// chunk (default-on under CMF_GPU=1; CMF_GPU_CHUNK=0 disables).
5529    /// Eligibility per layer: q8_row weights, plain full attention
5530    /// (no output gate), F32 KV, no o1/masks/gemma extras. Returns the
5531    /// first layer index NOT processed (== `li0` when the run is empty).
5532    #[cfg(target_os = "macos")]
5533    fn chunk_run_gpu(
5534        &mut self,
5535        li0: usize,
5536        h: &mut [f32],
5537        b: usize,
5538        pos0: usize,
5539        embed_ids: Option<&[u32]>,
5540        cap: usize,
5541    ) -> usize {
5542        // (The old streaming attend needed a depth bound at ~1k; the
5543        // GEMM attention scales like the CPU path and lifted it.)
5544        // CMF_GPU_CHUNK=0 disables the graph.
5545        if !crate::gpu::enabled_here()
5546            || std::env::var("CMF_GPU_CHUNK")
5547                .map(|v| v == "0")
5548                .unwrap_or(false)
5549            || b < 32
5550            || self.swa.is_some()
5551            || self.global_attn.is_some()
5552            || self.attn_v_norm
5553            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
5554        {
5555            return li0;
5556        }
5557        let Some(model) = self.model.clone() else {
5558            return li0;
5559        };
5560        let inv_freq = self.inv_freq.clone();
5561        let (nh, nkv, hd, hs) = (
5562            self.num_heads,
5563            self.num_kv_heads,
5564            self.head_dim,
5565            self.hidden_size,
5566        );
5567        // Collect the longest run of consecutive eligible layers.
5568        // Looped Transformer: stop at the loop boundary so the CPU can
5569        // apply loop_final_norm between iterations.
5570        let loop_end = if self.loop_final_norm {
5571            ((li0 / self.physical_layers) + 1) * self.physical_layers
5572        } else {
5573            self.num_layers
5574        };
5575        let mut layers: Vec<crate::gpu_metal::ChunkLayer> = Vec::new();
5576        let mut stored_at: Vec<usize> = Vec::new();
5577        for li in li0..self.num_layers.min(loop_end).min(cap) {
5578            let lw = &self.weights.layers[self.phys_layer(li)];
5579            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
5580                break;
5581            }
5582            let AttnKind::Full {
5583                wq,
5584                wk,
5585                wv,
5586                wo,
5587                q_norm,
5588                k_norm,
5589                output_gate: false,
5590                softplus_gate: None,
5591                bias,
5592            } = &lw.attn
5593            else {
5594                break;
5595            };
5596            let FfnKind::Dense(d) = &lw.ffn else { break };
5597            if d.act != Act::Silu {
5598                break;
5599            }
5600            // q8_row (row_scale populated), or q4_tiled / q4tp (row_scale
5601            // empty — their scales are in the payload). Mixing across the
5602            // seven projections of one layer is fine; the encoder branches
5603            // per weight on the tensor's dtype. Anything else refuses.
5604            fn cw(t: &QTensor) -> Option<(usize, usize, usize, &[f32])> {
5605                t.q8_row_parts()
5606                    .or_else(|| t.q4t_parts().map(|(i, r, c)| (i, r, c, &[][..])))
5607                    .or_else(|| t.q4tp_parts().map(|(i, r, c)| (i, r, c, &[][..])))
5608            }
5609            let parts = (
5610                cw(wq),
5611                cw(wk),
5612                cw(wv),
5613                cw(wo),
5614                cw(&d.gate_proj),
5615                cw(&d.up_proj),
5616                cw(&d.down_proj),
5617            );
5618            let (Some(pq), Some(pk), Some(pv), Some(po), Some(pg), Some(pu), Some(pd)) = parts
5619            else {
5620                break;
5621            };
5622            let layer = &self.kv_cache.layers[li];
5623            if layer.mode != crate::kv_cache::KvMode::F32 || layer.o1.is_some() {
5624                break;
5625            }
5626            stored_at.push(layer.head_len(0));
5627            layers.push(crate::gpu_metal::ChunkLayer {
5628                model: &model,
5629                kv_id: self.graph_kv_id,
5630                layer: li,
5631                wq: pq,
5632                wk: pk,
5633                wv: pv,
5634                wo: po,
5635                gate: pg,
5636                up: pu,
5637                down: pd,
5638                input_norm: &lw.input_norm,
5639                post_norm: &lw.post_norm,
5640                bias: bias
5641                    .as_ref()
5642                    .map(|(a, bb, cc)| (a.as_slice(), bb.as_slice(), cc.as_slice())),
5643                q_norm: q_norm.as_deref(),
5644                k_norm: k_norm.as_deref(),
5645                inv_freq: &inv_freq,
5646                rd: self.rotary_dim,
5647                nh,
5648                nkv,
5649                hd,
5650                hs,
5651                inter: d.gate_proj.rows(),
5652                gemma: matches!(self.norm_style, cortiq_core::NormStyle::Gemma),
5653                eps: self.rms_eps as f32,
5654            });
5655        }
5656        if layers.is_empty() {
5657            return li0;
5658        }
5659        let row = nkv * hd;
5660        let mut store: Vec<(Vec<f32>, Vec<f32>, Vec<f32>)> = stored_at
5661            .iter()
5662            .map(|&st| (vec![0f32; b * row], vec![0f32; b * row], vec![0f32; st + b]))
5663            .collect();
5664        let mut io: Vec<crate::gpu_metal::ChunkIo> = Vec::with_capacity(layers.len());
5665        for (i, (ok, ov, oi)) in store.iter_mut().enumerate() {
5666            let li = layers[i].layer;
5667            let layer = &self.kv_cache.layers[li];
5668            io.push(crate::gpu_metal::ChunkIo {
5669                cpu_stored: stored_at[i],
5670                cpu_k: (0..nkv).map(|g| layer.head_keys(g)).collect(),
5671                cpu_v: (0..nkv).map(|g| layer.head_values(g)).collect(),
5672                out_k: ok,
5673                out_v: ov,
5674                imp: oi,
5675            });
5676        }
5677        let n_run = layers.len();
5678        let last = layers.last().map(|l| l.layer + 1).unwrap_or(li0);
5679        // Device-side embedding when the run starts the model and the
5680        // embedding matrix is q8_row-mapped.
5681        let ep = embed_ids.and_then(|ids| {
5682            self.weights
5683                .embed_tokens
5684                .q8_row_parts()
5685                .map(|(idx, rows, _c, rs)| crate::gpu_metal::ChunkEmbed {
5686                    idx,
5687                    rows,
5688                    row_scale: rs,
5689                    ids,
5690                    mult: self.embed_multiplier,
5691                })
5692        });
5693        if embed_ids.is_some() && ep.is_none() {
5694            return li0;
5695        }
5696        if !crate::gpu_metal::chunk_run_gpu(&layers, &mut io, h, b, pos0, ep.as_ref()) {
5697            return li0;
5698        }
5699        drop(io);
5700        drop(layers);
5701        // CPU caches stay the owners of record: append the chunk rows
5702        // and bank the importance masses per layer.
5703        for (i, (ok, ov, oi)) in store.iter().enumerate().take(n_run) {
5704            let li = li0 + i;
5705            let layer = &mut self.kv_cache.layers[li];
5706            for bi in 0..b {
5707                layer.append(
5708                    &ok[bi * row..(bi + 1) * row],
5709                    &ov[bi * row..(bi + 1) * row],
5710                    &[],
5711                );
5712            }
5713            layer.accumulate_imp(oi);
5714        }
5715        last
5716    }
5717
5718    /// Is layer `li` a sliding-window (local-RoPE) layer? Gemma-3:
5719    /// every `pattern`-th layer is global, the rest are local.
5720    fn layer_is_local(&self, li: usize) -> bool {
5721        if let Some(layers) = &self.sliding_layers {
5722            return layers.get(li).copied().unwrap_or(false);
5723        }
5724        match self.swa {
5725            Some((_, pattern)) => (li + 1) % pattern.max(1) != 0,
5726            None => false,
5727        }
5728    }
5729
5730    /// The RoPE table for layer `li` (local layers may have their own;
5731    /// Gemma-4 global layers use the proportional padded table).
5732    fn layer_inv_freq(&self, li: usize) -> std::sync::Arc<Vec<f32>> {
5733        if self.layer_is_local(li) {
5734            if let Some(f) = &self.inv_freq_local {
5735                return f.clone();
5736            }
5737        } else if let Some(f) = &self.inv_freq_global {
5738            return f.clone();
5739        }
5740        self.inv_freq.clone()
5741    }
5742
5743    /// The attend window for layer `li` (None = full context).
5744    fn layer_window(&self, li: usize) -> Option<usize> {
5745        self.swa
5746            .and_then(|(w, _)| self.layer_is_local(li).then_some(w))
5747    }
5748
5749    fn layer_num_heads(&self, li: usize) -> usize {
5750        self.attention_heads_per_layer
5751            .as_ref()
5752            .and_then(|v| v.get(li).copied())
5753            .unwrap_or(self.num_heads)
5754    }
5755
5756    fn layer_rope_scale(&self, li: usize) -> f32 {
5757        if self.layer_is_local(li) {
5758            self.rope_scale_local
5759        } else {
5760            self.rope_scale
5761        }
5762    }
5763
5764    /// Attention geometry of layer `li`: (num_kv_heads, head_dim,
5765    /// rotary_dim). Gemma-4 global layers override all three.
5766    fn layer_geom(&self, li: usize) -> (usize, usize, usize) {
5767        if !self.layer_is_local(li) {
5768            if let Some((ghd, gkv)) = self.global_attn {
5769                return (gkv, ghd, ghd);
5770            }
5771        }
5772        (
5773            self.num_kv_heads,
5774            self.head_dim,
5775            if self.layer_is_local(li) {
5776                self.rotary_dim_local.unwrap_or(self.rotary_dim)
5777            } else {
5778                self.rotary_dim
5779            },
5780        )
5781    }
5782
5783    /// Forward one position through all layers (hybrid dispatch).
5784    fn forward_layers(
5785        &mut self,
5786        hidden: &[f32],
5787        position: usize,
5788        task_mask: Option<&TaskMask>,
5789    ) -> Vec<f32> {
5790        self.forward_layers_upto(hidden, position, task_mask, None)
5791    }
5792
5793    // ── Network pipeline-split building blocks (coordinator/worker) ──
5794    // A remote worker owns layers [from ..= upto] and their KV; the
5795    // coordinator owns the rest plus embed / final norm / head. Attention
5796    // causality is per-layer, so a whole prompt's boundary hiddens ship
5797    // as one batch and decode ships one vector per token.
5798
5799    /// Embed one token id (embed multiplier applied).
5800    pub fn embed_id(&self, id: u32) -> Vec<f32> {
5801        self.embed_single(id)
5802    }
5803
5804    /// Refuse the archs/modes whose forward cannot be cut at a layer
5805    /// boundary. Loud by design: a split that silently changed the math
5806    /// would be a chimera.
5807    pub fn split_supported(&self) -> Result<(), String> {
5808        if self.dsv4.is_some() {
5809            return Err(
5810                "network split: DeepSeek-V4 runs its own fused stack (not splittable yet)".into(),
5811            );
5812        }
5813        if self.g3n.is_some() {
5814            return Err(
5815                "network split: Gemma-3n runs its own AltUp stack (not splittable yet)".into(),
5816            );
5817        }
5818        Ok(())
5819    }
5820
5821    /// Forward `hidden` through layers [from ..= upto] at `position`,
5822    /// appending those layers' KV/state. Both split sides call this
5823    /// over their own range; a task mask applies to the span's own
5824    /// layers (each side masks what it runs).
5825    pub fn forward_span(
5826        &mut self,
5827        hidden: &[f32],
5828        position: usize,
5829        from: usize,
5830        upto: usize,
5831        task_mask: Option<&TaskMask>,
5832    ) -> Result<Vec<f32>, String> {
5833        self.split_supported()?;
5834        if from > upto || upto >= self.num_layers {
5835            return Err(format!(
5836                "forward_span: layer range {from}..={upto} outside 0..{}",
5837                self.num_layers
5838            ));
5839        }
5840        if hidden.len() != self.hidden_size {
5841            return Err(format!(
5842                "forward_span: hidden len {} ≠ hidden_size {}",
5843                hidden.len(),
5844                self.hidden_size
5845            ));
5846        }
5847        Ok(self.forward_layers_span(hidden, position, task_mask, from, Some(upto)))
5848    }
5849
5850    /// Final norm + lm_head over a boundary hidden (the final-logit
5851    /// softcap is applied by lm_head_forward itself).
5852    pub fn logits_from_hidden(&mut self, hidden: &[f32]) -> Vec<f32> {
5853        let normed = inference::rms_norm(
5854            hidden,
5855            &self.weights.final_norm,
5856            self.rms_eps,
5857            self.norm_style,
5858        );
5859        self.lm_head_forward(&normed)
5860    }
5861
5862    /// Sample the next token with this pipeline's sampler state.
5863    pub fn sample_next(&mut self, logits: &[f32], past_tokens: &[u32]) -> u32 {
5864        sampler::sample_with_scratch(
5865            logits,
5866            &self.sampler_config,
5867            past_tokens,
5868            &mut self.rng,
5869            &mut self.sampler_scratch,
5870        )
5871    }
5872
5873    /// Fresh sequence: clear KV, reuse history and device mirrors.
5874    pub fn reset_session(&mut self) {
5875        self.kv_cache.clear();
5876        self.kv_history.clear();
5877        crate::gpu::graph_kv_reset(self.graph_kv_id);
5878    }
5879
5880    /// Batched span prefill from token ids (coordinator side): embed +
5881    /// layers [0 ..= upto]; returns the boundary hiddens of ALL positions
5882    /// (ids.len() × hidden). Rides the same layer-major machinery as the
5883    /// local prefill; falls back to the per-position walk under
5884    /// CMF_PREFILL=seq.
5885    pub fn prefill_span_ids(
5886        &mut self,
5887        ids: &[u32],
5888        start_pos: usize,
5889        upto: usize,
5890        task_mask: Option<&TaskMask>,
5891    ) -> Result<Vec<f32>, String> {
5892        self.split_supported()?;
5893        if upto >= self.num_layers {
5894            return Err(format!(
5895                "prefill_span_ids: upto {upto} outside 0..{}",
5896                self.num_layers
5897            ));
5898        }
5899        // Same predicate as the whole-stack prefill: a span whose GDN
5900        // state lives on the device must walk positions through the
5901        // graph, not through the batched CPU span.
5902        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
5903            Ok(self.prefill_batch_span(PrefillIn::Ids(ids), start_pos, task_mask, 0, upto + 1))
5904        } else {
5905            let hs = self.hidden_size;
5906            let mut out = Vec::with_capacity(ids.len() * hs);
5907            for (i, &id) in ids.iter().enumerate() {
5908                let emb = self.embed_id(id);
5909                out.extend_from_slice(&self.forward_span(
5910                    &emb,
5911                    start_pos + i,
5912                    0,
5913                    upto,
5914                    task_mask,
5915                )?);
5916            }
5917            Ok(out)
5918        }
5919    }
5920
5921    /// Batched span prefill from boundary hiddens (worker side): layers
5922    /// [from ..= upto] for every position in the batch; returns the batch.
5923    pub fn prefill_span_hidden(
5924        &mut self,
5925        hidden: &[f32],
5926        start_pos: usize,
5927        from: usize,
5928        upto: usize,
5929        task_mask: Option<&TaskMask>,
5930    ) -> Result<Vec<f32>, String> {
5931        self.split_supported()?;
5932        let hs = self.hidden_size;
5933        if hidden.is_empty() || hidden.len() % hs != 0 {
5934            return Err(format!(
5935                "prefill_span_hidden: {} floats is not a multiple of hidden {hs}",
5936                hidden.len()
5937            ));
5938        }
5939        if from > upto || upto >= self.num_layers {
5940            return Err(format!(
5941                "prefill_span_hidden: layer range {from}..={upto} outside 0..{}",
5942                self.num_layers
5943            ));
5944        }
5945        if self.can_prefill_batched() && !self.graph_prefill_preferred() {
5946            Ok(self.prefill_batch_span(
5947                PrefillIn::Hidden(hidden),
5948                start_pos,
5949                task_mask,
5950                from,
5951                upto + 1,
5952            ))
5953        } else {
5954            let b = hidden.len() / hs;
5955            let mut out = Vec::with_capacity(hidden.len());
5956            for i in 0..b {
5957                let h = self.forward_span(
5958                    &hidden[i * hs..(i + 1) * hs],
5959                    start_pos + i,
5960                    from,
5961                    upto,
5962                    task_mask,
5963                )?;
5964                out.extend_from_slice(&h);
5965            }
5966            Ok(out)
5967        }
5968    }
5969
5970    /// Build the whole-token wgpu graph for a pure-attention q1 model (every
5971    /// layer Full q1 + dense q1 FFN, no gate/bias). Returns the post-stack
5972    /// hidden (caller does final norm + lm_head), or None to fall back.
5973    fn try_token_graph_wgpu(
5974        &self,
5975        hidden: &[f32],
5976        position: usize,
5977        logits_out: &mut Vec<f32>,
5978        layers_run: &mut usize,
5979    ) -> Option<Vec<f32>> {
5980        self.try_token_graph_wgpu_steps(
5981            hidden,
5982            position,
5983            logits_out,
5984            1,
5985            None,
5986            Some(layers_run),
5987            0,
5988            self.num_layers,
5989        )
5990    }
5991
5992    /// The span twin (network split): the graph covers [from..upto_excl)
5993    /// — one submit per SEGMENT per token. lm_head folds in only when
5994    /// the span reaches the last layer.
5995    fn try_token_graph_wgpu_span(
5996        &self,
5997        hidden: &[f32],
5998        position: usize,
5999        logits_out: &mut Vec<f32>,
6000        from: usize,
6001        upto_excl: usize,
6002        layers_run: &mut usize,
6003    ) -> Option<Vec<f32>> {
6004        self.try_token_graph_wgpu_steps(
6005            hidden,
6006            position,
6007            logits_out,
6008            1,
6009            None,
6010            Some(layers_run),
6011            from,
6012            upto_excl,
6013        )
6014    }
6015
6016    /// Greedy burst: forward `t_next` and let the device pick + re-embed
6017    /// the next k−1 tokens — k frames, ONE submit, k ids back. The ZML
6018    /// trade, on wgpu. None ⇒ caller keeps the per-token path.
6019    fn try_multi_burst(&self, t_next: u32, position: usize, k: usize) -> Option<Vec<u32>> {
6020        if self.o1_active() || self.attn_softcap > 0.0 {
6021            return None;
6022        }
6023        let graph_on = crate::gpu::wgpu_graph_on(crate::gpu::GraphPhase::Decode);
6024        if !graph_on || crate::gpu::graph_unsupported() {
6025            // Same memo as the decode site: this path builds the very
6026            // same graph, so a model it cannot build for must not be
6027            // walked again here either. Missing this guard was worth
6028            // 2.5x on an Adreno — 0.361 tok/s against 0.905 — because
6029            // the burst retried per token what decode had already given
6030            // up on.
6031            return None;
6032        }
6033        let emb = self.embed_single(t_next);
6034        let mut lg = Vec::new();
6035        let mut ids = Vec::new();
6036        self.try_token_graph_wgpu_steps(
6037            &emb,
6038            position,
6039            &mut lg,
6040            k,
6041            Some(&mut ids),
6042            None,
6043            0,
6044            self.num_layers,
6045        )?;
6046        (ids.len() == k).then_some(ids)
6047    }
6048
6049    /// Multi-step greedy: k whole frames in ONE submit, argmax and re-embed
6050    /// on the device. `ids_out` receives the k winner ids; the hidden/logits
6051    /// outputs are NOT produced in that mode.
6052    fn try_token_graph_wgpu_steps(
6053        &self,
6054        hidden: &[f32],
6055        position: usize,
6056        logits_out: &mut Vec<f32>,
6057        steps: usize,
6058        ids_out: Option<&mut Vec<u32>>,
6059        layers_run: Option<&mut usize>,
6060        from: usize,
6061        upto_excl: usize,
6062    ) -> Option<Vec<f32>> {
6063        // O(1) Nyström decode runs off the sealed state, not the KV cache the
6064        // graph mirrors — never take the graph while o1 is active.
6065        let o1_gpu = std::env::var("CMF_O1_GPU").as_deref() == Ok("1");
6066        if (self.o1_active() && !o1_gpu) || self.attn_softcap > 0.0 {
6067            // Softcapped scores have no graph kernel yet — CPU owns them.
6068            // o1 rides the graph only behind CMF_O1_GPU=1 while the port
6069            // proves itself; without it the CPU path owns o1 as before.
6070            return None;
6071        }
6072        // Per-layer sealed o1 state for the graph. During prefill the
6073        // state is still Collecting -> views are None -> the graph
6074        // refuses below and the CPU prefill records the q trace and
6075        // seals, exactly as the o1 design requires.
6076        let o1_views: Vec<Option<Vec<crate::nystrom::O1DeviceView<'_>>>> = (from..upto_excl)
6077            .map(|li| {
6078                if !o1_gpu {
6079                    return None;
6080                }
6081                self.kv_cache.layers[self.phys_layer(li)].o1_views()
6082            })
6083            .collect();
6084        if self.o1_active() && o1_gpu {
6085            // Any o1 layer not sealed (or degenerate exact-only) keeps the
6086            // whole token on the CPU: half-graph forwards would desync.
6087            let want: usize = (from..upto_excl)
6088                .filter(|li| !matches!(self.kv_cache.layers[self.phys_layer(*li)].o1, None))
6089                .count();
6090            let have = o1_views.iter().filter(|v| v.is_some()).count();
6091            if want == 0 || have != want {
6092                // The silent twin of the gpu-side o1 gates, found the
6093                // same way: a 15x decode drop with an empty log. Views
6094                // stay None until the layer's state SEALS, so `have`
6095                // lagging `want` early in a run is the o1 design working
6096                // — but it must say so, or the next reader spends a
6097                // night proving the kernels innocent.
6098                // On CHANGE, not once: the first decline is the legal
6099                // unsealed prefill, and a once-print buries the state
6100                // that matters — what the count reads AFTER the seal.
6101                use std::sync::atomic::{AtomicUsize, Ordering};
6102                static LAST: AtomicUsize = AtomicUsize::new(usize::MAX);
6103                let code = have * 1000 + want;
6104                if LAST.swap(code, Ordering::Relaxed) != code {
6105                    tracing::warn!(
6106                        "o1 graph: {have} of {want} layers sealed — per-op until all seal"
6107                    );
6108                }
6109                return None;
6110            }
6111        }
6112        let nh = self.num_heads;
6113        let (nkv, hd, rd) = self.layer_geom(0);
6114        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
6115        let mut layers = Vec::with_capacity(upto_excl - from);
6116        let mut model = None;
6117        let dbg = std::env::var("CMF_GRAPH_DEBUG").is_ok();
6118        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
6119            if let Some((_, i, kind, rs)) = t.graph_weight() {
6120                return Some(crate::gpu::GraphW {
6121                    idx: i,
6122                    kind,
6123                    row_scale: rs,
6124                    data: &[],
6125                });
6126            }
6127            // Small unquantized projections (GDN in_proj_a/b) stay f32.
6128            t.as_f32().map(|d| crate::gpu::GraphW {
6129                idx: 0,
6130                kind: 4,
6131                row_scale: &[],
6132                data: d,
6133            })
6134        }
6135        for li in from..upto_excl {
6136            let lw = &self.weights.layers[self.phys_layer(li)];
6137            if dbg {
6138                let ak = match &lw.attn {
6139                    AttnKind::Mla(_) => "Mla".into(),
6140                    AttnKind::Full {
6141                        output_gate, bias, ..
6142                    } => format!("Full gate={output_gate} bias={}", bias.is_some()),
6143                    AttnKind::LinearGdn(_) => "LinearGdn".into(),
6144                    AttnKind::Kda(_) => "Kda".into(),
6145                    AttnKind::Linear(_) => "Linear".into(),
6146                    AttnKind::ShortConv(_) => "ShortConv".into(),
6147                };
6148                let fk = match &lw.ffn {
6149                    FfnKind::Dense(_) => "Dense",
6150                    FfnKind::Moe(_) => "Moe",
6151                    FfnKind::DenseMoe(_) => "DenseMoe",
6152                };
6153                eprintln!("graph L{li}: attn={ak} ffn={fk}");
6154            }
6155            let gffn = match &lw.ffn {
6156                FfnKind::DenseMoe(_) => return None, // dual branch: CPU path
6157                FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
6158                    gate: gw(&d.gate_proj)?,
6159                    up: gw(&d.up_proj)?,
6160                    down: gw(&d.down_proj)?,
6161                },
6162                FfnKind::Moe(m) => {
6163                    // v1 scope: softmax router + shared expert + uniform
6164                    // q4t expert trios (the MoE-hybrid coder class). The
6165                    // biased/sigmoid routers and adaptive τ keep the CPU
6166                    // path, where they are implemented.
6167                    if m.router_sigmoid
6168                        || m.expert_bias.is_some()
6169                        || m.route_tau.is_some()
6170                        || m.mask.is_some()
6171                    {
6172                        return None;
6173                    }
6174                    let (se, sg) = m.shared.as_ref()?;
6175                    let sgate = gw(sg.as_ref()?)?;
6176                    let router = gw(&m.router)?;
6177                    let inter = m.experts.first()?.gate_proj.rows();
6178                    let mut experts = Vec::with_capacity(m.experts.len() + 1);
6179                    // q4t or q4tp, but not both in one layer — the kernels
6180                    // are picked per layer, not per expert.
6181                    let mut q4tp: Option<bool> = None;
6182                    // The mixed 2-bit profile: q2tp gate/up over a q4tp
6183                    // down. Uniform across the layer, like `q4tp` itself.
6184                    let mut gu_q2: Option<bool> = None;
6185                    for e in m.experts.iter().chain(std::iter::once(se)) {
6186                        if !matches!(e.act, Act::Silu)
6187                            || e.gate_proj.rows() != inter
6188                            || e.up_proj.rows() != inter
6189                        {
6190                            return None;
6191                        }
6192                        let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
6193                            Some((mm, gi)) => (
6194                                mm,
6195                                gi,
6196                                e.up_proj.mapped_q4t()?.1,
6197                                e.down_proj.mapped_q4t()?.1,
6198                                false,
6199                                false,
6200                            ),
6201                            None => match e.gate_proj.mapped_q2tp() {
6202                                Some((mm, gi)) => (
6203                                    mm,
6204                                    gi,
6205                                    e.up_proj.mapped_q2tp()?.1,
6206                                    e.down_proj.mapped_q4tp()?.1,
6207                                    true,
6208                                    true,
6209                                ),
6210                                None => {
6211                                    let (mm, gi) = e.gate_proj.mapped_q4tp()?;
6212                                    (
6213                                        mm,
6214                                        gi,
6215                                        e.up_proj.mapped_q4tp()?.1,
6216                                        e.down_proj.mapped_q4tp()?.1,
6217                                        true,
6218                                        false,
6219                                    )
6220                                }
6221                            },
6222                        };
6223                        if *q4tp.get_or_insert(is_p) != is_p || *gu_q2.get_or_insert(is_q2) != is_q2
6224                        {
6225                            // The shared expert rides in the same packed
6226                            // buffer as the routed ones, so a layer that
6227                            // mixes layouts cannot be indexed by one stride.
6228                            // Say so: the symptom is a whole model quietly
6229                            // running its MoE on the CPU.
6230                            tracing::warn!(
6231                                "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."
6232                            );
6233                            return None;
6234                        }
6235                        model.get_or_insert_with(|| mm.clone());
6236                        experts.push((gi, ui, di));
6237                    }
6238                    crate::gpu::GraphFfn::Moe {
6239                        router,
6240                        shared_gate: sgate,
6241                        experts,
6242                        n_exp: m.experts.len(),
6243                        // CMF_TOPK_PROBE: timing probe only — output is WRONG.
6244                        // Fewer experts shrink the MoE arithmetic while the
6245                        // dispatch count stays identical, which is the only
6246                        // clean way to tell a launch-bound decode from a
6247                        // compute-bound one.
6248                        top_k: std::env::var("CMF_TOPK_PROBE")
6249                            .ok()
6250                            .and_then(|v| v.parse::<usize>().ok())
6251                            .filter(|k| *k > 0 && *k <= m.top_k)
6252                            .unwrap_or(m.top_k),
6253                        inter,
6254                        norm_topk: m.norm_topk_prob,
6255                        q4tp: q4tp?,
6256                        gu_q2: gu_q2.unwrap_or(false),
6257                    }
6258                }
6259            };
6260            let attn = match &lw.attn {
6261                AttnKind::Full {
6262                    wq,
6263                    wk,
6264                    wv,
6265                    wo,
6266                    q_norm,
6267                    k_norm,
6268                    output_gate,
6269                    softplus_gate,
6270                    bias,
6271                } => {
6272                    if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
6273                        return None;
6274                    }
6275                    let (m, _, _, _) = wq.graph_weight()?;
6276                    model = Some(m.clone());
6277                    crate::gpu::GraphAttn::Full {
6278                        wq: gw(wq)?,
6279                        wk: gw(wk)?,
6280                        wv: gw(wv)?,
6281                        wo: gw(wo)?,
6282                        q_norm: q_norm.as_deref(),
6283                        k_norm: k_norm.as_deref(),
6284                        bias: bias
6285                            .as_ref()
6286                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
6287                        output_gate: *output_gate,
6288                        cpu_k: self.kv_cache.layers[li].k_heads(),
6289                        cpu_v: self.kv_cache.layers[li].v_heads(),
6290                    }
6291                }
6292                AttnKind::LinearGdn(w) => {
6293                    let cfg = self.gdn_cfg?;
6294                    let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
6295                    model = Some(m.clone());
6296                    crate::gpu::GraphAttn::Gdn {
6297                        qkv: gw(&w.in_proj_qkv)?,
6298                        z: gw(&w.in_proj_z)?,
6299                        a: gw(&w.in_proj_a)?,
6300                        b: gw(&w.in_proj_b)?,
6301                        out: gw(&w.out_proj)?,
6302                        conv1d: &w.conv1d,
6303                        a_log: &w.a_log,
6304                        dt_bias: &w.dt_bias,
6305                        norm: &w.norm,
6306                        nv: cfg.num_v_heads,
6307                        nk: cfg.num_k_heads,
6308                        dk: cfg.key_head_dim,
6309                        dv: cfg.value_head_dim,
6310                        kk: cfg.conv_kernel,
6311                        cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
6312                    }
6313                }
6314                _ => return None,
6315            };
6316            layers.push(crate::gpu::GraphLayer {
6317                input_norm: &lw.input_norm,
6318                attn,
6319                post_norm: &lw.post_norm,
6320                ffn: gffn,
6321            });
6322        }
6323        let model = model?;
6324        // Fold final-norm + lm_head into the graph when this call wants logits
6325        // and the lm_head is a graphable (quantized) weight — the graph then
6326        // reads back logits (into logits_out) instead of the hidden, dropping
6327        // the separate CPU/GPU lm_head op + its sync. Never the f32 fallback:
6328        // an unquantized lm_head is vocab·hidden and must not be uploaded.
6329        let lm_gw = if upto_excl == self.num_layers
6330            && self.graph_want_logits
6331            && std::env::var("CMF_GPU_LMHEAD")
6332                .map(|v| v != "0")
6333                .unwrap_or(true)
6334        {
6335            self.weights.lm_head.graph_weight().map(|(_, i, kind, rs)| {
6336                (
6337                    crate::gpu::GraphW {
6338                        idx: i,
6339                        kind,
6340                        row_scale: rs,
6341                        data: &[],
6342                    },
6343                    self.weights.lm_head.rows(),
6344                )
6345            })
6346        } else {
6347            None
6348        };
6349        let lm = lm_gw.as_ref().map(|(gw, rows)| (gw, *rows));
6350        // Multi-step re-embeds the winner on the device.
6351        let emb_gw = if steps > 1 {
6352            self.weights
6353                .embed_tokens
6354                .graph_weight()
6355                .map(|(_, i, kind, rs)| {
6356                    (
6357                        crate::gpu::GraphW {
6358                            idx: i,
6359                            kind,
6360                            row_scale: rs,
6361                            data: &[],
6362                        },
6363                        self.weights.embed_tokens.rows(),
6364                        self.embed_multiplier as f32,
6365                    )
6366                })
6367        } else {
6368            None
6369        };
6370
6371        // Loop boundaries: virtual layer indices after which final_norm is
6372        // applied (mid-stack only; the GLOBAL last layer's norm folds into
6373        // lm_head). Span-relative — the executor compares its enumerate
6374        // index. A span ending mid-stack keeps its boundary norm even when
6375        // it is the span's own last layer.
6376        let loop_norm_at: Vec<usize> = if self.loop_final_norm {
6377            (from..upto_excl.min(self.num_layers - 1))
6378                .filter(|&li| (li + 1) % self.physical_layers == 0)
6379                .map(|li| li - from)
6380                .collect()
6381        } else {
6382            Vec::new()
6383        };
6384        let mut h = hidden.to_vec();
6385        crate::gpu::forward_token_graph(
6386            &model,
6387            self.graph_kv_id,
6388            &layers,
6389            &o1_views,
6390            self.o1_epoch,
6391            &self.inv_freq,
6392            &mut h,
6393            nh,
6394            nkv,
6395            hd,
6396            rd,
6397            self.hidden_size,
6398            self.intermediate_size,
6399            position,
6400            self.kv_cache.max_seq_len,
6401            gemma,
6402            self.rms_eps as f32,
6403            lm,
6404            &self.weights.final_norm,
6405            logits_out,
6406            &loop_norm_at,
6407            steps,
6408            emb_gw.as_ref().map(|(gw, rows, m)| (gw, *rows, *m)),
6409            ids_out,
6410            layers_run,
6411            from,
6412            false,
6413        )
6414        .then_some(h)
6415    }
6416
6417    /// Batched prefill: k contiguous prompt positions through the whole wgpu
6418    /// graph in ONE submit (projections/FFN as GEMMs). `hiddens` is [k·hidden]
6419    /// in/out (embeddings in, layer output out); KV mirror / GDN state advance.
6420    /// false ⇒ unsupported → caller keeps the per-position graph.
6421    /// The b-row Metal graph plan for the whole model: every layer as a
6422    /// GDN run or a full-attention item, all-or-nothing (a layer outside the
6423    /// graph's contract → None, the caller runs plain). Shared by the
6424    /// speculative verify and the batched prefill.
6425    #[cfg(target_os = "macos")]
6426    #[allow(clippy::type_complexity)]
6427    fn metal_rows_plan(&self) -> Option<(Vec<MetalRowsItem<'_>>, std::sync::Arc<cortiq_core::CmfModel>, Option<crate::gpu_metal::GdnGpuCfg>)> {
6428        use crate::gpu_metal::{AttnGpuLayer, GdnGpuCfg, GdnGpuLayer, MetalFfn};
6429        if !crate::gpu::q1_force()
6430            || !crate::gpu::enabled_here()
6431            || std::env::var("CMF_GPU_BLOCK").map(|v| v == "0").unwrap_or(false)
6432            || self.attn_softcap > 0.0
6433            || self.o1_active()
6434            || self.swa.is_some()
6435            || self.global_attn.is_some()
6436            || self.attention_heads_per_layer.is_some()
6437            || self.attn_v_norm
6438            || self.loop_final_norm
6439            || (self.attn_scale - 1.0 / (self.head_dim as f32).sqrt()).abs() > 1e-9
6440        {
6441            return None;
6442        }
6443        let attend_contract = self.head_dim % 4 == 0
6444            && self.head_dim <= 256
6445            && self.rotary_dim >= 2
6446            && self.rotary_dim <= self.head_dim
6447            && (self.rotary_dim / 2) % 32 == 0
6448            && self.num_kv_heads > 0
6449            && self.num_heads % self.num_kv_heads == 0;
6450        if !attend_contract {
6451            return None;
6452        }
6453        let mut plan: Vec<MetalRowsItem> = Vec::new();
6454        let mut model_ref: Option<std::sync::Arc<cortiq_core::CmfModel>> = None;
6455        for li in 0..self.num_layers {
6456            let lw = &self.weights.layers[self.phys_layer(li)];
6457            if lw.attn_out_norm.is_some() || lw.ffn_out_norm.is_some() || lw.layer_scale.is_some() {
6458                return None;
6459            }
6460            let ffn = match &lw.ffn {
6461                FfnKind::Dense(d) if d.act == Act::Silu => {
6462                    let (Some(g), Some(u), Some(dn)) =
6463                        (d.gate_proj.q1_parts(), d.up_proj.q1_parts(), d.down_proj.q1_parts())
6464                    else {
6465                        return None;
6466                    };
6467                    MetalFfn::Dense { gate: g, up: u, down: dn }
6468                }
6469                _ => return None,
6470            };
6471            match &lw.attn {
6472                AttnKind::LinearGdn(w) if self.gdn_cfg.is_some() => {
6473                    let (Some(qkv), Some(z), Some(a), Some(bb), Some(out)) = (
6474                        w.in_proj_qkv.q1_parts(),
6475                        w.in_proj_z.q1_parts(),
6476                        w.in_proj_a.f32_parts(),
6477                        w.in_proj_b.f32_parts(),
6478                        w.out_proj.q1_parts(),
6479                    ) else {
6480                        return None;
6481                    };
6482                    if let QTensor::Mapped { model, .. } = &w.in_proj_qkv {
6483                        model_ref.get_or_insert_with(|| model.clone());
6484                    }
6485                    let gl = GdnGpuLayer {
6486                        attn_norm: &lw.input_norm,
6487                        post_norm: &lw.post_norm,
6488                        qkv,
6489                        z,
6490                        a,
6491                        b: bb,
6492                        out,
6493                        ffn,
6494                        conv1d: &w.conv1d,
6495                        a_log: &w.a_log,
6496                        dt_bias: &w.dt_bias,
6497                        gnorm: &w.norm,
6498                    };
6499                    match plan.last_mut() {
6500                        Some(MetalRowsItem::Gdn { run, .. }) => run.push(gl),
6501                        _ => plan.push(MetalRowsItem::Gdn { run: vec![gl], first: li }),
6502                    }
6503                }
6504                AttnKind::Full {
6505                    wq,
6506                    wk,
6507                    wv,
6508                    wo,
6509                    q_norm,
6510                    k_norm,
6511                    output_gate,
6512                    softplus_gate: None,
6513                    bias: None,
6514                } => {
6515                    let (Some(pq), Some(pk), Some(pv), Some(po)) =
6516                        (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts())
6517                    else {
6518                        return None;
6519                    };
6520                    if let QTensor::Mapped { model, .. } = wq {
6521                        model_ref.get_or_insert_with(|| model.clone());
6522                    }
6523                    let cache = &self.kv_cache.layers[li];
6524                    if cache.mode != crate::kv_cache::KvMode::F32 || cache.o1.is_some() {
6525                        return None;
6526                    }
6527                    plan.push(MetalRowsItem::Attn {
6528                        l: AttnGpuLayer {
6529                            attn_norm: &lw.input_norm,
6530                            post_norm: &lw.post_norm,
6531                            wq: pq,
6532                            wk: pk,
6533                            wv: pv,
6534                            wo: po,
6535                            ffn,
6536                        },
6537                        li,
6538                        q_norm: q_norm.as_deref(),
6539                        k_norm: k_norm.as_deref(),
6540                        output_gate: *output_gate,
6541                    });
6542                }
6543                _ => return None,
6544            }
6545        }
6546        let model = model_ref?;
6547        let gcfg = self.gdn_cfg.map(|cfg| GdnGpuCfg {
6548            nv: cfg.num_v_heads,
6549            nk: cfg.num_k_heads,
6550            dk: cfg.key_head_dim,
6551            dv: cfg.value_head_dim,
6552            kk: cfg.conv_kernel,
6553            hidden: self.hidden_size,
6554            inter: self.intermediate_size,
6555            c_dim: cfg.conv_dim(),
6556            eps: cfg.rms_eps as f32,
6557            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6558        });
6559        Some((plan, model, gcfg))
6560    }
6561
6562    /// `AttnDeviceParams` for a plan item over the CPU cache as it stands.
6563    #[cfg(target_os = "macos")]
6564    #[allow(clippy::too_many_arguments)]
6565    fn metal_attn_params<'a>(
6566        li: usize,
6567        cache: &'a crate::kv_cache::LayerKvCache,
6568        q_norm: Option<&'a [f32]>,
6569        k_norm: Option<&'a [f32]>,
6570        output_gate: bool,
6571        inv_freq: &'a [f32],
6572        geom: (usize, usize, usize, usize),
6573        pos0: usize,
6574        kv_id: u64,
6575        eps: f32,
6576        gemma: bool,
6577    ) -> (crate::gpu_metal::AttnDeviceParams<'a>, usize) {
6578        let (nh, nkv, hd, rd) = geom;
6579        let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
6580        let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
6581        let cpu_stored = cpu_k[0].len() / hd;
6582        (
6583            crate::gpu_metal::AttnDeviceParams {
6584                kv_id,
6585                layer: li,
6586                nh,
6587                nkv,
6588                hd,
6589                rd,
6590                position: pos0,
6591                eps,
6592                gemma,
6593                output_gate,
6594                q_norm,
6595                k_norm,
6596                inv_freq,
6597                cpu_k,
6598                cpu_v,
6599                cpu_stored,
6600                o1: None,
6601            },
6602            cpu_stored,
6603        )
6604    }
6605
6606    /// Run the rows plan over `hiddens` (b rows at `pos0..`): validate,
6607    /// encode every item, optionally the head, sync. Returns the graph
6608    /// (for the commit / state finish) plus the GDN layer indices and the
6609    /// attention layers with the row count they were encoded against.
6610    #[cfg(target_os = "macos")]
6611    #[allow(clippy::type_complexity)]
6612    fn metal_rows_run(
6613        &mut self,
6614        hiddens: &mut [f32],
6615        pos0: usize,
6616        b: usize,
6617        prefill: bool,
6618        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
6619    ) -> Option<MetalVerifyPending> {
6620        use crate::gpu_metal::{GraphDims, VerifyGraph};
6621        let want = self.gdn_cfg.map(|c| c.state_len()).unwrap_or(0);
6622        for l in &mut self.kv_cache.layers {
6623            if l.linear_state.len() != want && want > 0 {
6624                l.linear_state = vec![0f32; want];
6625            }
6626        }
6627        let (plan, model, gcfg) = self.metal_rows_plan()?;
6628        let dims = GraphDims {
6629            hidden: self.hidden_size,
6630            eps: self.rms_eps as f32,
6631            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6632        };
6633        let mut graph = if prefill {
6634            VerifyGraph::new_prefill(&model, dims, hiddens, b)?
6635        } else {
6636            VerifyGraph::new(&model, dims, hiddens, b)?
6637        };
6638        let geom = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
6639        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
6640        let eps = self.rms_eps as f32;
6641        let kv_id = self.graph_kv_id;
6642        let inv_freq = self.inv_freq.clone();
6643        for item in &plan {
6644            let ok = match item {
6645                MetalRowsItem::Gdn { run, .. } => gcfg
6646                    .as_ref()
6647                    .map(|gc| run.iter().all(|l| graph.gdn_ok(l, gc)))
6648                    .unwrap_or(false),
6649                MetalRowsItem::Attn { l, li, q_norm, k_norm, output_gate } => {
6650                    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);
6651                    graph.attn_ok(l, &p)
6652                }
6653            };
6654            if !ok {
6655                use std::sync::atomic::{AtomicBool, Ordering};
6656                static SAID: AtomicBool = AtomicBool::new(false);
6657                if !SAID.swap(true, Ordering::Relaxed) {
6658                    tracing::warn!("metal rows graph: a layer failed preflight — declining");
6659                }
6660                return None;
6661            }
6662        }
6663        let lm = match &spec {
6664            Some((lm, _, _)) => {
6665                if !graph.lm_head_ok(*lm) {
6666                    return None;
6667                }
6668                Some(*lm)
6669            }
6670            None => None,
6671        };
6672        let mut gdn_layers = Vec::new();
6673        let mut attn_layers = Vec::new();
6674        for item in &plan {
6675            match item {
6676                MetalRowsItem::Gdn { run, first } => {
6677                    let ro: Vec<&[f32]> = self.kv_cache.layers[*first..*first + run.len()]
6678                        .iter()
6679                        .map(|l| l.linear_state.as_slice())
6680                        .collect();
6681                    if !graph.encode_gdn_run_b(run, &ro, gcfg.as_ref().unwrap()) {
6682                        return None;
6683                    }
6684                    gdn_layers.extend(*first..*first + run.len());
6685                }
6686                MetalRowsItem::Attn { l, li, q_norm, k_norm, output_gate } => {
6687                    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);
6688                    if !graph.encode_attn_b(l, &p) {
6689                        return None;
6690                    }
6691                    attn_layers.push((*li, cpu_stored));
6692                }
6693            }
6694        }
6695        if let (Some(lm), Some((_, final_norm, _))) = (lm, spec.as_ref()) {
6696            if !graph.encode_lm_head_b(final_norm, lm) {
6697                return None;
6698            }
6699        }
6700        graph.sync();
6701        if let Some((lm, _, logits)) = spec {
6702            logits.resize(b * lm.1, 0.0);
6703            graph.read_logits(logits);
6704        }
6705        graph.read_hidden(hiddens);
6706        Some(MetalVerifyPending { graph, gdn_layers, attn_layers })
6707    }
6708
6709    /// Native-Metal twin of `try_batch_graph_wgpu`: the b rows through the
6710    /// whole model on the `VerifyGraph` (one submit), the head folded in
6711    /// when `spec` asks; `hiddens` come back as the last layer's output
6712    /// rows, `spec.2` as `[b][lm_rows]` logits. The graph is parked in
6713    /// `metal_verify` for `metal_verify_commit`.
6714    #[cfg(target_os = "macos")]
6715    fn try_batch_graph_metal(
6716        &mut self,
6717        hiddens: &mut [f32],
6718        positions: &[usize],
6719        b: usize,
6720        spec: Option<((usize, usize, usize), &[f32], &mut Vec<f32>)>,
6721    ) -> bool {
6722        let _t0 = std::time::Instant::now();
6723        if positions.len() != b
6724            || positions.windows(2).any(|w| w[1] != w[0] + 1)
6725            || hiddens.len() != b * self.hidden_size
6726        {
6727            return false;
6728        }
6729        let Some(pending) = self.metal_rows_run(hiddens, positions[0], b, false, spec) else {
6730            return false;
6731        };
6732        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
6733            eprintln!("metal-verify: {:.1} ms | b={b}", _t0.elapsed().as_secs_f64() * 1e3);
6734        }
6735        self.metal_verify = Some(pending);
6736        true
6737    }
6738
6739    /// Batched prefill on the Metal rows graph: `ids` (≤ 512) at
6740    /// `start_pos..`, states written in place, K/V rows appended to the
6741    /// CPU caches; returns every position's output hidden (`[b][hidden]`).
6742    /// None = the graph declined before touching anything.
6743    #[cfg(target_os = "macos")]
6744    fn prefill_batch_metal(&mut self, ids: &[u32], start_pos: usize) -> Option<Vec<f32>> {
6745        let b = ids.len();
6746        if b == 0 || b > 512 {
6747            return None;
6748        }
6749        let hs = self.hidden_size;
6750        let mut hiddens = vec![0f32; b * hs];
6751        for (j, &id) in ids.iter().enumerate() {
6752            let e = self.embed_single(id);
6753            hiddens[j * hs..(j + 1) * hs].copy_from_slice(&e);
6754        }
6755        let mut pending = self.metal_rows_run(&mut hiddens, start_pos, b, true, None)?;
6756        // states are final: copy them to the owners
6757        let idxs = pending.gdn_layers.clone();
6758        let mut outs: Vec<&mut [f32]> = self
6759            .kv_cache
6760            .layers
6761            .iter_mut()
6762            .enumerate()
6763            .filter(|(i, _)| idxs.binary_search(i).is_ok())
6764            .map(|(_, l)| l.linear_state.as_mut_slice())
6765            .collect();
6766        pending.graph.finish_states(&mut outs);
6767        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
6768        let mut kbuf = vec![0f32; b * nkv * hd];
6769        let mut vbuf = vec![0f32; b * nkv * hd];
6770        for (li, cpu_stored) in &pending.attn_layers {
6771            if crate::gpu_metal::kv_mirror_read_rows(self.graph_kv_id, *li, nkv, hd, *cpu_stored, b, &mut kbuf, &mut vbuf) {
6772                let cache = &mut self.kv_cache.layers[*li];
6773                for r in 0..b {
6774                    cache.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6775                }
6776                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + b);
6777            }
6778        }
6779        Some(hiddens)
6780    }
6781
6782    /// Commit a Metal verify round: replay the GDN recurrences over the
6783    /// `a + 1` accepted positions into the CPU states, append the accepted
6784    /// K/V rows from the mirrors to the CPU caches, re-point the mirrors.
6785    #[cfg(target_os = "macos")]
6786    fn metal_verify_commit(&mut self, a: usize) -> bool {
6787        let Some(mut pending) = self.metal_verify.take() else {
6788            return false;
6789        };
6790        let n = a + 1;
6791        // encode order == ascending layer order (the plan walks 0..layers)
6792        let idxs = pending.gdn_layers.clone();
6793        let mut outs: Vec<&mut [f32]> = self
6794            .kv_cache
6795            .layers
6796            .iter_mut()
6797            .enumerate()
6798            .filter(|(i, _)| idxs.binary_search(i).is_ok())
6799            .map(|(_, l)| l.linear_state.as_mut_slice())
6800            .collect();
6801        if !pending.graph.commit(n, &mut outs) {
6802            return false;
6803        }
6804        let (nkv, hd) = (self.num_kv_heads, self.head_dim);
6805        let mut kbuf = vec![0f32; n * nkv * hd];
6806        let mut vbuf = vec![0f32; n * nkv * hd];
6807        for (li, cpu_stored) in &pending.attn_layers {
6808            if crate::gpu_metal::kv_mirror_read_rows(self.graph_kv_id, *li, nkv, hd, *cpu_stored, n, &mut kbuf, &mut vbuf) {
6809                let cache = &mut self.kv_cache.layers[*li];
6810                for r in 0..n {
6811                    cache.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6812                }
6813                crate::gpu_metal::kv_mirror_set_stored(self.graph_kv_id, *li, cpu_stored + n);
6814            }
6815        }
6816        true
6817    }
6818
6819    /// The round's warm-ups as ONE b-row graph run over the MTP block on
6820    /// Metal: `pairs` = (trunk hidden, next token) at consecutive positions
6821    /// from `first_pos`; the block's input projection is folded in, the
6822    /// appended K/V rows are pulled into the CPU MTP cache. False = the
6823    /// graph declined (nothing appended).
6824    #[cfg(target_os = "macos")]
6825    fn mtp_warm_batch_metal(&mut self, m: &mut MtpModule, pairs: &[(&[f32], u32)], first_pos: usize) -> bool {
6826        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, VerifyGraph};
6827        let b = pairs.len();
6828        if b == 0 || b > 512 || m.kv.mode != crate::kv_cache::KvMode::F32 || m.kv.o1.is_some() {
6829            return false;
6830        }
6831        let AttnKind::Full { wq, wk, wv, wo, q_norm, k_norm, output_gate, softplus_gate: None, bias: None } = &m.layer.attn else {
6832            return false;
6833        };
6834        let FfnKind::Dense(d) = &m.layer.ffn else { return false };
6835        let (Some(pq), Some(pk), Some(pv), Some(po)) = (wq.q1_parts(), wk.q1_parts(), wv.q1_parts(), wo.q1_parts()) else {
6836            return false;
6837        };
6838        let (Some(g), Some(u), Some(dn)) = (d.gate_proj.q1_parts(), d.up_proj.q1_parts(), d.down_proj.q1_parts()) else {
6839            return false;
6840        };
6841        let Some(eh) = m.eh_proj.q1_parts() else { return false };
6842        let QTensor::Mapped { model, .. } = wq else { return false };
6843        let model = model.clone();
6844        let hs = self.hidden_size;
6845        // [enorm(embed(tok)); hnorm(hidden)] rows
6846        let mut cat = vec![0f32; b * 2 * hs];
6847        for (j, (h, tok)) in pairs.iter().enumerate() {
6848            let e = self.embed_single(*tok);
6849            let (ce, ch) = cat[j * 2 * hs..(j + 1) * 2 * hs].split_at_mut(hs);
6850            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, ce);
6851            inference::rms_norm_into(h, &m.hnorm, self.rms_eps, self.norm_style, ch);
6852        }
6853        let dims = GraphDims { hidden: hs, eps: self.rms_eps as f32, gemma: self.norm_style == cortiq_core::NormStyle::Gemma };
6854        let Some(mut graph) = VerifyGraph::new_via_proj(&model, dims, eh, &cat, b) else {
6855            return false;
6856        };
6857        let l = AttnGpuLayer {
6858            attn_norm: &m.layer.input_norm,
6859            post_norm: &m.layer.post_norm,
6860            wq: pq,
6861            wk: pk,
6862            wv: pv,
6863            wo: po,
6864            ffn: MetalFfn::Dense { gate: g, up: u, down: dn },
6865        };
6866        let (nh, nkv, hd, rd) = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
6867        let inv_freq = self.inv_freq.clone();
6868        let cpu_stored;
6869        {
6870            let cache = &m.kv;
6871            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
6872            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
6873            cpu_stored = cpu_k[0].len() / hd;
6874            if cpu_stored != first_pos {
6875                return false;
6876            }
6877            let p = AttnDeviceParams {
6878                kv_id: self.mtp_kv_id(),
6879                layer: Self::MTP_LAYER_BASE,
6880                nh,
6881                nkv,
6882                hd,
6883                rd,
6884                position: first_pos,
6885                eps: self.rms_eps as f32,
6886                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6887                output_gate: *output_gate,
6888                q_norm: q_norm.as_deref(),
6889                k_norm: k_norm.as_deref(),
6890                inv_freq: &inv_freq,
6891                cpu_k,
6892                cpu_v,
6893                cpu_stored,
6894                o1: None,
6895            };
6896            if !graph.attn_ok(&l, &p) || !graph.encode_attn_b(&l, &p) {
6897                return false;
6898            }
6899        }
6900        graph.sync();
6901        let mut kbuf = vec![0f32; b * nkv * hd];
6902        let mut vbuf = vec![0f32; b * nkv * hd];
6903        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) {
6904            return false;
6905        }
6906        for r in 0..b {
6907            m.kv.append(&kbuf[r * nkv * hd..(r + 1) * nkv * hd], &vbuf[r * nkv * hd..(r + 1) * nkv * hd], &[]);
6908        }
6909        crate::gpu_metal::kv_mirror_set_stored(self.mtp_kv_id(), Self::MTP_LAYER_BASE, cpu_stored + b);
6910        true
6911    }
6912
6913    /// Draft-head shortlist size: `CMF_DRAFT_VOCAB` rows (default 65536,
6914    /// capped at the head; 0 = full head).
6915    fn draft_vocab_rows(head_rows: usize) -> usize {
6916        static N: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
6917        let n = *N.get_or_init(|| {
6918            std::env::var("CMF_DRAFT_VOCAB")
6919                .ok()
6920                .and_then(|v| v.parse().ok())
6921                .unwrap_or(65536)
6922        });
6923        if n == 0 { head_rows } else { n.min(head_rows) }
6924    }
6925
6926    /// One MTP block step on the native Metal token graph: block input on
6927    /// the host, the attention layer + FFN device-resident over the MTP
6928    /// mirror, the head folded in when `want_logits`. The appended K/V row
6929    /// is pulled into the CPU MTP cache (owner of record) after the sync.
6930    #[cfg(target_os = "macos")]
6931    fn mtp_step_metal(
6932        &mut self,
6933        m: &mut MtpModule,
6934        hidden: &[f32],
6935        next_token: u32,
6936        position: usize,
6937        want_logits: bool,
6938    ) -> Option<(Vec<f32>, Vec<f32>)> {
6939        use crate::gpu_metal::{AttnDeviceParams, AttnGpuLayer, GraphDims, MetalFfn, TokenGraph};
6940        if std::env::var("CMF_MTP_GRAPH").as_deref() == Ok("0")
6941            || !crate::gpu::q1_force()
6942            || !crate::gpu::enabled_here()
6943            || self.attn_softcap > 0.0
6944            || self.attention_heads_per_layer.is_some()
6945            || m.kv.mode != crate::kv_cache::KvMode::F32
6946            || m.kv.o1.is_some()
6947        {
6948            return None;
6949        }
6950        let AttnKind::Full {
6951            wq,
6952            wk,
6953            wv,
6954            wo,
6955            q_norm,
6956            k_norm,
6957            output_gate,
6958            softplus_gate: None,
6959            bias: None,
6960        } = &m.layer.attn
6961        else {
6962            return None;
6963        };
6964        let FfnKind::Dense(d) = &m.layer.ffn else { return None };
6965        if d.act != Act::Silu {
6966            return None;
6967        }
6968        let (pq, pk, pv, po) = (wq.q1_parts()?, wk.q1_parts()?, wv.q1_parts()?, wo.q1_parts()?);
6969        let (g, u, dn) = (d.gate_proj.q1_parts()?, d.up_proj.q1_parts()?, d.down_proj.q1_parts()?);
6970        let QTensor::Mapped { model, .. } = wq else { return None };
6971        let model = model.clone();
6972        let lm = if want_logits { Some(self.weights.lm_head.q1_parts()?) } else { None };
6973        let dims = GraphDims {
6974            hidden: self.hidden_size,
6975            eps: self.rms_eps as f32,
6976            gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
6977        };
6978        // The block input `eh_proj · [enorm(e); hnorm(h)]` rides in the
6979        // graph (one submit a step); the host per-op matvec if it cannot.
6980        let hs = self.hidden_size;
6981        let mut x = vec![0f32; hs];
6982        let mut graph = TokenGraph::new(&model, dims, &x)?;
6983        let mut folded = false;
6984        if let Some(eh) = m.eh_proj.q1_parts() {
6985            let e = self.embed_single(next_token);
6986            let mut cat = vec![0.0f32; 2 * hs];
6987            let (cat_e, cat_h) = cat.split_at_mut(hs);
6988            inference::rms_norm_into(&e, &m.enorm, self.rms_eps, self.norm_style, cat_e);
6989            inference::rms_norm_into(hidden, &m.hnorm, self.rms_eps, self.norm_style, cat_h);
6990            folded = graph.encode_input_proj(eh, &cat);
6991        }
6992        if !folded {
6993            x = self.mtp_block_input(m, hidden, next_token);
6994            graph = TokenGraph::new(&model, dims, &x)?;
6995        }
6996        let l = AttnGpuLayer {
6997            attn_norm: &m.layer.input_norm,
6998            post_norm: &m.layer.post_norm,
6999            wq: pq,
7000            wk: pk,
7001            wv: pv,
7002            wo: po,
7003            ffn: MetalFfn::Dense { gate: g, up: u, down: dn },
7004        };
7005        let (nh, nkv, hd, rd) = (self.num_heads, self.num_kv_heads, self.head_dim, self.rotary_dim);
7006        let inv_freq = self.inv_freq.clone();
7007        {
7008            let cache = &m.kv;
7009            let cpu_k: Vec<&[f32]> = (0..nkv).map(|g| cache.head_keys(g)).collect();
7010            let cpu_v: Vec<&[f32]> = (0..nkv).map(|g| cache.head_values(g)).collect();
7011            let cpu_stored = cpu_k[0].len() / hd;
7012            let p = AttnDeviceParams {
7013                kv_id: self.mtp_kv_id(),
7014                layer: Self::MTP_LAYER_BASE,
7015                nh,
7016                nkv,
7017                hd,
7018                rd,
7019                position,
7020                eps: self.rms_eps as f32,
7021                gemma: self.norm_style == cortiq_core::NormStyle::Gemma,
7022                output_gate: *output_gate,
7023                q_norm: q_norm.as_deref(),
7024                k_norm: k_norm.as_deref(),
7025                inv_freq: &inv_freq,
7026                cpu_k,
7027                cpu_v,
7028                cpu_stored,
7029                o1: None,
7030            };
7031            if !graph.attn_device_ok(&l, &p) || !graph.encode_attn_device(&l, &p) {
7032                return None;
7033            }
7034        }
7035        // The draft's head over a vocabulary SHORTLIST (the first
7036        // CMF_DRAFT_VOCAB rows — BPE ids run roughly by merge rank, so the
7037        // low ids carry the mass): the verify keeps the full head, so a true
7038        // token past the cut is only a rejected draft, never a wrong token.
7039        // 662 MB a step on Qwen3.8 becomes 170 MB at 65536.
7040        let draft_rows = if let Some(lm) = lm { Self::draft_vocab_rows(lm.1) } else { 0 };
7041        if let Some(lm) = lm {
7042            if !graph.lm_head_ok(lm) {
7043                return None;
7044            }
7045            if draft_rows < lm.1 {
7046                if !graph.encode_lm_head_part(&m.final_norm, lm, draft_rows) {
7047                    return None;
7048                }
7049            } else {
7050                graph.encode_lm_head(&m.final_norm, lm);
7051            }
7052        }
7053        graph.sync();
7054        let mut logits = Vec::new();
7055        if let Some(lm) = lm {
7056            let n_read = draft_rows.min(lm.1).min(self.vocab_size);
7057            logits = attention::take_buf(n_read);
7058            graph.read_logits(&mut logits);
7059            // ids past the shortlist: never drafted (−∞ in every chain)
7060            logits.resize(self.vocab_size, f32::NEG_INFINITY);
7061        }
7062        graph.finish(&mut x);
7063        let mut krow = attention::take_buf(nkv * hd);
7064        let mut vrow = attention::take_buf(nkv * hd);
7065        if crate::gpu_metal::kv_mirror_read_last(self.mtp_kv_id(), Self::MTP_LAYER_BASE, nkv, hd, &mut krow, &mut vrow) {
7066            m.kv.append(&krow, &vrow, &[]);
7067        }
7068        attention::recycle_buf(&mut krow);
7069        attention::recycle_buf(&mut vrow);
7070        Some((logits, x))
7071    }
7072
7073    fn try_batch_graph_wgpu(
7074        &self,
7075        hiddens: &mut [f32],
7076        positions: &[usize],
7077        k: usize,
7078        spec: Option<crate::gpu::SpecTail<'_>>,
7079    ) -> bool {
7080        let _tb = std::time::Instant::now();
7081        if self.attn_softcap > 0.0 {
7082            return false; // capped scores: no graph kernel — CPU path
7083        }
7084        if self.o1_active() {
7085            return false;
7086        }
7087        let nh = self.num_heads;
7088        let (nkv, hd, rd) = self.layer_geom(0);
7089        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
7090        fn gw(t: &QTensor) -> Option<crate::gpu::GraphW<'_>> {
7091            if let Some((_, i, kind, rs)) = t.graph_weight() {
7092                return Some(crate::gpu::GraphW {
7093                    idx: i,
7094                    kind,
7095                    row_scale: rs,
7096                    data: &[],
7097                });
7098            }
7099            t.as_f32().map(|d| crate::gpu::GraphW {
7100                idx: 0,
7101                kind: 4,
7102                row_scale: &[],
7103                data: d,
7104            })
7105        }
7106        let built: Option<(
7107            Vec<crate::gpu::GraphLayer<'_>>,
7108            std::sync::Arc<cortiq_core::CmfModel>,
7109        )> = (|| {
7110            let mut layers = Vec::with_capacity(self.num_layers);
7111            let mut model = None;
7112            for li in 0..self.num_layers {
7113                let lw = &self.weights.layers[self.phys_layer(li)];
7114                // MoE routes per token, so its experts are encoded token by
7115                // token inside the batched submit while attention and the
7116                // projections stay GEMMs. Refusing MoE here is what left
7117                // prefill running one position at a time: 33 tok/s against
7118                // 54 on decode, i.e. reading the prompt was slower than
7119                // writing the answer.
7120                let gffn = match &lw.ffn {
7121                    FfnKind::Dense(d) => crate::gpu::GraphFfn::Dense {
7122                        gate: gw(&d.gate_proj)?,
7123                        up: gw(&d.up_proj)?,
7124                        down: gw(&d.down_proj)?,
7125                    },
7126                    FfnKind::Moe(m) => {
7127                        if m.router_sigmoid
7128                            || m.expert_bias.is_some()
7129                            || m.route_tau.is_some()
7130                            || m.mask.is_some()
7131                        {
7132                            return None;
7133                        }
7134                        let (se, sg) = m.shared.as_ref()?;
7135                        let sgate = gw(sg.as_ref()?)?;
7136                        let router = gw(&m.router)?;
7137                        let inter = m.experts.first()?.gate_proj.rows();
7138                        let mut experts = Vec::with_capacity(m.experts.len() + 1);
7139                        let mut q4tp: Option<bool> = None;
7140                        let mut gu_q2: Option<bool> = None;
7141                        for e in m.experts.iter().chain(std::iter::once(se)) {
7142                            if !matches!(e.act, Act::Silu)
7143                                || e.gate_proj.rows() != inter
7144                                || e.up_proj.rows() != inter
7145                            {
7146                                return None;
7147                            }
7148                            // Same ladder as the token graph: q4t → q2tp
7149                            // (mixed profile: 2-bit gate/up over a q4tp
7150                            // down) → q4tp. Uniform across the layer.
7151                            let (mm, gi, ui, di, is_p, is_q2) = match e.gate_proj.mapped_q4t() {
7152                                Some((mm, gi)) => (
7153                                    mm,
7154                                    gi,
7155                                    e.up_proj.mapped_q4t()?.1,
7156                                    e.down_proj.mapped_q4t()?.1,
7157                                    false,
7158                                    false,
7159                                ),
7160                                None => match e.gate_proj.mapped_q2tp() {
7161                                    Some((mm, gi)) => (
7162                                        mm,
7163                                        gi,
7164                                        e.up_proj.mapped_q2tp()?.1,
7165                                        e.down_proj.mapped_q4tp()?.1,
7166                                        true,
7167                                        true,
7168                                    ),
7169                                    None => {
7170                                        let (mm, gi) = e.gate_proj.mapped_q4tp()?;
7171                                        (
7172                                            mm,
7173                                            gi,
7174                                            e.up_proj.mapped_q4tp()?.1,
7175                                            e.down_proj.mapped_q4tp()?.1,
7176                                            true,
7177                                            false,
7178                                        )
7179                                    }
7180                                },
7181                            };
7182                            if *q4tp.get_or_insert(is_p) != is_p
7183                                || *gu_q2.get_or_insert(is_q2) != is_q2
7184                            {
7185                                return None;
7186                            }
7187                            model.get_or_insert_with(|| mm.clone());
7188                            experts.push((gi, ui, di));
7189                        }
7190                        crate::gpu::GraphFfn::Moe {
7191                            router,
7192                            shared_gate: sgate,
7193                            experts,
7194                            n_exp: m.experts.len(),
7195                            top_k: m.top_k,
7196                            inter,
7197                            norm_topk: m.norm_topk_prob,
7198                            q4tp: q4tp?,
7199                            gu_q2: gu_q2.unwrap_or(false),
7200                        }
7201                    }
7202                    _ => return None,
7203                };
7204                let attn = match &lw.attn {
7205                    AttnKind::Full {
7206                        wq,
7207                        wk,
7208                        wv,
7209                        wo,
7210                        q_norm,
7211                        k_norm,
7212                        output_gate,
7213                        softplus_gate,
7214                        bias,
7215                    } => {
7216                        if softplus_gate.is_some() || self.attention_heads_per_layer.is_some() {
7217                            return None;
7218                        }
7219                        let (m, _, _, _) = wq.graph_weight()?;
7220                        model = Some(m.clone());
7221                        crate::gpu::GraphAttn::Full {
7222                            wq: gw(wq)?,
7223                            wk: gw(wk)?,
7224                            wv: gw(wv)?,
7225                            wo: gw(wo)?,
7226                            q_norm: q_norm.as_deref(),
7227                            k_norm: k_norm.as_deref(),
7228                            bias: bias
7229                                .as_ref()
7230                                .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
7231                            output_gate: *output_gate,
7232                            cpu_k: self.kv_cache.layers[li].k_heads(),
7233                            cpu_v: self.kv_cache.layers[li].v_heads(),
7234                        }
7235                    }
7236                    AttnKind::LinearGdn(w) => {
7237                        let cfg = self.gdn_cfg?;
7238                        let (m, _, _, _) = w.in_proj_qkv.graph_weight()?;
7239                        model = Some(m.clone());
7240                        crate::gpu::GraphAttn::Gdn {
7241                            qkv: gw(&w.in_proj_qkv)?,
7242                            z: gw(&w.in_proj_z)?,
7243                            a: gw(&w.in_proj_a)?,
7244                            b: gw(&w.in_proj_b)?,
7245                            out: gw(&w.out_proj)?,
7246                            conv1d: &w.conv1d,
7247                            a_log: &w.a_log,
7248                            dt_bias: &w.dt_bias,
7249                            norm: &w.norm,
7250                            nv: cfg.num_v_heads,
7251                            nk: cfg.num_k_heads,
7252                            dk: cfg.key_head_dim,
7253                            dv: cfg.value_head_dim,
7254                            kk: cfg.conv_kernel,
7255                            cpu_state: &self.kv_cache.layers[self.phys_layer(li)].linear_state,
7256                        }
7257                    }
7258                    _ => return None,
7259                };
7260                layers.push(crate::gpu::GraphLayer {
7261                    input_norm: &lw.input_norm,
7262                    attn,
7263                    post_norm: &lw.post_norm,
7264                    ffn: gffn,
7265                });
7266            }
7267            Some((layers, model?))
7268        })();
7269        let Some((layers, model)) = built else {
7270            {
7271                use std::sync::atomic::{AtomicBool, Ordering};
7272                static SAID: AtomicBool = AtomicBool::new(false);
7273                if !SAID.swap(true, Ordering::Relaxed) {
7274                    tracing::warn!("batch graph: BUILDER refused (layer weights/kinds)");
7275                }
7276            }
7277            return false;
7278        };
7279        if std::env::var("CMF_GRAPH_SPEC_TIME").is_ok() {
7280            eprintln!("batch-build: {:.1} ms", _tb.elapsed().as_secs_f64() * 1e3);
7281        }
7282        crate::gpu::forward_batch_graph(
7283            &model,
7284            self.graph_kv_id,
7285            &layers,
7286            &self.inv_freq,
7287            hiddens,
7288            nh,
7289            nkv,
7290            hd,
7291            rd,
7292            self.hidden_size,
7293            self.intermediate_size,
7294            positions,
7295            self.kv_cache.max_seq_len,
7296            gemma,
7297            self.rms_eps as f32,
7298            k,
7299            spec,
7300        )
7301    }
7302
7303    /// Same, stopping after layer `upto` inclusive (routing probe φ).
7304    /// `CMF_DSV4_DRAFT_PROBE=1` — grade the draft against what the trunk goes on
7305    /// to produce. Off by default; it runs a whole draft per decoded token.
7306    fn draft_probe() -> bool {
7307        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7308        *ON.get_or_init(|| std::env::var("CMF_DSV4_DRAFT_PROBE").is_ok_and(|v| v != "0"))
7309    }
7310
7311    /// `CMF_DSV4_DRAFT_PROBE=1`: measure how much of the draft the trunk
7312    /// would have agreed with, WITHOUT verifying or rolling anything back.
7313    ///
7314    /// The number this produces decides the whole speculation design — at
7315    /// acceptance a, a block of B positions yields 1 + a + a² + ... tokens
7316    /// per trunk pass — so it is worth measuring before any of the machinery
7317    /// that would exploit it exists. Each draft is parked with the position
7318    /// it was made at, and graded as the real tokens arrive.
7319    /// `CMF_DSV4_SPEC=1` — the DeepSeek-V4 speculative decode: draft five
7320    /// on the card, verify them in one batched trunk pass, commit the
7321    /// accepted prefix, roll the rest back.
7322    #[cfg(feature = "gpu")]
7323    fn dsv4_spec_on() -> bool {
7324        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7325        *ON.get_or_init(|| {
7326            std::env::var("CMF_DSV4_SPEC")
7327                .map(|v| v != "0")
7328                .unwrap_or(true)
7329        })
7330    }
7331
7332    /// One speculative round at the decode tip. `t_next` is the token the
7333    /// sampler just committed for `next_pos`. Returns the EXTRA accepted
7334    /// tokens (possibly none) and the new position, with `graph_logits`
7335    /// left holding the last accepted position's logits — exactly what the
7336    /// loop top expects. `None` means "speculate not this round": nothing
7337    /// was committed, the caller forwards normally.
7338    #[cfg(feature = "gpu")]
7339    fn dsv4_spec_step(
7340        &mut self,
7341        tip_token: u32,
7342        t_next: u32,
7343        next_pos: usize,
7344        drafted: &mut usize,
7345        accepted_ctr: &mut usize,
7346    ) -> Option<(Vec<u32>, usize)> {
7347        let t_all = std::time::Instant::now();
7348        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7349            thread_local! {
7350                static LAST: std::cell::Cell<Option<std::time::Instant>> =
7351                    const { std::cell::Cell::new(None) };
7352            }
7353            LAST.with(|l| {
7354                if let Some(prev) = l.get() {
7355                    eprintln!(
7356                        "между раундами {:.1} мс",
7357                        prev.elapsed().as_secs_f64() * 1e3
7358                    );
7359                }
7360                l.set(Some(std::time::Instant::now()));
7361            });
7362        }
7363        if std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
7364            eprintln!("spec_step: вход pos={next_pos}");
7365        }
7366        let n_layers = self.dsv4.as_ref().map(|b| b.1.len())?;
7367        let cfg = self.dsv4.as_ref().map(|b| b.2)?;
7368        // The draft state and its capture, armed exactly as the probe does.
7369        if self.dspark.is_none() {
7370            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7371            if t.is_empty() {
7372                return None;
7373            }
7374            crate::dsv4::dspark_arm(&t, cfg.dim);
7375            self.dspark = Some(crate::dsv4::DsparkState::new(
7376                self.dsv4_mtp.len(),
7377                &cfg,
7378                t.len(),
7379            ));
7380        }
7381        let targets = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7382        let pack = crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg);
7383        if pack.is_none() && std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok() {
7384            eprintln!("spec_step: пак не построился (targets {targets:?})");
7385        }
7386        let pack = pack?;
7387        let block = crate::dsv4::dspark_block();
7388        let b_box = self.dsv4.as_mut()?;
7389        let (g, layers, st) = (&b_box.0, &b_box.1, &mut b_box.3);
7390        let ds = self.dspark.as_mut()?;
7391        // The tip's captures: either this token ran on a normal path that
7392        // filled the thread-local, or the previous spec round left them.
7393        let dbg = std::env::var("CMF_DSV4_SPEC_DEBUG").is_ok();
7394        if !crate::dsv4::dspark_take(&mut ds.main_hidden) && !ds.have_hidden {
7395            if dbg {
7396                eprintln!("spec_step: нет захвата");
7397            }
7398            return None;
7399        }
7400        ds.have_hidden = true;
7401        let tip_pos = next_pos.checked_sub(1)?;
7402        let draft_started = std::time::Instant::now();
7403        let mut conf = Vec::new();
7404        let props = crate::dsv4::dspark_draft_gpu(
7405            g,
7406            &self.dsv4_mtp,
7407            &cfg,
7408            ds,
7409            pack,
7410            st.kv_id,
7411            tip_token,
7412            tip_pos,
7413            self.pool.as_deref(),
7414            &mut conf,
7415        );
7416        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
7417        *drafted += block;
7418        if props.is_empty() || props[0] != t_next {
7419            if dbg {
7420                eprintln!(
7421                    "spec_step: черновик {} (props0={:?} t_next={t_next})",
7422                    if props.is_empty() {
7423                        "пуст"
7424                    } else {
7425                        "мимо"
7426                    },
7427                    props.first()
7428                );
7429            }
7430            return None;
7431        }
7432        let mut k_verify = crate::dsv4::dspark_verify_k().min(props.len());
7433        // Adaptive depth: positions the draft itself doubts are paid for on
7434        // every verify and delivered almost never (natural-text survival
7435        // [.67 .50 .29 .08 .04]). `CMF_DSPARK_CONF_MIN=p` trims the fed
7436        // prefix at the first proposal whose confidence drops below p; on
7437        // predictable text the confidences stay high and nothing changes.
7438        let conf_min = {
7439            static M: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
7440            *M.get_or_init(|| {
7441                std::env::var("CMF_DSPARK_CONF_MIN")
7442                    .ok()
7443                    .and_then(|v| v.parse().ok())
7444                    .unwrap_or(0.0)
7445            })
7446        };
7447        if conf_min > 0.0 && conf.len() >= props.len() {
7448            let mut keep = 1usize;
7449            while keep < k_verify && conf.get(keep).copied().unwrap_or(0.0) >= conf_min {
7450                keep += 1;
7451            }
7452            k_verify = k_verify.min(keep.max(2));
7453        }
7454        if k_verify < 2 {
7455            return None;
7456        }
7457        let mut fed = Vec::with_capacity(k_verify);
7458        fed.push(t_next);
7459        fed.extend_from_slice(&props[1..k_verify]);
7460        let mut argmax = Vec::new();
7461        let mut logits_all = Vec::new();
7462        let mut walked = Vec::new();
7463        let txn = crate::dsv4::dsv4_verify_chunk(
7464            g,
7465            layers,
7466            &cfg,
7467            st,
7468            &fed,
7469            next_pos,
7470            &self.inv_freq,
7471            self.pool.as_deref(),
7472            &targets,
7473            &mut argmax,
7474            &mut logits_all,
7475            &mut walked,
7476        );
7477        if txn.is_none() && dbg {
7478            eprintln!("spec_step: verify отказал");
7479        }
7480        let txn = txn?;
7481        let b = fed.len();
7482        let mut accepted = 1usize;
7483        while accepted < b && fed[accepted] == argmax[accepted - 1] {
7484            accepted += 1;
7485        }
7486        // `CMF_DSV4_SPEC_FORCE_REJECT=1` — accept nothing beyond the known
7487        // token, every round: the pure rollback exerciser. The output must
7488        // stay byte-identical to the plain walk; anything else is a
7489        // transaction bug, isolated from the acceptance logic.
7490        if std::env::var("CMF_DSV4_SPEC_FORCE_REJECT").is_ok_and(|v| v != "0") {
7491            accepted = 1;
7492        }
7493        if std::env::var("CMF_DSV4_SPEC_TRACE").is_ok() {
7494            eprintln!("spec@{next_pos}: fed={fed:?} argmax={argmax:?} accepted={accepted}");
7495        }
7496        let t_fin = std::time::Instant::now();
7497        if !crate::dsv4::dsv4_spec_finish(
7498            g,
7499            layers,
7500            &cfg,
7501            st,
7502            txn,
7503            accepted,
7504            &fed,
7505            &self.inv_freq,
7506            self.pool.as_deref(),
7507        ) {
7508            tracing::warn!("dsv4: спекулятивный откат не удался — состояние подозрительно");
7509            return None;
7510        }
7511        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7512            eprintln!(
7513                "finish(k={accepted}): {:.1} мс",
7514                t_fin.elapsed().as_secs_f64() * 1e3
7515            );
7516        }
7517        *accepted_ctr += accepted - 1;
7518        // Captures per accepted token: device targets photographed by the
7519        // batch, host targets from the verify's own walk. The last one
7520        // becomes the new tip's draft input; every one owes the ring an
7521        // entry for its position.
7522        let (hc, dim) = (cfg.hc_mult, cfg.dim);
7523        // A PARTIAL capture layer never rides the chain, so the batch has
7524        // no photograph of it — its tip capture comes from the walk's own
7525        // note like any host layer's. Filtering on the device set alone
7526        // handed the draft a never-written photo slot for exactly the
7527        // most important input (the last layer feeds main_proj), and the
7528        // split configurations drafted at 27% no matter the residency.
7529        let dev_caps: Vec<usize> = targets
7530            .iter()
7531            .copied()
7532            .filter(|&t| {
7533                st.dev_set.get(t).copied().unwrap_or(false)
7534                    && !st.partial_set.get(t).copied().unwrap_or(false)
7535            })
7536            .collect();
7537        let mut caps_all = vec![0.0f32; dev_caps.len() * b * hc * dim];
7538        if !crate::gpu_wgpu::dsv4_spec_cap_read_all(b, dev_caps.len(), hc * dim, &mut caps_all) {
7539            return None;
7540        }
7541        for t in 0..accepted {
7542            let tip = t + 1 == accepted;
7543            for (slot, &tl) in targets.iter().enumerate() {
7544                if let Some(di) = dev_caps.iter().position(|&d| d == tl) {
7545                    let lo = (di * b + t) * hc * dim;
7546                    crate::dsv4::dspark_capture(
7547                        &caps_all[lo..lo + hc * dim],
7548                        &cfg,
7549                        slot,
7550                        &mut ds.main_hidden,
7551                    );
7552                } else if tip
7553                    && crate::dsv4::dspark_peek_slot(slot, dim, {
7554                        let lo = slot * dim;
7555                        &mut ds.main_hidden[lo..lo + dim]
7556                    })
7557                {
7558                    // The tip's host-layer captures are the walk's own
7559                    // per-layer notes — exact. (The walk that ran last ended
7560                    // on exactly this token, on both the accept-all and the
7561                    // rollback path.)
7562                } else {
7563                    // Intermediate tokens: the post-tail state stands in for
7564                    // the per-layer capture on host targets below the last
7565                    // layer. Ring-entry quality only; the tip is exact.
7566                    crate::dsv4::dspark_capture(
7567                        &walked[t * hc * dim..(t + 1) * hc * dim],
7568                        &cfg,
7569                        slot,
7570                        &mut ds.main_hidden,
7571                    );
7572                }
7573            }
7574            crate::dsv4::dspark_ring_append(
7575                g,
7576                &self.dsv4_mtp,
7577                &cfg,
7578                ds,
7579                next_pos + t,
7580                self.pool.as_deref(),
7581            );
7582        }
7583        let row = logits_all[(accepted - 1) * cfg.vocab..accepted * cfg.vocab].to_vec();
7584        self.graph_logits = Some(row);
7585        // The speculative loop never runs the probe, so the trunk tally has
7586        // no other place to cycle. Armed only when someone asked for the
7587        // dump; the host tail is the only tallying path here, which is
7588        // precisely the population a partial pack would serve.
7589        if std::env::var("CMF_DSV4_TRUNK_PICK_DUMP").is_ok() {
7590            crate::dsv4::trunk_freq_note(&crate::dsv4::pick_tally_take());
7591            crate::dsv4::pick_tally_arm();
7592        }
7593        if std::env::var("CMF_DSV4_SPEC_TIME").is_ok() {
7594            eprintln!(
7595                "spec_step total {:.1} мс (k={accepted})",
7596                t_all.elapsed().as_secs_f64() * 1e3
7597            );
7598        }
7599        Some((fed[1..accepted].to_vec(), next_pos + accepted))
7600    }
7601
7602    fn dspark_probe(&mut self, position: usize, token_id: u32) {
7603        if self.dsv4_mtp.is_empty() || !Self::draft_probe() {
7604            return;
7605        }
7606        // What the trunk just routed to, for this token.
7607        let trunk_now = crate::dsv4::pick_tally_take();
7608        crate::dsv4::trunk_freq_note(&trunk_now);
7609        if !trunk_now.is_empty() {
7610            self.dspark_trunk_picks.push(trunk_now);
7611            let keep = crate::dsv4::dspark_block();
7612            if self.dspark_trunk_picks.len() > keep {
7613                self.dspark_trunk_picks.remove(0);
7614            }
7615        }
7616        // Grade whatever is waiting: the token just decoded sits at
7617        // `position`, so it answers the draft made at `position - 1 - i`.
7618        for p in std::mem::take(&mut self.dspark_pending) {
7619            let Some(i) = position.checked_sub(p.0 + 1) else {
7620                continue;
7621            };
7622            let mut p = p;
7623            if i < p.1.len() {
7624                if p.2 && p.1[i] == token_id {
7625                    p.3 = i + 1;
7626                } else {
7627                    p.2 = false;
7628                }
7629                if i + 1 < p.1.len() {
7630                    self.dspark_pending.push(p);
7631                    continue;
7632                }
7633            }
7634            self.dspark_hist.push(p.3);
7635            self.dspark_real.push(token_id);
7636        }
7637        let Some(b) = &mut self.dsv4 else { return };
7638        let (g, layers, cfg) = (&b.0, &b.1, b.2);
7639        let n_layers = layers.len();
7640        if self.dspark.is_none() {
7641            let t = crate::dsv4::dspark_targets(&self.dsv4_mtp, &cfg, n_layers);
7642            if t.is_empty() {
7643                return;
7644            }
7645            eprintln!(
7646                "DSpark: захват со слоёв {t:?}, блок {}",
7647                crate::dsv4::dspark_block()
7648            );
7649            crate::dsv4::dspark_arm(&t, cfg.dim);
7650            self.dspark = Some(crate::dsv4::DsparkState::new(
7651                self.dsv4_mtp.len(),
7652                &cfg,
7653                t.len(),
7654            ));
7655        }
7656        let ds = self.dspark.as_mut().unwrap();
7657        if !crate::dsv4::dspark_take(&mut ds.main_hidden) {
7658            return; // this token ran on a path that captures nothing
7659        }
7660        let mut conf = Vec::new();
7661        crate::dsv4::pick_tally_arm();
7662        // The trunk has already consumed the adaptive VRAM budget. Until the
7663        // draft owns an explicit bounded device pack, its tensors are an
7664        // out-of-core CPU/disk tier by contract: never let per-op probes try
7665        // to squeeze another multi-gigabyte MTP expert cache onto the card.
7666        let draft_started = std::time::Instant::now();
7667        #[cfg(feature = "gpu")]
7668        let gpu_draft = crate::dsv4::dspark_gpu_on();
7669        #[cfg(not(feature = "gpu"))]
7670        let gpu_draft = false;
7671        let props = if gpu_draft {
7672            #[cfg(feature = "gpu")]
7673            {
7674                let kv_id = b.3.kv_id;
7675                match crate::dsv4::dspark_pack_get(&self.dsv4_mtp, &cfg) {
7676                    Some(pk) => crate::dsv4::dspark_draft_gpu(
7677                        g,
7678                        &self.dsv4_mtp,
7679                        &cfg,
7680                        ds,
7681                        pk,
7682                        kv_id,
7683                        token_id,
7684                        position,
7685                        self.pool.as_deref(),
7686                        &mut conf,
7687                    ),
7688                    None => Vec::new(),
7689                }
7690            }
7691            #[cfg(not(feature = "gpu"))]
7692            Vec::new()
7693        } else {
7694            crate::gpu::cpu_scope(|| {
7695                crate::dsv4::dspark_draft(
7696                    g,
7697                    &self.dsv4_mtp,
7698                    &cfg,
7699                    ds,
7700                    token_id,
7701                    position,
7702                    self.pool.as_deref(),
7703                    &mut conf,
7704                )
7705            })
7706        };
7707        self.dspark_draft_ns += draft_started.elapsed().as_nanos();
7708        let draft_picks = crate::dsv4::pick_tally_take();
7709        crate::dsv4::dspark_freq_note(&draft_picks);
7710        // Re-arm for the NEXT trunk token; the probe runs after the forward,
7711        // so this is the only place that can.
7712        crate::dsv4::pick_tally_arm();
7713        if !props.is_empty() {
7714            // Two ratios, side by side: what a batched verify over the trunk
7715            // would read against what it asks for, and the same for the
7716            // draft's three stages. Near 1.0 means a batch amortises nothing.
7717            let (tu, tt) = {
7718                let flat: Vec<(usize, Vec<usize>)> = self
7719                    .dspark_trunk_picks
7720                    .iter()
7721                    .flat_map(|v| v.iter().cloned())
7722                    .collect();
7723                // Per layer, across the window of tokens.
7724                let mut per: std::collections::HashMap<usize, Vec<usize>> =
7725                    std::collections::HashMap::new();
7726                for (li, picks) in flat {
7727                    per.entry(li).or_default().extend(picks);
7728                }
7729                let n = per.len().max(1);
7730                let mut u = 0usize;
7731                let mut t = 0usize;
7732                for (_, v) in per {
7733                    t += v.len();
7734                    u += v.iter().collect::<std::collections::HashSet<_>>().len();
7735                }
7736                (u / n, t / n)
7737            };
7738            let (du, dt) = crate::dsv4::tally_unique(&draft_picks);
7739            self.dspark_exp.push((tu, tt, du, dt));
7740            self.dspark_pending.push((position, props, true, 0));
7741        }
7742        if self.dspark_hist.len() >= 8 && self.dspark_hist.len() % 8 == 0 {
7743            let n = self.dspark_hist.len() as f32;
7744            let mean: f32 = self.dspark_hist.iter().sum::<usize>() as f32 / n;
7745            let block = crate::dsv4::dspark_block();
7746            let mut at = vec![0usize; block + 1];
7747            for &k in &self.dspark_hist {
7748                at[k] += 1;
7749            }
7750            // Prefix survival: S_i = P(the first i positions all held).
7751            let mut surv = Vec::with_capacity(block);
7752            for i in 1..=block {
7753                let k = at[i..].iter().sum::<usize>() as f32 / n;
7754                surv.push(format!("{k:.2}"));
7755            }
7756            let distinct = self
7757                .dspark_real
7758                .iter()
7759                .collect::<std::collections::HashSet<_>>()
7760                .len();
7761            let (tu, tt, du, dt) = self.dspark_exp.iter().fold((0, 0, 0, 0), |a, b| {
7762                (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3)
7763            });
7764            let m = self.dspark_exp.len().max(1);
7765            eprintln!(
7766                "DSpark: черновиков {}, принято в среднем {mean:.2} из {block} \
7767                 (токенов за проход {:.2}), распределение {at:?}, выживание [{}]",
7768                self.dspark_hist.len(),
7769                mean + 1.0,
7770                surv.join(" ")
7771            );
7772            eprintln!(
7773                "DSpark: разных токенов {distinct} из {} (вырожденность), \
7774                 эксперты ствол {}/{} на слой за {block} токенов, \
7775                 черновик {}/{} за блок, draft {:.2} мс/блок",
7776                self.dspark_real.len(),
7777                tu / m,
7778                tt / m,
7779                du / m,
7780                dt / m,
7781                self.dspark_draft_ns as f64 / self.dspark_exp.len().max(1) as f64 / 1e6
7782            );
7783        }
7784    }
7785
7786    fn forward_layers_upto(
7787        &mut self,
7788        hidden: &[f32],
7789        position: usize,
7790        task_mask: Option<&TaskMask>,
7791        upto: Option<usize>,
7792    ) -> Vec<f32> {
7793        // In-process multi-GPU: each segment runs pinned to its card,
7794        // and the only thing crossing the boundary is one hidden vector
7795        // that never leaves this address space. Same layer split the
7796        // network mode does, minus the second process, the socket, the
7797        // serialization and the dir_hash handshake.
7798        if let Some(plan) = self.gpu_plan.clone() {
7799            if upto.is_none() && plan.len() > 1 {
7800                let mut h = hidden.to_vec();
7801                for &(dev, from, upto_incl) in plan.iter() {
7802                    h = crate::gpu::with_device(dev, || {
7803                        self.forward_layers_span(&h, position, task_mask, from, Some(upto_incl))
7804                    });
7805                }
7806                return h;
7807            }
7808        }
7809        self.forward_layers_span(hidden, position, task_mask, 0, upto)
7810    }
7811
7812    /// Split this pipeline's layer stack across local GPUs: segment i
7813    /// runs on `devices[i]`. Contiguous and even by layer count — the
7814    /// VRAM-weighted planner is the next step, and an uneven card pair
7815    /// is why it will be needed. `None` clears the plan.
7816    pub fn set_gpu_plan(&mut self, devices: Option<&[usize]>) -> Result<(), String> {
7817        self.set_gpu_plan_at(devices, None)
7818    }
7819
7820    /// The same, with an explicit first boundary (`--peer-split`): card
7821    /// 0 takes layers `[0..at)`, the rest split what remains. Uneven
7822    /// cards, or an attention-heavy head, are why this knob exists.
7823    pub fn set_gpu_plan_at(
7824        &mut self,
7825        devices: Option<&[usize]>,
7826        at: Option<usize>,
7827    ) -> Result<(), String> {
7828        let Some(devs) = devices.filter(|d| d.len() > 1) else {
7829            self.gpu_plan = None;
7830            return Ok(());
7831        };
7832        self.split_supported()?;
7833        let n = self.num_layers;
7834        if devs.len() > n {
7835            return Err(format!("{} devices for {n} layers", devs.len()));
7836        }
7837        if let Some(k) = at {
7838            if k == 0 || k >= n {
7839                return Err(format!("split at {k}: the model has {n} layers"));
7840            }
7841            if devs.len() == 2 {
7842                self.gpu_plan = Some(std::sync::Arc::new(vec![
7843                    (devs[0], 0, k - 1),
7844                    (devs[1], k, n - 1),
7845                ]));
7846                return Ok(());
7847            }
7848            return Err(format!(
7849                "an explicit split point takes exactly 2 devices, got {}",
7850                devs.len()
7851            ));
7852        }
7853        let per = n.div_ceil(devs.len());
7854        let mut plan = Vec::with_capacity(devs.len());
7855        let mut from = 0usize;
7856        for &d in devs {
7857            if from >= n {
7858                break;
7859            }
7860            let upto = (from + per - 1).min(n - 1);
7861            plan.push((d, from, upto));
7862            from = upto + 1;
7863        }
7864        self.gpu_plan = Some(std::sync::Arc::new(plan));
7865        Ok(())
7866    }
7867
7868    /// The active in-process split, if any: (device, first layer, last).
7869    pub fn gpu_plan(&self) -> Option<Vec<(usize, usize, usize)>> {
7870        self.gpu_plan.as_ref().map(|p| p.as_ref().clone())
7871    }
7872
7873    /// Layer span [from ..= upto] (upto None = last layer): the building
7874    /// block the network pipeline-split rides on. `from > 0` skips the
7875    /// arch escape hatches (the pub `forward_span` refuses those archs
7876    /// first) and the whole-token graph — the plain per-layer loop is
7877    /// the canonical executor for a partial stack.
7878    fn forward_layers_span(
7879        &mut self,
7880        hidden: &[f32],
7881        position: usize,
7882        task_mask: Option<&TaskMask>,
7883        from: usize,
7884        upto: Option<usize>,
7885    ) -> Vec<f32> {
7886        debug_assert!(from == 0 || (self.dsv4.is_none() && self.g3n.is_none()));
7887        // DeepSeek-V4 runs its own stack: the state is hc_mult copies, and
7888        // the forward returns LOGITS, not a hidden — the head is inside it
7889        // (the final fold sits between the last layer and the norm). The
7890        // token id rides in `hidden[0]`, written by embed_single, because
7891        // the hash layers route by id rather than by content.
7892        if let Some(b) = &mut self.dsv4 {
7893            let _ = (task_mask, upto);
7894            let token_id = hidden.first().copied().unwrap_or(0.0) as u32;
7895            let (g, layers, cfg, st) = (&b.0, &b.1, b.2, &mut b.3);
7896            st.pos = position;
7897            let mut logits = Vec::new();
7898            crate::dsv4::forward_token(
7899                g,
7900                layers,
7901                &cfg,
7902                st,
7903                token_id,
7904                &self.inv_freq,
7905                self.pool.as_deref(),
7906                &mut logits,
7907            );
7908            self.graph_logits = Some(logits);
7909            self.dspark_probe(position, token_id);
7910            // The caller expects a hidden; the logits went out of band, as
7911            // with the fused lm_head path.
7912            return vec![0.0; self.hidden_size];
7913        }
7914        // Gemma-3n runs its own stack (4 AltUp replicas don't fit this
7915        // loop); `hidden` is the extended embedding from embed_single.
7916        if let Some(b) = &self.g3n {
7917            let _ = (task_mask, upto);
7918            return crate::g3n::g3n_forward(
7919                &b.0,
7920                &b.1,
7921                hidden,
7922                position,
7923                &mut self.kv_cache.layers,
7924                self.num_heads,
7925                self.num_kv_heads,
7926                self.head_dim,
7927                self.pool.as_deref(),
7928            );
7929        }
7930        let mut h = hidden.to_vec();
7931        // Split borrows: copy scalars / clone handles so the per-layer
7932        // cfg does not hold `&self` while the KV cache is `&mut`.
7933        let (nh, _nkv, _hd, hs, _rd, eps) = (
7934            self.num_heads,
7935            self.num_kv_heads,
7936            self.head_dim,
7937            self.hidden_size,
7938            self.rotary_dim,
7939            self.rms_eps,
7940        );
7941        let pool = self.pool.clone();
7942        // Opt-in wgpu token-graph attention (discrete Vulkan/DX12): the whole
7943        // attention sub-block runs resident in one submit. Off by default.
7944        // Whole-token wgpu graph: eligibility + arbitration.
7945        //  - explicit CMF_GPU_WGPU_GRAPH forces it on/off;
7946        //  - discrete adapters (4090: decode 76 -> 137 tok/s) and GDN
7947        //    hybrids (recurrent state device-resident, no CPU twin to
7948        //    race) TRUST it;
7949        //  - integrated/mobile adapters RACE it against the normal path
7950        //    at generation granularity (gpu::graph_race_*) — tiled
7951        //    mobile GPUs can turn the ~300-dispatch graph into seconds
7952        //    per token, while a fast phone GPU keeps its win.
7953        let graph_env = std::env::var("CMF_GPU_WGPU_GRAPH").ok();
7954        let graph_on = match graph_env.as_deref() {
7955            Some("0") => false,
7956            Some("prefill") => false, // decode keeps the per-op path
7957            Some(_) => true,
7958            // Unset: same discrete-only default as every other graph
7959            // site. "Is the GPU on" used to stand in here — which made
7960            // the 0.2 tok/s whole-token graph race-eligible on mobile
7961            // adapters and cost 12-14× on first tokens (cmfmobile
7962            // TUNING.md); integrated GPUs keep the per-op probe path.
7963            None => crate::gpu::wgpu_graph_default(),
7964        };
7965        let graph_trusted =
7966            graph_env.is_some() || crate::gpu::wgpu_graph_default() || self.gdn_cfg.is_some();
7967        let race_eligible = graph_on
7968            && upto.is_none()
7969            && task_mask.is_none()
7970            && from == 0
7971            && !crate::gpu::graph_unsupported();
7972        let mut tail_start = 0usize;
7973        if race_eligible && crate::gpu::graph_race_use_graph(graph_trusted) {
7974            let t_graph = std::time::Instant::now();
7975            let mut lg = Vec::new();
7976            let mut gl = 0usize;
7977            let built = self.try_token_graph_wgpu(hidden, position, &mut lg, &mut gl);
7978            // Past the transient guards (o1 still collecting, a softcap)
7979            // a refusal is about the weights and will never change —
7980            // remember it instead of walking every layer again next
7981            // token.
7982            if built.is_none() && !self.o1_active() && self.attn_softcap == 0.0 {
7983                crate::gpu::graph_mark_unsupported();
7984            }
7985            graph_note(built.is_some());
7986            if let Some(hh) = built {
7987                let dur = t_graph.elapsed();
7988                if std::env::var("CMF_GRAPH_PROF").is_ok() {
7989                    eprintln!("graph-call: {:.2} ms total", dur.as_secs_f64() * 1000.0);
7990                }
7991                if gl > 0 && gl < self.num_layers {
7992                    // Device prefix: the graph ran layers 0..gl and handed
7993                    // back the boundary hidden — the loop below owns the
7994                    // tail. The prefix layers' KV/state advanced on the
7995                    // device; the tail's advances on the host below. One
7996                    // boundary crossing per token.
7997                    h = hh;
7998                    tail_start = gl;
7999                } else if graph_trusted || !crate::gpu::graph_race_first_token_hopeless(dur) {
8000                    if !graph_trusted {
8001                        crate::gpu::graph_race_record(true, dur);
8002                    }
8003                    if !lg.is_empty() {
8004                        // Graph produced logits (final-norm + lm_head folded in) —
8005                        // pad/cap to vocab and hand them to the sampler directly.
8006                        lg.resize(self.vocab_size, 0.0);
8007                        if let Some(c) = self.final_softcap {
8008                            for l in lg.iter_mut() {
8009                                *l = c * (*l / c).tanh();
8010                            }
8011                        }
8012                        self.graph_logits = Some(lg);
8013                    }
8014                    return hh;
8015                }
8016                // Hopeless first graph token: discard it and fall through
8017                // to the normal path. Safe exactly here — the prompt KV is
8018                // still CPU-owned (chunked prefill), so recomputing this
8019                // position is exact; the mirror's extra row is never read
8020                // (the race just settled on the normal path).
8021            }
8022        }
8023        // KIMI-LINEAR HAS NO SPLIT BUG. The 2.6× reported from the
8024        // model rotation (12.2 tok/s on one card against 4.6 on two)
8025        // was a single measurement of a model whose arm arbitration is
8026        // borderline, and it did not survive repetition. Three runs an
8027        // arm, same binary, back to back:
8028        //   probe on : 1 GPU 9.5 / 5.7 / 5.9   2 GPU 7.8 / 13.0 / 13.3
8029        //   pinned   : 1 GPU 5.6 / 5.3 / 5.2   2 GPU 3.5 / 4.2 / 3.4
8030        // With the arms pinned the split costs about 1.45×, which is
8031        // what a layer split costs. With the probe free, TWO CARDS RUN
8032        // FASTER — because for this model the CPU arm wins some op
8033        // classes and the probe finds that.
8034        //
8035        // Two things do stand, and both are measured. The token graph
8036        // builds NOTHING here (`covered 0 of 14 layers [0..14)`), so
8037        // every layer walks per-op on either arm — that is where the
8038        // headroom is, not in the split. And this model's benchmark is
8039        // unusable without `CMF_GPU_PROBE=0`: the arbitration alone
8040        // moves it by more than 2×.
8041        //
8042        // Span runs (network split): the graph covers exactly [from..=upto]
8043        // — one submit per SEGMENT per token. No race: its state is global
8044        // and calibrated on full stacks, so spans take the graph only where
8045        // it is trusted by default (discrete adapters / CMF_GPU_WGPU_GRAPH).
8046        let span = from > 0 || upto.is_some();
8047        if span && graph_on && task_mask.is_none() && graph_trusted {
8048            let upto_excl = upto.map_or(self.num_layers, |u| u + 1);
8049            let mut lg = Vec::new();
8050            let mut gl = 0usize;
8051            let span_res =
8052                self.try_token_graph_wgpu_span(hidden, position, &mut lg, from, upto_excl, &mut gl);
8053            graph_note(span_res.is_some() && gl == upto_excl - from);
8054            if std::env::var("CMF_GPU_DEBUG").is_ok() {
8055                // How much of the span the graph actually covered. A
8056                // prefix of nothing means every layer walks per-op and
8057                // the split's extra cost is elsewhere.
8058                static SEEN: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
8059                if SEEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 4 {
8060                    eprintln!(
8061                        "span graph: covered {gl} of {} layers [{from}..{upto_excl}) res={}",
8062                        upto_excl - from,
8063                        span_res.is_some()
8064                    );
8065                }
8066            }
8067            if let Some(hh) = span_res {
8068                if gl == upto_excl - from {
8069                    if !lg.is_empty() {
8070                        lg.resize(self.vocab_size, 0.0);
8071                        if let Some(c) = self.final_softcap {
8072                            for l in lg.iter_mut() {
8073                                *l = c * (*l / c).tanh();
8074                            }
8075                        }
8076                        self.graph_logits = Some(lg);
8077                    }
8078                    crate::gpu::set_layer(-1);
8079                    return hh;
8080                }
8081                // Partial device prefix of the span: CPU owns the tail.
8082                h = hh;
8083                tail_start = from + gl;
8084            }
8085        }
8086        let t_race_cpu = (race_eligible && !graph_trusted).then(std::time::Instant::now);
8087
8088        #[cfg(target_os = "macos")]
8089        let mut gpu_skip_until = 0usize;
8090        for li in tail_start.max(from)..self.num_layers {
8091            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU (CMF_GPU_LAYERS)
8092            if let Some(u) = upto {
8093                if li > u {
8094                    break;
8095                }
8096            }
8097            if let Some(mask) = task_mask {
8098                if !mask.layer_alive(li) {
8099                    continue; // dead layer: residual pass-through
8100                }
8101            }
8102            // Whole-block q1 token graph: a run of consecutive q1
8103            // layers — GDN and full attention — executes with one sync
8104            // per CPU attend instead of per op (macOS/Metal).
8105            #[cfg(target_os = "macos")]
8106            {
8107                if li < gpu_skip_until {
8108                    continue;
8109                }
8110                if task_mask.is_none() {
8111                    let end = self.q1_graph_gpu(li, upto, position, &mut h);
8112                    if end > li {
8113                        gpu_skip_until = end;
8114                        // Looped Transformer: the graph stopped at a loop
8115                        // boundary — apply final norm before the next iteration.
8116                        if self.is_loop_end(end - 1) && end < self.num_layers {
8117                            h = inference::rms_norm(
8118                                &h,
8119                                &self.weights.final_norm,
8120                                self.rms_eps,
8121                                self.norm_style,
8122                            );
8123                        }
8124                        continue;
8125                    }
8126                }
8127            }
8128
8129            let lw = &self.weights.layers[self.phys_layer(li)];
8130            if let Ok(tp) = std::env::var("CMF_TRACE_POS") {
8131                if tp.parse::<usize>().ok() == Some(position) {
8132                    let n: f32 = h.iter().map(|x| x * x).sum::<f32>().sqrt();
8133                    eprintln!(
8134                        "TRACE pos {position} layer {li}: |h| = {n:.6} h0 {:.6} h1 {:.6}",
8135                        h[0], h[1]
8136                    );
8137                }
8138            }
8139            // Norm into the pipeline scratch — the returning rms_norm
8140            // allocated twice per layer per token (roadmap §3 P0).
8141            inference::rms_norm_into(
8142                &h,
8143                &lw.input_norm,
8144                self.rms_eps,
8145                self.norm_style,
8146                &mut self.ws.n1,
8147            );
8148
8149            let attn_out = match &lw.attn {
8150                AttnKind::Mla(w) => {
8151                    let inv_freq_l = self.layer_inv_freq(li);
8152                    let rs = self.layer_rope_scale(li);
8153                    let eps = self.rms_eps;
8154                    let pool = self.pool.clone();
8155                    mla_attention(
8156                        w,
8157                        &self.ws.n1,
8158                        &mut self.kv_cache.layers[li],
8159                        position,
8160                        &inv_freq_l,
8161                        rs,
8162                        eps,
8163                        pool.as_deref(),
8164                    )
8165                }
8166                AttnKind::Linear(w) => {
8167                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
8168                    vmf_phase_forward(
8169                        &self.ws.n1,
8170                        w,
8171                        &cfg,
8172                        &mut self.kv_cache.layers[li].linear_state,
8173                        self.pool.as_deref(),
8174                    )
8175                }
8176                AttnKind::Kda(w) => {
8177                    let cfg = self.kda_cfg.expect("kda layer without kda_cfg");
8178                    crate::linear_core::kda_forward(
8179                        &self.ws.n1,
8180                        w,
8181                        &cfg,
8182                        &mut self.kv_cache.layers[li].linear_state,
8183                        self.pool.as_deref(),
8184                    )
8185                }
8186                AttnKind::LinearGdn(w) => {
8187                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
8188                    gdn_forward(
8189                        &self.ws.n1,
8190                        w,
8191                        &cfg,
8192                        &mut self.kv_cache.layers[li].linear_state,
8193                        self.pool.as_deref(),
8194                    )
8195                }
8196                AttnKind::ShortConv(w) => {
8197                    let cfg = self
8198                        .short_conv_cfg
8199                        .expect("short-conv layer without short_conv_cfg");
8200                    short_conv_forward(
8201                        &self.ws.n1,
8202                        w,
8203                        &cfg,
8204                        &mut self.kv_cache.layers[li].linear_state,
8205                        self.pool.as_deref(),
8206                    )
8207                }
8208                AttnKind::Full {
8209                    wq,
8210                    wk,
8211                    wv,
8212                    wo,
8213                    q_norm,
8214                    k_norm,
8215                    output_gate,
8216                    softplus_gate,
8217                    bias,
8218                } if self.kv_cache.layers[li].o1_sealed() => {
8219                    // O(1) override: decode on the sealed Nyström state
8220                    // instead of the growing KV cache.
8221                    let inv_freq_l = self.layer_inv_freq(li);
8222                    let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8223                    let cfg = QwenAttnCfg {
8224                        num_heads: self.layer_num_heads(li),
8225                        num_kv_heads: nkv_l,
8226                        head_dim: hd_l,
8227                        hidden_size: hs,
8228                        position,
8229                        inv_freq: &inv_freq_l,
8230                        rotary_dim: rd_l,
8231                        scale: self.attn_scale,
8232                        softcap: self.attn_softcap,
8233                        window: None,
8234                        v_norm: self.attn_v_norm,
8235                        q_norm: q_norm.as_deref(),
8236                        k_norm: k_norm.as_deref(),
8237                        output_gate: *output_gate,
8238                        softplus_gate: softplus_gate
8239                            .as_ref()
8240                            .map(|(gate, per_head)| (gate, *per_head)),
8241                        rope_scale: self.layer_rope_scale(li),
8242                        bias: bias
8243                            .as_ref()
8244                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
8245                        rms_eps: eps,
8246                        norm_style: self.norm_style,
8247                        pool: pool.as_deref(),
8248                    };
8249                    attention::qwen_attention_nystrom(
8250                        &self.ws.n1,
8251                        wq,
8252                        wk,
8253                        wv,
8254                        wo,
8255                        &mut self.kv_cache.layers[li],
8256                        &cfg,
8257                    )
8258                }
8259                AttnKind::Full {
8260                    wq,
8261                    wk,
8262                    wv,
8263                    wo,
8264                    q_norm,
8265                    k_norm,
8266                    output_gate,
8267                    softplus_gate,
8268                    bias,
8269                } => 'attn: {
8270                    // wgpu token-graph attention (opt-in): whole sub-block in
8271                    // one submit, device K/V mirror. q1 only, no gate/bias/mask.
8272                    if graph_on
8273                        && !*output_gate
8274                        && softplus_gate.is_none()
8275                        && self.attention_heads_per_layer.is_none()
8276                        && bias.is_none()
8277                        && task_mask.is_none()
8278                    {
8279                        let inv_freq_l = self.layer_inv_freq(li);
8280                        let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8281                        let gemma = self.norm_style == cortiq_core::NormStyle::Gemma;
8282                        if let (Some((gm, qi)), Some((_, ki)), Some((_, vi)), Some((_, oi))) = (
8283                            wq.mapped_q1(),
8284                            wk.mapped_q1(),
8285                            wv.mapped_q1(),
8286                            wo.mapped_q1(),
8287                        ) {
8288                            let gm = gm.clone();
8289                            let mut out = vec![0f32; hs];
8290                            let cache = &self.kv_cache.layers[li];
8291                            if crate::gpu::attn_dropin(
8292                                &gm,
8293                                self.graph_kv_id,
8294                                li,
8295                                &self.ws.n1,
8296                                qi,
8297                                ki,
8298                                vi,
8299                                oi,
8300                                q_norm.as_deref(),
8301                                k_norm.as_deref(),
8302                                &inv_freq_l,
8303                                nh,
8304                                nkv_l,
8305                                hd_l,
8306                                rd_l,
8307                                hs,
8308                                position,
8309                                self.kv_cache.max_seq_len,
8310                                gemma,
8311                                eps as f32,
8312                                cache.k_heads(),
8313                                cache.v_heads(),
8314                                &mut out,
8315                            ) {
8316                                break 'attn out;
8317                            }
8318                        }
8319                    }
8320                    let masked = task_mask
8321                        .map(|m| m.head_flags(li, self.num_heads).iter().any(|&a| !a))
8322                        .unwrap_or(false);
8323                    let f32_view = (wq.as_f32(), wk.as_f32(), wv.as_f32(), wo.as_f32());
8324                    match (masked, f32_view) {
8325                        // Historical masked path (f32 slices; the loader
8326                        // keeps masked models in f32).
8327                        (true, (Some(q), Some(k), Some(v), Some(o))) => {
8328                            let active_heads = task_mask.unwrap().head_flags(li, self.num_heads);
8329                            attention::multi_head_attention(
8330                                &self.ws.n1,
8331                                q,
8332                                k,
8333                                v,
8334                                o,
8335                                &mut self.kv_cache.layers[li],
8336                                self.num_heads,
8337                                self.num_kv_heads,
8338                                self.head_dim,
8339                                self.hidden_size,
8340                                position,
8341                                &active_heads,
8342                                &self.inv_freq,
8343                            )
8344                        }
8345                        (masked, _) => {
8346                            if masked {
8347                                tracing::warn!(
8348                                    "layer {li}: head mask on quantized weights not \
8349                                     supported yet — executing dense"
8350                                );
8351                            }
8352                            let inv_freq_l = self.layer_inv_freq(li);
8353                            let (nkv_l, hd_l, rd_l) = self.layer_geom(li);
8354                            let cfg = QwenAttnCfg {
8355                                num_heads: self.layer_num_heads(li),
8356                                num_kv_heads: nkv_l,
8357                                head_dim: hd_l,
8358                                hidden_size: hs,
8359                                position,
8360                                inv_freq: &inv_freq_l,
8361                                rotary_dim: rd_l,
8362                                scale: self.attn_scale,
8363                                softcap: self.attn_softcap,
8364                                window: self.layer_window(li),
8365                                v_norm: self.attn_v_norm,
8366                                q_norm: q_norm.as_deref(),
8367                                k_norm: k_norm.as_deref(),
8368                                output_gate: *output_gate,
8369                                softplus_gate: softplus_gate
8370                                    .as_ref()
8371                                    .map(|(gate, per_head)| (gate, *per_head)),
8372                                rope_scale: self.layer_rope_scale(li),
8373                                bias: bias
8374                                    .as_ref()
8375                                    .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
8376                                rms_eps: eps,
8377                                norm_style: self.norm_style,
8378                                pool: pool.as_deref(),
8379                            };
8380                            attention::qwen_attention(
8381                                &self.ws.n1,
8382                                wq,
8383                                wk,
8384                                wv,
8385                                wo,
8386                                &mut self.kv_cache.layers[li],
8387                                &cfg,
8388                            )
8389                        }
8390                    }
8391                }
8392            };
8393            // Gemma sandwich norm: normalize the attention branch before
8394            // it joins the residual stream.
8395            let attn_out = match &self.weights.layers[self.phys_layer(li)].attn_out_norm {
8396                Some(w) => inference::rms_norm(&attn_out, w, self.rms_eps, self.norm_style),
8397                None => attn_out,
8398            };
8399            let lw = &self.weights.layers[self.phys_layer(li)];
8400            inference::add_rmsnorm_fused_into(
8401                &mut h,
8402                &attn_out,
8403                &lw.post_norm,
8404                self.rms_eps,
8405                self.norm_style,
8406                &mut self.ws.p1,
8407            );
8408            let mut attn_out = attn_out;
8409            attention::recycle_buf(&mut attn_out);
8410            let post_normed = &self.ws.p1;
8411
8412            let ffn_masked = task_mask
8413                .map(|m| m.ffn_active_count(li) < self.intermediate_size)
8414                .unwrap_or(false);
8415            // One masked dense CONTRACT, dispatched by cost. The
8416            // activation-zeroing arm (the batched sweep's, validated
8417            // against the replica to 0.8%) computes the FULL fused FFN
8418            // and zeroes the dead — right whenever most neurons live.
8419            // The sparse arm reads ONLY active rows and down columns —
8420            // per-row dots are slower per element than the fused kernel,
8421            // so it pays only once the mask is deep enough. The 0.5
8422            // crossover is first-principles (fused kernels run ~2x the
8423            // per-row dot throughput); a shallow specialist (95% alive)
8424            // stays fused, a --target-sparsity bake flips arms on its
8425            // own weight.
8426            let ffn_out = match (ffn_masked, &lw.ffn) {
8427                (true, FfnKind::Dense(d)) => {
8428                    let tm = task_mask.unwrap();
8429                    let alive = tm.ffn_active_count(li);
8430                    let deep = alive * 2 <= self.intermediate_size;
8431                    if deep && d.down_proj.sparse_col_ok() {
8432                        let active = tm.ffn_active_indices(li);
8433                        sparse_ffn_quant(
8434                            d,
8435                            post_normed,
8436                            &active,
8437                            self.hidden_size,
8438                            self.pool.as_deref(),
8439                        )
8440                    } else if deep
8441                        && let (Some(g), Some(u), Some(dn)) = (
8442                            d.gate_proj.as_f32(),
8443                            d.up_proj.as_f32(),
8444                            d.down_proj.as_f32(),
8445                        )
8446                    {
8447                        let active = tm.ffn_active_indices(li);
8448                        inference::sparse_ffn_forward(
8449                            post_normed,
8450                            g,
8451                            u,
8452                            dn,
8453                            self.hidden_size,
8454                            self.intermediate_size,
8455                            &active,
8456                            self.pool.as_deref(),
8457                        )
8458                    } else {
8459                        let row = tm.ffn_masks.get(li).map(|v| v.as_slice());
8460                        dense_ffn_batch(d, post_normed, 1, self.pool.as_deref(), row)
8461                    }
8462                }
8463                (true, FfnKind::Moe(m)) => {
8464                    // MoE is sparse by expert selection; a task mask
8465                    // narrows the ROUTABLE set via its expert fields
8466                    // (spec §5) when it carries them.
8467                    let allowed = task_mask.and_then(|tm| tm.expert_flags(li, m.experts.len()));
8468                    ffn_forward(
8469                        &lw.ffn,
8470                        post_normed,
8471                        self.pool.as_deref(),
8472                        allowed.as_deref(),
8473                    )
8474                }
8475                (true, FfnKind::DenseMoe(dm)) => dense_moe_ffn(
8476                    dm,
8477                    post_normed,
8478                    &h,
8479                    self.rms_eps,
8480                    self.norm_style,
8481                    self.pool.as_deref(),
8482                ),
8483                (false, _) => match &lw.ffn {
8484                    FfnKind::DenseMoe(dm) => dense_moe_ffn(
8485                        dm,
8486                        post_normed,
8487                        &h,
8488                        self.rms_eps,
8489                        self.norm_style,
8490                        self.pool.as_deref(),
8491                    ),
8492                    _ => {
8493                        let allowed = match (&lw.ffn, task_mask) {
8494                            (FfnKind::Moe(m), Some(tm)) => tm.expert_flags(li, m.experts.len()),
8495                            _ => None,
8496                        };
8497                        ffn_forward(
8498                            &lw.ffn,
8499                            post_normed,
8500                            self.pool.as_deref(),
8501                            allowed.as_deref(),
8502                        )
8503                    }
8504                },
8505            };
8506            let ffn_out = match &self.weights.layers[self.phys_layer(li)].ffn_out_norm {
8507                Some(w) => inference::rms_norm(&ffn_out, w, self.rms_eps, self.norm_style),
8508                None => ffn_out,
8509            };
8510            for (i, &f) in ffn_out.iter().enumerate() {
8511                h[i] += f;
8512            }
8513            let mut ffn_out = ffn_out;
8514            attention::recycle_buf(&mut ffn_out);
8515
8516            // Gemma-4: the layer output is scaled by a learned scalar.
8517            if let Some(sc) = self.weights.layers[self.phys_layer(li)].layer_scale {
8518                for v in h.iter_mut() {
8519                    *v *= sc;
8520                }
8521            }
8522
8523            // Looped Transformer: apply final norm at the end of each loop iteration.
8524            // Nanbeige 4.2: after layer 21 (virtual), apply norm before looping back to layer 0.
8525            if self.is_loop_end(li) && li + 1 < self.num_layers {
8526                h = inference::rms_norm(
8527                    &h,
8528                    &self.weights.final_norm,
8529                    self.rms_eps,
8530                    self.norm_style,
8531                );
8532            }
8533
8534            // Dynamic routing φ capture (on-policy, fireball-style): the
8535            // EMA of the post-residual hidden at the router's phi_layer,
8536            // updated as the context evolves during decode.
8537            if self.dyn_phi_layer == Some(li) {
8538                self.update_dyn_phi(&h);
8539            }
8540        }
8541        crate::gpu::set_layer(-1); // layers done — lm_head outside layer-split
8542        if let Some(t) = t_race_cpu {
8543            crate::gpu::graph_race_record(false, t.elapsed());
8544        }
8545
8546        h
8547    }
8548
8549    /// EMA of φ at the router layer (rolling, weight 0.2 = ~5-token
8550    /// horizon). First observation seeds it exactly.
8551    fn update_dyn_phi(&mut self, h: &[f32]) {
8552        const A: f32 = 0.2;
8553        if self.dyn_phi_ema.len() != h.len() {
8554            self.dyn_phi_ema = vec![0.0; h.len()];
8555            self.dyn_phi_seen = 0;
8556        }
8557        if self.dyn_phi_seen == 0 {
8558            self.dyn_phi_ema.copy_from_slice(h);
8559        } else {
8560            for (e, &v) in self.dyn_phi_ema.iter_mut().zip(h) {
8561                *e = (1.0 - A) * *e + A * v;
8562            }
8563        }
8564        self.dyn_phi_seen += 1;
8565    }
8566
8567    /// Current router φ (EMA at phi_layer); empty until first capture.
8568    pub fn dyn_phi(&self) -> &[f32] {
8569        &self.dyn_phi_ema
8570    }
8571
8572    /// Enable/disable φ capture at the router layer, reset the EMA.
8573    pub fn set_dyn_phi_layer(&mut self, layer: Option<usize>) {
8574        self.dyn_phi_layer = layer;
8575        self.dyn_phi_ema.clear();
8576        self.dyn_phi_seen = 0;
8577    }
8578
8579    /// Skills eligible for dynamic switching: (index, id, phi_layer).
8580    pub fn dynamic_skills(&self) -> Vec<(usize, String, usize)> {
8581        let Some(model) = &self.model else {
8582            return Vec::new();
8583        };
8584        model
8585            .header
8586            .skills
8587            .iter()
8588            .enumerate()
8589            .filter_map(|(i, sk)| {
8590                let ok = matches!(self.dyn_skill_layers.get(i), Some(Some(_)));
8591                let sel = sk.selection.as_ref()?;
8592                (ok).then(|| (i, sk.id.clone(), sel.phi_layer))
8593            })
8594            .collect()
8595    }
8596
8597    /// Index of the currently overlaid skill (None = backbone).
8598    pub fn active_skill(&self) -> Option<usize> {
8599        self.dyn_active
8600    }
8601
8602    /// Enable dynamic per-token skill routing: build the hysteresis
8603    /// router from the container's routable skills, start φ capture at
8604    /// their (shared) phi_layer. Returns the number of routable skills
8605    /// (0 = nothing to route; router stays off). Idempotent.
8606    pub fn enable_dynamic_routing(&mut self) -> usize {
8607        use crate::swarm::{DynRouter, RoutableSkill};
8608        let Some(model) = self.model.clone() else {
8609            return 0;
8610        };
8611        // A blend materialized f32 working tensors into the layers; there
8612        // is no single skill index to revert from → refuse (honest).
8613        if self.dyn_blend_loaded {
8614            tracing::warn!("dynamic routing unavailable on a blend-loaded pipeline");
8615            return 0;
8616        }
8617        // A statically-overlaid skill that is NOT FFN-eligible can't be
8618        // cheaply reverted at generation start → refuse rather than
8619        // silently keep it overlaid.
8620        if let Some(a) = self.dyn_active {
8621            if !matches!(self.dyn_skill_layers.get(a), Some(Some(_))) {
8622                tracing::warn!("loaded skill is not FFN-eligible — dynamic routing unavailable");
8623                return 0;
8624            }
8625        }
8626        let hidden = self.hidden_size;
8627        let mut skills = Vec::new();
8628        for (idx, id, _phi) in self.dynamic_skills() {
8629            if let Some(sel) = model.header.skills[idx].selection.as_ref() {
8630                if let Some(rs) = RoutableSkill::from_descriptor(idx, id, sel, hidden) {
8631                    skills.push(rs);
8632                }
8633            }
8634        }
8635        if skills.is_empty() {
8636            return 0;
8637        }
8638        // Skills should share a phi_layer; warn (not fail) if they don't.
8639        let phi = skills[0].phi_layer;
8640        if skills.iter().any(|s| s.phi_layer != phi) {
8641            tracing::warn!("routable skills disagree on phi_layer; using {phi}");
8642        }
8643        let n = skills.len();
8644        self.set_dyn_phi_layer(Some(phi));
8645        self.dyn_router = Some(DynRouter::new(skills));
8646        n
8647    }
8648
8649    /// Human-readable switch log from the last dynamic-routed generation.
8650    pub fn route_switches(&self) -> Vec<(usize, Option<String>, Option<String>)> {
8651        self.dyn_router
8652            .as_ref()
8653            .map(|r| r.switches.clone())
8654            .unwrap_or_default()
8655    }
8656
8657    /// LM head: hidden → logits [vocab_size]. The dominant matvec of
8658    /// every decode step — row-parallel on the worker pool.
8659    fn lm_head_forward(&self, hidden: &[f32]) -> Vec<f32> {
8660        let rows = self.weights.lm_head.rows();
8661        let mut logits = attention::take_buf(rows.min(self.vocab_size));
8662        self.weights
8663            .lm_head
8664            .matvec(hidden, &mut logits, self.pool.as_deref());
8665        logits.resize(self.vocab_size, 0.0);
8666        if let Some(m) = self.logit_multiplier {
8667            for l in logits.iter_mut() {
8668                *l *= m;
8669            }
8670        }
8671        if let Some(c) = self.final_softcap {
8672            for l in logits.iter_mut() {
8673                *l = c * (*l / c).tanh();
8674            }
8675        }
8676        if let Some(cm) = self.head_clusters.as_ref() {
8677            self.hierarchical_head_logprobs(hidden, cm, &mut logits);
8678        }
8679        logits
8680    }
8681
8682    /// Two-level head (Cortiq Embryo): in place, logits[v] ← log p(v) =
8683    /// (lc[c] − lse(lc)) + (logit[v] − lse over v's cluster block), c = v / S.
8684    fn hierarchical_head_logprobs(&self, hidden: &[f32], cm: &[f32], logits: &mut [f32]) {
8685        let h = hidden.len();
8686        let ncl = cm.len() / h.max(1);
8687        if ncl == 0 || logits.len() % ncl != 0 {
8688            return;
8689        }
8690        let cs = logits.len() / ncl;
8691        // cluster logits + log-softmax
8692        let mut lc = vec![0.0f32; ncl];
8693        for c in 0..ncl {
8694            let row = &cm[c * h..(c + 1) * h];
8695            let mut s = 0.0f32;
8696            for j in 0..h {
8697                s += row[j] * hidden[j];
8698            }
8699            lc[c] = s;
8700        }
8701        let mx = lc.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
8702        let lse: f32 = mx + lc.iter().map(|v| (v - mx).exp()).sum::<f32>().ln();
8703        for c in 0..ncl {
8704            let blk = &mut logits[c * cs..(c + 1) * cs];
8705            let bm = blk.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
8706            let bl: f32 = bm + blk.iter().map(|v| (v - bm).exp()).sum::<f32>().ln();
8707            let add = lc[c] - lse - bl;
8708            for v in blk.iter_mut() {
8709                *v += add;
8710            }
8711        }
8712    }
8713
8714    /// Prefill `ids` and return the next-token logits — what the model
8715    /// would predict next, WITHOUT committing to generation (introspection
8716    /// for `cortiq explain`). Clears and repopulates the KV cache; leaves
8717    /// the active overlay untouched.
8718    pub fn prefill_next_logits(&mut self, ids: &[u32], task_mask: Option<&TaskMask>) -> Vec<f32> {
8719        self.kv_cache.clear();
8720        self.kv_history.clear();
8721        let mut hidden = vec![0.0f32; self.hidden_size];
8722        for (pos, &id) in ids.iter().enumerate() {
8723            let emb = self.embed_single(id);
8724            hidden = self.forward_layers(&emb, pos, task_mask);
8725        }
8726        inference::rms_norm_into(
8727            &hidden,
8728            &self.weights.final_norm,
8729            self.rms_eps,
8730            self.norm_style,
8731            &mut self.ws.n1,
8732        );
8733        self.lm_head_forward(&self.ws.n1)
8734    }
8735}
8736
8737/// Convenience: deterministic tiny pipeline for tests.
8738pub fn create_test_pipeline(
8739    hidden_size: usize,
8740    intermediate_size: usize,
8741    num_heads: usize,
8742    num_kv_heads: usize,
8743    head_dim: usize,
8744    num_layers: usize,
8745    vocab_size: usize,
8746) -> Pipeline {
8747    // Small pseudo-random weights: constant weights make attention
8748    // degenerate and hide indexing bugs.
8749    let synth = |n: usize, salt: usize| -> Vec<f32> {
8750        (0..n)
8751            .map(|i| (((i * 31 + salt * 17 + 7) % 97) as f32 / 97.0 - 0.5) * 0.2)
8752            .collect()
8753    };
8754    let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
8755        QTensor::from_f32(synth(rows * cols, salt), rows, cols)
8756    };
8757    let layer_weights: Vec<LayerWeights> = (0..num_layers)
8758        .map(|li| LayerWeights {
8759            input_norm: vec![1.0; hidden_size],
8760            post_norm: vec![1.0; hidden_size],
8761            attn_out_norm: None,
8762            ffn_out_norm: None,
8763            layer_scale: None,
8764            ffn: FfnKind::Dense(DenseFfn {
8765                gate_proj: qt(intermediate_size, hidden_size, li * 10 + 5),
8766                up_proj: qt(intermediate_size, hidden_size, li * 10 + 6),
8767                down_proj: qt(hidden_size, intermediate_size, li * 10 + 7),
8768                act: Act::Silu,
8769            }),
8770            attn: AttnKind::Full {
8771                bias: None,
8772                wq: qt(num_heads * head_dim, hidden_size, li * 10 + 1),
8773                wk: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 2),
8774                wv: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 3),
8775                wo: qt(hidden_size, num_heads * head_dim, li * 10 + 4),
8776                q_norm: None,
8777                k_norm: None,
8778                output_gate: false,
8779                softplus_gate: None,
8780            },
8781        })
8782        .collect();
8783
8784    Pipeline::new(
8785        Tokenizer::byte_level(),
8786        PipelineWeights {
8787            embed_tokens: qt(vocab_size, hidden_size, 100),
8788            layers: layer_weights,
8789            lm_head: qt(vocab_size, hidden_size, 200),
8790            final_norm: vec![1.0; hidden_size],
8791        },
8792        hidden_size,
8793        intermediate_size,
8794        num_heads,
8795        num_kv_heads,
8796        head_dim,
8797        num_layers,
8798        num_layers, // physical_layers = num_layers (non-looped)
8799        false,      // loop_final_norm
8800        vocab_size,
8801        1e-6,
8802        10_000.0,
8803        NormStyle::Qwen,
8804        4096,
8805        SamplerConfig {
8806            seed: Some(42),
8807            ..Default::default()
8808        },
8809    )
8810}
8811
8812/// Batched dense-FFN: gate/up/down via matmat (element-wise the same
8813/// math as b × dense_ffn — the same dot kernels).
8814/// One mask bit, LSB-first per byte — `TaskMask::ffn_active_indices`'s
8815/// convention.
8816#[inline]
8817fn mask_bit(row: &[u8], j: usize) -> bool {
8818    (row.get(j >> 3).copied().unwrap_or(0) >> (j & 7)) & 1 != 0
8819}
8820
8821/// Zero the CLOSED neurons' activations in a [rows × inter] panel — the
8822/// masked-inference fast path's whole trick: full fused quant compute,
8823/// then the mask lands on the ACTIVATIONS, which is arithmetically the
8824/// pruned network without touching a quantized weight byte. Whole open
8825/// bytes (0xFF = 8 open neurons) skip in one test.
8826fn zero_masked_cols(g: &mut [f32], rows: usize, inter: usize, row: &[u8]) {
8827    for r in 0..rows {
8828        let base = r * inter;
8829        for (bi, &byte) in row.iter().enumerate() {
8830            if byte == 0xFF {
8831                continue;
8832            }
8833            let j0 = bi * 8;
8834            for bit in 0..8 {
8835                let j = j0 + bit;
8836                if j < inter && byte & (1 << bit) == 0 {
8837                    g[base + j] = 0.0;
8838                }
8839            }
8840        }
8841    }
8842}
8843
8844fn dense_ffn_batch(
8845    d: &DenseFfn,
8846    xs: &[f32],
8847    b: usize,
8848    pool: Option<&Pool>,
8849    mask_row: Option<&[u8]>,
8850) -> Vec<f32> {
8851    let inter = d.gate_proj.rows();
8852    let hidden = d.down_proj.rows();
8853    // Fused on-device SwiGLU when the device is in play: three separate
8854    // `matmat` calls are three round trips per layer, and the gate/up
8855    // panels (b × inter — 22 MB each at a 512-token chunk) cross the bus
8856    // twice for nothing. The kernel already existed for the image DiT;
8857    // the LLM prefill was simply never wired to it. A task mask needs the
8858    // activations on the host between the halves, so it keeps the CPU
8859    // arm below.
8860    if mask_row.is_none()
8861        && d.act == Act::Silu
8862        && b >= 32
8863        && crate::gpu::enabled_here()
8864        && !crate::gpu::mm_killed()
8865    {
8866        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
8867            d.gate_proj.mapped_q4t(),
8868            d.up_proj.mapped_q4t(),
8869            d.down_proj.mapped_q4t(),
8870        ) {
8871            let mut out = vec![0.0f32; b * hidden];
8872            if crate::gpu::q4t_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
8873                return out;
8874            }
8875        }
8876        // The q4tp twin (same kernel family, scale from the row ladder) —
8877        // the DiT has run it in production since the pipeline containers;
8878        // the LLM prefill was simply never wired to it, so a q4tp model's
8879        // prefill panels stayed on the CPU.
8880        if let (Some((model, w1)), Some((_, w3)), Some((_, w2))) = (
8881            d.gate_proj.mapped_q4tp(),
8882            d.up_proj.mapped_q4tp(),
8883            d.down_proj.mapped_q4tp(),
8884        ) {
8885            let mut out = vec![0.0f32; b * hidden];
8886            if crate::gpu::q4tp_ffn(model, w1, w3, w2, xs, b, hidden, inter, &mut out) {
8887                return out;
8888            }
8889        }
8890    }
8891    let mut g = vec![0.0f32; b * inter];
8892    d.gate_proj.matmat(xs, b, &mut g, pool);
8893    let mut u = vec![0.0f32; b * inter];
8894    d.up_proj.matmat(xs, b, &mut u, pool);
8895    for i in 0..b * inter {
8896        g[i] = d.act.combine(g[i], u[i]);
8897    }
8898    if let Some(row) = mask_row {
8899        zero_masked_cols(&mut g, b, inter, row);
8900    }
8901    let mut out = vec![0.0f32; b * hidden];
8902    d.down_proj.matmat(&g, b, &mut out, pool);
8903    out
8904}
8905
8906/// Batched MoE-FFN: router batched, positions are GROUPED by expert —
8907/// an expert's weights are read once for all its positions in the chunk
8908/// (the main prefill-GEMM win on MoE: 960MB/token of 35B experts).
8909/// Accumulate per-channel activation energy for `CMF_RMS_TRACE`.
8910fn accumulate_act(m: &MoeFfn, xs: &[f32], b: usize) {
8911    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8912    static DUMP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
8913    let on = *ON.get_or_init(|| std::env::var("CMF_RMS_TRACE").is_ok());
8914    let dump = *DUMP.get_or_init(|| std::env::var("CMF_ACT_DUMP").is_ok());
8915    if (!on && !dump) || b == 0 {
8916        return;
8917    }
8918    let hidden = xs.len() / b;
8919    if on {
8920        let mut acc = m.act_sq.borrow_mut();
8921        if acc.len() < hidden {
8922            acc.resize(hidden, 0.0);
8923        }
8924        for t in 0..b {
8925            let row = &xs[t * hidden..(t + 1) * hidden];
8926            for (a, &v) in acc.iter_mut().zip(row) {
8927                *a += (v as f64) * (v as f64);
8928            }
8929        }
8930    }
8931    if dump {
8932        // Cap the capture: the covariance needs a few thousand rows, and a
8933        // whole prefill of every layer would be gigabytes for no extra rank.
8934        let cap: usize = std::env::var("CMF_ACT_DUMP_ROWS")
8935            .ok()
8936            .and_then(|v| v.parse().ok())
8937            .unwrap_or(4096);
8938        let mut rows = m.act_rows.borrow_mut();
8939        if rows.len() < cap * hidden {
8940            let take = b.min((cap * hidden - rows.len()) / hidden.max(1));
8941            rows.extend_from_slice(&xs[..take * hidden]);
8942        }
8943    }
8944}
8945
8946/// Send-able cursor over a Vec-of-Vecs: each pool worker writes only its
8947/// own slots (disjoint by construction in the caller).
8948#[derive(Clone, Copy)]
8949struct SendVecs(*mut Vec<f32>);
8950unsafe impl Send for SendVecs {}
8951unsafe impl Sync for SendVecs {}
8952impl SendVecs {
8953    #[inline]
8954    fn at(self, i: usize) -> *mut Vec<f32> {
8955        unsafe { self.0.add(i) }
8956    }
8957}
8958
8959fn moe_ffn_batch(
8960    m: &MoeFfn,
8961    xs: &[f32],
8962    b: usize,
8963    hidden: usize,
8964    pool: Option<&Pool>,
8965    allowed: Option<&[bool]>,
8966) -> Vec<f32> {
8967    accumulate_act(m, xs, b);
8968    let ne = m.experts.len();
8969    let mut logits = vec![0.0f32; b * ne];
8970    match &m.resonance {
8971        Some(r) => {
8972            let hdim = xs.len() / b.max(1);
8973            for bi in 0..b {
8974                r.scores(&xs[bi * hdim..(bi + 1) * hdim], &mut logits[bi * ne..(bi + 1) * ne]);
8975            }
8976        }
8977        None => m.router.matmat(xs, b, &mut logits, pool),
8978    }
8979
8980    // Assignments: expert → [(position, weight)] — same routing as
8981    // moe_ffn, per position (see `moe_route`).
8982    let mut assign: Vec<Vec<(usize, f32)>> = vec![Vec::new(); ne];
8983    {
8984        let mut st = m.stats.borrow_mut();
8985        if st.len() < ne {
8986            st.resize(ne, 0);
8987        }
8988        for bi in 0..b {
8989            let (idx, p, wsum) = moe_route(&logits[bi * ne..(bi + 1) * ne], m, allowed);
8990            for &e in &idx {
8991                st[e] += 1;
8992                assign[e].push((bi, p[e] / wsum));
8993            }
8994        }
8995    }
8996
8997    let mut out = vec![0.0f32; b * hidden];
8998    let cols = m.experts[0].gate_proj.cols();
8999    let run_expert = |d: &DenseFfn, list: &[(usize, f32)], out: &mut [f32]| {
9000        let sb = list.len();
9001        let mut sub = vec![0.0f32; sb * cols];
9002        for (k, &(bi, _)) in list.iter().enumerate() {
9003            sub[k * cols..(k + 1) * cols].copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
9004        }
9005        let eo = dense_ffn_batch(d, &sub, sb, pool, None);
9006        for (k, &(bi, w)) in list.iter().enumerate() {
9007            for i in 0..hidden {
9008                out[bi * hidden + i] += w * eo[k * hidden + i];
9009            }
9010        }
9011    };
9012    // Routed experts: the panels are TINY (b·top_k spread over every
9013    // expert — a few positions each), so a pool dispatch per expert is
9014    // pure barrier cost. Invert the parallelism: workers take WHOLE
9015    // experts (serial math inside), then one deterministic scatter in
9016    // expert order — the exact accumulation order the serial loop had.
9017    let active: Vec<usize> = (0..ne).filter(|&e| !assign[e].is_empty()).collect();
9018    if pool.is_some() && active.len() >= 8 {
9019        let mut panels: Vec<Vec<f32>> = vec![Vec::new(); active.len()];
9020        {
9021            let panel_ptr = SendVecs(panels.as_mut_ptr());
9022            // Capture only the expert table: `m` itself carries RefCell
9023            // stats and must not cross the pool boundary.
9024            let experts = &m.experts;
9025            let (active_r, assign_r) = (&active, &assign);
9026            let run = |start: usize, end: usize| {
9027                for ai in start..end {
9028                    let e = active_r[ai];
9029                    let list = &assign_r[e];
9030                    let sb = list.len();
9031                    let mut sub = vec![0.0f32; sb * cols];
9032                    for (k, &(bi, _)) in list.iter().enumerate() {
9033                        sub[k * cols..(k + 1) * cols]
9034                            .copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
9035                    }
9036                    // SAFETY: each worker owns a disjoint panels[ai].
9037                    unsafe {
9038                        *panel_ptr.at(ai) = dense_ffn_batch(&experts[e], &sub, sb, None, None);
9039                    }
9040                }
9041            };
9042            match pool {
9043                Some(p) => p.run_rows(active.len(), &run),
9044                None => run(0, active.len()),
9045            }
9046        }
9047        for (ai, &e) in active.iter().enumerate() {
9048            for (k, &(bi, w)) in assign[e].iter().enumerate() {
9049                let eo = &panels[ai][k * hidden..(k + 1) * hidden];
9050                for i in 0..hidden {
9051                    out[bi * hidden + i] += w * eo[i];
9052                }
9053            }
9054        }
9055    } else {
9056        for &e in &active {
9057            run_expert(&m.experts[e], &assign[e], &mut out);
9058        }
9059    }
9060    if let Some((se, gate)) = &m.shared {
9061        let all: Vec<(usize, f32)> = if let Some(gate) = gate {
9062            let mut gl = vec![0.0f32; b];
9063            gate.matmat(xs, b, &mut gl, pool);
9064            (0..b)
9065                .map(|bi| (bi, 1.0 / (1.0 + (-gl[bi]).exp())))
9066                .collect()
9067        } else {
9068            (0..b).map(|bi| (bi, 1.0)).collect()
9069        };
9070        run_expert(se, &all, &mut out);
9071    }
9072    out
9073}
9074
9075thread_local! {
9076    /// gate/up activation scratch for the dense FFN paths (single uses
9077    /// two slots, the fused pair all four) — these were fresh
9078    /// intermediate-size Vecs on every layer of every token.
9079    static FFN_SCRATCH: std::cell::RefCell<[Vec<f32>; 4]> =
9080        const { std::cell::RefCell::new([Vec::new(), Vec::new(), Vec::new(), Vec::new()]) };
9081}
9082
9083/// Dense SwiGLU FFN through QTensor matvecs (any storage).
9084fn dense_ffn(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
9085    // Whole-FFN GPU submit (этап 4.2 increment): gate → silu·up → down
9086    // chained in ONE command buffer with the intermediate activations
9087    // resident on the device — 3 per-op polls become 1 per layer. The
9088    // moe_block backend already implements exactly this chain; a dense
9089    // FFN is one expert with weight 1. Runtime probe: the chain still
9090    // pays one submit+poll per layer — alternate it against the pure-CPU
9091    // FFN and keep whichever is faster on this machine.
9092    // q1 FFNs offload at any practical size: the q1 CPU kernel is
9093    // compute-bound, so the UMA threshold logic does not apply — the
9094    // probe measures and decides either way.
9095    if crate::gpu::enabled_here()
9096        && (d.gate_proj.rows() >= crate::gpu::min_rows() || d.gate_proj.is_q1())
9097    {
9098        let arm = if d.gate_proj.is_q1() && crate::gpu::q1_force() {
9099            crate::gpu::ProbeArm::Gpu
9100        } else {
9101            crate::gpu::probe_arm(crate::gpu::OpClass::Ffn)
9102        };
9103        match arm {
9104            crate::gpu::ProbeArm::Gpu => {
9105                let t0 = std::time::Instant::now();
9106                if let Some(out) = dense_ffn_gpu(d, x, pool) {
9107                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
9108                    return out;
9109                }
9110            }
9111            crate::gpu::ProbeArm::CpuTimed => {
9112                let t0 = std::time::Instant::now();
9113                let out = crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
9114                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
9115                return out;
9116            }
9117            crate::gpu::ProbeArm::Cpu => {
9118                return crate::gpu::cpu_scope(|| dense_ffn_cpu(d, x, pool));
9119            }
9120        }
9121    }
9122    dense_ffn_cpu(d, x, pool)
9123}
9124
9125/// The pure-CPU dense-FFN body (also the fallback of every GPU refusal).
9126fn dense_ffn_cpu(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
9127    let inter = d.gate_proj.rows();
9128    FFN_SCRATCH.with(|s| {
9129        let mut s = s.borrow_mut();
9130        let [g, u, ..] = &mut *s;
9131        g.resize(inter, 0.0);
9132        // Fused gate+up+silu: one dispatch, no separate silu pass.
9133        // Falls back to matvec_many + silu loop for unsupported dtypes.
9134        if d.act == Act::Silu && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool) {
9135            // g now holds silu(gate)·up directly.
9136        } else {
9137            u.resize(inter, 0.0);
9138            // Multi-matrix job: gate+up under one pool dispatch.
9139            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
9140            for i in 0..inter {
9141                g[i] = d.act.combine(g[i], u[i]);
9142            }
9143        }
9144        // DTG-MA bake probe (Patent 2): accumulate this layer's
9145        // per-neuron activation mass while a probe pass is active.
9146        FFN_PROBE.with(|pr| {
9147            if let Some(acc) = pr.borrow_mut().as_mut() {
9148                let li = crate::gpu::cur_layer();
9149                if li >= 0 {
9150                    if let Some(row) = acc.get_mut(li as usize) {
9151                        for (a, &v) in row.iter_mut().zip(g.iter()) {
9152                            *a += (v as f64).abs();
9153                        }
9154                    }
9155                }
9156            }
9157        });
9158        let mut out = attention::take_buf(d.down_proj.rows());
9159        d.down_proj.matvec(g, &mut out, pool);
9160        out
9161    })
9162}
9163
9164thread_local! {
9165    /// DTG-MA activation probe: per-layer per-neuron Σ|silu(g)·u|
9166    /// accumulator, alive only during `Pipeline::probe_ffn_mass`.
9167    static FFN_PROBE: std::cell::RefCell<Option<Vec<Vec<f64>>>> =
9168        const { std::cell::RefCell::new(None) };
9169}
9170
9171/// `dense_ffn_cpu` with a per-visit mask landing on the activations —
9172/// the masked-inference fast path's decode arm. Full fused quant
9173/// compute, closed neurons zeroed before down: arithmetically the
9174/// pruned network, no dequant, no weight bytes touched.
9175fn dense_ffn_masked(d: &DenseFfn, x: &[f32], pool: Option<&Pool>, mask_row: &[u8]) -> Vec<f32> {
9176    let inter = d.gate_proj.rows();
9177    FFN_SCRATCH.with(|s| {
9178        let mut s = s.borrow_mut();
9179        let [g, u, ..] = &mut *s;
9180        g.resize(inter, 0.0);
9181        if d.act == Act::Silu && QTensor::matvec_silu_mul(&d.gate_proj, &d.up_proj, x, g, pool) {
9182            // g holds silu(gate)·up.
9183        } else {
9184            u.resize(inter, 0.0);
9185            QTensor::matvec_many([&d.gate_proj, &d.up_proj], x, [g, u], pool);
9186            for i in 0..inter {
9187                g[i] = d.act.combine(g[i], u[i]);
9188            }
9189        }
9190        zero_masked_cols(g, 1, inter, mask_row);
9191        let mut out = attention::take_buf(d.down_proj.rows());
9192        d.down_proj.matvec(g, &mut out, pool);
9193        out
9194    })
9195}
9196
9197/// Dense FFN as one GPU submission via the MoE block path (single
9198/// expert, weight 1.0): gate → silu·up → down chained in one command
9199/// buffer, intermediate activations device-resident. None → weights
9200/// not q8-mapped in the primary shard / over the VRAM budget / backend
9201/// refusal → honest CPU path.
9202fn dense_ffn_gpu(d: &DenseFfn, x: &[f32], _pool: Option<&Pool>) -> Option<Vec<f32>> {
9203    // The GPU block hardcodes SiLU; GeLU FFNs (Gemma) stay on CPU.
9204    if d.act != Act::Silu {
9205        return None;
9206    }
9207    // Threshold: tiny FFNs are not worth a submission (q1 excepted —
9208    // see the caller's gate).
9209    if d.gate_proj.rows() < crate::gpu::min_rows() && !d.gate_proj.is_q1() {
9210        return None;
9211    }
9212    let mut jobs: Vec<crate::gpu::MoeJob> = Vec::with_capacity(1);
9213    let mut model_ref = None;
9214    moe_push_job(d, x, 1.0, &mut jobs, &mut model_ref)?;
9215    let model = model_ref?;
9216    let hidden = jobs[0].down.1;
9217    let mut out = attention::take_buf(hidden);
9218    if crate::gpu::moe_block(&model, &jobs, &mut out) {
9219        Some(out)
9220    } else {
9221        let mut out = out;
9222        attention::recycle_buf(&mut out);
9223        None
9224    }
9225}
9226
9227/// q8-mapped primary-shard tensor parts for a GPU job: q8_2f carries
9228/// its column field, q8_row runs with empty col slices (the backend
9229/// skips the multiply). Shared by the MoE block and the dense-FFN
9230/// single-job path.
9231#[allow(clippy::type_complexity)]
9232#[allow(clippy::type_complexity)]
9233pub(crate) fn moe_parts(
9234    t: &QTensor,
9235) -> Option<(
9236    &std::sync::Arc<cortiq_core::CmfModel>,
9237    usize,
9238    usize,
9239    usize,
9240    &[f32],
9241    &[f32],
9242    bool,
9243    bool,
9244    bool,
9245)> {
9246    match t {
9247        QTensor::Mapped {
9248            model,
9249            idx,
9250            dtype: dt @ (cortiq_core::TensorDtype::Q8_2f | cortiq_core::TensorDtype::Q8Row),
9251            rows,
9252            cols,
9253            row_scale,
9254            col_field,
9255            ..
9256        } if (*dt == cortiq_core::TensorDtype::Q8Row) || !col_field.is_empty() => Some((
9257            model, *idx, *rows, *cols, row_scale, col_field, false, false, false,
9258        )),
9259        // q1: tile-embedded scales — empty rs/col slices, raw xs.
9260        QTensor::Mapped {
9261            model,
9262            idx,
9263            dtype: cortiq_core::TensorDtype::Q1,
9264            rows,
9265            cols,
9266            ..
9267        } => Some((
9268            model,
9269            *idx,
9270            *rows,
9271            *cols,
9272            &[][..],
9273            &[][..],
9274            true,
9275            false,
9276            false,
9277        )),
9278        // q4_tiled: 18-byte tiles with embedded f16 scales — raw xs.
9279        QTensor::Mapped {
9280            model,
9281            idx,
9282            dtype: cortiq_core::TensorDtype::Q4Tiled,
9283            rows,
9284            cols,
9285            ..
9286        } => Some((
9287            model,
9288            *idx,
9289            *rows,
9290            *cols,
9291            &[][..],
9292            &[][..],
9293            false,
9294            true,
9295            false,
9296        )),
9297        // q4tp: same raw-xs contract, different stride and scale plane.
9298        QTensor::Mapped {
9299            model,
9300            idx,
9301            dtype: cortiq_core::TensorDtype::Q4TiledP,
9302            rows,
9303            cols,
9304            ..
9305        } => Some((
9306            model,
9307            *idx,
9308            *rows,
9309            *cols,
9310            &[][..],
9311            &[][..],
9312            false,
9313            true,
9314            false,
9315        )),
9316        // q2tp: the 2-bit expert plane of the mixed profile — q4 family
9317        // for stride bookkeeping, flagged q2 so the trio validation can
9318        // demand a q4tp down.
9319        QTensor::Mapped {
9320            model,
9321            idx,
9322            dtype: cortiq_core::TensorDtype::Q2TiledP,
9323            rows,
9324            cols,
9325            ..
9326        } => Some((
9327            model,
9328            *idx,
9329            *rows,
9330            *cols,
9331            &[][..],
9332            &[][..],
9333            false,
9334            true,
9335            true,
9336        )),
9337        _ => None,
9338    }
9339}
9340
9341/// Map a softmax-router MoE onto the Metal token graph's contract:
9342/// f32 router, gated shared expert, experts uniformly q4tp (or the
9343/// mixed profile: q2tp gate/up over a q4tp down). Sigmoid/bias/τ
9344/// routers, masks, per-expert scales and Gemma's router-input norm
9345/// refuse here — those semantics stay on the CPU path.
9346#[cfg(target_os = "macos")]
9347fn metal_moe_graph_parts(m: &MoeFfn, hidden: usize) -> Option<crate::gpu::GpuMoe<'_>> {
9348    if m.router_sigmoid
9349        || m.router_input_norm
9350        || m.expert_bias.is_some()
9351        || m.route_tau.is_some()
9352        || m.mask.is_some()
9353        || m.per_expert_scale.is_some()
9354        || m.experts.is_empty()
9355        || m.top_k == 0
9356        || m.resonance.is_some()
9357    {
9358        return None;
9359    }
9360    // The select kernel hard-codes the gated shared expert; an
9361    // ungated one would need its own weight-1 slot.
9362    let (sh, sg) = match &m.shared {
9363        Some((sh, Some(sg))) => (sh, sg),
9364        _ => return None,
9365    };
9366    let (rf, rr, rc) = m.router.f32_parts()?;
9367    if rr != m.experts.len() || rc != hidden {
9368        return None;
9369    }
9370    let (sf, sr, sc) = sg.f32_parts()?;
9371    if sr * sc != hidden {
9372        return None;
9373    }
9374    let inter = m.experts[0].gate_proj.rows();
9375    // The first expert's gate decides the profile; every trio (shared
9376    // included) must agree — the jobs ladder flips ONE kernel for all.
9377    let gu_q2 = m.experts[0].gate_proj.mapped_q2tp().is_some();
9378    let trio = |e: &DenseFfn| -> Option<(usize, usize, usize)> {
9379        if e.act != Act::Silu
9380            || e.gate_proj.rows() != inter
9381            || e.gate_proj.cols() != hidden
9382            || e.up_proj.rows() != inter
9383            || e.up_proj.cols() != hidden
9384            || e.down_proj.rows() != hidden
9385            || e.down_proj.cols() != inter
9386        {
9387            return None;
9388        }
9389        let pick = |t: &QTensor| -> Option<usize> {
9390            if gu_q2 {
9391                t.mapped_q2tp().map(|(_, i)| i)
9392            } else {
9393                t.mapped_q4tp().map(|(_, i)| i)
9394            }
9395        };
9396        Some((
9397            pick(&e.gate_proj)?,
9398            pick(&e.up_proj)?,
9399            e.down_proj.mapped_q4tp().map(|(_, i)| i)?,
9400        ))
9401    };
9402    let experts = m.experts.iter().map(trio).collect::<Option<Vec<_>>>()?;
9403    let shared = trio(sh)?;
9404    Some(crate::gpu::GpuMoe {
9405        router: rf,
9406        sgate: sf,
9407        experts,
9408        shared,
9409        n_exp: m.experts.len(),
9410        top_k: m.top_k,
9411        inter,
9412        norm_topk: m.norm_topk_prob,
9413        route_scale: m.routed_scaling,
9414        gu_q2,
9415    })
9416}
9417
9418/// Build one gate/up/down GPU job from three tensors. `moe_push_job` is the
9419/// DenseFfn-shaped caller; architectures that keep their experts in their own
9420/// structs (DeepSeek-V4) come here directly.
9421pub(crate) fn moe_push_job_parts<'a>(
9422    gate: &'a QTensor,
9423    up: &'a QTensor,
9424    down: &'a QTensor,
9425    x: &[f32],
9426    w: f32,
9427    swiglu_limit: f32,
9428    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
9429    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
9430) -> Option<()> {
9431    use crate::qtensor::prescale;
9432    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(gate)?;
9433    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(up)?;
9434    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(down)?;
9435    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
9436        return None; // mixed-dtype trio — honest CPU path
9437    }
9438    // The 2-bit profile is gate/up q2tp over a PLAIN q4tp down; any other
9439    // 2-bit arrangement stays on the CPU.
9440    if gq2 && (dq2 || !dq4 || down.mapped_q4tp().is_none()) {
9441        return None;
9442    }
9443    if !gq2 && dq2 {
9444        return None;
9445    }
9446    model_ref.get_or_insert_with(|| gm.clone());
9447    let dt = |cf: &[f32]| {
9448        if cf.is_empty() {
9449            cortiq_core::TensorDtype::Q8Row
9450        } else {
9451            cortiq_core::TensorDtype::Q8_2f
9452        }
9453    };
9454    jobs.push(crate::gpu::MoeJob {
9455        gate: (gi, gr, gc, grs),
9456        up: (ui, ur, uc, urs),
9457        down: (di, dr, dc, drs),
9458        xs_gate: prescale(x, gcf, dt(gcf)).into_owned(),
9459        xs_up: prescale(x, ucf, dt(ucf)).into_owned(),
9460        down_col: dcf,
9461        w,
9462        q1: gq1,
9463        q4t: gq4 && !gq2 && gate.mapped_q4tp().is_none(),
9464        q4tp: gq4 && (gq2 || gate.mapped_q4tp().is_some()),
9465        gu_q2: gq2,
9466        swiglu_limit,
9467    });
9468    Some(())
9469}
9470
9471/// Build one gate/up/down GPU job (see `moe_parts`).
9472fn moe_push_job<'a>(
9473    d: &'a DenseFfn,
9474    x: &[f32],
9475    w: f32,
9476    jobs: &mut Vec<crate::gpu::MoeJob<'a>>,
9477    model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
9478) -> Option<()> {
9479    use crate::qtensor::prescale;
9480    if d.act != Act::Silu {
9481        return None; // GPU block hardcodes SiLU
9482    }
9483    let (gm, gi, gr, gc, grs, gcf, gq1, gq4, gq2) = moe_parts(&d.gate_proj)?;
9484    let (_, ui, ur, uc, urs, ucf, uq1, uq4, uq2) = moe_parts(&d.up_proj)?;
9485    let (_, di, dr, dc, drs, dcf, dq1, dq4, dq2) = moe_parts(&d.down_proj)?;
9486    if gq1 != uq1 || uq1 != dq1 || gq4 != uq4 || uq4 != dq4 || gq2 != uq2 {
9487        return None; // mixed-dtype trio — honest CPU path
9488    }
9489    if gq2 && (dq2 || !dq4 || d.down_proj.mapped_q4tp().is_none()) {
9490        return None;
9491    }
9492    if !gq2 && dq2 {
9493        return None;
9494    }
9495    model_ref.get_or_insert_with(|| gm.clone());
9496    let gdt = if gcf.is_empty() {
9497        cortiq_core::TensorDtype::Q8Row
9498    } else {
9499        cortiq_core::TensorDtype::Q8_2f
9500    };
9501    let udt = if ucf.is_empty() {
9502        cortiq_core::TensorDtype::Q8Row
9503    } else {
9504        cortiq_core::TensorDtype::Q8_2f
9505    };
9506    jobs.push(crate::gpu::MoeJob {
9507        gate: (gi, gr, gc, grs),
9508        up: (ui, ur, uc, urs),
9509        down: (di, dr, dc, drs),
9510        xs_gate: prescale(x, gcf, gdt).into_owned(),
9511        xs_up: prescale(x, ucf, udt).into_owned(),
9512        down_col: dcf,
9513        w,
9514        q1: gq1,
9515        q4t: gq4 && !gq2 && d.gate_proj.mapped_q4tp().is_none(),
9516        q4tp: gq4 && (gq2 || d.gate_proj.mapped_q4tp().is_some()),
9517        gu_q2: gq2,
9518        swiglu_limit: 0.0,
9519    });
9520    Some(())
9521}
9522
9523/// Sparse dense-FFN directly on QUANTIZED weights (mask × mmap): reads
9524/// ONLY the active neurons' gate/up rows and down columns from the mmap
9525/// — no full-matrix dequant, no f32 model copy. This is what lets a
9526/// masked big model run at quantized RSS (the historical mask path
9527/// forced the whole model to f32). Semantics identical to the f32
9528/// sparse path within quant tolerance.
9529fn sparse_ffn_quant(
9530    d: &DenseFfn,
9531    x: &[f32],
9532    active: &[u16],
9533    hidden: usize,
9534    pool: Option<&Pool>,
9535) -> Vec<f32> {
9536    let n = active.len();
9537    let inter = d.gate_proj.rows();
9538    let mut act = vec![0.0f32; n];
9539    // Scratch is needed if EITHER projection is group-packed (q4/vbit);
9540    // gate/up normally share a dtype but sizing on both is robust.
9541    let need_scratch = !(d.gate_proj.sparse_col_ok() && d.up_proj.sparse_col_ok());
9542    let compute = |ai: usize| -> f32 {
9543        let idx = active[ai] as usize;
9544        if idx >= inter {
9545            return 0.0; // defensive parity with the f32 sparse path
9546        }
9547        let mut s = if need_scratch {
9548            vec![0.0f32; hidden]
9549        } else {
9550            Vec::new()
9551        };
9552        let gate = d.gate_proj.row_dot(idx, x, &mut s);
9553        let up = d.up_proj.row_dot(idx, x, &mut s);
9554        d.act.combine(gate, up)
9555    };
9556    match pool {
9557        Some(p) if n >= 256 => {
9558            let ptr = SendMut(act.as_mut_ptr());
9559            p.run(&|widx, nw| {
9560                let chunk = n.div_ceil(nw);
9561                let (s, e) = (widx * chunk, ((widx + 1) * chunk).min(n));
9562                for ai in s..e {
9563                    unsafe { *ptr.at(ai) = compute(ai) };
9564                }
9565            });
9566        }
9567        _ => {
9568            for (ai, a) in act.iter_mut().enumerate() {
9569                *a = compute(ai);
9570            }
9571        }
9572    }
9573    // Scatter through active down columns (reads only those columns).
9574    let mut out = vec![0.0f32; hidden];
9575    for (ai, &idx) in active.iter().enumerate() {
9576        let w = act[ai];
9577        if w.abs() >= 1e-12 && (idx as usize) < inter {
9578            d.down_proj.add_col_scaled(idx as usize, w, &mut out);
9579        }
9580    }
9581    out
9582}
9583
9584/// Test-only re-export of the private sparse-quant FFN (mask × mmap gate).
9585#[doc(hidden)]
9586pub fn sparse_ffn_quant_for_test(
9587    d: &DenseFfn,
9588    x: &[f32],
9589    active: &[u16],
9590    hidden: usize,
9591) -> Vec<f32> {
9592    sparse_ffn_quant(d, x, active, hidden, None)
9593}
9594
9595/// Dequantize a DenseFfn's three matrices to f32 (transient; only the
9596/// q4/vbit-masked fallback uses it — the memory-lean path is
9597/// sparse_ffn_quant). Reuses row_f32 row-by-row.
9598fn dequant_dense_f32(d: &DenseFfn) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
9599    let deq = |t: &QTensor| -> Vec<f32> {
9600        let (rows, cols) = (t.rows(), t.cols());
9601        let mut out = vec![0.0f32; rows * cols];
9602        for r in 0..rows {
9603            t.row_f32(r, &mut out[r * cols..(r + 1) * cols]);
9604        }
9605        out
9606    };
9607    (deq(&d.gate_proj), deq(&d.up_proj), deq(&d.down_proj))
9608}
9609
9610/// Pointer wrapper for the worker-pool scatter (same pattern as qtensor).
9611struct SendMut(*mut f32);
9612unsafe impl Send for SendMut {}
9613unsafe impl Sync for SendMut {}
9614impl SendMut {
9615    #[inline]
9616    // Deliberate unsynchronized scatter: pool workers write disjoint indices
9617    // in parallel, so returning `&mut` from `&self` is intentional here.
9618    #[allow(clippy::mut_from_ref)]
9619    unsafe fn at(&self, i: usize) -> &mut f32 {
9620        unsafe { &mut *self.0.add(i) }
9621    }
9622}
9623
9624/// Router → (selected experts in torch.topk order, per-expert score
9625/// vector, normalizer). The final weight of expert `e` is `p[e] / wsum`.
9626///
9627/// Two regimes share this. Qwen: softmax over ALL experts, top-k of the
9628/// probabilities, optional renorm — `router_sigmoid=false`, no bias,
9629/// scale 1 → bit-identical to the historical path. LFM2-MoE /
9630/// DeepSeek-V3 `noaux_tc`: per-expert sigmoid scores, an optional
9631/// selection bias (top-k CHOICE only; weights stay unbiased), a 1e-6 renorm
9632/// floor and a routed scale.
9633fn moe_route(logits: &[f32], m: &MoeFfn, allowed: Option<&[bool]>) -> (Vec<usize>, Vec<f32>, f32) {
9634    let ne = logits.len();
9635    let p: Vec<f32> = if m.router_sigmoid {
9636        logits.iter().map(|&l| 1.0 / (1.0 + (-l).exp())).collect()
9637    } else {
9638        let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
9639        let mut e: Vec<f32> = logits.iter().map(|&l| (l - mx).exp()).collect();
9640        let s: f32 = e.iter().sum();
9641        for v in &mut e {
9642            *v /= s;
9643        }
9644        e
9645    };
9646    // Expert restriction: the static env mask (CMF_MOE_MASK) AND the
9647    // active task mask's expert fields (spec §5) both narrow the
9648    // candidate set; selection happens over the admitted experts only.
9649    // With norm_topk the kept weights renormalize below; without it
9650    // the excluded mass is honestly dropped.
9651    let admit = |e: usize| {
9652        m.mask.as_ref().is_none_or(|mk| mk[e])
9653            && allowed.is_none_or(|a| a.get(e).copied().unwrap_or(false))
9654    };
9655    let mut idx: Vec<usize> = (0..ne).filter(|&e| admit(e)).collect();
9656    // Descending by selection score, lower index wins ties (torch.topk).
9657    match &m.expert_bias {
9658        Some(b) => idx.sort_unstable_by(|&x, &y| {
9659            (p[y] + b[y])
9660                .partial_cmp(&(p[x] + b[x]))
9661                .unwrap()
9662                .then(x.cmp(&y))
9663        }),
9664        None => idx.sort_unstable_by(|&x, &y| p[y].partial_cmp(&p[x]).unwrap().then(x.cmp(&y))),
9665    }
9666    idx.truncate(m.top_k);
9667    // Adaptive τ-routing: trim the tail experts once the kept mass is
9668    // enough. wsum below renormalizes over the KEPT set, so the output
9669    // stays a proper weighted average.
9670    if let Some(tau) = m.route_tau {
9671        let total: f32 = idx.iter().map(|&e| p[e]).sum();
9672        if total > 0.0 {
9673            let mut acc = 0.0f32;
9674            let mut keep = idx.len();
9675            for (i, &e) in idx.iter().enumerate() {
9676                acc += p[e];
9677                if acc >= tau * total {
9678                    keep = i + 1;
9679                    break;
9680                }
9681            }
9682            idx.truncate(keep);
9683        }
9684    }
9685    let wsum: f32 = if m.norm_topk_prob {
9686        let s: f32 = idx.iter().map(|&e| p[e]).sum();
9687        // LFM2 floors the denom (matches HF `+ 1e-6`); the softmax path's
9688        // probs already sum near 1, so it stays exactly as before.
9689        (if m.router_sigmoid { s + 1e-6 } else { s }) / m.routed_scaling
9690    } else {
9691        1.0 / m.routed_scaling
9692    };
9693    (idx, p, wsum)
9694}
9695
9696/// MoE FFN: router → top-k experts (see `moe_route`). Only selected
9697/// experts' pages are touched in mmap.
9698fn moe_ffn(m: &MoeFfn, x: &[f32], pool: Option<&Pool>, allowed: Option<&[bool]>) -> Vec<f32> {
9699    accumulate_act(m, x, 1);
9700    let ne = m.experts.len();
9701    let mut logits = vec![0.0f32; ne];
9702    match &m.resonance {
9703        Some(r) => r.scores(x, &mut logits),
9704        None => m.router.matvec(x, &mut logits, pool),
9705    }
9706    let (idx, p, wsum) = moe_route(&logits, m, allowed);
9707    {
9708        let mut st = m.stats.borrow_mut();
9709        if st.len() < ne {
9710            st.resize(ne, 0);
9711        }
9712        for &e in &idx {
9713            st[e] += 1;
9714        }
9715    }
9716    // D5: the whole layer MoE block in one GPU command buffer (experts — the
9717    // same mmap via a no-copy buffer; intermediate activations on the GPU).
9718    // Same Ffn probe class as the dense chain: one submit per layer
9719    // either wins on this driver stack or it doesn't.
9720    if crate::gpu::enabled_here() {
9721        match crate::gpu::probe_arm(crate::gpu::OpClass::Ffn) {
9722            crate::gpu::ProbeArm::Gpu => {
9723                let t0 = std::time::Instant::now();
9724                if let Some(out) = moe_ffn_gpu(m, x, &idx, &p, wsum, pool) {
9725                    crate::gpu::probe_record(crate::gpu::OpClass::Ffn, true, t0.elapsed());
9726                    return out;
9727                }
9728            }
9729            crate::gpu::ProbeArm::CpuTimed => {
9730                let t0 = std::time::Instant::now();
9731                let out = crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
9732                crate::gpu::probe_record(crate::gpu::OpClass::Ffn, false, t0.elapsed());
9733                return out;
9734            }
9735            crate::gpu::ProbeArm::Cpu => {
9736                return crate::gpu::cpu_scope(|| moe_ffn_cpu(m, x, &idx, &p, wsum, pool));
9737            }
9738        }
9739    }
9740    moe_ffn_cpu(m, x, &idx, &p, wsum, pool)
9741}
9742
9743/// One-shot report of whether the whole-token wgpu graph actually formed.
9744/// A refusal silently reverts to the per-op path, which is how a model can
9745/// look "GPU-accelerated" while every layer walks the host.
9746fn graph_note(built: bool) {
9747    use std::sync::atomic::{AtomicBool, Ordering};
9748    if built {
9749        GRAPH_TOK_OK.fetch_add(1, Ordering::Relaxed);
9750    } else {
9751        GRAPH_TOK_MISS.fetch_add(1, Ordering::Relaxed);
9752    }
9753    static SAID: AtomicBool = AtomicBool::new(false);
9754    if !SAID.swap(true, Ordering::Relaxed) {
9755        if built {
9756            tracing::info!("wgpu whole-token graph: ACTIVE");
9757        } else {
9758            tracing::warn!("wgpu whole-token graph refused — per-op path");
9759        }
9760    }
9761}
9762
9763/// Whole-token graph outcomes, process-wide: a benchmark that claims a
9764/// GPU number while MISS climbs is measuring the CPU — the honest-bench
9765/// contract makes that an error, not a footnote.
9766pub static GRAPH_TOK_OK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
9767pub static GRAPH_TOK_MISS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
9768
9769/// `CMF_MOE_BATCH=0` restores the per-expert serial loop — the A/B lever
9770/// for the batched kernel, and how its bit-identity is checked.
9771fn moe_batch_enabled() -> bool {
9772    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
9773    *ON.get_or_init(|| std::env::var("CMF_MOE_BATCH").as_deref() != Ok("0"))
9774}
9775
9776/// Two-dispatch CPU MoE: every routed expert (and the shared one) fused
9777/// into one gate/up/SiLU dispatch and one down dispatch, instead of two
9778/// pool barriers per expert. Bit-identical to the serial loop below —
9779/// see `moe_gate_up_many` / `moe_down_many`. `None` = the batched kernel
9780/// does not cover this layer, walk the serial path.
9781fn moe_ffn_cpu_batched(
9782    m: &MoeFfn,
9783    x: &[f32],
9784    idx: &[usize],
9785    p: &[f32],
9786    wsum: f32,
9787    pool: Option<&Pool>,
9788) -> Option<Vec<f32>> {
9789    if idx.is_empty() || !moe_batch_enabled() {
9790        return None;
9791    }
9792    // The bake probe reads per-neuron activation mass out of the
9793    // single-expert path; batching would skip it. Rare and offline —
9794    // hand those runs to the serial loop.
9795    if FFN_PROBE.with(|pr| pr.borrow().is_some()) {
9796        return None;
9797    }
9798    let n = idx.len() + usize::from(m.shared.is_some());
9799    let mut pairs = Vec::with_capacity(n);
9800    let mut downs = Vec::with_capacity(n);
9801    let mut ws = Vec::with_capacity(n);
9802    for &e in idx {
9803        let d = &m.experts[e];
9804        if d.act != Act::Silu {
9805            return None;
9806        }
9807        pairs.push((&d.gate_proj, &d.up_proj));
9808        downs.push(&d.down_proj);
9809        ws.push(p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]));
9810    }
9811    // The shared expert goes last, matching the serial loop's order —
9812    // the f32 accumulation order is part of the bit-identity claim.
9813    if let Some((se, gate)) = &m.shared {
9814        if se.act != Act::Silu {
9815            return None;
9816        }
9817        let g = gate.as_ref().map_or(1.0, |gate| {
9818            let mut gl = [0.0f32; 1];
9819            gate.matvec(x, &mut gl, pool);
9820            1.0 / (1.0 + (-gl[0]).exp())
9821        });
9822        pairs.push((&se.gate_proj, &se.up_proj));
9823        downs.push(&se.down_proj);
9824        ws.push(g);
9825    }
9826    let inter = pairs[0].0.rows();
9827    let mut gs: Vec<Vec<f32>> = (0..pairs.len()).map(|_| vec![0f32; inter]).collect();
9828    if !QTensor::moe_gate_up_many(&pairs, x, &mut gs, pool) {
9829        return None;
9830    }
9831    let mut out = attention::take_buf(x.len());
9832    if !QTensor::moe_down_many(&downs, &gs, &ws, &mut out, pool) {
9833        attention::recycle_buf(&mut out);
9834        return None;
9835    }
9836    Some(out)
9837}
9838
9839/// The pure-CPU MoE expert loop (also the fallback of every GPU refusal).
9840fn moe_ffn_cpu(
9841    m: &MoeFfn,
9842    x: &[f32],
9843    idx: &[usize],
9844    p: &[f32],
9845    wsum: f32,
9846    pool: Option<&Pool>,
9847) -> Vec<f32> {
9848    if let Some(out) = moe_ffn_cpu_batched(m, x, idx, p, wsum, pool) {
9849        return out;
9850    }
9851    let mut out = attention::take_buf(x.len());
9852    for &e in idx {
9853        let mut eo = dense_ffn(&m.experts[e], x, pool);
9854        let w = p[e] / wsum * m.per_expert_scale.as_ref().map_or(1.0, |v| v[e]);
9855        for i in 0..out.len() {
9856            out[i] += w * eo[i];
9857        }
9858        attention::recycle_buf(&mut eo);
9859    }
9860    if let Some((se, gate)) = &m.shared {
9861        let mut so = dense_ffn(se, x, pool);
9862        let g = gate.as_ref().map_or(1.0, |gate| {
9863            let mut gl = [0.0f32; 1];
9864            gate.matvec(x, &mut gl, pool);
9865            1.0 / (1.0 + (-gl[0]).exp())
9866        });
9867        for i in 0..out.len() {
9868            out[i] += g * so[i];
9869        }
9870        attention::recycle_buf(&mut so);
9871    }
9872    out
9873}
9874
9875/// DeepSeek-V2 MLA forward, expand-to-MHA form (see `AttnKind::Mla`):
9876/// per token the latent expands to every head's K/V and the ordinary
9877/// cache + grouped attend do the rest. K head layout is [rope | nope]
9878/// (rotary_dim = qk_rope rotates the shared rope key and each q head's
9879/// prefix); V rows are zero-padded to the K head_dim inside the cache
9880/// and the pad is sliced off before O. Born importance is not
9881/// accumulated for MLA yet (no eviction interplay).
9882#[allow(clippy::too_many_arguments)]
9883fn mla_attention(
9884    w: &MlaWeights,
9885    normed: &[f32],
9886    cache: &mut crate::kv_cache::LayerKvCache,
9887    position: usize,
9888    inv_freq: &[f32],
9889    rope_scale: f32,
9890    eps: f64,
9891    pool: Option<&Pool>,
9892) -> Vec<f32> {
9893    let (nh, dr, dn, dv, lora) = (w.nh, w.qk_rope, w.qk_nope, w.v_dim, w.lora);
9894    let hd = dr + dn;
9895    let mut q = vec![0.0f32; nh * hd];
9896    match (&w.q_a, &w.q_a_norm) {
9897        (Some(qa), Some(qn)) => {
9898            let mut t = vec![0.0f32; qa.rows()];
9899            qa.matvec(normed, &mut t, pool);
9900            let tn = inference::rms_norm(&t, qn, eps, NormStyle::Qwen);
9901            w.q_proj.matvec(&tn, &mut q, pool);
9902        }
9903        _ => w.q_proj.matvec(normed, &mut q, pool),
9904    }
9905    let mut ca = vec![0.0f32; lora + dr];
9906    w.kv_a.matvec(normed, &mut ca, pool);
9907    let (c_lat, k_rope) = ca.split_at_mut(lora);
9908    let latn = inference::rms_norm(c_lat, &w.kv_a_norm, eps, NormStyle::Qwen);
9909    let mut kvb = vec![0.0f32; nh * (dn + dv)];
9910    w.kv_b.matvec(&latn, &mut kvb, pool);
9911    if !w.nope {
9912        attention::rope_rotate_scaled(k_rope, position, inv_freq, rope_scale);
9913    }
9914    for h in 0..nh {
9915        if !w.nope {
9916            attention::rope_rotate_scaled(
9917                &mut q[h * hd..h * hd + dr],
9918                position,
9919                inv_freq,
9920                rope_scale,
9921            );
9922        }
9923    }
9924    let mut k = vec![0.0f32; nh * hd];
9925    let mut v = vec![0.0f32; nh * hd];
9926    for h in 0..nh {
9927        k[h * hd..h * hd + dr].copy_from_slice(k_rope);
9928        k[h * hd + dr..(h + 1) * hd].copy_from_slice(&kvb[h * (dn + dv)..h * (dn + dv) + dn]);
9929        v[h * hd..h * hd + dv].copy_from_slice(&kvb[h * (dn + dv) + dn..(h + 1) * (dn + dv)]);
9930    }
9931    cache.append(&k, &v, &vec![true; nh]);
9932    let (ao, mut imp) = attention::attend_all_heads(&q, cache, nh, 1, hd, w.scale, None, 0.0);
9933    attention::recycle_buf(&mut imp);
9934    let mut ov = vec![0.0f32; nh * dv];
9935    for h in 0..nh {
9936        ov[h * dv..(h + 1) * dv].copy_from_slice(&ao[h * hd..h * hd + dv]);
9937    }
9938    let mut out = vec![0.0f32; w.o_proj.rows()];
9939    w.o_proj.matvec(&ov, &mut out, pool);
9940    out
9941}
9942
9943/// Gemma-4 dual-branch FFN (spec: see `FfnKind::DenseMoe`). The dense
9944/// branch reads the pre-FFN-normed activation; the router and the
9945/// expert branch read the RAW residual — the router through a
9946/// scale-less rms norm (its constant gain is folded into the weights),
9947/// the experts through `pre_norm_2`. CPU path; GPU graphs refuse the
9948/// layer kind honestly.
9949fn dense_moe_ffn(
9950    dm: &DenseMoeFfn,
9951    x_normed: &[f32],
9952    h_raw: &[f32],
9953    eps: f64,
9954    norm_style: NormStyle,
9955    pool: Option<&Pool>,
9956) -> Vec<f32> {
9957    let mut d = dense_ffn(&dm.dense, x_normed, pool);
9958    d = inference::rms_norm(&d, &dm.post_norm_1, eps, norm_style);
9959    let m = &dm.moe;
9960    let ne = m.experts.len();
9961    let mut logits = vec![0.0f32; ne];
9962    if m.router_input_norm {
9963        let ss: f32 = h_raw.iter().map(|v| v * v).sum::<f32>() / h_raw.len() as f32;
9964        let inv = 1.0 / (ss + eps as f32).sqrt();
9965        let xr: Vec<f32> = h_raw.iter().map(|v| v * inv).collect();
9966        m.router.matvec(&xr, &mut logits, pool);
9967    } else {
9968        m.router.matvec(h_raw, &mut logits, pool);
9969    }
9970    let (idx, p, wsum) = moe_route(&logits, m, None);
9971    {
9972        let mut st = m.stats.borrow_mut();
9973        if st.len() < ne {
9974            st.resize(ne, 0);
9975        }
9976        for &e in &idx {
9977            st[e] += 1;
9978        }
9979    }
9980    let x2 = inference::rms_norm(h_raw, &dm.pre_norm_2, eps, norm_style);
9981    let mo = moe_ffn_cpu(m, &x2, &idx, &p, wsum, pool);
9982    let mo = inference::rms_norm(&mo, &dm.post_norm_2, eps, norm_style);
9983    for (di, mi) in d.iter_mut().zip(&mo) {
9984        *di += mi;
9985    }
9986    d
9987}
9988
9989/// Building the MoE-layer GPU jobs: all selected experts (+shared) must
9990/// be q8_2f-Mapped from the primary mapping; otherwise None → CPU path.
9991/// One-shot report of why the MoE GPU block refused. A silent `?` here
9992/// sends every expert to the CPU with nothing in the logs to say so —
9993/// which is exactly how a q4tp MoE model looked "GPU-accelerated" while
9994/// running entirely on the host.
9995fn moe_gpu_refused(why: &'static str) {
9996    use std::sync::atomic::{AtomicBool, Ordering};
9997    static SAID: AtomicBool = AtomicBool::new(false);
9998    if !SAID.swap(true, Ordering::Relaxed) {
9999        tracing::warn!("MoE GPU block refused ({why}) — experts run on the CPU");
10000    }
10001}
10002
10003fn moe_ffn_gpu(
10004    m: &MoeFfn,
10005    x: &[f32],
10006    idx: &[usize],
10007    p: &[f32],
10008    wsum: f32,
10009    pool: Option<&Pool>,
10010) -> Option<Vec<f32>> {
10011    use crate::gpu::MoeJob;
10012
10013    let mut jobs: Vec<MoeJob> = Vec::with_capacity(idx.len() + 1);
10014    let mut model_ref = None;
10015    for &e in idx {
10016        if moe_push_job(&m.experts[e], x, p[e] / wsum, &mut jobs, &mut model_ref).is_none() {
10017            moe_gpu_refused("push_job(expert)");
10018            return None;
10019        }
10020    }
10021    if let Some((se, gate)) = &m.shared {
10022        let g = gate.as_ref().map_or(1.0, |gate| {
10023            let mut gl = [0.0f32; 1];
10024            gate.matvec(x, &mut gl, pool);
10025            1.0 / (1.0 + (-gl[0]).exp())
10026        });
10027        if moe_push_job(se, x, g, &mut jobs, &mut model_ref).is_none() {
10028            moe_gpu_refused("push_job(shared)");
10029            return None;
10030        }
10031    }
10032    let Some(model) = model_ref else {
10033        moe_gpu_refused("no model_ref");
10034        return None;
10035    };
10036    let hidden = jobs[0].down.1;
10037    let mut out = vec![0.0f32; hidden];
10038    if crate::gpu::moe_block(&model, &jobs, &mut out) {
10039        Some(out)
10040    } else {
10041        moe_gpu_refused("gpu::moe_block");
10042        None
10043    }
10044}
10045
10046/// Single-position FFN dispatch.
10047fn ffn_forward(
10048    ffn: &FfnKind,
10049    x: &[f32],
10050    pool: Option<&Pool>,
10051    experts_allowed: Option<&[bool]>,
10052) -> Vec<f32> {
10053    match ffn {
10054        FfnKind::Dense(d) => dense_ffn(d, x, pool),
10055        FfnKind::Moe(m) => moe_ffn(m, x, pool, experts_allowed),
10056        // Dual-branch layers need the raw residual — their callers
10057        // dispatch dense_moe_ffn directly; the auxiliary paths that land
10058        // here (MTP draft, o1 replay) do not co-occur with gemma-4 MoE.
10059        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
10060    }
10061}
10062
10063/// Fused two-position FFN: gate/up/down streamed once (dense). MoE
10064/// falls back to two singles — expert sets differ per position, there
10065/// is nothing to fuse.
10066fn ffn_forward_pair(
10067    ffn: &FfnKind,
10068    x1: &[f32],
10069    x2: &[f32],
10070    pool: Option<&Pool>,
10071    experts_allowed: Option<&[bool]>,
10072) -> (Vec<f32>, Vec<f32>) {
10073    let d = match ffn {
10074        FfnKind::Dense(d) => d,
10075        FfnKind::Moe(m) => {
10076            return (
10077                moe_ffn(m, x1, pool, experts_allowed),
10078                moe_ffn(m, x2, pool, experts_allowed),
10079            );
10080        }
10081        FfnKind::DenseMoe(_) => unreachable!("DenseMoe dispatches via dense_moe_ffn"),
10082    };
10083    let inter = d.gate_proj.rows();
10084    FFN_SCRATCH.with(|s| {
10085        let mut s = s.borrow_mut();
10086        let [g1, g2, u1, u2] = &mut *s;
10087        g1.resize(inter, 0.0);
10088        g2.resize(inter, 0.0);
10089        u1.resize(inter, 0.0);
10090        u2.resize(inter, 0.0);
10091        // Multi-matrix pair job: gate+up under one pool dispatch
10092        // (o1s = lane-1 outputs across tensors, o2s = lane-2).
10093        QTensor::matvec2_many(
10094            [&d.gate_proj, &d.up_proj],
10095            x1,
10096            x2,
10097            [g1.as_mut_slice(), u1.as_mut_slice()],
10098            [g2.as_mut_slice(), u2.as_mut_slice()],
10099            pool,
10100        );
10101        for i in 0..inter {
10102            g1[i] = d.act.combine(g1[i], u1[i]);
10103            g2[i] = d.act.combine(g2[i], u2[i]);
10104        }
10105        let mut o1 = attention::take_buf(d.down_proj.rows());
10106        let mut o2 = attention::take_buf(d.down_proj.rows());
10107        d.down_proj.matvec2(g1, g2, &mut o1, &mut o2, pool);
10108        (o1, o2)
10109    })
10110}
10111
10112#[cfg(test)]
10113mod tests {
10114
10115    #[test]
10116    fn cancel_flag_stops_generation() {
10117        let mut p = create_test_pipeline(16, 32, 2, 2, 8, 2, 32);
10118        // Set before the call: the prefill loops honour it, the run
10119        // returns immediately with the cancelled reason and no tokens.
10120        p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
10121        let r = p.generate_from_ids(&[1, 2, 3], 8, None, None).unwrap();
10122        assert_eq!(r.finish_reason, "cancelled");
10123        assert!(
10124            r.token_ids.is_empty(),
10125            "no tokens after cancel: {:?}",
10126            r.token_ids
10127        );
10128        // Flag auto-cleared: the next call generates normally.
10129        let r2 = p.generate_from_ids(&[1, 2, 3], 4, None, None).unwrap();
10130        assert_ne!(r2.finish_reason, "cancelled");
10131    }
10132    use super::*;
10133
10134    /// sparse_ffn_quant must equal a dense FFN where inactive neurons are
10135    /// zeroed (mask × mmap correctness). On F32 tensors this is EXACT —
10136    /// it validates the row_dot / add_col_scaled / scatter indexing, the
10137    /// bug-prone part. The q8 branches reuse the golden-tested linear
10138    /// scale, structurally identical to the matvec kernels.
10139    #[test]
10140    fn sparse_ffn_quant_equals_dense_with_inactive_zeroed() {
10141        let (hidden, inter) = (16usize, 40usize);
10142        let synth = |n: usize, salt: usize| -> Vec<f32> {
10143            (0..n)
10144                .map(|i| (((i * 37 + salt * 11 + 3) % 101) as f32 / 101.0 - 0.5) * 0.4)
10145                .collect()
10146        };
10147        let d = DenseFfn {
10148            gate_proj: QTensor::from_f32(synth(inter * hidden, 1), inter, hidden),
10149            up_proj: QTensor::from_f32(synth(inter * hidden, 2), inter, hidden),
10150            down_proj: QTensor::from_f32(synth(hidden * inter, 3), hidden, inter),
10151            act: Act::Silu,
10152        };
10153        let x = synth(hidden, 9);
10154        // Active = every 3rd neuron.
10155        let active: Vec<u16> = (0..inter as u16).filter(|i| i % 3 == 0).collect();
10156
10157        let sparse = sparse_ffn_quant(&d, &x, &active, hidden, None);
10158
10159        // Reference: full dense FFN but g[i]=0 for inactive neurons.
10160        let mut g = vec![0.0f32; inter];
10161        d.gate_proj.matvec(&x, &mut g, None);
10162        let mut u = vec![0.0f32; inter];
10163        d.up_proj.matvec(&x, &mut u, None);
10164        let act_set: std::collections::HashSet<u16> = active.iter().copied().collect();
10165        for i in 0..inter {
10166            g[i] = if act_set.contains(&(i as u16)) {
10167                inference::silu(g[i]) * u[i]
10168            } else {
10169                0.0
10170            };
10171        }
10172        let mut reference = vec![0.0f32; hidden];
10173        d.down_proj.matvec(&g, &mut reference, None);
10174
10175        let max_d = sparse
10176            .iter()
10177            .zip(&reference)
10178            .map(|(a, b)| (a - b).abs())
10179            .fold(0.0f32, f32::max);
10180        assert!(max_d < 1e-5, "sparse != dense-zeroed: max|Δ| = {max_d}");
10181    }
10182
10183    /// Attach a synthetic MTP head (same structure as a main layer).
10184    fn attach_test_mtp(p: &mut Pipeline) {
10185        let (h, inter, heads, kv, hd) = (
10186            p.hidden_size,
10187            p.intermediate_size,
10188            p.num_heads,
10189            p.num_kv_heads,
10190            p.head_dim,
10191        );
10192        let synth = |n: usize, salt: usize| -> Vec<f32> {
10193            (0..n)
10194                .map(|i| (((i * 29 + salt * 23 + 5) % 101) as f32 / 101.0 - 0.5) * 0.2)
10195                .collect()
10196        };
10197        let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
10198            QTensor::from_f32(synth(rows * cols, salt), rows, cols)
10199        };
10200        p.mtp = Some(MtpModule {
10201            enorm: vec![1.0; h],
10202            hnorm: vec![1.0; h],
10203            eh_proj: qt(h, 2 * h, 301),
10204            layer: LayerWeights {
10205                input_norm: vec![1.0; h],
10206                post_norm: vec![1.0; h],
10207                attn_out_norm: None,
10208                ffn_out_norm: None,
10209                layer_scale: None,
10210                ffn: FfnKind::Dense(DenseFfn {
10211                    gate_proj: qt(inter, h, 315),
10212                    up_proj: qt(inter, h, 316),
10213                    down_proj: qt(h, inter, 317),
10214                    act: Act::Silu,
10215                }),
10216                attn: AttnKind::Full {
10217                    bias: None,
10218                    wq: qt(heads * hd, h, 311),
10219                    wk: qt(kv * hd, h, 312),
10220                    wv: qt(kv * hd, h, 313),
10221                    wo: qt(h, heads * hd, 314),
10222                    q_norm: None,
10223                    k_norm: None,
10224                    output_gate: false,
10225                    softplus_gate: None,
10226                },
10227            },
10228            final_norm: vec![1.0; h],
10229            kv: crate::kv_cache::LayerKvCache::new(kv, hd),
10230        });
10231    }
10232
10233    #[test]
10234    fn speculative_equals_vanilla_greedy() {
10235        // Speculative decode and the wgpu token graph are mutually
10236        // exclusive; a leaked CMF_GPU=wgpu from a parallel gpu test
10237        // would silently disable drafting. Pin the graph off.
10238        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
10239        let run = |spec: bool| {
10240            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10241            p.sampler_config.temperature = 0.0;
10242            attach_test_mtp(&mut p);
10243            p.speculative = spec;
10244            let r = p.generate("abcdef", 12, None, None).unwrap();
10245            (r.token_ids, r.mtp_drafted, r.mtp_accepted)
10246        };
10247        let (vanilla, d0, _) = run(false);
10248        let (spec, d1, a1) = run(true);
10249        assert_eq!(d0, 0, "vanilla path must not draft");
10250        assert!(d1 > 0, "speculative path must draft");
10251        assert_eq!(
10252            vanilla, spec,
10253            "speculative must reproduce the exact greedy sequence (accepted {a1}/{d1})"
10254        );
10255    }
10256
10257    #[test]
10258    fn speculative_accepts_constant_oracle() {
10259        // See speculative_equals_vanilla_greedy: pin the wgpu graph off.
10260        unsafe { std::env::set_var("CMF_GPU_WGPU_GRAPH", "0") };
10261        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10262        p.sampler_config.temperature = 0.0;
10263        p.sampler_config.repetition_penalty = 1.0;
10264        // Constant lm_head → every logit equal → both the main model and
10265        // the draft head argmax to token 0: acceptance must be 100%.
10266        p.weights.lm_head = QTensor::from_f32(vec![0.01; 64 * 8], 64, 8);
10267        attach_test_mtp(&mut p);
10268        p.speculative = true;
10269        let r = p.generate("abcd", 10, None, None).unwrap();
10270        assert!(r.mtp_drafted > 0);
10271        assert_eq!(
10272            r.mtp_accepted, r.mtp_drafted,
10273            "constant logits → every draft accepted"
10274        );
10275        // Ties resolve to the same token in both the main and draft
10276        // heads — the sequence is one repeated token.
10277        assert!(r.token_ids.windows(2).all(|w| w[0] == w[1]));
10278    }
10279
10280    #[test]
10281    fn empty_prompt_is_an_error_not_a_panic() {
10282        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
10283        let r = p.generate("", 4, None, None);
10284        assert!(r.is_err(), "empty prompt must be a clean error");
10285    }
10286
10287    #[test]
10288    fn every_token_enters_kv_exactly_once() {
10289        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10290        // Greedy so no RNG variance; byte tokenizer → 3 prompt tokens.
10291        p.sampler_config.temperature = 0.0;
10292        let r = p.generate("abc", 2, None, None).unwrap();
10293        assert_eq!(r.prompt_tokens, 3);
10294        // prompt(3) + first sampled token forwarded before second logits:
10295        // step0 samples from prefill hidden (no extra forward), then
10296        // forwards t1 → cache 4; step1 samples, loop ends (max_tokens).
10297        assert_eq!(
10298            p.kv_cache.seq_len(),
10299            3 + r.tokens_generated - 1,
10300            "each token must be cached exactly once (v1 cached the last prompt token twice)"
10301        );
10302    }
10303
10304    #[test]
10305    fn generation_is_reproducible_with_seed() {
10306        let run = || {
10307            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10308            p.generate("hello", 8, None, None).unwrap().token_ids
10309        };
10310        assert_eq!(run(), run());
10311    }
10312
10313    #[test]
10314    fn resetting_sampler_restarts_the_seeded_stream() {
10315        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
10316        let config = SamplerConfig {
10317            seed: Some(1234),
10318            ..SamplerConfig::default()
10319        };
10320        p.set_sampler_config(config.clone());
10321        let first = p.generate("hello", 8, None, None).unwrap().token_ids;
10322        p.set_sampler_config(config);
10323        let second = p.generate("hello", 8, None, None).unwrap().token_ids;
10324        assert_eq!(first, second);
10325    }
10326
10327    #[test]
10328    fn eviction_bounds_the_cache() {
10329        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
10330        p.kv_cache.max_seq_len = 6;
10331        p.sampler_config.temperature = 0.0;
10332        let _ = p.generate("abcd", 12, None, None).unwrap();
10333        assert!(
10334            p.kv_cache.seq_len() <= 6 + 1,
10335            "cache must stay bounded by max_seq_len (got {})",
10336            p.kv_cache.seq_len()
10337        );
10338    }
10339
10340    #[test]
10341    fn confidence_matches_tokens_and_is_a_probability() {
10342        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10343        p.sampler_config.temperature = 0.0;
10344        p.sampler_config.repetition_penalty = 1.0;
10345        let r = p.generate("abcd", 10, None, None).unwrap();
10346        assert_eq!(
10347            r.token_confidence.len(),
10348            r.token_ids.len(),
10349            "one confidence per emitted token"
10350        );
10351        for &c in &r.token_confidence {
10352            assert!((0.0..=1.0).contains(&c), "confidence out of [0,1]: {c}");
10353        }
10354        // top1_prob is a valid softmax probability.
10355        let logits = [1.0f32, 3.0, 0.5, 3.0];
10356        let p0 = top1_prob_t(&logits, 1, 1.0);
10357        let p1 = top1_prob_t(&logits, 3, 1.0);
10358        assert!((p0 - p1).abs() < 1e-6, "equal logits → equal prob");
10359        assert!(p0 > 0.0 && p0 < 1.0);
10360        // Calibration temperature > 1 softens an over-confident peak.
10361        let sharp = top1_prob_t(&logits, 1, 1.0);
10362        let soft = top1_prob_t(&logits, 1, 2.0);
10363        assert!(soft < sharp, "higher temperature lowers peak confidence");
10364    }
10365
10366    #[test]
10367    fn trace_is_opt_in_and_parallels_the_output() {
10368        // Off by default: the runtime is silent unless observation asked.
10369        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10370        p.sampler_config.temperature = 0.0;
10371        p.sampler_config.repetition_penalty = 1.0;
10372        let r = p.generate("abcd", 10, None, None).unwrap();
10373        assert!(r.traces.is_empty(), "trace must be empty unless enabled");
10374
10375        // On: exactly one row per emitted token, aligned with the output.
10376        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10377        p.sampler_config.temperature = 0.0;
10378        p.sampler_config.repetition_penalty = 1.0;
10379        p.set_trace(true);
10380        let r = p.generate("abcd", 10, None, None).unwrap();
10381        assert_eq!(r.traces.len(), r.token_ids.len(), "one trace row per token");
10382        for (i, tr) in r.traces.iter().enumerate() {
10383            assert_eq!(tr.t, i, "trace index is sequential");
10384            assert_eq!(tr.token_id, r.token_ids[i], "trace token_id matches output");
10385            assert_eq!(
10386                tr.confidence, r.token_confidence[i],
10387                "trace confidence matches the confidence channel"
10388            );
10389            // No dynamic router in this pipeline → no skill, no coherence.
10390            assert!(tr.active_skill.is_none() && tr.recon.is_none() && !tr.switched);
10391        }
10392    }
10393
10394    #[test]
10395    fn explain_prefill_logits_match_greedy_first_token() {
10396        // `cortiq explain` shows the next-token distribution from
10397        // prefill_next_logits; its argmax must equal what greedy generate
10398        // actually emits first — otherwise explain would lie.
10399        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
10400        p.sampler_config.temperature = 0.0;
10401        p.sampler_config.repetition_penalty = 1.0;
10402        let ids = p.tokenizer.encode("abcd");
10403        let logits = p.prefill_next_logits(&ids, None);
10404        let argmax = logits
10405            .iter()
10406            .enumerate()
10407            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
10408            .unwrap()
10409            .0 as u32;
10410        let r = p.generate("abcd", 1, None, None).unwrap();
10411        assert_eq!(
10412            argmax, r.token_ids[0],
10413            "explain preview must match greedy emit"
10414        );
10415    }
10416
10417    #[test]
10418    fn laguna_shared_expert_is_unconditionally_added() {
10419        let matrix = |values: Vec<f32>| QTensor::from_f32(values, 2, 2);
10420        let identity = || matrix(vec![1.0, 0.0, 0.0, 1.0]);
10421        let zero_dense = || DenseFfn {
10422            gate_proj: matrix(vec![0.0; 4]),
10423            up_proj: matrix(vec![0.0; 4]),
10424            down_proj: matrix(vec![0.0; 4]),
10425            act: Act::Silu,
10426        };
10427        let shared = DenseFfn {
10428            gate_proj: identity(),
10429            up_proj: identity(),
10430            down_proj: identity(),
10431            act: Act::Silu,
10432        };
10433        let x = [1.0, 2.0];
10434        let expected = dense_ffn(&shared, &x, None);
10435        let moe = MoeFfn {
10436            router: QTensor::from_f32(vec![0.0, 0.0], 1, 2),
10437            experts: vec![zero_dense()],
10438            top_k: 1,
10439            norm_topk_prob: true,
10440            router_sigmoid: true,
10441            expert_bias: None,
10442            routed_scaling: 1.0,
10443            route_tau: None,
10444            shared: Some((shared, None)),
10445            stats: std::cell::RefCell::new(Vec::new()),
10446            act_sq: std::cell::RefCell::new(Vec::new()),
10447            act_rows: std::cell::RefCell::new(Vec::new()),
10448            mask: None,
10449            per_expert_scale: None,
10450            router_input_norm: false,
10451            resonance: None,
10452        };
10453        let actual = moe_ffn_cpu(&moe, &x, &[0], &[0.0], 1.0, None);
10454        for (actual, expected) in actual.iter().zip(expected) {
10455            assert!((actual - expected).abs() < 1e-6);
10456        }
10457    }
10458}