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    gdn_forward, gdn_pair, vmf_phase_forward, vmf_phase_pair, GdnCfg, GdnWeights, VmfPhaseCfg,
14    VmfPhaseWeights,
15};
16use crate::pool::Pool;
17use crate::qtensor::QTensor;
18use crate::sampler::{self, SamplerConfig, SplitMix64};
19use crate::tokenizer::Tokenizer;
20use cortiq_core::mask::TaskMask;
21use cortiq_core::types::NormStyle;
22
23/// Complete inference pipeline state.
24pub struct Pipeline {
25    pub tokenizer: Tokenizer,
26    pub kv_cache: KvCache,
27    pub sampler_config: SamplerConfig,
28    pub weights: PipelineWeights,
29    pub hidden_size: usize,
30    pub intermediate_size: usize,
31    pub num_heads: usize,
32    pub num_kv_heads: usize,
33    pub head_dim: usize,
34    pub num_layers: usize,
35    pub vocab_size: usize,
36    pub rms_eps: f64,
37    pub rope_base: f32,
38    pub norm_style: NormStyle,
39    /// RoPE dims actually rotated (≤ head_dim; Qwen3.5 uses head_dim/4).
40    pub rotary_dim: usize,
41    /// Linear-core geometry (present when the model has linear layers).
42    pub vmf_cfg: Option<VmfPhaseCfg>,
43    /// GatedDeltaNet geometry (faithful vendor operator).
44    pub gdn_cfg: Option<GdnCfg>,
45    /// Multi-token-prediction head (None = absent).
46    pub mtp: Option<MtpModule>,
47    /// Speculative decode via MTP (greedy only; `CMF_MTP=0` disables).
48    pub speculative: bool,
49    rng: SplitMix64,
50    /// Precomputed RoPE inverse frequencies [head_dim/2].
51    inv_freq: Vec<f32>,
52    /// Persistent worker pool (None = serial; see CMF_THREADS).
53    pool: Option<std::sync::Arc<Pool>>,
54    // ── Dynamic per-token skill routing (spec §9, claim 14/16) ──
55    /// Source model, retained so a skill switch can re-resolve the
56    /// touched layers' FFN tensors (Mapped = mmap pointers, cheap).
57    pub(crate) model: Option<std::sync::Arc<cortiq_core::CmfModel>>,
58    /// Masks present → weights are dequantized f32 (rebuild path).
59    pub(crate) dyn_force_f32: bool,
60    /// Per-skill FFN layers actually replaced (derived from tensors, not
61    /// the meta `layers` field — ru2 replaces down_proj in 0..23 while
62    /// its meta says [20..23]). None = skill touches non-FFN tensors →
63    /// ineligible for cheap dynamic switching (honest refusal).
64    pub(crate) dyn_skill_layers: Vec<Option<Vec<usize>>>,
65    /// Currently overlaid skill (index into model.header.skills); None =
66    /// backbone. Set at load time to the statically-overlaid skill so
67    /// `set_active_skill(None)` correctly reverts it (else a static
68    /// skill would silently persist — the union-diff assumes dyn_active
69    /// always mirrors the live overlay). Switched by `set_active_skill`.
70    pub(crate) dyn_active: Option<usize>,
71    /// Pipeline was loaded with a soft blend (materialized working
72    /// tensors, not a single skill index) → dynamic routing refuses:
73    /// there is no single index to revert the blend from.
74    pub(crate) dyn_blend_loaded: bool,
75    /// Layer whose post-residual hidden feeds the router φ (shared by
76    /// swarm skills). None = φ capture off.
77    pub(crate) dyn_phi_layer: Option<usize>,
78    /// EMA of φ at `dyn_phi_layer` over the decode window (on-policy).
79    dyn_phi_ema: Vec<f32>,
80    dyn_phi_seen: usize,
81    /// Hysteresis router driving per-token skill switches during decode
82    /// (None = static/no dynamic routing). Taken out during generation.
83    pub dyn_router: Option<crate::swarm::DynRouter>,
84    /// Emit a structured per-token trace (B4 telemetry channel). Off by
85    /// default — the runtime is silent unless observation is requested.
86    trace: bool,
87    /// Confidence-calibration temperature (B1): reported Born mass is
88    /// softmax(logits / calib_temp). 1.0 = raw. Set from header.calibration.
89    calib_temp: f32,
90}
91
92/// Model weights. Matrices are `QTensor` (owned f32 for small models
93/// and tests — bit-identical to the historical paths — or quantized
94/// bytes zero-copy from the CMF mmap for big models). 1-D norms are
95/// always small and stay f32.
96pub struct PipelineWeights {
97    /// Embedding table: [vocab_size, hidden_size]
98    pub embed_tokens: QTensor,
99    /// Per-layer weights
100    pub layers: Vec<LayerWeights>,
101    /// LM head: [vocab_size, hidden_size]
102    pub lm_head: QTensor,
103    /// Final norm: [hidden_size]
104    pub final_norm: Vec<f32>,
105}
106
107/// One transformer layer: shared norms + MLP, attention by kind.
108pub struct LayerWeights {
109    pub input_norm: Vec<f32>,
110    pub post_norm: Vec<f32>,
111    pub ffn: FfnKind,
112    pub attn: AttnKind,
113}
114
115/// Dense SwiGLU triple — the FFN of a dense layer or of one expert.
116pub struct DenseFfn {
117    pub gate_proj: QTensor,
118    pub up_proj: QTensor,
119    pub down_proj: QTensor,
120}
121
122/// FFN operator of a layer, decided by tensor presence at load time
123/// (router `mlp.gate.weight` in the directory = MoE layer).
124pub enum FfnKind {
125    Dense(DenseFfn),
126    /// Mixture-of-Experts (Qwen2-MoE / Qwen3-MoE): softmax over ALL
127    /// expert logits → top-k, optional renorm; experts stay quantized
128    /// in mmap — only the selected ones are touched per token.
129    Moe(MoeFfn),
130}
131
132pub struct MoeFfn {
133    /// Router `mlp.gate.weight` [num_experts, hidden].
134    pub router: QTensor,
135    pub experts: Vec<DenseFfn>,
136    pub top_k: usize,
137    pub norm_topk_prob: bool,
138    /// Qwen2-MoE always-on shared expert: (FFN, sigmoid-gate [1, hidden]).
139    pub shared: Option<(DenseFfn, QTensor)>,
140    /// Expert-selection counters (truncated Fisher B-field of claim 12:
141    /// routing frequency during calibration). Filled by every forward,
142    /// read by the CLI via CMF_MOE_STATS. RefCell: decode is single-threaded.
143    pub stats: std::cell::RefCell<Vec<u64>>,
144}
145
146/// Attention operator of a layer. Extension point: new operators are
147/// new variants here + a forward in their own module.
148pub enum AttnKind {
149    /// GQA softmax attention (+ optional Qwen3.5 qk-norm / output gate).
150    Full {
151        wq: QTensor,
152        wk: QTensor,
153        wv: QTensor,
154        wo: QTensor,
155        q_norm: Option<Vec<f32>>,
156        k_norm: Option<Vec<f32>>,
157        output_gate: bool,
158        /// Qwen2-family projection biases (q, k, v).
159        bias: Option<(Vec<f32>, Vec<f32>, Vec<f32>)>,
160    },
161    /// Canonical linear core (VMF phase attention).
162    Linear(VmfPhaseWeights),
163    /// Faithful vendor linear operator (Qwen3.5 GatedDeltaNet).
164    LinearGdn(GdnWeights),
165}
166
167/// Multi-token-prediction head (DeepSeek/Qwen style, spec §2.1):
168/// `x = eh_proj·[enorm(embed(next)); hnorm(hidden)]` → one transformer
169/// block over its own KV → shared lm_head. Drafts the token after next;
170/// the main model verifies, so output is exact — MTP only buys speed.
171pub struct MtpModule {
172    pub enorm: Vec<f32>,
173    pub hnorm: Vec<f32>,
174    /// [hidden, 2·hidden]
175    pub eh_proj: QTensor,
176    pub layer: LayerWeights,
177    pub final_norm: Vec<f32>,
178    pub kv: crate::kv_cache::LayerKvCache,
179}
180
181/// Result of a generation call.
182pub struct GenerateResult {
183    pub text: String,
184    pub token_ids: Vec<u32>,
185    pub prompt_tokens: usize,
186    pub tokens_generated: usize,
187    pub finish_reason: String,
188    /// Speculative-decode stats (0/0 when MTP is absent or inactive).
189    pub mtp_drafted: usize,
190    pub mtp_accepted: usize,
191    /// Per-generated-token confidence = softmax probability of the token
192    /// that was actually emitted (Born mass on the chosen state). High =
193    /// the model was sure; low = it was guessing. Same length as the
194    /// generated slice of `token_ids`.
195    pub token_confidence: Vec<f32>,
196    /// Structured per-token telemetry (B4 channel). Empty unless
197    /// `set_trace(true)`; otherwise same length as the generated slice.
198    pub traces: Vec<TokenTrace>,
199}
200
201/// One row of the structured telemetry trace (B4): the model's internal
202/// routing state at the moment a token was emitted. Every field is a
203/// quantity the runtime already computes — nothing is inferred or
204/// estimated (anti-principle: only measured bytes).
205#[derive(Clone, Debug)]
206pub struct TokenTrace {
207    /// 0-based index within the generated slice.
208    pub t: usize,
209    /// The emitted token id.
210    pub token_id: u32,
211    /// Born mass on the emitted token (softmax prob) — how sure the model was.
212    pub confidence: f32,
213    /// Skill in force while this token was generated (None = backbone).
214    pub active_skill: Option<String>,
215    /// Recon error E = ‖r−BBᵀr‖²/‖φ‖² at the last routing eval — coherence
216    /// with the active skill's subspace (low = coherent). None = no router
217    /// or not yet evaluated.
218    pub recon: Option<f32>,
219    /// The router changed the active skill right after this token (a
220    /// domain boundary crossed under the hysteresis barrier).
221    pub switched: bool,
222}
223
224/// Calibrated softmax probability of `id` under `logits` (the Born mass on
225/// the emitted token) — the confidence signal, cheap from logits already
226/// computed for sampling. `temp` is the calibration temperature (B1):
227/// softmax(logits / temp); 1.0 = raw.
228fn top1_prob_t(logits: &[f32], id: u32, temp: f32) -> f32 {
229    let t = if temp > 1e-3 { temp } else { 1.0 };
230    let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
231    let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
232    if sum > 0.0 {
233        (((logits[id as usize] - max) / t).exp()) / sum
234    } else {
235        0.0
236    }
237}
238
239/// prefill-GEMM enabled? (CMF_PREFILL=seq — emergency fallback to the
240/// sequential path.)
241fn prefill_batched() -> bool {
242    std::env::var("CMF_PREFILL").map(|v| v != "seq").unwrap_or(true)
243}
244
245/// Callback for streaming tokens. Return `false` to cancel.
246pub type TokenCallback = Box<dyn FnMut(&str) -> bool + Send>;
247
248impl Pipeline {
249    /// Build a pipeline from parts (used by the loader and tests).
250    #[allow(clippy::too_many_arguments)]
251    pub fn new(
252        tokenizer: Tokenizer,
253        weights: PipelineWeights,
254        hidden_size: usize,
255        intermediate_size: usize,
256        num_heads: usize,
257        num_kv_heads: usize,
258        head_dim: usize,
259        num_layers: usize,
260        vocab_size: usize,
261        rms_eps: f64,
262        rope_base: f32,
263        norm_style: NormStyle,
264        max_seq_len: usize,
265        sampler_config: SamplerConfig,
266    ) -> Self {
267        let rng = match sampler_config.seed {
268            Some(s) => SplitMix64::new(s),
269            None => SplitMix64::from_entropy(),
270        };
271        let inv_freq = attention::rope_inv_freq(head_dim, rope_base);
272        let pool = Pool::from_env();
273        if let Some(p) = &pool {
274            tracing::info!("worker pool: {} threads", p.n_workers());
275        }
276        Self {
277            tokenizer,
278            kv_cache: KvCache::new(num_layers, num_kv_heads, head_dim, max_seq_len),
279            sampler_config,
280            weights,
281            hidden_size,
282            intermediate_size,
283            num_heads,
284            num_kv_heads,
285            head_dim,
286            num_layers,
287            vocab_size,
288            rms_eps,
289            rope_base,
290            norm_style,
291            rotary_dim: head_dim,
292            vmf_cfg: None,
293            gdn_cfg: None,
294            mtp: None,
295            speculative: std::env::var("CMF_MTP").map(|v| v != "0").unwrap_or(true),
296            rng,
297            inv_freq,
298            pool,
299            model: None,
300            dyn_force_f32: false,
301            dyn_skill_layers: Vec::new(),
302            dyn_active: None,
303            dyn_blend_loaded: false,
304            dyn_phi_layer: None,
305            dyn_phi_ema: Vec::new(),
306            dyn_phi_seen: 0,
307            dyn_router: None,
308            trace: false,
309            calib_temp: 1.0,
310        }
311    }
312
313    /// Enable/disable the structured per-token telemetry trace (B4).
314    pub fn set_trace(&mut self, on: bool) {
315        self.trace = on;
316    }
317
318    /// Set the confidence-calibration temperature (B1). Values ≤0 are
319    /// clamped to raw (1.0).
320    pub fn set_calib_temp(&mut self, t: f32) {
321        self.calib_temp = if t > 1e-3 { t } else { 1.0 };
322    }
323
324    /// The active calibration temperature (1.0 = raw Born mass).
325    pub fn calib_temp(&self) -> f32 {
326        self.calib_temp
327    }
328
329    /// Partial rotary (Qwen3.5): rotate only the first `rotary_dim` dims;
330    /// the frequency table is rebuilt over the rotary dims.
331    pub fn set_rotary(&mut self, rotary_dim: usize, base: f32) {
332        self.rotary_dim = rotary_dim.min(self.head_dim);
333        self.inv_freq = attention::rope_inv_freq(self.rotary_dim, base);
334    }
335
336    fn attn_cfg(&self, position: usize) -> QwenAttnCfg<'_> {
337        QwenAttnCfg {
338            num_heads: self.num_heads,
339            num_kv_heads: self.num_kv_heads,
340            head_dim: self.head_dim,
341            hidden_size: self.hidden_size,
342            position,
343            inv_freq: &self.inv_freq,
344            rotary_dim: self.rotary_dim,
345            q_norm: None,
346            k_norm: None,
347            output_gate: false,
348            bias: None,
349            rms_eps: self.rms_eps,
350            norm_style: self.norm_style,
351            pool: self.pool.as_deref(),
352        }
353    }
354
355    /// Generate text from a plain-text prompt. Streams tokens via `on_token`.
356    pub fn generate(
357        &mut self,
358        prompt: &str,
359        max_tokens: usize,
360        task_mask: Option<&TaskMask>,
361        on_token: Option<TokenCallback>,
362    ) -> Result<GenerateResult, String> {
363        let input_ids = self.tokenizer.encode(prompt);
364        self.generate_from_ids(&input_ids, max_tokens, task_mask, on_token)
365    }
366
367    /// Generate from prepared token ids (e.g. a chat template).
368    ///
369    /// With an MTP head, greedy generation without a task mask takes the
370    /// speculative path: the MTP module drafts the token after next and
371    /// the main model verifies both in one fused two-position forward
372    /// (weights streamed once). The output is EXACTLY the vanilla greedy
373    /// sequence — a rejected draft is rolled back — MTP only buys speed.
374    pub fn generate_from_ids(
375        &mut self,
376        input_ids: &[u32],
377        max_tokens: usize,
378        task_mask: Option<&TaskMask>,
379        mut on_token: Option<TokenCallback>,
380    ) -> Result<GenerateResult, String> {
381        if input_ids.is_empty() {
382            return Err("empty prompt: nothing to generate from".to_string());
383        }
384
385        // Fresh sequence — the cache holds absolute positions.
386        self.kv_cache.clear();
387
388        let spec_active = self.speculative
389            && self.mtp.is_some()
390            && task_mask.is_none()
391            && self.sampler_config.temperature < 1e-6;
392        // The MTP module is detached during generation so its mutable
393        // state does not fight the borrow on `self`.
394        let mut mtp = if spec_active { self.mtp.take() } else { None };
395        if let Some(m) = &mut mtp {
396            m.kv.clear();
397        }
398        // Dynamic router detached during decode (same borrow trick as MTP).
399        // Speculative decode and dynamic routing are mutually exclusive
400        // for now — the fused-pair path doesn't carry per-token φ.
401        let mut router = if mtp.is_none() { self.dyn_router.take() } else { None };
402        if let Some(r) = &mut router {
403            r.reset(); // active=backbone, matching a fresh overlay
404            self.dyn_phi_seen = 0; // fresh φ EMA per generation
405            let _ = self.set_active_skill(None);
406        }
407
408        let mut all_ids = input_ids.to_vec();
409        let mut generated = 0usize;
410        let mut finish_reason = "max_tokens".to_string();
411        let mut drafted = 0usize;
412        let mut accepted = 0usize;
413        let mut confidence: Vec<f32> = Vec::new();
414        let trace_on = self.trace;
415        let calib_temp = self.calib_temp;
416        let mut traces: Vec<TokenTrace> = Vec::new();
417
418        // ── Prefill: forward each prompt token once, KEEP the last hidden.
419        //    Dense prefill runs in fused pairs (weights streamed once per
420        //    two positions — bit-identical to sequential, proven by the
421        //    pair tests). With MTP: warm the draft head on
422        //    (hidden_p, token_{p+1}) pairs.
423        let mut hidden = vec![0.0f32; self.hidden_size];
424        let mut pos = 0usize;
425        // With dynamic routing, prefill sequentially so the φ hook fires
426        // over the PROMPT — the router enters decode with a warm φ (the
427        // fused-pair path skips the per-layer φ capture).
428        let dyn_prefill = router.is_some();
429        if task_mask.is_none() && !dyn_prefill {
430            while pos + 1 < input_ids.len() {
431                let e1 = self.embed_single(input_ids[pos]);
432                let e2 = self.embed_single(input_ids[pos + 1]);
433                let (h1, h2) = self.forward_pair(&e1, &e2, pos);
434                // Both prefill tokens are real → commit lane-2 states.
435                self.commit_linear_scratch();
436                if let Some(m) = &mut mtp {
437                    let _ = self.mtp_step(m, &h1, input_ids[pos + 1], pos);
438                    if pos + 2 < input_ids.len() {
439                        let _ = self.mtp_step(m, &h2, input_ids[pos + 2], pos + 1);
440                    }
441                }
442                hidden = h2;
443                pos += 2;
444            }
445        }
446        while pos < input_ids.len() {
447            hidden = self.forward_layers(&self.embed_single(input_ids[pos]), pos, task_mask);
448            if let Some(m) = &mut mtp {
449                if pos + 1 < input_ids.len() {
450                    let _ = self.mtp_step(m, &hidden, input_ids[pos + 1], pos);
451                }
452            }
453            pos += 1;
454        }
455
456        // Commit one token: push, check EOS, stream. Returns false = stop.
457        macro_rules! commit {
458            ($id:expr) => {{
459                all_ids.push($id);
460                generated += 1;
461                if self.tokenizer.is_eos($id) {
462                    finish_reason = "stop".to_string();
463                    false
464                } else {
465                    let token_text = self.tokenizer.decode_token($id);
466                    let mut go = true;
467                    if let Some(ref mut cb) = on_token {
468                        if !cb(&token_text) {
469                            finish_reason = "cancelled".to_string();
470                            go = false;
471                        }
472                    }
473                    go
474                }
475            }};
476        }
477
478        // ── Decode ──
479        let mut next_pos = input_ids.len();
480        'decode: while generated < max_tokens {
481            let normed = inference::rms_norm(
482                &hidden,
483                &self.weights.final_norm,
484                self.rms_eps,
485                self.norm_style,
486            );
487            let logits = self.lm_head_forward(&normed);
488            let t_next = sampler::sample(&logits, &self.sampler_config, &all_ids, &mut self.rng);
489            confidence.push(top1_prob_t(&logits, t_next, calib_temp));
490            if trace_on {
491                // active_skill = the overlay in force while this token was
492                // generated; recon/switched are filled after the post-emit
493                // routing eval below (freshest coherence for this token).
494                let skill = router.as_ref().and_then(|r| r.active_id());
495                traces.push(TokenTrace {
496                    t: generated,
497                    token_id: t_next,
498                    confidence: *confidence.last().unwrap(),
499                    active_skill: skill,
500                    recon: None,
501                    switched: false,
502                });
503            }
504            if !commit!(t_next) {
505                break 'decode;
506            }
507            if generated >= max_tokens {
508                break 'decode;
509            }
510
511            if self.kv_cache.needs_eviction() {
512                let keep = (self.kv_cache.max_seq_len / 2).max(1);
513                self.kv_cache.evict(keep);
514            }
515
516            match &mut mtp {
517                // ── Speculative: draft t+2, verify in a fused pair ──
518                Some(m) if generated + 1 < max_tokens => {
519                    let draft = self.mtp_step(m, &hidden, t_next, next_pos - 1);
520                    drafted += 1;
521                    let emb1 = self.embed_single(t_next);
522                    let emb2 = self.embed_single(draft);
523                    let (h1, h2) = self.forward_pair(&emb1, &emb2, next_pos);
524
525                    let n1 = inference::rms_norm(
526                        &h1,
527                        &self.weights.final_norm,
528                        self.rms_eps,
529                        self.norm_style,
530                    );
531                    let logits1 = self.lm_head_forward(&n1);
532                    let t_after =
533                        sampler::sample(&logits1, &self.sampler_config, &all_ids, &mut self.rng);
534                    confidence.push(top1_prob_t(&logits1, t_after, calib_temp));
535                    if trace_on {
536                        // Speculative decode is mutually exclusive with
537                        // dynamic routing (router is None here) — no skill.
538                        traces.push(TokenTrace {
539                            t: generated,
540                            token_id: t_after,
541                            confidence: *confidence.last().unwrap(),
542                            active_skill: None,
543                            recon: None,
544                            switched: false,
545                        });
546                    }
547                    let stop = !commit!(t_after);
548
549                    if t_after == draft {
550                        accepted += 1;
551                        self.commit_linear_scratch();
552                        let _ = self.mtp_step(m, &h1, t_after, next_pos);
553                        hidden = h2;
554                        next_pos += 2;
555                    } else {
556                        // The draft lane is wrong: roll its KV entry back.
557                        for layer in &mut self.kv_cache.layers {
558                            layer.truncate_last(1);
559                        }
560                        if !stop {
561                            let _ = self.mtp_step(m, &h1, t_after, next_pos);
562                            hidden = self
563                                .forward_layers(&self.embed_single(t_after), next_pos + 1, None);
564                        }
565                        next_pos += 2;
566                    }
567                    if stop {
568                        break 'decode;
569                    }
570                }
571                // ── Vanilla: forward the sampled token ──
572                _ => {
573                    hidden = self.forward_layers(&self.embed_single(t_next), next_pos, task_mask);
574                    next_pos += 1;
575                    // Dynamic routing: the forward updated φ; ask the
576                    // router whether to switch skills before the next token.
577                    if let Some(r) = &mut router {
578                        let phi = self.dyn_phi_ema.clone();
579                        let decision = r.step(&phi, generated);
580                        if let Some(new_active) = decision {
581                            let _ = self.set_active_skill(new_active);
582                        }
583                        // Backfill this token's coherence + switch flag from
584                        // the just-run eval (freshest measured values).
585                        if trace_on {
586                            if let Some(last) = traces.last_mut() {
587                                let e = r.last_best_e();
588                                last.recon = e.is_finite().then_some(e);
589                                last.switched = decision.is_some();
590                            }
591                        }
592                    }
593                }
594            }
595        }
596
597        // Restore backbone overlay and re-attach the router for reuse.
598        if router.is_some() {
599            let _ = self.set_active_skill(None);
600        }
601        self.dyn_router = router.or(self.dyn_router.take());
602        self.mtp = mtp.or(self.mtp.take());
603
604        let output_ids = &all_ids[input_ids.len()..];
605        confidence.truncate(output_ids.len()); // guard against any overshoot
606        traces.truncate(output_ids.len());
607        Ok(GenerateResult {
608            text: self.tokenizer.decode(output_ids),
609            token_ids: output_ids.to_vec(),
610            prompt_tokens: input_ids.len(),
611            tokens_generated: generated,
612            finish_reason,
613            mtp_drafted: drafted,
614            mtp_accepted: accepted,
615            token_confidence: confidence,
616            traces,
617        })
618    }
619
620    /// One MTP step: feed `(hidden_p, token_{p+1})` into the draft head,
621    /// advance its KV cache at position `p`, return the drafted token
622    /// for position `p+2`.
623    fn mtp_step(&mut self, m: &mut MtpModule, hidden: &[f32], next_token: u32, position: usize) -> u32 {
624        let h_n = inference::rms_norm(hidden, &m.hnorm, self.rms_eps, self.norm_style);
625        let e = self.embed_single(next_token);
626        let e_n = inference::rms_norm(&e, &m.enorm, self.rms_eps, self.norm_style);
627        // fc concat order is [enorm(embed); hnorm(hidden)] — EMBEDDING
628        // FIRST. Verified by the oracle (converter/mtp_oracle.py):
629        // [emb;hid] → 45.8% acceptance, [hid;emb] → 0.00%.
630        let mut cat = Vec::with_capacity(2 * self.hidden_size);
631        cat.extend_from_slice(&e_n);
632        cat.extend_from_slice(&h_n);
633        let mut x = vec![0.0f32; self.hidden_size];
634        m.eh_proj.matvec(&cat, &mut x, self.pool.as_deref());
635
636        // One standard transformer block over the MTP's own cache.
637        let lw = &m.layer;
638        let normed = inference::rms_norm(&x, &lw.input_norm, self.rms_eps, self.norm_style);
639        let attn = match &lw.attn {
640            AttnKind::Full {
641                wq,
642                wk,
643                wv,
644                wo,
645                q_norm,
646                k_norm,
647                output_gate,
648                bias,
649            } => {
650                let mut cfg = self.attn_cfg(position);
651                cfg.q_norm = q_norm.as_deref();
652                cfg.k_norm = k_norm.as_deref();
653                cfg.output_gate = *output_gate;
654                cfg.bias = bias
655                    .as_ref()
656                    .map(|(q, k, v)| (q.as_slice(), k.as_slice(), v.as_slice()));
657                attention::qwen_attention(&normed, wq, wk, wv, wo, &mut m.kv, &cfg)
658            }
659            AttnKind::Linear(_) | AttnKind::LinearGdn(_) => {
660                unreachable!("MTP block is full attention")
661            }
662        };
663        for (i, &a) in attn.iter().enumerate() {
664            x[i] += a;
665        }
666        let post = inference::rms_norm(&x, &lw.post_norm, self.rms_eps, self.norm_style);
667        let ffn = ffn_forward(&lw.ffn, &post, self.pool.as_deref());
668        for (i, &f) in ffn.iter().enumerate() {
669            x[i] += f;
670        }
671
672        let out = inference::rms_norm(&x, &m.final_norm, self.rms_eps, self.norm_style);
673        sampler::argmax(&self.lm_head_forward(&out))
674    }
675
676    /// Micro-benchmark: two single-position forwards vs one fused pair
677    /// from the current cache state (KV rewound after each probe).
678    /// Returns (two_singles_ms, fused_pair_ms) per probe.
679    pub fn measure_pair_fusion(&mut self, iters: usize) -> (f64, f64) {
680        let emb1 = self.embed_single(1);
681        let emb2 = self.embed_single(2);
682        let pos = self.kv_cache.seq_len();
683
684        let t0 = std::time::Instant::now();
685        for _ in 0..iters {
686            let _ = self.forward_layers(&emb1, pos, None);
687            let _ = self.forward_layers(&emb2, pos + 1, None);
688            for l in &mut self.kv_cache.layers {
689                l.truncate_last(2);
690            }
691        }
692        let singles_ms = t0.elapsed().as_secs_f64() * 1000.0 / iters as f64;
693
694        let t1 = std::time::Instant::now();
695        for _ in 0..iters {
696            let _ = self.forward_pair(&emb1, &emb2, pos);
697            for l in &mut self.kv_cache.layers {
698                l.truncate_last(2);
699            }
700        }
701        let pair_ms = t1.elapsed().as_secs_f64() * 1000.0 / iters as f64;
702        (singles_ms, pair_ms)
703    }
704
705    /// Fused two-position forward: weight rows are streamed from memory
706    /// once per layer for both positions. Full layers → fused GQA pair;
707    /// linear layers → vmf_phase pair (lane 2 state is tentative in the
708    /// per-layer scratch until the draft is accepted).
709    fn forward_pair(&mut self, emb1: &[f32], emb2: &[f32], position: usize) -> (Vec<f32>, Vec<f32>) {
710        let mut h1 = emb1.to_vec();
711        let mut h2 = emb2.to_vec();
712        let (nh, nkv, hd, hs, rd, eps) = (
713            self.num_heads,
714            self.num_kv_heads,
715            self.head_dim,
716            self.hidden_size,
717            self.rotary_dim,
718            self.rms_eps,
719        );
720        let inv_freq = self.inv_freq.clone();
721        let pool = self.pool.clone();
722
723        for li in 0..self.num_layers {
724            let lw = &self.weights.layers[li];
725            let n1 = inference::rms_norm(&h1, &lw.input_norm, self.rms_eps, self.norm_style);
726            let n2 = inference::rms_norm(&h2, &lw.input_norm, self.rms_eps, self.norm_style);
727
728            let (a1, a2) = match &lw.attn {
729                AttnKind::Linear(w) => {
730                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
731                    let layer = &mut self.kv_cache.layers[li];
732                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
733                    vmf_phase_pair(&n1, &n2, w, &cfg, state, scratch, self.pool.as_deref())
734                }
735                AttnKind::LinearGdn(w) => {
736                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
737                    let layer = &mut self.kv_cache.layers[li];
738                    let (state, scratch) = (&mut layer.linear_state, &mut layer.linear_scratch);
739                    gdn_pair(&n1, &n2, w, &cfg, state, scratch, self.pool.as_deref())
740                }
741                AttnKind::Full {
742                    wq,
743                    wk,
744                    wv,
745                    wo,
746                    q_norm,
747                    k_norm,
748                    output_gate,
749                    bias,
750                } => {
751                    let cfg = QwenAttnCfg {
752                        num_heads: nh,
753                        num_kv_heads: nkv,
754                        head_dim: hd,
755                        hidden_size: hs,
756                        position,
757                        inv_freq: &inv_freq,
758                        rotary_dim: rd,
759                        q_norm: q_norm.as_deref(),
760                        k_norm: k_norm.as_deref(),
761                        output_gate: *output_gate,
762                        bias: bias
763                            .as_ref()
764                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
765                        rms_eps: eps,
766                        norm_style: self.norm_style,
767                        pool: pool.as_deref(),
768                    };
769                    attention::qwen_attention_pair(
770                        &n1,
771                        &n2,
772                        wq,
773                        wk,
774                        wv,
775                        wo,
776                        &mut self.kv_cache.layers[li],
777                        &cfg,
778                    )
779                }
780            };
781            for i in 0..self.hidden_size {
782                h1[i] += a1[i];
783                h2[i] += a2[i];
784            }
785
786            let lw = &self.weights.layers[li];
787            let p1 = inference::rms_norm(&h1, &lw.post_norm, self.rms_eps, self.norm_style);
788            let p2 = inference::rms_norm(&h2, &lw.post_norm, self.rms_eps, self.norm_style);
789            let (f1, f2) = ffn_forward_pair(&lw.ffn, &p1, &p2, self.pool.as_deref());
790            for i in 0..self.hidden_size {
791                h1[i] += f1[i];
792                h2[i] += f2[i];
793            }
794        }
795        (h1, h2)
796    }
797
798    /// Commit lane-2 linear states after an accepted draft.
799    fn commit_linear_scratch(&mut self) {
800        for layer in &mut self.kv_cache.layers {
801            if !layer.linear_scratch.is_empty() {
802                std::mem::swap(&mut layer.linear_state, &mut layer.linear_scratch);
803                layer.linear_scratch.clear();
804            }
805        }
806    }
807
808    /// Forward a full id sequence from a fresh cache and return the
809    /// logits after the last position (golden-parity harness, bench).
810    pub fn forward_ids(
811        &mut self,
812        ids: &[u32],
813        task_mask: Option<&TaskMask>,
814    ) -> Result<Vec<f32>, String> {
815        if ids.is_empty() {
816            return Err("empty id sequence".to_string());
817        }
818        self.kv_cache.clear();
819        let mut hidden = vec![0.0f32; self.hidden_size];
820        let mut pos = 0usize;
821        if task_mask.is_none() && prefill_batched() && ids.len() > 2 {
822            // prefill-GEMM in chunks; only the last position's hidden is needed.
823            const CHUNK: usize = 48;
824            let hs = self.hidden_size;
825            while pos < ids.len() {
826                let end = (pos + CHUNK).min(ids.len());
827                let hb = self.prefill_batch(&ids[pos..end], pos);
828                hidden.copy_from_slice(&hb[(end - pos - 1) * hs..]);
829                pos = end;
830            }
831        }
832        if task_mask.is_none() {
833            while pos + 1 < ids.len() {
834                let e1 = self.embed_single(ids[pos]);
835                let e2 = self.embed_single(ids[pos + 1]);
836                let (_, h2) = self.forward_pair(&e1, &e2, pos);
837                self.commit_linear_scratch();
838                hidden = h2;
839                pos += 2;
840            }
841        }
842        while pos < ids.len() {
843            hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, task_mask);
844            pos += 1;
845        }
846        let normed = inference::rms_norm(
847            &hidden,
848            &self.weights.final_norm,
849            self.rms_eps,
850            self.norm_style,
851        );
852        Ok(self.lm_head_forward(&normed))
853    }
854
855    /// Teacher-forced perplexity over a token sequence (phase-C gate:
856    /// honest quant comparisons instead of prompt vibes).
857    pub fn ppl_ids(&mut self, ids: &[u32]) -> f64 {
858        self.kv_cache.clear();
859        let mut nll = 0f64;
860        let mut cnt = 0usize;
861        if prefill_batched() {
862            // prefill-GEMM: layer-major position chunks, lm_head batched
863            // (254MB lm_head read once per chunk, not per position).
864            // The layer chunk is large (grouping positions by MoE experts
865            // wins with size), lm_head in sub-blocks (logit buffer
866            // 32×vocab ≈ 32MB instead of 128×).
867            const CHUNK: usize = 128;
868            const LM_SUB: usize = 32;
869            let n = ids.len().saturating_sub(1);
870            let hs = self.hidden_size;
871            let rows = self.weights.lm_head.rows();
872            let mut pos = 0usize;
873            while pos < n {
874                let end = (pos + CHUNK).min(n);
875                let bsz = end - pos;
876                let hb = self.prefill_batch(&ids[pos..end], pos);
877                let mut k0 = 0usize;
878                while k0 < bsz {
879                    let k1 = (k0 + LM_SUB).min(bsz);
880                    let sb = k1 - k0;
881                    let mut normed = vec![0.0f32; sb * hs];
882                    for k in 0..sb {
883                        let r = inference::rms_norm(
884                            &hb[(k0 + k) * hs..(k0 + k + 1) * hs],
885                            &self.weights.final_norm,
886                            self.rms_eps,
887                            self.norm_style,
888                        );
889                        normed[k * hs..(k + 1) * hs].copy_from_slice(&r);
890                    }
891                    let mut logits = vec![0.0f32; sb * rows];
892                    self.weights
893                        .lm_head
894                        .matmat(&normed, sb, &mut logits, self.pool.as_deref());
895                    for k in 0..sb {
896                        let lg = &logits[k * rows..k * rows + self.vocab_size.min(rows)];
897                        let target = ids[pos + k0 + k + 1] as usize;
898                        let max = lg.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
899                        let lse: f64 = lg
900                            .iter()
901                            .map(|&v| ((v - max) as f64).exp())
902                            .sum::<f64>()
903                            .ln()
904                            + max as f64;
905                        nll += lse - lg[target] as f64;
906                        cnt += 1;
907                    }
908                    k0 = k1;
909                }
910                pos = end;
911            }
912            self.kv_cache.clear();
913            return (nll / cnt.max(1) as f64).exp();
914        }
915        for pos in 0..ids.len().saturating_sub(1) {
916            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
917            let normed = inference::rms_norm(
918                &hidden,
919                &self.weights.final_norm,
920                self.rms_eps,
921                self.norm_style,
922            );
923            let logits = self.lm_head_forward(&normed);
924            let target = ids[pos + 1] as usize;
925            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
926            let lse: f64 = logits.iter().map(|&v| ((v - max) as f64).exp()).sum::<f64>().ln()
927                + max as f64;
928            nll += lse - logits[target] as f64;
929            cnt += 1;
930        }
931        self.kv_cache.clear();
932        (nll / cnt.max(1) as f64).exp()
933    }
934
935    /// Teacher-forced calibration data (B1): for each position, whether the
936    /// argmax equals the actual next token, and the top-1 softmax prob
937    /// (Born mass) under EACH temperature in `temps` — all from ONE forward
938    /// pass (argmax/correctness are temperature-invariant; only p_max
939    /// reshapes). Feeds `cortiq calibrate` (reliability/ECE + temperature
940    /// fit): is the model's confidence a true property, or does it need a
941    /// measured scaling?
942    pub fn calib_ids(&mut self, ids: &[u32], temps: &[f32]) -> (Vec<bool>, Vec<Vec<f32>>) {
943        self.kv_cache.clear();
944        let n = ids.len().saturating_sub(1);
945        let mut correct = Vec::with_capacity(n);
946        let mut pmax = Vec::with_capacity(n);
947        for pos in 0..n {
948            let emb = self.embed_single(ids[pos]);
949            let hidden = self.forward_layers(&emb, pos, None);
950            let normed = inference::rms_norm(
951                &hidden,
952                &self.weights.final_norm,
953                self.rms_eps,
954                self.norm_style,
955            );
956            let logits = self.lm_head_forward(&normed);
957            let target = ids[pos + 1] as usize;
958            let (mut amax, mut mval) = (0usize, f32::NEG_INFINITY);
959            for (i, &v) in logits.iter().enumerate() {
960                if v > mval {
961                    mval = v;
962                    amax = i;
963                }
964            }
965            correct.push(amax == target);
966            let row: Vec<f32> = temps
967                .iter()
968                .map(|&t| {
969                    let tt = t.max(1e-3);
970                    let s: f32 = logits.iter().map(|&v| ((v - mval) / tt).exp()).sum();
971                    1.0 / s.max(1e-12) // numerator at the max is exp(0)=1
972                })
973                .collect();
974            pmax.push(row);
975        }
976        self.kv_cache.clear();
977        (correct, pmax)
978    }
979
980    /// Teacher-forced PPL with the dynamic router driving per-window
981    /// skill switches (VMF experiment №2 measurement). Sequential (φ
982    /// must update per token), returns (ppl, switch_count). The router
983    /// must be enabled (`enable_dynamic_routing`); else this equals
984    /// plain `ppl_ids`. The active skill when scoring token t shapes the
985    /// logits for t+1 — on-policy over the held-out text itself.
986    pub fn ppl_ids_dynamic(&mut self, ids: &[u32]) -> (f64, usize) {
987        let mut router = match self.dyn_router.take() {
988            Some(r) => r,
989            None => return (self.ppl_ids(ids), 0),
990        };
991        router.reset();
992        self.dyn_phi_seen = 0;
993        let _ = self.set_active_skill(None);
994
995        self.kv_cache.clear();
996        let mut nll = 0f64;
997        let mut cnt = 0usize;
998        for pos in 0..ids.len().saturating_sub(1) {
999            let hidden = self.forward_layers(&self.embed_single(ids[pos]), pos, None);
1000            let normed = inference::rms_norm(
1001                &hidden,
1002                &self.weights.final_norm,
1003                self.rms_eps,
1004                self.norm_style,
1005            );
1006            let logits = self.lm_head_forward(&normed);
1007            let target = ids[pos + 1] as usize;
1008            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
1009            let lse: f64 = logits.iter().map(|&v| ((v - max) as f64).exp()).sum::<f64>().ln()
1010                + max as f64;
1011            nll += lse - logits[target] as f64;
1012            cnt += 1;
1013            // Route on the evolving φ (drives the NEXT token's skill).
1014            let phi = self.dyn_phi_ema.clone();
1015            if let Some(new_active) = router.step(&phi, pos) {
1016                let _ = self.set_active_skill(new_active);
1017            }
1018        }
1019        let switches = router.switches.len();
1020        let _ = self.set_active_skill(None);
1021        self.dyn_router = Some(router);
1022        self.kv_cache.clear();
1023        ((nll / cnt.max(1) as f64).exp(), switches)
1024    }
1025
1026    /// Routing probe φ (spec §9): mean-pooled hidden after `layer`.
1027    pub fn probe_phi(&mut self, ids: &[u32], layer: usize) -> Vec<f32> {
1028        self.kv_cache.clear();
1029        let mut acc = vec![0f32; self.hidden_size];
1030        for (pos, &id) in ids.iter().enumerate() {
1031            let h = self.forward_layers_upto(&self.embed_single(id), pos, None, Some(layer));
1032            for (a, v) in acc.iter_mut().zip(&h) {
1033                *a += v;
1034            }
1035        }
1036        let n = ids.len().max(1) as f32;
1037        for a in acc.iter_mut() {
1038            *a /= n;
1039        }
1040        self.kv_cache.clear();
1041        acc
1042    }
1043
1044    /// Layer-major batched prefill (prefill-GEMM): full-attention —
1045    /// per-position with the existing operators (KV grows naturally,
1046    /// causality preserved), GDN projections / FFN / MoE — batched
1047    /// (a weight row is read from DRAM once per chunk, not per
1048    /// position). Returns the hidden of all positions [b × hidden].
1049    fn prefill_batch(&mut self, ids: &[u32], start_pos: usize) -> Vec<f32> {
1050        let b = ids.len();
1051        let hs = self.hidden_size;
1052        let mut h: Vec<f32> = Vec::with_capacity(b * hs);
1053        for &id in ids {
1054            h.extend_from_slice(&self.embed_single(id));
1055        }
1056        let (nh, nkv, hd, rd, eps) = (
1057            self.num_heads,
1058            self.num_kv_heads,
1059            self.head_dim,
1060            self.rotary_dim,
1061            self.rms_eps,
1062        );
1063        let inv_freq = self.inv_freq.clone();
1064        let pool = self.pool.clone();
1065        let norm_style = self.norm_style;
1066
1067        for li in 0..self.num_layers {
1068            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU
1069            let lw = &self.weights.layers[li];
1070            // ── attention ──
1071            match &lw.attn {
1072                AttnKind::LinearGdn(w) => {
1073                    // Projections batched, recurrence sequential.
1074                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
1075                    let mut normed = vec![0.0f32; b * hs];
1076                    for bi in 0..b {
1077                        let r = inference::rms_norm(
1078                            &h[bi * hs..(bi + 1) * hs], &lw.input_norm, eps, norm_style);
1079                        normed[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
1080                    }
1081                    let attn = crate::linear_core::gdn_forward_batch(
1082                        &normed, b, w, &cfg,
1083                        &mut self.kv_cache.layers[li].linear_state,
1084                        pool.as_deref(),
1085                    );
1086                    for (dst, &a) in h.iter_mut().zip(&attn) {
1087                        *dst += a;
1088                    }
1089                }
1090                _ => {
1091                    for bi in 0..b {
1092                        let normed = inference::rms_norm(
1093                            &h[bi * hs..(bi + 1) * hs], &lw.input_norm, eps, norm_style);
1094                        let position = start_pos + bi;
1095                        let attn = match &lw.attn {
1096                            AttnKind::Linear(w) => {
1097                                let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
1098                                vmf_phase_forward(
1099                                    &normed, w, &cfg,
1100                                    &mut self.kv_cache.layers[li].linear_state,
1101                                    pool.as_deref(),
1102                                )
1103                            }
1104                            AttnKind::LinearGdn(_) => unreachable!(),
1105                            AttnKind::Full {
1106                                wq, wk, wv, wo, q_norm, k_norm, output_gate, bias,
1107                            } => {
1108                                let cfg = QwenAttnCfg {
1109                                    num_heads: nh,
1110                                    num_kv_heads: nkv,
1111                                    head_dim: hd,
1112                                    hidden_size: hs,
1113                                    position,
1114                                    inv_freq: &inv_freq,
1115                                    rotary_dim: rd,
1116                                    q_norm: q_norm.as_deref(),
1117                                    k_norm: k_norm.as_deref(),
1118                                    output_gate: *output_gate,
1119                                    bias: bias.as_ref().map(|(a, b, c)| {
1120                                        (a.as_slice(), b.as_slice(), c.as_slice())
1121                                    }),
1122                                    rms_eps: eps,
1123                                    norm_style,
1124                                    pool: pool.as_deref(),
1125                                };
1126                                attention::qwen_attention(
1127                                    &normed, wq, wk, wv, wo,
1128                                    &mut self.kv_cache.layers[li], &cfg)
1129                            }
1130                        };
1131                        for (i, &a) in attn.iter().enumerate() {
1132                            h[bi * hs + i] += a;
1133                        }
1134                    }
1135                }
1136            }
1137
1138            // ── FFN batched ──
1139            let lw = &self.weights.layers[li];
1140            let mut post = vec![0.0f32; b * hs];
1141            for bi in 0..b {
1142                let r = inference::rms_norm(
1143                    &h[bi * hs..(bi + 1) * hs], &lw.post_norm, eps, norm_style);
1144                post[bi * hs..(bi + 1) * hs].copy_from_slice(&r);
1145            }
1146            let ffn = match &lw.ffn {
1147                FfnKind::Dense(d) => dense_ffn_batch(d, &post, b, pool.as_deref()),
1148                FfnKind::Moe(m) => moe_ffn_batch(m, &post, b, hs, pool.as_deref()),
1149            };
1150            for (dst, &f) in h.iter_mut().zip(&ffn) {
1151                *dst += f;
1152            }
1153        }
1154        crate::gpu::set_layer(-1); // lm_head/final ops outside layer-split
1155        h
1156    }
1157
1158    /// Embed a single token.
1159    fn embed_single(&self, id: u32) -> Vec<f32> {
1160        let mut out = vec![0.0f32; self.hidden_size];
1161        if (id as usize) < self.weights.embed_tokens.rows() {
1162            self.weights.embed_tokens.row_f32(id as usize, &mut out);
1163        }
1164        out
1165    }
1166
1167    /// Forward one position through all layers (hybrid dispatch).
1168    fn forward_layers(
1169        &mut self,
1170        hidden: &[f32],
1171        position: usize,
1172        task_mask: Option<&TaskMask>,
1173    ) -> Vec<f32> {
1174        self.forward_layers_upto(hidden, position, task_mask, None)
1175    }
1176
1177    /// Same, stopping after layer `upto` inclusive (routing probe φ).
1178    fn forward_layers_upto(
1179        &mut self,
1180        hidden: &[f32],
1181        position: usize,
1182        task_mask: Option<&TaskMask>,
1183        upto: Option<usize>,
1184    ) -> Vec<f32> {
1185        let mut h = hidden.to_vec();
1186        // Split borrows: copy scalars / clone handles so the per-layer
1187        // cfg does not hold `&self` while the KV cache is `&mut`.
1188        let (nh, nkv, hd, hs, rd, eps) = (
1189            self.num_heads,
1190            self.num_kv_heads,
1191            self.head_dim,
1192            self.hidden_size,
1193            self.rotary_dim,
1194            self.rms_eps,
1195        );
1196        let inv_freq = self.inv_freq.clone();
1197        let pool = self.pool.clone();
1198
1199        for li in 0..self.num_layers {
1200            crate::gpu::set_layer(li as i64); // layer-split GPU/CPU (CMF_GPU_LAYERS)
1201            if let Some(u) = upto {
1202                if li > u {
1203                    break;
1204                }
1205            }
1206            if let Some(mask) = task_mask {
1207                if !mask.layer_alive(li) {
1208                    continue; // dead layer: residual pass-through
1209                }
1210            }
1211
1212            let lw = &self.weights.layers[li];
1213            let normed = inference::rms_norm(&h, &lw.input_norm, self.rms_eps, self.norm_style);
1214
1215            let attn_out = match &lw.attn {
1216                AttnKind::Linear(w) => {
1217                    let cfg = self.vmf_cfg.expect("linear layer without vmf_cfg");
1218                    vmf_phase_forward(
1219                        &normed,
1220                        w,
1221                        &cfg,
1222                        &mut self.kv_cache.layers[li].linear_state,
1223                        self.pool.as_deref(),
1224                    )
1225                }
1226                AttnKind::LinearGdn(w) => {
1227                    let cfg = self.gdn_cfg.expect("gdn layer without gdn_cfg");
1228                    gdn_forward(
1229                        &normed,
1230                        w,
1231                        &cfg,
1232                        &mut self.kv_cache.layers[li].linear_state,
1233                        self.pool.as_deref(),
1234                    )
1235                }
1236                AttnKind::Full {
1237                    wq,
1238                    wk,
1239                    wv,
1240                    wo,
1241                    q_norm,
1242                    k_norm,
1243                    output_gate,
1244                    bias,
1245                } => {
1246                    let masked = task_mask
1247                        .map(|m| m.head_flags(li, self.num_heads).iter().any(|&a| !a))
1248                        .unwrap_or(false);
1249                    let f32_view = (wq.as_f32(), wk.as_f32(), wv.as_f32(), wo.as_f32());
1250                    match (masked, f32_view) {
1251                        // Historical masked path (f32 slices; the loader
1252                        // keeps masked models in f32).
1253                        (true, (Some(q), Some(k), Some(v), Some(o))) => {
1254                            let active_heads = task_mask.unwrap().head_flags(li, self.num_heads);
1255                            attention::multi_head_attention(
1256                                &normed,
1257                                q,
1258                                k,
1259                                v,
1260                                o,
1261                                &mut self.kv_cache.layers[li],
1262                                self.num_heads,
1263                                self.num_kv_heads,
1264                                self.head_dim,
1265                                self.hidden_size,
1266                                position,
1267                                &active_heads,
1268                                &self.inv_freq,
1269                            )
1270                        }
1271                        (masked, _) => {
1272                            if masked {
1273                                tracing::warn!(
1274                                    "layer {li}: head mask on quantized weights not \
1275                                     supported yet — executing dense"
1276                                );
1277                            }
1278                            let cfg = QwenAttnCfg {
1279                                num_heads: nh,
1280                                num_kv_heads: nkv,
1281                                head_dim: hd,
1282                                hidden_size: hs,
1283                                position,
1284                                inv_freq: &inv_freq,
1285                                rotary_dim: rd,
1286                                q_norm: q_norm.as_deref(),
1287                                k_norm: k_norm.as_deref(),
1288                                output_gate: *output_gate,
1289                        bias: bias
1290                            .as_ref()
1291                            .map(|(a, b, c)| (a.as_slice(), b.as_slice(), c.as_slice())),
1292                                rms_eps: eps,
1293                                norm_style: self.norm_style,
1294                                pool: pool.as_deref(),
1295                            };
1296                            attention::qwen_attention(
1297                                &normed,
1298                                wq,
1299                                wk,
1300                                wv,
1301                                wo,
1302                                &mut self.kv_cache.layers[li],
1303                                &cfg,
1304                            )
1305                        }
1306                    }
1307                }
1308            };
1309            for (i, &a) in attn_out.iter().enumerate() {
1310                h[i] += a;
1311            }
1312
1313            let lw = &self.weights.layers[li];
1314            let post_normed = inference::rms_norm(&h, &lw.post_norm, self.rms_eps, self.norm_style);
1315
1316            let ffn_masked = task_mask
1317                .map(|m| m.ffn_active_count(li) < self.intermediate_size)
1318                .unwrap_or(false);
1319            // Sparse mask path applies to dense f32 FFN only; MoE
1320            // layers route through the normal dispatch below.
1321            let f32_ffn = match &lw.ffn {
1322                FfnKind::Dense(d) => {
1323                    (d.gate_proj.as_f32(), d.up_proj.as_f32(), d.down_proj.as_f32())
1324                }
1325                FfnKind::Moe(_) => (None, None, None),
1326            };
1327            let ffn_out = match (ffn_masked, f32_ffn) {
1328                (true, (Some(g), Some(u), Some(d))) => {
1329                    let active = task_mask.unwrap().ffn_active_indices(li);
1330                    inference::sparse_ffn_forward(
1331                        &post_normed,
1332                        g,
1333                        u,
1334                        d,
1335                        self.hidden_size,
1336                        self.intermediate_size,
1337                        &active,
1338                        self.pool.as_deref(),
1339                    )
1340                }
1341                // Mask × quantized mmap: sparse FFN reads only active
1342                // neurons' rows/cols directly from the quant bytes — no
1343                // f32 model copy (a masked big model runs at quant RSS).
1344                (true, _) => match &lw.ffn {
1345                    FfnKind::Dense(d) if d.down_proj.sparse_col_ok() => {
1346                        let active = task_mask.unwrap().ffn_active_indices(li);
1347                        sparse_ffn_quant(
1348                            d,
1349                            &post_normed,
1350                            &active,
1351                            self.hidden_size,
1352                            self.pool.as_deref(),
1353                        )
1354                    }
1355                    // q4/vbit down_proj has no cheap column access → dequant
1356                    // the three matrices to f32 (transient) and run the f32
1357                    // sparse path. Correct (mask honored), just not
1358                    // memory-lean for those dtypes — a rare masked case.
1359                    FfnKind::Dense(d) => {
1360                        let active = task_mask.unwrap().ffn_active_indices(li);
1361                        let (gf, uf, df) = dequant_dense_f32(d);
1362                        inference::sparse_ffn_forward(
1363                            &post_normed,
1364                            &gf,
1365                            &uf,
1366                            &df,
1367                            self.hidden_size,
1368                            self.intermediate_size,
1369                            &active,
1370                            self.pool.as_deref(),
1371                        )
1372                    }
1373                    FfnKind::Moe(_) => {
1374                        // MoE is already sparse by expert selection; masks
1375                        // don't apply to routed experts.
1376                        ffn_forward(&lw.ffn, &post_normed, self.pool.as_deref())
1377                    }
1378                },
1379                (false, _) => ffn_forward(&lw.ffn, &post_normed, self.pool.as_deref()),
1380            };
1381            for (i, &f) in ffn_out.iter().enumerate() {
1382                h[i] += f;
1383            }
1384
1385            // Dynamic routing φ capture (on-policy, fireball-style): the
1386            // EMA of the post-residual hidden at the router's phi_layer,
1387            // updated as the context evolves during decode.
1388            if self.dyn_phi_layer == Some(li) {
1389                self.update_dyn_phi(&h);
1390            }
1391        }
1392        crate::gpu::set_layer(-1); // layers done — lm_head outside layer-split
1393
1394        h
1395    }
1396
1397    /// EMA of φ at the router layer (rolling, weight 0.2 = ~5-token
1398    /// horizon). First observation seeds it exactly.
1399    fn update_dyn_phi(&mut self, h: &[f32]) {
1400        const A: f32 = 0.2;
1401        if self.dyn_phi_ema.len() != h.len() {
1402            self.dyn_phi_ema = vec![0.0; h.len()];
1403            self.dyn_phi_seen = 0;
1404        }
1405        if self.dyn_phi_seen == 0 {
1406            self.dyn_phi_ema.copy_from_slice(h);
1407        } else {
1408            for (e, &v) in self.dyn_phi_ema.iter_mut().zip(h) {
1409                *e = (1.0 - A) * *e + A * v;
1410            }
1411        }
1412        self.dyn_phi_seen += 1;
1413    }
1414
1415    /// Current router φ (EMA at phi_layer); empty until first capture.
1416    pub fn dyn_phi(&self) -> &[f32] {
1417        &self.dyn_phi_ema
1418    }
1419
1420    /// Enable/disable φ capture at the router layer, reset the EMA.
1421    pub fn set_dyn_phi_layer(&mut self, layer: Option<usize>) {
1422        self.dyn_phi_layer = layer;
1423        self.dyn_phi_ema.clear();
1424        self.dyn_phi_seen = 0;
1425    }
1426
1427    /// Skills eligible for dynamic switching: (index, id, phi_layer).
1428    pub fn dynamic_skills(&self) -> Vec<(usize, String, usize)> {
1429        let Some(model) = &self.model else { return Vec::new() };
1430        model
1431            .header
1432            .skills
1433            .iter()
1434            .enumerate()
1435            .filter_map(|(i, sk)| {
1436                let ok = matches!(self.dyn_skill_layers.get(i), Some(Some(_)));
1437                let sel = sk.selection.as_ref()?;
1438                (ok).then(|| (i, sk.id.clone(), sel.phi_layer))
1439            })
1440            .collect()
1441    }
1442
1443    /// Index of the currently overlaid skill (None = backbone).
1444    pub fn active_skill(&self) -> Option<usize> {
1445        self.dyn_active
1446    }
1447
1448    /// Enable dynamic per-token skill routing: build the hysteresis
1449    /// router from the container's routable skills, start φ capture at
1450    /// their (shared) phi_layer. Returns the number of routable skills
1451    /// (0 = nothing to route; router stays off). Idempotent.
1452    pub fn enable_dynamic_routing(&mut self) -> usize {
1453        use crate::swarm::{DynRouter, RoutableSkill};
1454        let Some(model) = self.model.clone() else { return 0 };
1455        // A blend materialized f32 working tensors into the layers; there
1456        // is no single skill index to revert from → refuse (honest).
1457        if self.dyn_blend_loaded {
1458            tracing::warn!("dynamic routing unavailable on a blend-loaded pipeline");
1459            return 0;
1460        }
1461        // A statically-overlaid skill that is NOT FFN-eligible can't be
1462        // cheaply reverted at generation start → refuse rather than
1463        // silently keep it overlaid.
1464        if let Some(a) = self.dyn_active {
1465            if !matches!(self.dyn_skill_layers.get(a), Some(Some(_))) {
1466                tracing::warn!(
1467                    "loaded skill is not FFN-eligible — dynamic routing unavailable"
1468                );
1469                return 0;
1470            }
1471        }
1472        let hidden = self.hidden_size;
1473        let mut skills = Vec::new();
1474        for (idx, id, _phi) in self.dynamic_skills() {
1475            if let Some(sel) = model.header.skills[idx].selection.as_ref() {
1476                if let Some(rs) = RoutableSkill::from_descriptor(idx, id, sel, hidden) {
1477                    skills.push(rs);
1478                }
1479            }
1480        }
1481        if skills.is_empty() {
1482            return 0;
1483        }
1484        // Skills should share a phi_layer; warn (not fail) if they don't.
1485        let phi = skills[0].phi_layer;
1486        if skills.iter().any(|s| s.phi_layer != phi) {
1487            tracing::warn!("routable skills disagree on phi_layer; using {phi}");
1488        }
1489        let n = skills.len();
1490        self.set_dyn_phi_layer(Some(phi));
1491        self.dyn_router = Some(DynRouter::new(skills));
1492        n
1493    }
1494
1495    /// Human-readable switch log from the last dynamic-routed generation.
1496    pub fn route_switches(&self) -> Vec<(usize, Option<String>, Option<String>)> {
1497        self.dyn_router
1498            .as_ref()
1499            .map(|r| r.switches.clone())
1500            .unwrap_or_default()
1501    }
1502
1503    /// LM head: hidden → logits [vocab_size]. The dominant matvec of
1504    /// every decode step — row-parallel on the worker pool.
1505    fn lm_head_forward(&self, hidden: &[f32]) -> Vec<f32> {
1506        let rows = self.weights.lm_head.rows();
1507        let mut logits = vec![0.0f32; rows.min(self.vocab_size)];
1508        self.weights
1509            .lm_head
1510            .matvec(hidden, &mut logits, self.pool.as_deref());
1511        logits.resize(self.vocab_size, 0.0);
1512        logits
1513    }
1514
1515    /// Prefill `ids` and return the next-token logits — what the model
1516    /// would predict next, WITHOUT committing to generation (introspection
1517    /// for `cortiq explain`). Clears and repopulates the KV cache; leaves
1518    /// the active overlay untouched.
1519    pub fn prefill_next_logits(&mut self, ids: &[u32], task_mask: Option<&TaskMask>) -> Vec<f32> {
1520        self.kv_cache.clear();
1521        let mut hidden = vec![0.0f32; self.hidden_size];
1522        for (pos, &id) in ids.iter().enumerate() {
1523            let emb = self.embed_single(id);
1524            hidden = self.forward_layers(&emb, pos, task_mask);
1525        }
1526        let normed = inference::rms_norm(
1527            &hidden,
1528            &self.weights.final_norm,
1529            self.rms_eps,
1530            self.norm_style,
1531        );
1532        self.lm_head_forward(&normed)
1533    }
1534}
1535
1536/// Convenience: deterministic tiny pipeline for tests.
1537pub fn create_test_pipeline(
1538    hidden_size: usize,
1539    intermediate_size: usize,
1540    num_heads: usize,
1541    num_kv_heads: usize,
1542    head_dim: usize,
1543    num_layers: usize,
1544    vocab_size: usize,
1545) -> Pipeline {
1546    // Small pseudo-random weights: constant weights make attention
1547    // degenerate and hide indexing bugs.
1548    let synth = |n: usize, salt: usize| -> Vec<f32> {
1549        (0..n)
1550            .map(|i| (((i * 31 + salt * 17 + 7) % 97) as f32 / 97.0 - 0.5) * 0.2)
1551            .collect()
1552    };
1553    let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
1554        QTensor::from_f32(synth(rows * cols, salt), rows, cols)
1555    };
1556    let layer_weights: Vec<LayerWeights> = (0..num_layers)
1557        .map(|li| LayerWeights {
1558            input_norm: vec![1.0; hidden_size],
1559            post_norm: vec![1.0; hidden_size],
1560            ffn: FfnKind::Dense(DenseFfn {
1561                gate_proj: qt(intermediate_size, hidden_size, li * 10 + 5),
1562                up_proj: qt(intermediate_size, hidden_size, li * 10 + 6),
1563                down_proj: qt(hidden_size, intermediate_size, li * 10 + 7),
1564            }),
1565            attn: AttnKind::Full {
1566                bias: None,
1567                wq: qt(num_heads * head_dim, hidden_size, li * 10 + 1),
1568                wk: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 2),
1569                wv: qt(num_kv_heads * head_dim, hidden_size, li * 10 + 3),
1570                wo: qt(hidden_size, num_heads * head_dim, li * 10 + 4),
1571                q_norm: None,
1572                k_norm: None,
1573                output_gate: false,
1574            },
1575        })
1576        .collect();
1577
1578    Pipeline::new(
1579        Tokenizer::byte_level(),
1580        PipelineWeights {
1581            embed_tokens: qt(vocab_size, hidden_size, 100),
1582            layers: layer_weights,
1583            lm_head: qt(vocab_size, hidden_size, 200),
1584            final_norm: vec![1.0; hidden_size],
1585        },
1586        hidden_size,
1587        intermediate_size,
1588        num_heads,
1589        num_kv_heads,
1590        head_dim,
1591        num_layers,
1592        vocab_size,
1593        1e-6,
1594        10_000.0,
1595        NormStyle::Qwen,
1596        4096,
1597        SamplerConfig {
1598            seed: Some(42),
1599            ..Default::default()
1600        },
1601    )
1602}
1603
1604/// Batched dense-FFN: gate/up/down via matmat (element-wise the same
1605/// math as b × dense_ffn — the same dot kernels).
1606fn dense_ffn_batch(d: &DenseFfn, xs: &[f32], b: usize, pool: Option<&Pool>) -> Vec<f32> {
1607    let inter = d.gate_proj.rows();
1608    let hidden = d.down_proj.rows();
1609    let mut g = vec![0.0f32; b * inter];
1610    d.gate_proj.matmat(xs, b, &mut g, pool);
1611    let mut u = vec![0.0f32; b * inter];
1612    d.up_proj.matmat(xs, b, &mut u, pool);
1613    for i in 0..b * inter {
1614        g[i] = inference::silu(g[i]) * u[i];
1615    }
1616    let mut out = vec![0.0f32; b * hidden];
1617    d.down_proj.matmat(&g, b, &mut out, pool);
1618    out
1619}
1620
1621/// Batched MoE-FFN: router batched, positions are GROUPED by expert —
1622/// an expert's weights are read once for all its positions in the chunk
1623/// (the main prefill-GEMM win on MoE: 960MB/token of 35B experts).
1624fn moe_ffn_batch(m: &MoeFfn, xs: &[f32], b: usize, hidden: usize, pool: Option<&Pool>) -> Vec<f32> {
1625    let ne = m.experts.len();
1626    let mut logits = vec![0.0f32; b * ne];
1627    m.router.matmat(xs, b, &mut logits, pool);
1628
1629    // Assignments: expert → [(position, weight)] — the same top-k semantics
1630    // as moe_ffn (softmax over all, torch.topk order, optional renorm).
1631    let mut assign: Vec<Vec<(usize, f32)>> = vec![Vec::new(); ne];
1632    {
1633        let mut st = m.stats.borrow_mut();
1634        if st.len() < ne {
1635            st.resize(ne, 0);
1636        }
1637        for bi in 0..b {
1638            let lg = &logits[bi * ne..(bi + 1) * ne];
1639            let mx = lg.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1640            let mut p: Vec<f32> = lg.iter().map(|&l| (l - mx).exp()).collect();
1641            let sum: f32 = p.iter().sum();
1642            for v in &mut p {
1643                *v /= sum;
1644            }
1645            let mut order: Vec<usize> = (0..ne).collect();
1646            order.sort_unstable_by(|&x, &y| p[y].partial_cmp(&p[x]).unwrap().then(x.cmp(&y)));
1647            order.truncate(m.top_k);
1648            let wsum: f32 = if m.norm_topk_prob {
1649                order.iter().map(|&e| p[e]).sum()
1650            } else {
1651                1.0
1652            };
1653            for &e in &order {
1654                st[e] += 1;
1655                assign[e].push((bi, p[e] / wsum));
1656            }
1657        }
1658    }
1659
1660    let mut out = vec![0.0f32; b * hidden];
1661    let cols = m.experts[0].gate_proj.cols();
1662    let mut run_expert = |d: &DenseFfn, list: &[(usize, f32)]| {
1663        let sb = list.len();
1664        let mut sub = vec![0.0f32; sb * cols];
1665        for (k, &(bi, _)) in list.iter().enumerate() {
1666            sub[k * cols..(k + 1) * cols].copy_from_slice(&xs[bi * cols..(bi + 1) * cols]);
1667        }
1668        let eo = dense_ffn_batch(d, &sub, sb, pool);
1669        for (k, &(bi, w)) in list.iter().enumerate() {
1670            for i in 0..hidden {
1671                out[bi * hidden + i] += w * eo[k * hidden + i];
1672            }
1673        }
1674    };
1675    for e in 0..ne {
1676        if !assign[e].is_empty() {
1677            run_expert(&m.experts[e], &assign[e]);
1678        }
1679    }
1680    if let Some((se, gate)) = &m.shared {
1681        let mut gl = vec![0.0f32; b];
1682        gate.matmat(xs, b, &mut gl, pool);
1683        let all: Vec<(usize, f32)> = (0..b)
1684            .map(|bi| (bi, 1.0 / (1.0 + (-gl[bi]).exp())))
1685            .collect();
1686        run_expert(se, &all);
1687    }
1688    out
1689}
1690
1691/// Dense SwiGLU FFN through QTensor matvecs (any storage).
1692fn dense_ffn(d: &DenseFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
1693    let inter = d.gate_proj.rows();
1694    let mut g = vec![0.0f32; inter];
1695    d.gate_proj.matvec(x, &mut g, pool);
1696    let mut u = vec![0.0f32; inter];
1697    d.up_proj.matvec(x, &mut u, pool);
1698    for i in 0..inter {
1699        g[i] = inference::silu(g[i]) * u[i];
1700    }
1701    let mut out = vec![0.0f32; d.down_proj.rows()];
1702    d.down_proj.matvec(&g, &mut out, pool);
1703    out
1704}
1705
1706/// Sparse dense-FFN directly on QUANTIZED weights (mask × mmap): reads
1707/// ONLY the active neurons' gate/up rows and down columns from the mmap
1708/// — no full-matrix dequant, no f32 model copy. This is what lets a
1709/// masked big model run at quantized RSS (the historical mask path
1710/// forced the whole model to f32). Semantics identical to the f32
1711/// sparse path within quant tolerance.
1712fn sparse_ffn_quant(
1713    d: &DenseFfn,
1714    x: &[f32],
1715    active: &[u16],
1716    hidden: usize,
1717    pool: Option<&Pool>,
1718) -> Vec<f32> {
1719    let n = active.len();
1720    let inter = d.gate_proj.rows();
1721    let mut act = vec![0.0f32; n];
1722    // Scratch is needed if EITHER projection is group-packed (q4/vbit);
1723    // gate/up normally share a dtype but sizing on both is robust.
1724    let need_scratch = !(d.gate_proj.sparse_col_ok() && d.up_proj.sparse_col_ok());
1725    let compute = |ai: usize| -> f32 {
1726        let idx = active[ai] as usize;
1727        if idx >= inter {
1728            return 0.0; // defensive parity with the f32 sparse path
1729        }
1730        let mut s = if need_scratch { vec![0.0f32; hidden] } else { Vec::new() };
1731        let gate = d.gate_proj.row_dot(idx, x, &mut s);
1732        let up = d.up_proj.row_dot(idx, x, &mut s);
1733        inference::silu(gate) * up
1734    };
1735    match pool {
1736        Some(p) if n >= 256 => {
1737            let ptr = SendMut(act.as_mut_ptr());
1738            p.run(&|widx, nw| {
1739                let chunk = n.div_ceil(nw);
1740                let (s, e) = (widx * chunk, ((widx + 1) * chunk).min(n));
1741                for ai in s..e {
1742                    unsafe { *ptr.at(ai) = compute(ai) };
1743                }
1744            });
1745        }
1746        _ => {
1747            for (ai, a) in act.iter_mut().enumerate() {
1748                *a = compute(ai);
1749            }
1750        }
1751    }
1752    // Scatter through active down columns (reads only those columns).
1753    let mut out = vec![0.0f32; hidden];
1754    for (ai, &idx) in active.iter().enumerate() {
1755        let w = act[ai];
1756        if w.abs() >= 1e-12 && (idx as usize) < inter {
1757            d.down_proj.add_col_scaled(idx as usize, w, &mut out);
1758        }
1759    }
1760    out
1761}
1762
1763/// Test-only re-export of the private sparse-quant FFN (mask × mmap gate).
1764#[doc(hidden)]
1765pub fn sparse_ffn_quant_for_test(
1766    d: &DenseFfn,
1767    x: &[f32],
1768    active: &[u16],
1769    hidden: usize,
1770) -> Vec<f32> {
1771    sparse_ffn_quant(d, x, active, hidden, None)
1772}
1773
1774/// Dequantize a DenseFfn's three matrices to f32 (transient; only the
1775/// q4/vbit-masked fallback uses it — the memory-lean path is
1776/// sparse_ffn_quant). Reuses row_f32 row-by-row.
1777fn dequant_dense_f32(d: &DenseFfn) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
1778    let deq = |t: &QTensor| -> Vec<f32> {
1779        let (rows, cols) = (t.rows(), t.cols());
1780        let mut out = vec![0.0f32; rows * cols];
1781        for r in 0..rows {
1782            t.row_f32(r, &mut out[r * cols..(r + 1) * cols]);
1783        }
1784        out
1785    };
1786    (deq(&d.gate_proj), deq(&d.up_proj), deq(&d.down_proj))
1787}
1788
1789/// Pointer wrapper for the worker-pool scatter (same pattern as qtensor).
1790struct SendMut(*mut f32);
1791unsafe impl Send for SendMut {}
1792unsafe impl Sync for SendMut {}
1793impl SendMut {
1794    #[inline]
1795    // Deliberate unsynchronized scatter: pool workers write disjoint indices
1796    // in parallel, so returning `&mut` from `&self` is intentional here.
1797    #[allow(clippy::mut_from_ref)]
1798    unsafe fn at(&self, i: usize) -> &mut f32 {
1799        unsafe { &mut *self.0.add(i) }
1800    }
1801}
1802
1803/// MoE FFN: router softmax over ALL experts → top-k (HF Qwen2/3-MoE
1804/// semantics: probabilities BEFORE selection; optional renorm of the
1805/// selected k). Only selected experts' pages are touched in mmap.
1806fn moe_ffn(m: &MoeFfn, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
1807    let ne = m.experts.len();
1808    let mut logits = vec![0.0f32; ne];
1809    m.router.matvec(x, &mut logits, pool);
1810    let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1811    let mut p: Vec<f32> = logits.iter().map(|&l| (l - mx).exp()).collect();
1812    let s: f32 = p.iter().sum();
1813    for v in &mut p {
1814        *v /= s;
1815    }
1816    let mut idx: Vec<usize> = (0..ne).collect();
1817    // Descending by prob, lower index wins ties — torch.topk order.
1818    idx.sort_unstable_by(|&a, &b| p[b].partial_cmp(&p[a]).unwrap().then(a.cmp(&b)));
1819    idx.truncate(m.top_k);
1820    let wsum: f32 = if m.norm_topk_prob {
1821        idx.iter().map(|&e| p[e]).sum()
1822    } else {
1823        1.0
1824    };
1825    {
1826        let mut st = m.stats.borrow_mut();
1827        if st.len() < ne {
1828            st.resize(ne, 0);
1829        }
1830        for &e in &idx {
1831            st[e] += 1;
1832        }
1833    }
1834    // D5: the whole layer MoE block in one GPU command buffer (experts — the
1835    // same mmap via a no-copy buffer; intermediate activations on the GPU).
1836    if crate::gpu::enabled_here() {
1837        if let Some(out) = moe_ffn_gpu(m, x, &idx, &p, wsum, pool) {
1838            return out;
1839        }
1840    }
1841
1842    let mut out = vec![0.0f32; x.len()];
1843    for &e in &idx {
1844        let eo = dense_ffn(&m.experts[e], x, pool);
1845        let w = p[e] / wsum;
1846        for i in 0..out.len() {
1847            out[i] += w * eo[i];
1848        }
1849    }
1850    if let Some((se, gate)) = &m.shared {
1851        let so = dense_ffn(se, x, pool);
1852        let mut gl = vec![0.0f32; 1];
1853        gate.matvec(x, &mut gl, pool);
1854        let g = 1.0 / (1.0 + (-gl[0]).exp());
1855        for i in 0..out.len() {
1856            out[i] += g * so[i];
1857        }
1858    }
1859    out
1860}
1861
1862/// Building the MoE-layer GPU jobs: all selected experts (+shared) must
1863/// be q8_2f-Mapped from the primary mapping; otherwise None → CPU path.
1864fn moe_ffn_gpu(
1865    m: &MoeFfn,
1866    x: &[f32],
1867    idx: &[usize],
1868    p: &[f32],
1869    wsum: f32,
1870    pool: Option<&Pool>,
1871) -> Option<Vec<f32>> {
1872    use crate::gpu::MoeJob;
1873    use crate::qtensor::prescale;
1874    use cortiq_core::TensorDtype;
1875
1876    fn parts(
1877        t: &QTensor,
1878    ) -> Option<(&std::sync::Arc<cortiq_core::CmfModel>, usize, usize, usize, &[f32], &[f32])>
1879    {
1880        match t {
1881            QTensor::Mapped {
1882                model,
1883                idx,
1884                dtype: TensorDtype::Q8_2f,
1885                rows,
1886                cols,
1887                row_scale,
1888                col_field,
1889            } if !col_field.is_empty() => {
1890                Some((model, *idx, *rows, *cols, row_scale, col_field))
1891            }
1892            _ => None,
1893        }
1894    }
1895
1896    fn push<'a>(
1897        d: &'a DenseFfn,
1898        x: &[f32],
1899        w: f32,
1900        jobs: &mut Vec<MoeJob<'a>>,
1901        model_ref: &mut Option<std::sync::Arc<cortiq_core::CmfModel>>,
1902    ) -> Option<()> {
1903        let (gm, gi, gr, gc, grs, gcf) = parts(&d.gate_proj)?;
1904        let (_, ui, ur, uc, urs, ucf) = parts(&d.up_proj)?;
1905        let (_, di, dr, dc, drs, dcf) = parts(&d.down_proj)?;
1906        model_ref.get_or_insert_with(|| gm.clone());
1907        jobs.push(MoeJob {
1908            gate: (gi, gr, gc, grs),
1909            up: (ui, ur, uc, urs),
1910            down: (di, dr, dc, drs),
1911            xs_gate: prescale(x, gcf, TensorDtype::Q8_2f),
1912            xs_up: prescale(x, ucf, TensorDtype::Q8_2f),
1913            down_col: dcf,
1914            w,
1915        });
1916        Some(())
1917    }
1918
1919    let mut jobs: Vec<MoeJob> = Vec::with_capacity(idx.len() + 1);
1920    let mut model_ref = None;
1921    for &e in idx {
1922        push(&m.experts[e], x, p[e] / wsum, &mut jobs, &mut model_ref)?;
1923    }
1924    if let Some((se, gate)) = &m.shared {
1925        let mut gl = vec![0.0f32; 1];
1926        gate.matvec(x, &mut gl, pool);
1927        let g = 1.0 / (1.0 + (-gl[0]).exp());
1928        push(se, x, g, &mut jobs, &mut model_ref)?;
1929    }
1930    let model = model_ref?;
1931    let hidden = jobs[0].down.1;
1932    let mut out = vec![0.0f32; hidden];
1933    crate::gpu::moe_block(&model, &jobs, &mut out).then_some(out)
1934}
1935
1936/// Single-position FFN dispatch.
1937fn ffn_forward(ffn: &FfnKind, x: &[f32], pool: Option<&Pool>) -> Vec<f32> {
1938    match ffn {
1939        FfnKind::Dense(d) => dense_ffn(d, x, pool),
1940        FfnKind::Moe(m) => moe_ffn(m, x, pool),
1941    }
1942}
1943
1944/// Fused two-position FFN: gate/up/down streamed once (dense). MoE
1945/// falls back to two singles — expert sets differ per position, there
1946/// is nothing to fuse.
1947fn ffn_forward_pair(
1948    ffn: &FfnKind,
1949    x1: &[f32],
1950    x2: &[f32],
1951    pool: Option<&Pool>,
1952) -> (Vec<f32>, Vec<f32>) {
1953    let d = match ffn {
1954        FfnKind::Dense(d) => d,
1955        FfnKind::Moe(m) => return (moe_ffn(m, x1, pool), moe_ffn(m, x2, pool)),
1956    };
1957    let inter = d.gate_proj.rows();
1958    let mut g1 = vec![0.0f32; inter];
1959    let mut g2 = vec![0.0f32; inter];
1960    d.gate_proj.matvec2(x1, x2, &mut g1, &mut g2, pool);
1961    let mut u1 = vec![0.0f32; inter];
1962    let mut u2 = vec![0.0f32; inter];
1963    d.up_proj.matvec2(x1, x2, &mut u1, &mut u2, pool);
1964    for i in 0..inter {
1965        g1[i] = inference::silu(g1[i]) * u1[i];
1966        g2[i] = inference::silu(g2[i]) * u2[i];
1967    }
1968    let mut o1 = vec![0.0f32; d.down_proj.rows()];
1969    let mut o2 = vec![0.0f32; d.down_proj.rows()];
1970    d.down_proj.matvec2(&g1, &g2, &mut o1, &mut o2, pool);
1971    (o1, o2)
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976    use super::*;
1977
1978    /// sparse_ffn_quant must equal a dense FFN where inactive neurons are
1979    /// zeroed (mask × mmap correctness). On F32 tensors this is EXACT —
1980    /// it validates the row_dot / add_col_scaled / scatter indexing, the
1981    /// bug-prone part. The q8 branches reuse the golden-tested linear
1982    /// scale, structurally identical to the matvec kernels.
1983    #[test]
1984    fn sparse_ffn_quant_equals_dense_with_inactive_zeroed() {
1985        let (hidden, inter) = (16usize, 40usize);
1986        let synth = |n: usize, salt: usize| -> Vec<f32> {
1987            (0..n)
1988                .map(|i| (((i * 37 + salt * 11 + 3) % 101) as f32 / 101.0 - 0.5) * 0.4)
1989                .collect()
1990        };
1991        let d = DenseFfn {
1992            gate_proj: QTensor::from_f32(synth(inter * hidden, 1), inter, hidden),
1993            up_proj: QTensor::from_f32(synth(inter * hidden, 2), inter, hidden),
1994            down_proj: QTensor::from_f32(synth(hidden * inter, 3), hidden, inter),
1995        };
1996        let x = synth(hidden, 9);
1997        // Active = every 3rd neuron.
1998        let active: Vec<u16> = (0..inter as u16).filter(|i| i % 3 == 0).collect();
1999
2000        let sparse = sparse_ffn_quant(&d, &x, &active, hidden, None);
2001
2002        // Reference: full dense FFN but g[i]=0 for inactive neurons.
2003        let mut g = vec![0.0f32; inter];
2004        d.gate_proj.matvec(&x, &mut g, None);
2005        let mut u = vec![0.0f32; inter];
2006        d.up_proj.matvec(&x, &mut u, None);
2007        let act_set: std::collections::HashSet<u16> = active.iter().copied().collect();
2008        for i in 0..inter {
2009            g[i] = if act_set.contains(&(i as u16)) {
2010                inference::silu(g[i]) * u[i]
2011            } else {
2012                0.0
2013            };
2014        }
2015        let mut reference = vec![0.0f32; hidden];
2016        d.down_proj.matvec(&g, &mut reference, None);
2017
2018        let max_d = sparse
2019            .iter()
2020            .zip(&reference)
2021            .map(|(a, b)| (a - b).abs())
2022            .fold(0.0f32, f32::max);
2023        assert!(max_d < 1e-5, "sparse != dense-zeroed: max|Δ| = {max_d}");
2024    }
2025
2026    /// Attach a synthetic MTP head (same structure as a main layer).
2027    fn attach_test_mtp(p: &mut Pipeline) {
2028        let (h, inter, heads, kv, hd) = (
2029            p.hidden_size,
2030            p.intermediate_size,
2031            p.num_heads,
2032            p.num_kv_heads,
2033            p.head_dim,
2034        );
2035        let synth = |n: usize, salt: usize| -> Vec<f32> {
2036            (0..n)
2037                .map(|i| (((i * 29 + salt * 23 + 5) % 101) as f32 / 101.0 - 0.5) * 0.2)
2038                .collect()
2039        };
2040        let qt = |rows: usize, cols: usize, salt: usize| -> QTensor {
2041            QTensor::from_f32(synth(rows * cols, salt), rows, cols)
2042        };
2043        p.mtp = Some(MtpModule {
2044            enorm: vec![1.0; h],
2045            hnorm: vec![1.0; h],
2046            eh_proj: qt(h, 2 * h, 301),
2047            layer: LayerWeights {
2048                input_norm: vec![1.0; h],
2049                post_norm: vec![1.0; h],
2050                ffn: FfnKind::Dense(DenseFfn {
2051                    gate_proj: qt(inter, h, 315),
2052                    up_proj: qt(inter, h, 316),
2053                    down_proj: qt(h, inter, 317),
2054                }),
2055                attn: AttnKind::Full {
2056                bias: None,
2057                    wq: qt(heads * hd, h, 311),
2058                    wk: qt(kv * hd, h, 312),
2059                    wv: qt(kv * hd, h, 313),
2060                    wo: qt(h, heads * hd, 314),
2061                    q_norm: None,
2062                    k_norm: None,
2063                    output_gate: false,
2064                },
2065            },
2066            final_norm: vec![1.0; h],
2067            kv: crate::kv_cache::LayerKvCache::new(kv, hd),
2068        });
2069    }
2070
2071    #[test]
2072    fn speculative_equals_vanilla_greedy() {
2073        let run = |spec: bool| {
2074            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
2075            p.sampler_config.temperature = 0.0;
2076            attach_test_mtp(&mut p);
2077            p.speculative = spec;
2078            let r = p.generate("abcdef", 12, None, None).unwrap();
2079            (r.token_ids, r.mtp_drafted, r.mtp_accepted)
2080        };
2081        let (vanilla, d0, _) = run(false);
2082        let (spec, d1, a1) = run(true);
2083        assert_eq!(d0, 0, "vanilla path must not draft");
2084        assert!(d1 > 0, "speculative path must draft");
2085        assert_eq!(
2086            vanilla, spec,
2087            "speculative must reproduce the exact greedy sequence (accepted {a1}/{d1})"
2088        );
2089    }
2090
2091    #[test]
2092    fn speculative_accepts_constant_oracle() {
2093        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
2094        p.sampler_config.temperature = 0.0;
2095        p.sampler_config.repetition_penalty = 1.0;
2096        // Constant lm_head → every logit equal → both the main model and
2097        // the draft head argmax to token 0: acceptance must be 100%.
2098        p.weights.lm_head = QTensor::from_f32(vec![0.01; 64 * 8], 64, 8);
2099        attach_test_mtp(&mut p);
2100        p.speculative = true;
2101        let r = p.generate("abcd", 10, None, None).unwrap();
2102        assert!(r.mtp_drafted > 0);
2103        assert_eq!(
2104            r.mtp_accepted, r.mtp_drafted,
2105            "constant logits → every draft accepted"
2106        );
2107        // Ties resolve to the same token in both the main and draft
2108        // heads — the sequence is one repeated token.
2109        assert!(r.token_ids.windows(2).all(|w| w[0] == w[1]));
2110    }
2111
2112    #[test]
2113    fn empty_prompt_is_an_error_not_a_panic() {
2114        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
2115        let r = p.generate("", 4, None, None);
2116        assert!(r.is_err(), "empty prompt must be a clean error");
2117    }
2118
2119    #[test]
2120    fn every_token_enters_kv_exactly_once() {
2121        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
2122        // Greedy so no RNG variance; byte tokenizer → 3 prompt tokens.
2123        p.sampler_config.temperature = 0.0;
2124        let r = p.generate("abc", 2, None, None).unwrap();
2125        assert_eq!(r.prompt_tokens, 3);
2126        // prompt(3) + first sampled token forwarded before second logits:
2127        // step0 samples from prefill hidden (no extra forward), then
2128        // forwards t1 → cache 4; step1 samples, loop ends (max_tokens).
2129        assert_eq!(
2130            p.kv_cache.seq_len(),
2131            3 + r.tokens_generated - 1,
2132            "each token must be cached exactly once (v1 cached the last prompt token twice)"
2133        );
2134    }
2135
2136    #[test]
2137    fn generation_is_reproducible_with_seed() {
2138        let run = || {
2139            let mut p = create_test_pipeline(8, 16, 2, 1, 4, 2, 260);
2140            p.generate("hello", 8, None, None).unwrap().token_ids
2141        };
2142        assert_eq!(run(), run());
2143    }
2144
2145    #[test]
2146    fn eviction_bounds_the_cache() {
2147        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 260);
2148        p.kv_cache.max_seq_len = 6;
2149        p.sampler_config.temperature = 0.0;
2150        let _ = p.generate("abcd", 12, None, None).unwrap();
2151        assert!(
2152            p.kv_cache.seq_len() <= 6 + 1,
2153            "cache must stay bounded by max_seq_len (got {})",
2154            p.kv_cache.seq_len()
2155        );
2156    }
2157
2158    #[test]
2159    fn confidence_matches_tokens_and_is_a_probability() {
2160        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
2161        p.sampler_config.temperature = 0.0;
2162        p.sampler_config.repetition_penalty = 1.0;
2163        let r = p.generate("abcd", 10, None, None).unwrap();
2164        assert_eq!(
2165            r.token_confidence.len(),
2166            r.token_ids.len(),
2167            "one confidence per emitted token"
2168        );
2169        for &c in &r.token_confidence {
2170            assert!((0.0..=1.0).contains(&c), "confidence out of [0,1]: {c}");
2171        }
2172        // top1_prob is a valid softmax probability.
2173        let logits = [1.0f32, 3.0, 0.5, 3.0];
2174        let p0 = top1_prob_t(&logits, 1, 1.0);
2175        let p1 = top1_prob_t(&logits, 3, 1.0);
2176        assert!((p0 - p1).abs() < 1e-6, "equal logits → equal prob");
2177        assert!(p0 > 0.0 && p0 < 1.0);
2178        // Calibration temperature > 1 softens an over-confident peak.
2179        let sharp = top1_prob_t(&logits, 1, 1.0);
2180        let soft = top1_prob_t(&logits, 1, 2.0);
2181        assert!(soft < sharp, "higher temperature lowers peak confidence");
2182    }
2183
2184    #[test]
2185    fn trace_is_opt_in_and_parallels_the_output() {
2186        // Off by default: the runtime is silent unless observation asked.
2187        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
2188        p.sampler_config.temperature = 0.0;
2189        p.sampler_config.repetition_penalty = 1.0;
2190        let r = p.generate("abcd", 10, None, None).unwrap();
2191        assert!(r.traces.is_empty(), "trace must be empty unless enabled");
2192
2193        // On: exactly one row per emitted token, aligned with the output.
2194        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
2195        p.sampler_config.temperature = 0.0;
2196        p.sampler_config.repetition_penalty = 1.0;
2197        p.set_trace(true);
2198        let r = p.generate("abcd", 10, None, None).unwrap();
2199        assert_eq!(r.traces.len(), r.token_ids.len(), "one trace row per token");
2200        for (i, tr) in r.traces.iter().enumerate() {
2201            assert_eq!(tr.t, i, "trace index is sequential");
2202            assert_eq!(tr.token_id, r.token_ids[i], "trace token_id matches output");
2203            assert_eq!(
2204                tr.confidence, r.token_confidence[i],
2205                "trace confidence matches the confidence channel"
2206            );
2207            // No dynamic router in this pipeline → no skill, no coherence.
2208            assert!(tr.active_skill.is_none() && tr.recon.is_none() && !tr.switched);
2209        }
2210    }
2211
2212    #[test]
2213    fn explain_prefill_logits_match_greedy_first_token() {
2214        // `cortiq explain` shows the next-token distribution from
2215        // prefill_next_logits; its argmax must equal what greedy generate
2216        // actually emits first — otherwise explain would lie.
2217        let mut p = create_test_pipeline(8, 16, 2, 1, 4, 1, 64);
2218        p.sampler_config.temperature = 0.0;
2219        p.sampler_config.repetition_penalty = 1.0;
2220        let ids = p.tokenizer.encode("abcd");
2221        let logits = p.prefill_next_logits(&ids, None);
2222        let argmax = logits
2223            .iter()
2224            .enumerate()
2225            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2226            .unwrap()
2227            .0 as u32;
2228        let r = p.generate("abcd", 1, None, None).unwrap();
2229        assert_eq!(argmax, r.token_ids[0], "explain preview must match greedy emit");
2230    }
2231}