Skip to main content

memra_engine/
gemma_spec.rs

1//! gemma4 MTP spec-decode: the "gemma4-assistant" drafter (4-layer, Q-only attention over the
2//! MAIN model's KV cache — no draft KV, no trims) + the greedy draft/verify loop.
3//!
4//! Wiring verified from llama gemma4-assistant.cpp + llama-model.cpp:2162 (HANDOVER "GEMMA4 MTP
5//! DRAFTER — VERIFIED WIRING"): per draft token, x = MAIN tok_embd(token) * sqrt(2816);
6//! xh = concat(x, h[2816]) -> pre_proj [5632->1024]; 4 gemma-style blocks whose attention
7//! projects Q ONLY and attends the main cache (SWA layers 0..2 -> main layer n-2 = 28 windowed;
8//! global layer 3 -> main layer n-1 = 29 full); dense GELU_PAR ffn; final output_norm ->
9//! TIED 1024-dim head (no softcap); h_next = post_proj [1024->2816].
10
11use crate::cache::Cache;
12use crate::hybrid::HybridModel;
13use crate::model::GpuTensor;
14use crate::Engine;
15use memra_gguf::source::{GgufSource, TensorSource};
16use memra_gguf::GgufFile;
17use cudarc::driver::CudaSlice;
18
19pub struct GemmaDraftLayer {
20    pub attn_norm: GpuTensor,
21    pub wq: GpuTensor,
22    pub wo: GpuTensor,
23    pub q_norm: GpuTensor,
24    pub post_attn_norm: GpuTensor,
25    pub ffn_norm: GpuTensor,
26    pub ffn_gate: GpuTensor,
27    pub ffn_up: GpuTensor,
28    pub ffn_down: GpuTensor,
29    pub ffn_post_norm: GpuTensor,
30    pub out_scale: f32,
31    pub swa: bool,
32    pub hd: usize,
33    pub nh: usize,
34}
35
36pub struct GemmaDraft {
37    pub layers: Vec<GemmaDraftLayer>,
38    pub pre_proj: GpuTensor,  // [5632 -> 1024]
39    pub post_proj: GpuTensor, // [1024 -> 2816]
40    pub output_norm: GpuTensor,
41    pub head: GpuTensor, // tied drafter token_embd [1024, n_vocab] (or FR-trimmed rows)
42    /// FR-Spec trim map: draft-row index -> target token id (None = full head, identity).
43    pub d2t: Option<Vec<u32>>,
44    /// Device copy of `d2t` — the async round translates each drafted trim-idx in place
45    /// (u32_map_k) before it seeds the next draft step or meets the verify argmax.
46    pub d2t_dev: Option<CudaSlice<u32>>,
47    /// Adaptive trim (coverage escapes are the entire trim cost — oracle-proven +2% on the
48    /// cell the static trim lost by 17%, jsonl 2026-07-19): spare head slots learned at
49    /// serve time from the prompt's own ids and verify-correction tokens.
50    pub trim_adapt: Option<TrimAdapt>,
51    pub rope_freqs: CudaSlice<f32>,
52    pub ones: CudaSlice<f32>, // weightless-norm weight (max hd 512)
53    pub n_embd: usize,        // 1024
54    pub n_backbone: usize,    // 2816
55    pub rope_base_global: f32,
56    pub rope_base_swa: f32,
57    pub sliding_window: usize,
58}
59
60/// Serve-time adaptive trim (MEMRA_GEMMA_TRIM_ADAPT=<spare slots>): the static FR trim's whole
61/// loss is coverage escapes — tokens the base emits that the trim can't propose (guaranteed
62/// rejections; the oracle control that injected the exact escapees flipped a -17% cell to +2%
63/// at identical acceptance, jsonl 2026-07-19). Every escape self-identifies at serve time: it
64/// arrives as a verify CORRECTION token (and its cousins ride in with the prompt), so the head
65/// keeps `n_spare` extra rows and learns them — prompt ids up front, corrections as they land.
66/// First miss pays one rejected round; every recurrence after is proposable. Rows are written
67/// into the existing device buffers (no realloc — captured graphs keep their baked addresses).
68pub struct TrimAdapt {
69    /// full-vocab head rows (host copy) — the gather source for learned rows.
70    src_rows: Vec<u8>,
71    row_bytes: usize,
72    n_vocab: usize,
73    /// trim-set membership by token id (ranked + learned).
74    present: Vec<bool>,
75    /// spare slots live at [spare_base, spare_base + n_spare) in the gathered head.
76    spare_base: usize,
77    n_spare: usize,
78    used: usize,
79    logged_full: bool,
80}
81
82impl TrimAdapt {
83    /// Add `tok`'s head row to the trim set if absent and a spare slot is free.
84    fn maybe_add(&mut self, e: &Engine, tok: u32, head: &mut GpuTensor,
85                 d2t: &mut [u32], d2t_dev: &mut CudaSlice<u32>)
86                 -> Result<bool, Box<dyn std::error::Error>> {
87        let t = tok as usize;
88        if t >= self.n_vocab || self.present[t] { return Ok(false); }
89        if self.used == self.n_spare {
90            if !self.logged_full {
91                self.logged_full = true;
92                eprintln!("[trim-adapt] spare slots exhausted ({}) — later escapes stay unproposable",
93                          self.n_spare);
94            }
95            return Ok(false);
96        }
97        let slot = self.spare_base + self.used;
98        self.used += 1;
99        self.present[t] = true;
100        if let GpuTensor::Quant { bytes, .. } = head {
101            e.htod_u8_into(bytes, slot * self.row_bytes,
102                           &self.src_rows[t * self.row_bytes..(t + 1) * self.row_bytes])?;
103        }
104        d2t[slot] = tok;
105        e.u32_set_k(d2t_dev, tok, slot)?;
106        Ok(true)
107    }
108}
109
110/// Union `toks` into the adaptive trim set (no-op when the draft has no adaptive state).
111/// Split-borrow helper: the fields move together or not at all.
112fn trim_adapt_learn(e: &Engine, d: &mut GemmaDraft, toks: &[u32])
113                    -> Result<(), Box<dyn std::error::Error>> {
114    let GemmaDraft { trim_adapt, head, d2t, d2t_dev, .. } = d;
115    let (Some(ta), Some(d2t), Some(d2t_dev)) =
116        (trim_adapt.as_mut(), d2t.as_mut(), d2t_dev.as_mut()) else { return Ok(()) };
117    for &tok in toks {
118        ta.maybe_add(e, tok, head, d2t, d2t_dev)?;
119    }
120    Ok(())
121}
122
123impl GemmaDraft {
124    /// Adaptive-trim stats: (slots used, slot budget). None when adaptation is off.
125    pub fn trim_adapt_stats(&self) -> Option<(usize, usize)> {
126        self.trim_adapt.as_ref().map(|ta| (ta.used, ta.n_spare))
127    }
128
129    /// Persist the learned trim rows: append ids not yet in the sidecar to
130    /// `<ranks>.learned` (the load path pre-fills spare slots from it, so a distribution's
131    /// escapes pay their first-miss round ONCE across the serve lifetime, not per request).
132    pub fn trim_adapt_save(&self) -> std::io::Result<usize> {
133        let (Some(ta), Some(d2t), Some(path)) =
134            (self.trim_adapt.as_ref(), self.d2t.as_ref(), self.trim_learned_path()) else {
135            return Ok(0);
136        };
137        let prior: std::collections::HashSet<u32> = std::fs::read_to_string(&path)
138            .map(|t| t.lines().filter_map(|l| l.trim().parse().ok()).collect())
139            .unwrap_or_default();
140        let fresh: Vec<u32> = d2t[ta.spare_base..ta.spare_base + ta.used].iter()
141            .copied().filter(|id| !prior.contains(id)).collect();
142        if !fresh.is_empty() {
143            use std::io::Write;
144            let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
145            for id in &fresh {
146                writeln!(f, "{id}")?;
147            }
148        }
149        Ok(fresh.len())
150    }
151
152    fn trim_learned_path(&self) -> Option<String> {
153        std::env::var("MEMRA_GEMMA_DRAFT_RANKS").ok().map(|p| format!("{p}.learned"))
154    }
155}
156
157fn load_t(e: &Engine, src: &dyn TensorSource, name: &str)
158          -> Result<GpuTensor, Box<dyn std::error::Error>> {
159    GpuTensor::load_from_source(e, src, name)
160}
161
162impl GemmaDraft {
163    pub fn load(e: &Engine, g: &GgufFile) -> Result<Self, Box<dyn std::error::Error>> {
164        // two published spellings of the same arch: the 26B/31B drafters ship
165        // "gemma4-assistant", the E4B assistant ships "gemma4_assistant" — the metadata
166        // key prefix follows the arch string verbatim.
167        let arch = match g.arch() {
168            Some(a @ ("gemma4-assistant" | "gemma4_assistant")) => a.to_string(),
169            other => panic!("not a gemma4-assistant drafter (arch {other:?})"),
170        };
171        let src = GgufSource(g);
172        let meta_u = |k: &str| -> u32 {
173            g.metadata.get(&format!("{arch}.{k}")).and_then(|v| v.as_u64()).unwrap_or(0) as u32
174        };
175        let meta_f = |k: &str, d: f32| -> f32 {
176            match g.metadata.get(&format!("{arch}.{k}")) {
177                Some(memra_gguf::MetaValue::F32(v)) => *v,
178                Some(memra_gguf::MetaValue::F64(v)) => *v as f32,
179                _ => d,
180            }
181        };
182        let n_layer = meta_u("block_count") as usize;
183        let n_embd = meta_u("embedding_length") as usize;
184        // 26B/31B carry the target width as embedding_length_out; the E4B assistant as
185        // n_embd_backbone.
186        let n_backbone = match meta_u("embedding_length_out") as usize {
187            0 => meta_u("n_embd_backbone") as usize,
188            v => v,
189        };
190        let hd_g = meta_u("attention.key_length") as usize;
191        let hd_s = meta_u("attention.key_length_swa") as usize;
192        let swa_pat: Vec<bool> = match g.metadata.get(&format!("{arch}.attention.sliding_window_pattern")) {
193            Some(memra_gguf::MetaValue::Array(a)) =>
194                a.iter().filter_map(|v| v.as_u64().map(|x| x != 0)).collect(),
195            _ => return Err("drafter missing sliding_window_pattern".into()),
196        };
197
198        let mut layers = Vec::with_capacity(n_layer);
199        for il in 0..n_layer {
200            let p = |n: &str| format!("blk.{il}.{n}");
201            let swa = swa_pat[il];
202            let out_scale = {
203                let t = src
204                    .find(&p("layer_output_scale.weight"))
205                    .ok_or("missing layer_output_scale")?;
206                memra_gguf::dequant::dequantize(t.ggml_type, &t.bytes, 1)[0]
207            };
208            let hd = if swa { hd_s } else { hd_g };
209            let wq = load_t(e, &src, &p("attn_q.weight"))?;
210            // heads per layer from the projection shape (the E4B assistant keeps 4 heads on
211            // BOTH classes — hd differs — while 26B/31B are uniform; the shape is the truth).
212            let nh = wq.out_features() / hd;
213            layers.push(GemmaDraftLayer {
214                attn_norm: load_t(e, &src, &p("attn_norm.weight"))?,
215                wq,
216                wo: load_t(e, &src, &p("attn_output.weight"))?,
217                q_norm: load_t(e, &src, &p("attn_q_norm.weight"))?,
218                post_attn_norm: load_t(e, &src, &p("post_attention_norm.weight"))?,
219                ffn_norm: load_t(e, &src, &p("ffn_norm.weight"))?,
220                ffn_gate: load_t(e, &src, &p("ffn_gate.weight"))?,
221                ffn_up: load_t(e, &src, &p("ffn_up.weight"))?,
222                ffn_down: load_t(e, &src, &p("ffn_down.weight"))?,
223                ffn_post_norm: load_t(e, &src, &p("post_ffw_norm.weight"))?,
224                out_scale,
225                swa,
226                hd,
227                nh,
228            });
229        }
230        let rope_freqs = {
231            let t = src
232                .find("rope_freqs.weight")
233                .ok_or("drafter missing rope_freqs")?;
234            e.htod(&memra_gguf::dequant::dequantize(
235                t.ggml_type,
236                &t.bytes,
237                t.ne.iter().product::<u64>() as usize,
238            ))?
239        };
240        // FR-Spec head trim (MEMRA_GEMMA_DRAFT_RANKS=<ids file, rank order>): gather the ranked
241        // rows of the drafter head + d2t map. (Top-N-IDS truncation measured NEGATIVE — id
242        // order is not frequency; the CORPUS-ranked gather is the real FR-Spec.)
243        // MEMRA_GEMMA_TRIM_ADAPT=<n> (default 512 when ranks are set, 0 = off) appends n spare
244        // rows the serve loop fills from prompt ids + verify corrections (see TrimAdapt).
245        let (head, d2t, trim_adapt) = {
246            let t = src.find("token_embd.weight").ok_or("drafter missing token_embd")?;
247            let in_f = t.ne[0] as usize;
248            let n_vocab = t.ne[1] as usize;
249            match std::env::var("MEMRA_GEMMA_DRAFT_RANKS").ok() {
250                Some(path) => {
251                    // row gather is layout-agnostic given the per-row byte stride: Q4_0 (26B
252                    // drafter) and Q8_0 (31B drafter) both ship 32-elem blocks row-major.
253                    // (qtype, elems/block, bytes/block) — the gather is stride-agnostic.
254                    let (qtype, blk_e, blk_b) = match t.ggml_type {
255                        memra_gguf::GgmlType::Q4_0 => (crate::QT_Q4_0, 32, 18),
256                        memra_gguf::GgmlType::Q8_0 => (crate::QT_Q8_0, 32, 34),
257                        memra_gguf::GgmlType::Q6_K => (crate::QT_Q6_K, 256, 210),
258                        other => panic!("drafter head trim: unsupported head type {other:?}"),
259                    };
260                    let ids: Vec<u32> = std::fs::read_to_string(&path)?
261                        .lines().filter_map(|l| l.trim().parse().ok())
262                        .filter(|&id| (id as usize) < n_vocab).collect();
263                    let n_spare: usize = std::env::var("MEMRA_GEMMA_TRIM_ADAPT").ok()
264                        .and_then(|v| v.parse().ok()).unwrap_or(512);
265                    let row_bytes = in_f / blk_e * blk_b;
266                    let mut gathered = Vec::with_capacity((ids.len() + n_spare) * row_bytes);
267                    for &id in &ids {
268                        let off = id as usize * row_bytes;
269                        gathered.extend_from_slice(&t.bytes[off..off + row_bytes]);
270                    }
271                    // spare slots start as copies of row ids[0] mapping to ids[0] — a real,
272                    // already-present token, so however the argmax resolves the duplicate-
273                    // logit tie, the d2t translation lands on the same token id.
274                    for _ in 0..n_spare {
275                        let off = ids[0] as usize * row_bytes;
276                        gathered.extend_from_slice(&t.bytes[off..off + row_bytes]);
277                    }
278                    eprintln!("[gemma-draft] FR head trim: {} rows + {} adaptive ({} MB vs {} MB full)",
279                              ids.len(), n_spare,
280                              (ids.len() + n_spare) * row_bytes / 1_000_000,
281                              n_vocab * row_bytes / 1_000_000);
282                    let mut trim_adapt = (n_spare > 0).then(|| {
283                        let mut present = vec![false; n_vocab];
284                        for &id in &ids { present[id as usize] = true; }
285                        TrimAdapt {
286                            src_rows: t.bytes.to_vec(),
287                            row_bytes, n_vocab, present,
288                            spare_base: ids.len(), n_spare, used: 0, logged_full: false,
289                        }
290                    });
291                    let mut d2t = ids;
292                    let spare_fill = d2t[0];
293                    d2t.extend(std::iter::repeat_n(spare_fill, n_spare));
294                    // pre-fill spare slots from the learned sidecar (trim_adapt_save):
295                    // prior serves' escapes are proposable from round 1 of THIS serve.
296                    if let Some(ta) = trim_adapt.as_mut() {
297                        let learned: Vec<u32> = std::fs::read_to_string(format!("{path}.learned"))
298                            .map(|t| t.lines().filter_map(|l| l.trim().parse().ok()).collect())
299                            .unwrap_or_default();
300                        let mut n_pre = 0usize;
301                        for id in learned {
302                            let i = id as usize;
303                            if i < n_vocab && !ta.present[i] && ta.used < ta.n_spare {
304                                let slot = ta.spare_base + ta.used;
305                                ta.used += 1;
306                                ta.present[i] = true;
307                                let off = i * row_bytes;
308                                gathered[slot * row_bytes..(slot + 1) * row_bytes]
309                                    .copy_from_slice(&t.bytes[off..off + row_bytes]);
310                                d2t[slot] = id;
311                                n_pre += 1;
312                            }
313                        }
314                        if n_pre > 0 {
315                            eprintln!("[trim-adapt] {n_pre} learned rows pre-filled from {path}.learned");
316                        }
317                    }
318                    // upload AFTER the sidecar pre-fill wrote its rows into `gathered`.
319                    let bytes = e.htod_bytes(&gathered)?;
320                    (GpuTensor::Quant {
321                        bytes, qtype, row_bytes,
322                        ne: vec![in_f as u64, d2t.len() as u64], scale: 1.0, rp: false,
323                        #[cfg(memra_cutlass)]
324                        cutlass: None,
325                        fp8: None, blk: None, rp4: None, f16: None,
326                    }, Some(d2t), trim_adapt)
327                }
328                None => (load_t(e, &src, "token_embd.weight")?, None, None),
329            }
330        };
331        // Q4_0 split-plane decode mirrors (MEMRA_Q4RP, same as the main trunk — see hybrid.rs):
332        // the draft chain is 3 serial mmvq trips/round; the head alone is ~137MB/draft.
333        // projection tensor prefix: 26B/31B "nextn.", the E4B assistant "mtp.".
334        let proj_prefix = if src.find("nextn.pre_projection.weight").is_some() { "nextn" }
335                          else { "mtp" };
336        let (mut pre_proj, mut post_proj) =
337            (load_t(e, &src, &format!("{proj_prefix}.pre_projection.weight"))?,
338             load_t(e, &src, &format!("{proj_prefix}.post_projection.weight"))?);
339        let mut head = head;
340        let mut layers = layers;
341        if crate::Engine::q4rp_enabled() {
342            // adaptive-trim heads skip the split-plane mirror: the mmvq _rp twins read the
343            // MIRROR, so an in-place row learn on `bytes` would be invisible to the matmul.
344            let head_ws: &mut [&mut GpuTensor] = if trim_adapt.is_some() {
345                &mut [&mut pre_proj, &mut post_proj]
346            } else {
347                &mut [&mut pre_proj, &mut post_proj, &mut head]
348            };
349            for w in head_ws.iter_mut() { e.build_q4_rp4(w)?; }
350            for l in layers.iter_mut() {
351                for w in [
352                    &mut l.wq,
353                    &mut l.wo,
354                    &mut l.ffn_gate,
355                    &mut l.ffn_up,
356                    &mut l.ffn_down,
357                ] {
358                    e.build_q4_rp4(w)?;
359                }
360            }
361        }
362        let d2t_dev = match &d2t {
363            Some(m) => Some(e.stream().clone_htod(&m[..])?),
364            None => None,
365        };
366        Ok(GemmaDraft {
367            layers,
368            pre_proj,
369            post_proj,
370            output_norm: load_t(e, &src, "output_norm.weight")?,
371            head,
372            d2t,
373            d2t_dev,
374            trim_adapt,
375            rope_freqs,
376            ones: e.htod(&[1.0f32; 512])?,
377            n_embd,
378            n_backbone,
379            rope_base_global: meta_f("rope.freq_base", 1e6),
380            rope_base_swa: meta_f("rope.freq_base_swa", 1e4),
381            sliding_window: meta_u("attention.sliding_window") as usize,
382        })
383    }
384}
385
386impl HybridModel {
387    /// The MAIN layer whose KV cache a drafter layer attends (llama-model.cpp:2139):
388    /// the last OWN-KV layer of the class — `boundary - 2` windowed / `boundary - 1`
389    /// global, where boundary = n_layer - shared_kv_layers. Shared across every
390    /// gemma4-assistant drafter (26B/31B: boundary = n_layer; E4B: 24).
391    pub(crate) fn gemma4_draft_kv_target(&self, swa: bool) -> usize {
392        let shared = self.cfg.gemma4.as_ref().map(|g| g.shared_kv_layers as usize).unwrap_or(0);
393        let boundary = self.layers.len() - shared;
394        boundary - if swa { 2 } else { 1 }
395    }
396
397    /// One drafter step: (token, h[2816 device]) at absolute position `pos` over the FROZEN main
398    /// cache. Returns (draft logits host [n_vocab], h_next [2816 device]).
399    pub fn gemma4_draft_step(
400        &self,
401        e: &Engine,
402        d: &GemmaDraft,
403        token: u32,
404        h: &CudaSlice<f32>,
405        pos: usize,
406        cache: &Cache,
407    ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
408        let (hn, h_next) = self.gemma4_draft_trunk(e, d, token, h, pos, cache)?;
409        let logits = e.dtoh(&e.matmul(&d.head, &hn, 1)?)?;
410        Ok((logits, h_next))
411    }
412
413    /// Drafter trunk with the token in DEVICE memory (a 1-elem view of the round's batch
414    /// buffer) — zero host traffic.
415    fn gemma4_draft_trunk_dev(
416        &self,
417        e: &Engine,
418        d: &GemmaDraft,
419        tok_v: &cudarc::driver::CudaView<u32>,
420        h: &CudaSlice<f32>,
421        pos_d: &CudaSlice<i32>,
422        cache: &Cache,
423        dc_bucket: Option<usize>,
424    ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
425        let nb = d.n_backbone;
426        let embd_gpu = self
427            .embd_gpu
428            .get_or_init(|| e.upload_u8(&self.embd.raw).expect("embed table upload"));
429        let (qt, rb) = self.embd.qt_and_row_bytes(nb);
430        let mut xs = e.embed_gather_device_tv(embd_gpu, tok_v, 1, nb, qt, rb)?;
431        e.scale_inplace(&mut xs, (nb as f32).sqrt(), nb)?;
432        self.gemma4_draft_trunk_from_x(e, d, &xs, h, pos_d, cache, dc_bucket)
433    }
434
435    /// Drafter trunk: returns (post-output_norm hidden [1024], h_next [2816]).
436    fn gemma4_draft_trunk(
437        &self,
438        e: &Engine,
439        d: &GemmaDraft,
440        token: u32,
441        h: &CudaSlice<f32>,
442        pos: usize,
443        cache: &Cache,
444    ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
445        let nb = d.n_backbone;
446        let mut xs = e.htod(&self.embd.gather(nb, &[token]))?;
447        e.scale_inplace(&mut xs, (nb as f32).sqrt(), nb)?;
448        let pos_d = e.htod_i32(&[pos as i32])?;
449        return self.gemma4_draft_trunk_from_x(e, d, &xs, h, &pos_d, cache, None);
450    }
451
452    /// Trunk body from the pre-scaled main-embed row.
453    fn gemma4_draft_trunk_from_x(
454        &self,
455        e: &Engine,
456        d: &GemmaDraft,
457        xs: &CudaSlice<f32>,
458        h: &CudaSlice<f32>,
459        pos_d: &CudaSlice<i32>,
460        cache: &Cache,
461        dc_bucket: Option<usize>,
462    ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
463        // pos rides a DEVICE slot (burst-arc step a, 2026-07-12): the round fills persistent
464        // slots via set_i32_one (kernel-arg stores — no per-step htod/alloc) and the chain
465        // becomes graph-capturable (an in-graph i32_copy_add can feed the slots later).
466        let eps = self.cfg.rms_eps;
467        let ne = d.n_embd;
468
469        // xh = concat(x, h) [2*n_backbone]
470        let nb = d.n_backbone;
471        let mut xh = e.uninit(2 * nb)?;
472        e.copy_into(&mut xh, 0, xs, nb)?;
473        e.copy_into(&mut xh, nb, h, nb)?;
474
475        let mut cur = e.matmul(&d.pre_proj, &xh, 1)?; // [1024]
476
477        for (_il, dl) in d.layers.iter().enumerate() {
478            // attention over the shared MAIN KV: swa -> the last OWN-KV windowed layer,
479            // global -> the last OWN-KV global layer (llama-model.cpp:2139 rule). Plain
480            // 26B/31B trunks have no shared tail, so this is n-2 / n-1 there; E4B's 18
481            // KV-shared tail layers move the boundary to 24 -> targets 22 (swa) / 23.
482            let main_il = self.gemma4_draft_kv_target(dl.swa);
483            let kvl = cache.kv[main_il].as_ref().unwrap();
484            let (hd, nhh) = (dl.hd, dl.nh);
485            let nkv = kvl.kv_dim_k / hd;
486            let base = if dl.swa {
487                d.rope_base_swa
488            } else {
489                d.rope_base_global
490            };
491
492            let mut hn = e.uninit(ne)?;
493            e.rms_norm(&cur, dl.attn_norm.float_data(), &mut hn, ne, 1, eps)?;
494            let q0 = e.matmul(&dl.wq, &hn, 1)?;
495            let mut q = e.uninit(nhh * hd)?;
496            e.rms_norm(&q0, dl.q_norm.float_data(), &mut q, hd, nhh, eps)?;
497            if dl.swa {
498                e.rope_neox(&mut q, pos_d, hd, hd, nhh, 1, base, 1.0)?;
499            } else {
500                e.rope_neox_ff(&mut q, pos_d, hd, hd, nhh, 1, base, 1.0, &d.rope_freqs)?;
501            }
502            let avail = kvl.len;
503            let win = d.sliding_window;
504            let mut attn = e.uninit(nhh * hd)?;
505            // drafter attends the MAIN cache — its format follows the main layer's class
506            // (windowed L28 = wkv arm, global L29 = gkv arm; gkv routing is hd-keyed inside).
507            // DEVICE-LEN arms (burst arc): the length rides the main layer's len_d counter
508            // so the chain is replay-correct across rounds. dc_bucket = the RUNG the round
509            // derived (power-of-2, shared by eager and captured replays — same n_splits,
510            // same combine order; the main graph arc's bucket lesson). None = host-len arm.
511            if let Some(bucket) = dc_bucket {
512                let k_view = e.view_u8(&kvl.k, kvl.k.len());
513                let v_view = e.view_u8(&kvl.v, kvl.v.len());
514                if dl.swa && avail > win {
515                    e.fa_decode_rows_w(
516                        &q,
517                        &k_view,
518                        &v_view,
519                        &mut attn,
520                        hd,
521                        nhh,
522                        nkv,
523                        &kvl.len_d,
524                        -1,
525                        1,
526                        1.0,
527                        win,
528                        kvl.k_tok_bytes,
529                        kvl.v_tok_bytes,
530                        None,
531                    )?;
532                } else {
533                    e.fa_decode_dc(
534                        &q,
535                        &k_view,
536                        &v_view,
537                        &mut attn,
538                        hd,
539                        nhh,
540                        nkv,
541                        &kvl.len_d,
542                        bucket,
543                        1.0,
544                        kvl.k_tok_bytes,
545                        kvl.v_tok_bytes,
546                        dl.swa && crate::Engine::wkv_on(),
547                    )?;
548                }
549            } else {
550                let (off_tok, t_kv) = if dl.swa && avail > win {
551                    (avail - win, win)
552                } else {
553                    (0, avail)
554                };
555                let k_view = e.view_u8_range(
556                    &kvl.k,
557                    off_tok * kvl.k_tok_bytes,
558                    (off_tok + t_kv) * kvl.k_tok_bytes,
559                );
560                let v_view = e.view_u8_range(
561                    &kvl.v,
562                    off_tok * kvl.v_tok_bytes,
563                    (off_tok + t_kv) * kvl.v_tok_bytes,
564                );
565                e.fa_decode_kvmod(
566                    &q,
567                    &k_view,
568                    &v_view,
569                    &mut attn,
570                    hd,
571                    nhh,
572                    nkv,
573                    t_kv,
574                    1.0,
575                    kvl.k_tok_bytes,
576                    kvl.v_tok_bytes,
577                    dl.swa && crate::Engine::wkv_on(),
578                )?;
579            }
580            let o = e.matmul(&dl.wo, &attn, 1)?;
581
582            let mut post = e.uninit(ne)?;
583            e.rms_norm(&o, dl.post_attn_norm.float_data(), &mut post, ne, 1, eps)?;
584            let mut attn_out = e.uninit(ne)?;
585            e.add(&post, &cur, &mut attn_out, ne)?;
586
587            let mut z = e.uninit(ne)?;
588            e.rms_norm(&attn_out, dl.ffn_norm.float_data(), &mut z, ne, 1, eps)?;
589            let n_ff = dl.ffn_gate.out_features();
590            let gate = e.matmul(&dl.ffn_gate, &z, 1)?;
591            let up = e.matmul(&dl.ffn_up, &z, 1)?;
592            let mut act = e.uninit(n_ff)?;
593            e.gelu_tanh_mul(&gate, &up, &mut act, n_ff)?;
594            let f0 = e.matmul(&dl.ffn_down, &act, 1)?;
595            let mut fpost = e.uninit(ne)?;
596            e.rms_norm(&f0, dl.ffn_post_norm.float_data(), &mut fpost, ne, 1, eps)?;
597            let mut xn = e.uninit(ne)?;
598            e.add_scale(&fpost, &attn_out, dl.out_scale, &mut xn, ne)?;
599            cur = xn;
600        }
601
602        let mut hn = e.uninit(ne)?;
603        e.rms_norm(&cur, d.output_norm.float_data(), &mut hn, ne, 1, eps)?;
604        let h_next = e.matmul(&d.post_proj, &hn, 1)?; // [2816]; head applied by callers (NO softcap)
605        Ok((hn, h_next))
606    }
607
608    /// Greedy draft step: like gemma4_draft_step but the token argmax stays on device —
609    /// host sees 4 bytes (no 1MB logits dtoh per draft). Returns (token, h_next).
610    pub fn gemma4_draft_step_greedy(
611        &self,
612        e: &Engine,
613        d: &GemmaDraft,
614        token: u32,
615        h: &CudaSlice<f32>,
616        pos: usize,
617        cache: &Cache,
618    ) -> Result<(u32, CudaSlice<f32>), Box<dyn std::error::Error>> {
619        let (hn, h_next) = self.gemma4_draft_trunk(e, d, token, h, pos, cache)?;
620        let ld = e.matmul(&d.head, &hn, 1)?;
621        let tok_d = e.argmax_token_device(&ld, d.head.out_features())?;
622        let idx = e.dtoh_u32(&tok_d)?[0];
623        let tok = match &d.d2t {
624            Some(map) => map[idx as usize],
625            None => idx,
626        };
627        Ok((tok, h_next))
628    }
629}
630
631impl HybridModel {
632    /// gemma4 MTP greedy spec loop: prime the prompt, then rounds of (chained K-token draft
633    /// over the frozen main cache) + (ONE batched verify) + longest-prefix accept + KV rollback.
634    /// Returns generated tokens; prints acceptance stats.
635    #[allow(clippy::too_many_arguments)]
636    pub fn generate_spec_gemma(&self, e: &Engine, d: &mut GemmaDraft, prompt: &[u32],
637                               max_new: usize, k: usize, eos: &[u32])
638                               -> Result<Vec<u32>, Box<dyn std::error::Error>> {
639        let n_embd = self.cfg.n_embd as usize;
640        let eps = self.cfg.rms_eps;
641        let mut cache = Cache::new(e, &self.cfg, prompt.len() + max_new + k + 8)?;
642
643        // Adaptive trim, learn point 1: the PROMPT's own ids — the measured escapees are the
644        // prompt's domain content words echoed back (▁oceans, clouds, Explain...), so the
645        // prompt is the cheapest predictor of what the trim is about to miss.
646        trim_adapt_learn(e, d, prompt)?;
647
648        let t_prime = std::time::Instant::now();
649        // short prompts fall below prime_cache's T floor — the batched verify IS a prime.
650        let (pl, h_seed) = if prompt.len() >= crate::hybrid_forward::PRIME_MIN_T {
651            let (l, hs, _hh) = self.prime_cache(e, prompt, &mut cache)?;
652            (l, hs)
653        } else if self.is_gemma4_e4b() {
654            // E4B short-prompt prime: TOKENWISE — the batched e4b trunk at base_len==0
655            // rides the PRIME-FA f32 arm (a different numerics class from the plain arm's
656            // tokenwise prime), and the class skew flipped near-tie streams (3/64,
657            // 2026-07-13). decode_step_h is the same chain the plain arm primes with.
658            let n_embd_ = self.cfg.n_embd as usize;
659            let mut ll = Vec::new();
660            let mut hx = e.zeros(n_embd_)?;
661            for &tok in prompt {
662                let (l, hh) = self.gemma4_e4b_decode_step_h(e, tok, &mut cache)?;
663                ll = l; hx = hh;
664            }
665            // decode_step_h returns the PRE-output_norm hidden; the short-prompt arm's
666            // h convention below is POST-norm — norm here.
667            let mut hp = e.uninit(n_embd_)?;
668            e.rms_norm(&hx, self.output_norm.float_data(), &mut hp, n_embd_, 1, eps)?;
669            (ll, hp)
670        } else {
671            let n_vocab = self.output.out_features();
672            let (lv, hv) = self.gemma4_decode_step_t_h(e, prompt, 0, &mut cache)?;
673            let t = prompt.len();
674            let last = lv[(t - 1) * n_vocab..t * n_vocab].to_vec();
675            // NOTE hv rows are POST-output_norm; h_seed convention below expects PRE-norm and
676            // re-norms — so recover a pre-norm-free path: use the post-norm row DIRECTLY.
677            let hvv = e.view(&hv, t * n_embd);
678            let row = hvv.slice((t - 1) * n_embd..t * n_embd);
679            let mut hrow = e.uninit(n_embd)?;
680            e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
681            // mark: already post-norm — skip the re-norm below via the flag
682            (last, hrow)
683        };
684        e.stream().synchronize()?;
685        crate::PRIME_NANOS.store(
686            t_prime.elapsed().as_nanos() as u64,
687            std::sync::atomic::Ordering::Relaxed,
688        );
689        // drafter h = POST-output_norm hidden (llama h_nextn); prime returns PRE-norm h_seed,
690        // the short-prompt verify path already returns post-norm rows.
691        let mut h = if prompt.len() >= crate::hybrid_forward::PRIME_MIN_T {
692            let mut hh = e.uninit(n_embd)?;
693            e.rms_norm(
694                &h_seed,
695                self.output_norm.float_data(),
696                &mut hh,
697                n_embd,
698                1,
699                eps,
700            )?;
701            hh
702        } else {
703            h_seed
704        };
705
706        let mut last = crate::forward::argmax(&pl) as u32;
707                // MEMRA_PROFILE_SPEC=2: capture starts at the ROUND LOOP (prime excluded) — pair
708        // with `nsys -c cudaProfilerApi` (the qwen loop's pattern, spec.rs).
709        if std::env::var("MEMRA_PROFILE_SPEC").as_deref() == Ok("2") {
710            unsafe extern "C" { fn cudaProfilerStart() -> i32; }
711            unsafe { cudaProfilerStart(); }
712        }
713        let mut out: Vec<u32> = Vec::with_capacity(max_new);
714        let (mut drafted, mut accepted, mut rounds) = (0usize, 0usize, 0usize);
715        // per-position accept histogram (MEMRA_SPEC_STATS): [attempted, accepted] per slot —
716        // the depth-K policy statistic (deep slots' marginal accept decides fixed-cap vs deep).
717        let mut pos_att = [0usize; 16];
718        let mut pos_acc = [0usize; 16];
719
720        // ASYNC ROUND v2 (dc class): the whole draft chain + verify enqueue with ZERO host
721        // syncs — token seeds via kernel-arg store (u32_set_k, no host-memory transfer), draft
722        // argmaxes land in the batch buffer, verify argmaxes in vam_d; ONE pack + ONE dtoh of
723        // (k drafts + k+1 vam) closes the round. (v1 with memcpy_htod seeding measured
724        // NEGATIVE — the pageable-copy sync; this is the retry with the sync removed.)
725        let mut batch_d = e.stream().alloc_zeros::<u32>(k + 1)?;
726        let mut packed = e.stream().alloc_zeros::<u32>(2 * k + 1)?;
727        // confidence-adaptive depth (MEMRA_SPEC_PMIN, default 0 = off): per-draft probs.
728        let pmin: f32 = std::env::var("MEMRA_SPEC_PMIN")
729            .ok()
730            .and_then(|v| v.parse().ok())
731            .unwrap_or(0.0);
732        // IN-ROUND confidence cut (2026-07-28): llama's draft-mtp stops drafting the
733        // moment a draft's top-1 prob falls below p-min; our MEMRA_SPEC_PMIN is one round
734        // LATE by design (zero-sync round). This arm pays one small dtoh sync per draft
735        // step (steps ~150µs; sync ~15µs) to cut the chain mid-round and verify at the
736        // shrunk width. Eager arm only — burst/graph arms draft fixed depth.
737        // DEFAULT is SELF-KEYED: active at depth (pos >= floor_ctx) and only in rounds
738        // following a MISS — measured: depth cells with sub-0.9 acceptance win (26B
739        // +1.4-3.2% @ 0.868-0.882 accept, 31B +2% @ 0.845-0.883), chat cells and the
740        // 0.95-accept 12B depth lose under an ALWAYS-on cut (-0.9 to -6%) but their
741        // rounds are mostly full-accept so the self-key idles there. Explicit
742        // MEMRA_SPEC_PMIN_INROUND pins the cut at every position/round; =0 disables.
743        let pmin_ir_env: Option<f32> = std::env::var("MEMRA_SPEC_PMIN_INROUND")
744            .ok()
745            .and_then(|v| v.parse().ok());
746        const PMIN_IR_DEFAULT: f32 = 0.7;
747        let mut prev_full = true; // round 1: no miss evidence yet — draft at full depth
748        let mut p_d = e.stream().alloc_zeros::<f32>(k.max(1))?;
749
750        // ADAPTIVE DRAFT LENGTH (default ON 2026-07-10; MEMRA_SPEC_ADAPT=0 reverts): llama's
751        // draft-mtp reaches 0.64-0.70 acceptance on the SAME drafter (ours fixed-K: 0.52) by
752        // drafting fewer tokens when unconfident (p-min gate). Zero-sync host proxy: next
753        // round's depth = last round's accepted run + 1, clamped to [floor=1, k] — rounds
754        // after a miss shrink, streaks re-deepen. The round's ONE dtoh already carries the
755        // acceptance; no new syncs. Policy sweep (short chat, N=1 each): floor1/cap3 239.2
756        // vs fixed-K3 231.1 (+3.5%, accept .52->.58); floor2 and cap4/5 all worse.
757        let adapt = std::env::var("MEMRA_SPEC_ADAPT").as_deref() != Ok("0");
758        // ADAPTIVE FLOOR default is per-model (MEMRA_SPEC_ADAPT_FLOOR overrides): the floor-1
759        // policy collapses to shallow drafts after any miss and pays a slow re-deepen; on
760        // models with an expensive verify step the deep-draft upside dwarfs the wasted-draft
761        // cost. Measured 2026-07-25 (chat cell, own-gen trim; peak grids both models):
762        // 31B K=5 floor=4 120.2 vs floor=1 103.8 (+15.7%, N=3; floor 5-6 falls off);
763        // 12B K=4-5 floor=4 240.5-240.8 vs floor=1 200.6 (+20%, floor 5+ falls off).
764        // The floor clamps to k_cap, so shallow-K callers are unaffected.
765        // 26B tier (2026-07-26 re-sweep under the f16pv spec flip): floor=2 wins BOTH its
766        // cells — short 329.5 vs 307.0 floor1 (+7%, best at every K), depth 329.7 vs ~318
767        // (the 2026-07-10 "floor2 worse" verdict predates the flip and is superseded).
768        // E4B (n_embd < 2500) keeps floor=1 — unmeasured, cheap verify.
769        let adapt_floor_default: usize = if self.cfg.n_embd >= 3500 { 4 }
770            else if self.cfg.n_embd >= 2500 { 2 } else { 1 };
771        // (stream-k spec key lives in HybridModel::load_from_source_impl — it must be set
772        // before the PRIME's GEMMs autotune, not here.)
773        let adapt_floor_env: Option<usize> = std::env::var("MEMRA_SPEC_ADAPT_FLOOR").ok()
774            .and_then(|v| v.parse().ok());
775        let adapt_floor: usize = adapt_floor_env.unwrap_or(adapt_floor_default);
776        // POSITION KEY (2026-07-26): the HIGH floor is a SHORT-CTX win. At depth the
777        // per-position acceptance is lower and FORCED-DEEP drafts turn net-negative:
778        // 31B d1736 floor4 99-101 and floor2 97.4-99.8 @ 0.758-0.778 vs floor1
779        // 103.8-104.2 @ 0.817 (two perf-ci batteries + flip-tree N=2 — floor2 is a REAL
780        // small loss there, not noise), while its chat cell holds +15-20% under floor4.
781        // The 26B is the opposite at depth: its mild floor2 WINS (304-305 vs ~297).
782        // Default: full floor while pos < floor_ctx; past it HIGH-floor models (>=4)
783        // relax to 1, MILD-floor models keep their floor. MEMRA_SPEC_FLOOR_CTX overrides
784        // the boundary; an explicit MEMRA_SPEC_ADAPT_FLOOR pins the floor everywhere.
785        let floor_ctx: usize = std::env::var("MEMRA_SPEC_FLOOR_CTX").ok()
786            .and_then(|v| v.parse().ok()).unwrap_or(1024);
787        let floor_at = |pos: usize| -> usize {
788            if adapt_floor_env.is_some() || pos < floor_ctx { adapt_floor }
789            else if adapt_floor >= 4 { 1 } else { adapt_floor }
790        };
791        // cap ceiling 7 by default; MEMRA_SPEC_CAPMAX opens the b16 verify tier (t=9..16).
792        // The historical cap>=8 "crash" was two host bugs, both fixed 2026-07-12: round 1
793        // ran UNCLAMPED (`kc = k` — verify t=K+1 entered the b16 tier while it was gated)
794        // and the b16 dispatch requested _r2 twins that were never compiled (mcols==16 now
795        // forces the base variant). Stream gates arbitrate any raised cap.
796        let cap_max: usize = std::env::var("MEMRA_SPEC_CAPMAX")
797            .ok()
798            .and_then(|v| v.parse().ok())
799            .unwrap_or(7);
800        let k_cap = k.min(cap_max).max(1);
801        // DRAFT-CHAIN GRAPHS (burst-arc step c, MEMRA_GEMMA_DRAFT_GRAPH=1): the whole k-step
802        // draft chain replays as ONE captured graph — position slots fill in-graph,
803        // the seed hidden rides the persistent g_seed buffer, KV lengths ride len_d (step b).
804        // Keyed on (kr, rung, over_win): a new depth/rung/window regime captures lazily.
805        let graph_on = std::env::var("MEMRA_GEMMA_DRAFT_GRAPH").as_deref() == Ok("1");
806        let mut draft_graphs: std::collections::HashMap<
807            (usize, usize, bool),
808            (
809                cudarc::driver::CudaGraph,
810                Vec<Box<dyn std::any::Any + Send>>,
811            ),
812        > = Default::default();
813        let mut g_seed = e.zeros(n_embd)?;
814        // seed len_d before round 1 (prime went through the host-len path).
815        for kvl in cache.kv.iter_mut().flatten() {
816            e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
817        }
818        // persistent per-step rope-pos slots (device; filled by set_i32_one kernel-arg stores).
819        let mut pos_slots: Vec<CudaSlice<i32>> = (0..k_cap.max(1))
820            .map(|_| e.htod_i32(&[0]))
821            .collect::<Result<_, _>>()?;
822        // clamp round 1 too (the leak above).
823        let mut kc = k_cap;
824        // BURST (MEMRA_GEMMA_SPEC_BURST=M, default off): pre-issue M full rounds — draft-graph
825        // replay + verify-stream + device accept/seed/rollback/ring-commit — with ONE host
826        // sync per M rounds (the ring drain). The draft(N+1)-overlapping-verify(N) window this
827        // opens is the burst arc's whole prize (~14% of a round; launch tax alone is hidden
828        // at 96.7% busy). Requires the draft graphs (step c) and a regime-stable horizon.
829        let burst_m: usize = std::env::var("MEMRA_GEMMA_SPEC_BURST")
830            .ok()
831            .and_then(|v| v.parse().ok())
832            .unwrap_or(0);
833        let mut burst_state: Option<(
834            crate::round_stream::StreamBufs,
835            CudaSlice<f32>,
836            CudaSlice<u64>,
837            crate::hybrid_forward::VerifyStreamScratch,
838        )> = None;
839        let win_main = self
840            .cfg
841            .gemma4
842            .as_ref()
843            .map(|g| g.sliding_window as usize)
844            .unwrap_or(0);
845        let g4_shared = self
846            .cfg
847            .gemma4
848            .as_ref()
849            .map(|g| g.shared_kv_layers)
850            .unwrap_or(0);
851        'outer: while out.len() < max_new {
852            // burst gate first (see the BURST ARM below): a burst round drafts at FULL depth
853            // (kr = k_cap — the captured chain replays a fixed K; adaptation is host logic).
854            let horizon = burst_m * (k_cap + 1);
855            let burst_ok = burst_m >= 1 && pmin == 0.0 && g4_shared == 0
856                && (cache.pos + horizon + k_cap + 4 < win_main || cache.pos > win_main)
857                // fa512 crossover: the whole horizon on one side (the stream verify's global
858                // arm picks per-row-dc vs rows by hint; straddling rounds stay eager).
859                && (cache.pos + horizon + k_cap + 4 < crate::fa512_min_tkv()
860                    || cache.pos + 1 >= crate::fa512_min_tkv())
861                && e.fa_rows_eligible(cache.pos, 256)
862                && cache.pos + horizon + k_cap + 2 <= cache.max_ctx
863                && out.len() + horizon <= max_new;
864            let mut kr = if burst_ok {
865                k_cap
866            } else if adapt {
867                kc
868            } else {
869                k_cap
870            };
871            // power-of-2 rung bucket for the dc arms (shared by eager and captured replays);
872            // MEMRA_GEMMA_DRAFT_DC=0 reverts to the host-len kvmod arm.
873            let dc_bucket: Option<usize> = {
874                static DC: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
875                if *DC.get_or_init(|| std::env::var("MEMRA_GEMMA_DRAFT_DC").as_deref() != Ok("0")) {
876                    let ml = cache
877                        .kv
878                        .iter()
879                        .flatten()
880                        .map(|kv| kv.len)
881                        .max()
882                        .unwrap_or(1);
883                    // burst rounds size the rung for the WHOLE horizon: the captured chain
884                    // replays M rounds between host looks, so the grid must cover the last
885                    // round's len too (a per-round rung undersizes past its pow2 boundary).
886                    let slack = if burst_ok { horizon } else { 0 };
887                    Some((ml + slack + k_cap + 2).next_power_of_two().max(512))
888                } else {
889                    None
890                }
891            };
892            e.u32_set_k(&mut batch_d, last, 0)?;
893            e.copy_into(&mut g_seed, 0, &h, n_embd)?;
894            // the draft chain, step j: reads g_seed via the hc chain, pos from pos_slots[j]
895            // (eager: host-filled; graph: filled in-graph).
896            let run_chain = |e: &Engine, d: &GemmaDraft, batch_d: &mut CudaSlice<u32>,
897                             p_d: &mut CudaSlice<f32>, g_seed: &CudaSlice<f32>,
898                             pos_slots: &Vec<CudaSlice<i32>>, inround: f32|
899             -> Result<usize, Box<dyn std::error::Error>> {
900                // uninit+copy (NOT clone_dtod): clone_dtod's internal alloc bypasses the
901                // capture-retain hooks — its address got pool-reused between replays and the
902                // replayed chain read a corrupted seed (accept 0.52 vs 0.76).
903                let mut hc = e.uninit(n_embd)?;
904                e.copy_into(&mut hc, 0, g_seed, n_embd)?;
905                for j in 0..kr {
906                    let tv = batch_d.slice(j..j + 1);
907                    let (hn, h_next) = self.gemma4_draft_trunk_dev(
908                        e,
909                        d,
910                        &tv,
911                        &hc,
912                        &pos_slots[j],
913                        &cache,
914                        dc_bucket,
915                    )?;
916                    let ld = e.matmul(&d.head, &hn, 1)?;
917                    e.argmax_token_device_col(&ld, 0, d.head.out_features(), batch_d, j + 1)?;
918                    // confidence-adaptive depth (MEMRA_SPEC_PMIN): TRIM-space prob before d2t.
919                    if pmin > 0.0 || inround > 0.0 {
920                        e.prob_of_token_device_col(
921                            &ld,
922                            batch_d,
923                            j + 1,
924                            p_d,
925                            j,
926                            d.head.out_features(),
927                        )?;
928                    }
929                    // FR-trimmed head: translate the trim-space argmax to the vocab id.
930                    if let Some(map) = &d.d2t_dev {
931                        e.u32_map_k(batch_d, map, j + 1)?;
932                    }
933                    hc = h_next;
934                    // IN-ROUND cut: one small dtoh sync per step; stop drafting the moment
935                    // confidence falls below the gate and verify at the shrunk width.
936                    // (A DSpark-class marginal-rate window — S_{j+1}*T(j) > E[tok](j)*t_d
937                    // with profiled t_draft/t_verify EMAs — measured FLAT here 2026-07-30:
938                    // never cuts at accept >= 0.8, par-to-noise on 26B/31B depth x3
939                    // interleaved; arm removed per flags doctrine, jsonl row is the record.)
940                    if inround > 0.0 && j + 1 < kr {
941                        let ph = e.dtoh(p_d)?;
942                        if ph[j] < inround {
943                            return Ok(j + 1);
944                        }
945                    }
946                }
947                Ok(kr)
948            };
949            let over_win = {
950                let win = d.sliding_window;
951                d.layers.iter().any(|dl| dl.swa
952                    && cache.kv[self.gemma4_draft_kv_target(true)].as_ref()
953                        .is_some_and(|kv| kv.len > win))
954            };
955            // ---- ROUND-GRAPH ARM ---- (MEMRA_GEMMA_ROUND_GRAPH=1): the WHOLE round —
956            // draft chain + stream verify + device accept/seed/rollback/commit + the
957            // device adaptive-depth update — captured ONCE per (k_cap, rung, over_win)
958            // regime and replayed as ONE graph launch per round (the llama round-cost
959            // mechanism: ~600 per-round enqueues collapse to 1). The round is SELF-FEEDING
960            // (pos_ctr/pend/brk/g_seed all advance in-graph), so the capture warmups are
961            // simply two SERVED rounds — their tokens land in the ring and drain normally
962            // (no snapshot/rollback needed, unlike the E4B token door).
963            // Adaptive K rides brk[0] via spec_adapt_k: drafts always run k_cap deep (the
964            // drafter is cheap) but the accept walk depth follows the host policy exactly.
965            let round_graph_on = std::env::var("MEMRA_GEMMA_ROUND_GRAPH").as_deref() == Ok("1");
966            if round_graph_on && burst_m == 0 && dc_bucket.is_some() && pmin == 0.0
967                && g4_shared == 0 && !self.is_gemma4_e4b()
968                && (cache.pos + 2 * (k_cap + 1) + k_cap + 4 < win_main || cache.pos > win_main)
969                && (cache.pos + 2 * (k_cap + 1) + k_cap + 4 < crate::fa512_min_tkv()
970                    || cache.pos + 1 >= crate::fa512_min_tkv())
971                && e.fa_rows_eligible(cache.pos, 256)
972                && cache.pos + 2 * (k_cap + 1) + k_cap + 2 <= cache.max_ctx
973            {
974                if burst_state.is_none() {
975                    // ring sized for the capture warmups (2 rounds) + the live round.
976                    let bufs = crate::round_stream::StreamBufs::new(e, k_cap, 3)?;
977                    let fill_dummy = e.zeros(n_embd)?;
978                    let ptrs = crate::round_stream::kv_len_ptr_table(e, &cache,
979                                                                     Some(&bufs.pos_ctr))?;
980                    let scr = self.verify_stream_scratch(e, k_cap + 1)?;
981                    burst_state = Some((bufs, fill_dummy, ptrs, scr));
982                }
983                // entry: `last` is the pending token (emitted at drain), h is the seed.
984                let (bufs, fill_dummy, ptrs, scr) = burst_state.as_mut().unwrap();
985                let n_rows = cache.kv.len() + 1;
986                e.set_i32_one(&mut bufs.pos_ctr, cache.pos as i32)?;
987                e.u32_set_k(&mut bufs.ring_d, 0, 0)?;
988                e.u32_set_k(&mut bufs.pend_d, last, 0)?;
989                e.u32_set_k(&mut bufs.brk_d, (if adapt { kc } else { k_cap }) as u32, 0)?;
990                e.u32_set_k(&mut bufs.brk_d, 1, 1)?;
991                e.copy_into(&mut g_seed, 0, &h, n_embd)?;
992                // entry pend is emitted host-side (the ring only carries accepted drafts
993                // + bonuses — the burst-arm contract).
994                out.push(last);
995                if eos.contains(&last) { break 'outer; }
996                if out.len() >= max_new { break 'outer; }
997                let key = (usize::MAX - k_cap, dc_bucket.unwrap(), over_win);
998                let mut fresh_rounds = 1usize;   // rounds executed by this iteration
999                // `hint` is the verify stream's ARM-GATING upper bound — it must sit on
1000                // the SAME side of every crossover as the live lengths this capture
1001                // serves, INCLUDING the arms' own margins (`hint + t < f512` gates the
1002                // global scalar arm; `hint + 1 >= win` gates rows_w), or the captured
1003                // verify bakes a different kernel class than the eager reference
1004                // (107-vs-106 / 4-64 drifts; the regime gate above guarantees the live
1005                // side with the same margins).
1006                let hint = if cache.pos > win_main {
1007                    dc_bucket.unwrap() + k_cap + 2          // over-window: rows_w regime
1008                } else if cache.pos + 1 >= crate::fa512_min_tkv() {
1009                    win_main - 2                             // above f512, under window
1010                } else {
1011                    crate::fa512_min_tkv().saturating_sub(k_cap + 5)   // under both
1012                };
1013                let bufs_ptr: *mut crate::round_stream::StreamBufs = &mut *bufs;
1014                let scr_ptr: *mut crate::hybrid_forward::VerifyStreamScratch = &mut *scr;
1015                let cache_ptr: *mut Cache = &mut cache;
1016                let batch_ptr: *mut CudaSlice<u32> = &mut batch_d;
1017                let seed_ptr: *mut CudaSlice<f32> = &mut g_seed;
1018                let slots_ptr: *mut Vec<CudaSlice<i32>> = &mut pos_slots;
1019                let mut round_body = |e: &Engine| -> Result<(), Box<dyn std::error::Error>> {
1020                    // SAFETY: single-threaded round body; the raw pointers alias the outer
1021                    // &mut only within this closure (no overlapping borrows).
1022                    let (bufs, scr, cache, batch_d, g_seed, pos_slots) = unsafe {
1023                        (&mut *bufs_ptr, &mut *scr_ptr, &mut *cache_ptr,
1024                         &mut *batch_ptr, &mut *seed_ptr, &mut *slots_ptr) };
1025                    e.i32_copy_add(&bufs.pos_ctr, &mut bufs.pos_start_d, 0)?;
1026                    e.u32_copy(&bufs.pend_d, batch_d)?;
1027                    for (j, slot) in pos_slots.iter_mut().take(k_cap).enumerate() {
1028                        e.i32_copy_add(&bufs.pos_ctr, slot, j as i32)?;
1029                    }
1030                    let mut hc = e.uninit(n_embd)?;
1031                    e.copy_into(&mut hc, 0, g_seed, n_embd)?;
1032                    for j in 0..k_cap {
1033                        let tv = batch_d.slice(j..j + 1);
1034                        let (hn, h_next) = self.gemma4_draft_trunk_dev(
1035                            e, d, &tv, &hc, &pos_slots[j], cache, dc_bucket)?;
1036                        let ld = e.matmul(&d.head, &hn, 1)?;
1037                        e.argmax_token_device_col(&ld, 0, d.head.out_features(),
1038                                                  batch_d, j + 1)?;
1039                        if let Some(map) = &d.d2t_dev {
1040                            e.u32_map_k(batch_d, map, j + 1)?;
1041                        }
1042                        hc = h_next;
1043                    }
1044                    let (vam_d, vh) = self.gemma4_verify_t_am_stream(
1045                        e, batch_d, k_cap + 1, &bufs.pos_ctr, hint, cache, scr)?;
1046                    e.spec_accept_greedy_dc(&vam_d, batch_d, &bufs.last_pred_d,
1047                                            &bufs.brk_d, &mut bufs.acc_d)?;
1048                    if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1")
1049                        && std::env::var("MEMRA_ROUND_GRAPH_CHECK").as_deref() == Ok("1") {
1050                        let vhh = e.dtoh(&vh)?;
1051                        let nrm = |r: usize| vhh[r * n_embd..(r + 1) * n_embd].iter()
1052                            .map(|x| x * x).sum::<f32>().sqrt();
1053                        let vamh = e.dtoh_u32(&vam_d)?;
1054                        eprintln!("[rg-vh] |row0|={:.3} |row1|={:.3} |row2|={:.3} vam={:?}",
1055                                  nrm(0), nrm(1), nrm(2), &vamh[..(k_cap + 1).min(7)]);
1056                    }
1057                    e.spec_seed_gather(&vh, fill_dummy, &bufs.acc_d, g_seed, 1, n_embd)?;
1058                    e.spec_rollback_stream(ptrs, &bufs.pos_start_d, &bufs.acc_d, 1, n_rows)?;
1059                    e.spec_ring_commit(batch_d, &bufs.acc_d, &bufs.brk_d,
1060                                       &mut bufs.ring_d, &mut bufs.pend_d)?;
1061                    e.spec_adapt_k(&bufs.acc_d, &mut bufs.brk_d, floor_at(cache.pos), k_cap)?;
1062                    Ok(())
1063                };
1064                // MEMRA_ROUND_GRAPH_CHECK=1: run the body EAGERLY (no capture/replay) —
1065                // splits "body semantics wrong" from "replay mechanics wrong".
1066                let body_check = std::env::var("MEMRA_ROUND_GRAPH_CHECK").as_deref() == Ok("1");
1067                if body_check {
1068                    round_body(e)?;
1069                    if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1070                        let acc = e.dtoh_u32(&bufs.acc_d)?;
1071                        let brk = e.dtoh_u32(&bufs.brk_d)?;
1072                        let bt = e.dtoh_u32(&batch_d)?;
1073                        let tgt = self.gemma4_draft_kv_target(true);
1074                        let ld = e.dtoh_i32(&cache.kv[tgt].as_ref().unwrap().len_d)?[0];
1075                        let gs = e.dtoh(&g_seed)?;
1076                        let gn: f32 = gs.iter().map(|x| x * x).sum::<f32>().sqrt();
1077                        eprintln!("[rg-check] pos0={} batch={bt:?} n_acc={} bonus={} brk_next={:?} len_d[L{tgt}]={ld} |g_seed|={gn:.3}",
1078                                  cache.pos, acc[0], acc[1], brk);
1079                    }
1080                } else {
1081                    if !draft_graphs.contains_key(&key) {
1082                        let g = e.capture_graph_retained(&mut round_body)?;
1083                        draft_graphs.insert(key, g);
1084                        fresh_rounds += 2;   // the capture warmups were served rounds
1085                    }
1086                    draft_graphs.get(&key).unwrap().0.launch()?;
1087                }
1088                // drain: ONE host sync per iteration (warmup rounds included on capture).
1089                let toks = bufs.drain_ring(e)?;
1090                let posh = e.dtoh_i32(&bufs.pos_ctr)?[0] as usize;
1091                if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1092                    eprintln!("[round-graph] fresh={fresh_rounds} drained={} posh={posh} toks={:?}",
1093                              toks.len(), &toks[..toks.len().min(12)]);
1094                }
1095                drafted += fresh_rounds * k_cap;
1096                rounds += fresh_rounds;
1097                accepted += toks.len().saturating_sub(fresh_rounds);
1098                let mut ended = false;
1099                for &tk in &toks[..toks.len() - 1] {
1100                    out.push(tk);
1101                    if eos.contains(&tk) || out.len() >= max_new { ended = true; break; }
1102                }
1103                last = *toks.last().unwrap();
1104                cache.pos = posh;
1105                for kvl in cache.kv.iter_mut().flatten() { kvl.len = posh; }
1106                // NO allocation between replays: a pool alloc here can land on a baked
1107                // transient address and corrupt the next replay (the draft-graph lesson).
1108                // g_seed already holds the next seed (in-graph gather); copy INTO the
1109                // existing h buffer for the (possible) eager-arm handoff.
1110                e.copy_into(&mut h, 0, &g_seed, n_embd)?;
1111                kc = k_cap;   // device brk owns the walk depth; host kc only seeds entry
1112                // learn point 2 (round-graph drain): ring = accepted drafts + bonuses; only
1113                // bonuses can be escapes, and the present-bitmap check skips the rest cheap.
1114                trim_adapt_learn(e, d, &toks)?;
1115                if ended { break 'outer; }
1116                continue 'outer;
1117            }
1118            // ---- BURST ARM ---- (gate computed at the loop top; needs dc arms too)
1119            if burst_ok && dc_bucket.is_some() {
1120                if burst_state.is_none() {
1121                    let bufs = crate::round_stream::StreamBufs::new(e, k_cap, burst_m)?;
1122                    let fill_dummy = e.zeros(n_embd)?; // spec_seed_gather j>=1 always: unread
1123                    let ptrs =
1124                        crate::round_stream::kv_len_ptr_table(e, &cache, Some(&bufs.pos_ctr))?;
1125                    let scr = self.verify_stream_scratch(e, k_cap + 1)?;
1126                    burst_state = Some((bufs, fill_dummy, ptrs, scr));
1127                }
1128                // the loop-top dc_bucket already carries the horizon slack on burst rounds,
1129                // so the key below matches the rung the captured chain actually launches with.
1130                let key = (k_cap, dc_bucket.unwrap(), over_win);
1131                if std::env::var("MEMRA_GEMMA_BURST_GRAPH").as_deref() == Ok("1")
1132                    && !draft_graphs.contains_key(&key)
1133                {
1134                    let g = e.capture_graph_retained(|e| {
1135                        run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, 0.0).map(|_| ())
1136                    })?;
1137                    draft_graphs.insert(key, g);
1138                }
1139                // entry: `last` is the not-yet-emitted pending token (the ring only ever
1140                // carries accepted drafts + bonuses; the entry pend is emitted host-side).
1141                out.push(last);
1142                if eos.contains(&last) {
1143                    break 'outer;
1144                }
1145                if out.len() >= max_new {
1146                    break 'outer;
1147                }
1148                let (bufs, fill_dummy, ptrs, scr) = burst_state.as_mut().unwrap();
1149                let n_rows = cache.kv.len() + 1; // + the pos counter row
1150                e.set_i32_one(&mut bufs.pos_ctr, cache.pos as i32)?;
1151                e.u32_set_k(&mut bufs.ring_d, 0, 0)?;
1152                e.u32_set_k(&mut bufs.pend_d, last, 0)?;
1153                e.u32_set_k(&mut bufs.brk_d, k_cap as u32, 0)?; // k_used = K (no p-min cut)
1154                e.u32_set_k(&mut bufs.brk_d, 1, 1)?; // base = 1 (pend always set)
1155                e.copy_into(&mut g_seed, 0, &h, n_embd)?;
1156                let pos0 = cache.pos;
1157                for r in 0..burst_m {
1158                    // every op below is ENQUEUED; nothing reads back until the drain.
1159                    e.i32_copy_add(&bufs.pos_ctr, &mut bufs.pos_start_d, 0)?;
1160                    e.u32_copy(&bufs.pend_d, &mut batch_d)?; // batch_d[0] <- pend
1161                    for (j, slot) in pos_slots.iter_mut().take(k_cap).enumerate() {
1162                        e.i32_copy_add(&bufs.pos_ctr, slot, j as i32)?;
1163                    }
1164                    // the chain enqueues ZERO-SYNC with device pos slots — the captured-graph
1165                    // replay is measured EXPENSIVE (26B eager 379 -> 253 with replay), so the
1166                    // burst runs the chain eagerly by default; MEMRA_GEMMA_BURST_GRAPH=1 keeps
1167                    // the replay door for A/B.
1168                    if std::env::var("MEMRA_GEMMA_BURST_GRAPH").as_deref() == Ok("1") {
1169                        draft_graphs.get(&key).unwrap().0.launch()?;
1170                    } else {
1171                        // run_chain's body inlined: the closure holds &cache for the loop's
1172                        // lifetime and collides with the verify's &mut cache borrow.
1173                        let mut hc = e.uninit(n_embd)?;
1174                        e.copy_into(&mut hc, 0, &g_seed, n_embd)?;
1175                        for j in 0..k_cap {
1176                            let tv = batch_d.slice(j..j + 1);
1177                            let (hn, h_next) = self.gemma4_draft_trunk_dev(
1178                                e,
1179                                d,
1180                                &tv,
1181                                &hc,
1182                                &pos_slots[j],
1183                                &cache,
1184                                dc_bucket,
1185                            )?;
1186                            let ld = e.matmul(&d.head, &hn, 1)?;
1187                            e.argmax_token_device_col(
1188                                &ld,
1189                                0,
1190                                d.head.out_features(),
1191                                &mut batch_d,
1192                                j + 1,
1193                            )?;
1194                            if let Some(map) = &d.d2t_dev {
1195                                e.u32_map_k(&mut batch_d, map, j + 1)?;
1196                            }
1197                            hc = h_next;
1198                        }
1199                    }
1200                    // host UPPER bound on this round's base (full-accept growth): sizes the
1201                    // stream verify's splits + window-arm gate; device len is the true bound.
1202                    let hint = pos0 + (r + 1) * (k_cap + 1) + 2;
1203                    let (vam_d, vh) = self.gemma4_verify_t_am_stream(
1204                        e,
1205                        &batch_d,
1206                        k_cap + 1,
1207                        &bufs.pos_ctr,
1208                        hint,
1209                        &mut cache,
1210                        scr,
1211                    )?;
1212                    e.spec_accept_greedy_dc(
1213                        &vam_d,
1214                        &batch_d,
1215                        &bufs.last_pred_d,
1216                        &bufs.brk_d,
1217                        &mut bufs.acc_d,
1218                    )?;
1219                    e.spec_seed_gather(&vh, fill_dummy, &bufs.acc_d, &mut g_seed, 1, n_embd)?;
1220                    e.spec_rollback_stream(ptrs, &bufs.pos_start_d, &bufs.acc_d, 1, n_rows)?;
1221                    e.spec_ring_commit(
1222                        &batch_d,
1223                        &bufs.acc_d,
1224                        &bufs.brk_d,
1225                        &mut bufs.ring_d,
1226                        &mut bufs.pend_d,
1227                    )?;
1228                }
1229                // drain: THE one sync per M rounds. Ring = [acc..., bonus] per round; the
1230                // final element is the next pending token (eager pushes it next round).
1231                let toks = bufs.drain_ring(e)?;
1232                let posh = e.dtoh_i32(&bufs.pos_ctr)?[0] as usize;
1233                drafted += burst_m * k_cap;
1234                rounds += burst_m;
1235                accepted += toks.len().saturating_sub(burst_m); // each round adds n_acc + 1
1236                let mut ended = false;
1237                for &tk in &toks[..toks.len() - 1] {
1238                    out.push(tk);
1239                    if eos.contains(&tk) || out.len() >= max_new {
1240                        ended = true;
1241                        break;
1242                    }
1243                }
1244                last = *toks.last().unwrap();
1245                // host mirrors re-sync (device counters are already correct from rollback).
1246                cache.pos = posh;
1247                for kvl in cache.kv.iter_mut().flatten() {
1248                    kvl.len = posh;
1249                }
1250                // next seed hidden = g_seed (the final round's device gather).
1251                let mut hrow = e.uninit(n_embd)?;
1252                e.copy_into(&mut hrow, 0, &g_seed, n_embd)?;
1253                h = hrow;
1254                kc = k_cap;
1255                // learn point 2 (burst drain): same contract as the round-graph drain.
1256                trim_adapt_learn(e, d, &toks)?;
1257                if ended { break 'outer; }
1258                continue 'outer;
1259            }
1260            if graph_on && dc_bucket.is_some() {
1261                let key = (kr, dc_bucket.unwrap(), over_win);
1262                if !draft_graphs.contains_key(&key) {
1263                    // chain-only capture; pos slots are graph INPUTS (filled eagerly before
1264                    // each launch, like g_seed — the in-graph copy_add fills replayed one
1265                    // round stale, see jsonl).
1266                    let g = e.capture_graph_retained(|e| {
1267                        run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, 0.0).map(|_| ())
1268                    })?;
1269                    draft_graphs.insert(key, g);
1270                }
1271                for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1272                    e.set_i32_one(slot, (cache.pos + j) as i32)?;
1273                }
1274                draft_graphs.get(&key).unwrap().0.launch()?;
1275                // MEMRA_DRAFT_GRAPH_CHECK=1: re-run the chain eagerly from the same state and
1276                // diff the drafted slots (replay-vs-eager divergence bisect).
1277                if std::env::var("MEMRA_DRAFT_GRAPH_CHECK").as_deref() == Ok("1") {
1278                    // NON-DESTRUCTIVE: compare, then restore the graph's tokens so the round
1279                    // proceeds exactly as it would without the check.
1280                    let gtoks = e.dtoh_u32(&batch_d)?;
1281                    for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1282                        e.set_i32_one(slot, (cache.pos + j) as i32)?;
1283                    }
1284                    run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, 0.0)?;
1285                    let etoks = e.dtoh_u32(&batch_d)?;
1286                    if gtoks[..=kr] != etoks[..=kr] {
1287                        eprintln!(
1288                            "[draft-graph] DIVERGE round={rounds} graph={:?} eager={:?}",
1289                            &gtoks[..=kr],
1290                            &etoks[..=kr]
1291                        );
1292                    }
1293                    for (j, &t) in gtoks.iter().enumerate().take(kr + 1) {
1294                        e.u32_set_k(&mut batch_d, t, j)?;
1295                    }
1296                }
1297            } else {
1298                for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1299                    e.set_i32_one(slot, (cache.pos + j) as i32)?;
1300                }
1301                let ir_now = match pmin_ir_env {
1302                    Some(p) => p, // explicit pin (0 disables)
1303                    None if cache.pos >= floor_ctx && !prev_full => PMIN_IR_DEFAULT,
1304                    None => 0.0,
1305                };
1306                kr = run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, ir_now)?;
1307            }
1308            drafted += kr;
1309            rounds += 1;
1310            let pos0 = cache.pos;
1311            // MEMRA_BURST_VCHECK=1: run the STREAM verify first on the same batch/state and
1312            // diff its argmaxes against the eager verify (bisect harness — the stream append
1313            // writes the same rows the eager append then overwrites, so state is untouched).
1314            let vcheck = std::env::var("MEMRA_BURST_VCHECK").as_deref() == Ok("1");
1315            let kvsum = |e: &Engine,
1316                         cache: &Cache|
1317             -> Result<Vec<(u64, u64)>, Box<dyn std::error::Error>> {
1318                let mut out = Vec::new();
1319                for kvl in cache.kv.iter().flatten() {
1320                    let kb = e.dtoh_u8(&kvl.k)?;
1321                    let vb = e.dtoh_u8(&kvl.v)?;
1322                    let lo = pos0 * kvl.k_tok_bytes;
1323                    let hi = (pos0 + kr + 1) * kvl.k_tok_bytes;
1324                    let lov = pos0 * kvl.v_tok_bytes;
1325                    let hiv = (pos0 + kr + 1) * kvl.v_tok_bytes;
1326                    out.push((
1327                        kb[lo..hi].iter().map(|&b| b as u64).sum(),
1328                        vb[lov..hiv].iter().map(|&b| b as u64).sum(),
1329                    ));
1330                }
1331                Ok(out)
1332            };
1333            let vam_s = if vcheck && !self.is_gemma4_e4b() {
1334                let mut ctr = e.htod_i32(&[pos0 as i32])?;
1335                e.set_i32_one(&mut ctr, pos0 as i32)?;
1336                let mut scr0 = self.verify_stream_scratch(e, kr + 1)?;
1337                let (vs, vhs) = self.gemma4_verify_t_am_stream(e, &batch_d, kr + 1, &ctr,
1338                                                               pos0 + kr + 3, &mut cache,
1339                                                               &mut scr0)?;
1340                let ss = kvsum(e, &cache)?;
1341                Some((e.dtoh_u32(&vs)?, ss, e.dtoh(&vhs)?))
1342            } else { None };
1343            let (vam_d, vh) = if self.is_gemma4_e4b() {
1344                self.gemma4_e4b_decode_step_t_am_dev(e, &batch_d, kr + 1, pos0, &mut cache)?
1345            } else {
1346                self.gemma4_decode_step_t_am_dev(e, &batch_d, kr + 1, pos0, &mut cache)?
1347            };
1348            if let Some((vs, ss, vhs)) = vam_s {
1349                let vhe = e.dtoh(&vh)?;
1350                for r in 0..kr + 1 {
1351                    let md = vhs[r * n_embd..(r + 1) * n_embd].iter()
1352                        .zip(&vhe[r * n_embd..(r + 1) * n_embd])
1353                        .map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
1354                    if md > 1e-3 {
1355                        eprintln!("[vcheck-vh] round={rounds} row={r} maxdiff={md:.3e}");
1356                    }
1357                }
1358                let se = kvsum(e, &cache)?;
1359                for (il, (a, b)) in ss.iter().zip(&se).enumerate() {
1360                    if a != b {
1361                        eprintln!("[vcheck-kv] round={rounds} il={il} stream={a:?} eager={b:?}");
1362                    }
1363                }
1364                let ve = e.dtoh_u32(&vam_d)?;
1365                if vs[..kr + 1] != ve[..kr + 1] {
1366                    eprintln!(
1367                        "[vcheck] DIVERGE round={rounds} pos0={pos0} stream={:?} eager={:?}",
1368                        &vs[..kr + 1],
1369                        &ve[..kr + 1]
1370                    );
1371                } else {
1372                    eprintln!("[vcheck] match round={rounds} pos0={pos0}");
1373                }
1374            }
1375            e.u32_pack2(&batch_d, 1, kr, &vam_d, kr + 1, &mut packed)?;
1376            let host = e.dtoh_u32(&packed)?; // the round's ONE sync
1377            let k = kr;
1378            let dtoks: Vec<u32> = host[..k].to_vec();
1379            let vam: Vec<u32> = host[k..2 * k + 1].to_vec();
1380            // longest accepted prefix: d_i accepted iff d_i == argmax(verify[i-1])
1381            // (trimmed heads: batch_d slots were d2t-translated in the draft loop, so dtoks
1382            // are full-vocab ids here — the 2026-07-10 async rewrite silently dropped this
1383            // and the trim probes read accept=0.000 through it.)
1384            let mut m = 0usize;
1385            while m < k {
1386                if dtoks[m] == vam[m] {
1387                    m += 1;
1388                } else {
1389                    break;
1390                }
1391            }
1392            prev_full = m == k; // feeds the self-keyed in-round cut (miss → next round cuts)
1393            if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1394                let l0 = cache.kv.iter().flatten().next().map(|kv| kv.len).unwrap_or(0);
1395                let hh = e.dtoh(&h)?;
1396                let hn: f32 = hh.iter().map(|x| x * x).sum::<f32>().sqrt();
1397                eprintln!("[round {rounds}] pos0={pos0} post_pos={} kv0_len={l0} last={last} dtoks={dtoks:?} vam={vam:?} m={m} |h_in|={hn:.3}",
1398                          cache.pos);
1399            }
1400            accepted += m;
1401            for j in 0..k.min(16) {
1402                pos_att[j] += 1;
1403                if j < m { pos_acc[j] += 1; }
1404            }
1405            // emit last + accepted drafts; the correction token comes from verify row m.
1406            out.push(last);
1407            if eos.contains(&last) {
1408                break 'outer;
1409            }
1410            for &dt in &dtoks[..m] {
1411                out.push(dt);
1412                if eos.contains(&dt) {
1413                    break 'outer;
1414                }
1415                if out.len() >= max_new {
1416                    break 'outer;
1417                }
1418            }
1419            let next = vam[m];
1420            // roll back rejected rows: batch appended k+1 rows; keep m+1 (positions of
1421            // last + accepted drafts). SWA layers cap t_kv by the window view, so a plain
1422            // len rewind is safe for every layer.
1423            let keep = m + 1;
1424            for kvl in cache.kv.iter_mut().flatten() {
1425                kvl.len -= (k + 1) - keep;
1426                // keep len_d in lockstep: the drafter's device-len attention arms read it
1427                // (the gemma round appends via the HOST-len path, which doesn't maintain
1428                // the counter — stale len_d gutted acceptance to 0.059 on the dc probe).
1429                e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
1430            }
1431            cache.pos -= (k + 1) - keep;
1432            // h for the next round = main hidden at the LAST KEPT position (verify row m).
1433            let hv = e.view(&vh, (k + 1) * n_embd);
1434            let row = hv.slice(m * n_embd..(m + 1) * n_embd);
1435            let mut hrow = e.uninit(n_embd)?;
1436            e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
1437            h = hrow;
1438            last = next;
1439            // Adaptive trim, learn point 2: ALL verify argmaxes — vam[m] is the emitted
1440            // correction (the only emitted token that can sit outside the trim set; accepted
1441            // drafts are trim members by construction), and vam[i>m] are main-model
1442            // predictions for positions never reached this round: next round usually wants
1443            // exactly those tokens, so learning them here lets the draft propose them
1444            // BEFORE any miss is paid (prose escapes are first-occurrence-dominated —
1445            // corrections-only learning measured +0.5 acceptance pts, jsonl 2026-07-19).
1446            trim_adapt_learn(e, d, &vam)?;
1447            if adapt {
1448                let fl_now = floor_at(cache.pos);
1449                kc = (m + 1).clamp(fl_now.min(k_cap), k_cap);
1450                // confidence cut (MEMRA_SPEC_PMIN > 0): next round drafts no deeper than one
1451                // past the first low-confidence draft of THIS round (llama's p-min class,
1452                // one round late — the zero-sync enqueue stays intact). One extra tiny dtoh.
1453                if pmin > 0.0 {
1454                    let ph = e.dtoh(&p_d)?;
1455                    if let Some(fl) = ph[..kr].iter().position(|&p| p < pmin) {
1456                        kc = kc.min((fl + 1).max(fl_now.min(k_cap)));
1457                    }
1458                }
1459            }
1460        }
1461        eprintln!("[gemma-spec] rounds={rounds} drafted={drafted} accepted={accepted}                    accept-rate={:.3} tok/round={:.2}",
1462                  accepted as f64 / drafted.max(1) as f64,
1463                  out.len() as f64 / rounds.max(1) as f64);
1464        if let Some((used, budget)) = d.trim_adapt_stats() {
1465            eprintln!("[trim-adapt] {used}/{budget} spare slots learned");
1466            match d.trim_adapt_save() {
1467                Ok(n) if n > 0 => eprintln!("[trim-adapt] {n} new ids appended to the .learned sidecar"),
1468                Ok(_) => {}
1469                Err(err) => eprintln!("[trim-adapt] sidecar save failed: {err}"),
1470            }
1471        }
1472        if std::env::var("MEMRA_SPEC_STATS").as_deref() == Ok("1") {
1473            let hist: Vec<String> = (0..16).filter(|&j| pos_att[j] > 0)
1474                .map(|j| format!("p{j}:{}/{}", pos_acc[j], pos_att[j])).collect();
1475            eprintln!("[gemma-spec] per-position accept: {}", hist.join(" "));
1476        }
1477        Ok(out)
1478    }
1479}
1480
1481
1482impl HybridModel {
1483    /// PLAIN-DECODE CUDA-GRAPH loop (gemma4, greedy): one captured verify-trunk step
1484    /// (t=1, device tokens/pos/lens) replayed per token — the launch-gap eraser the
1485    /// decode decomposition demanded (2026-07-23: ~2.3ms/token idle at 128 launches).
1486    /// Self-feeding: argmax -> tok_d -> next embed; counters advance in-graph via
1487    /// spec_rollback_stream(base=1, acc=0). Tokens land in a device ring; ONE host sync
1488    /// per drain window. Captures are keyed on the (rung, window-side, f512-side) regime
1489    /// (the round-graph hint law); regime-crossing stretches run the same body eagerly.
1490    /// Caller guarantees: gemma4, greedy, shared_kv_layers == 0, prompt already primed
1491    /// (cache.pos = prompt len, host kvl.len mirrors set).
1492    pub fn gemma4_generate_plain_graph(
1493        &self,
1494        e: &Engine,
1495        cache: &mut Cache,
1496        last: u32,
1497        max_new: usize,
1498        eos: &[u32],
1499    ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
1500        const RING: usize = 64;
1501        const DRAIN: usize = 32;                 // replays per host sync
1502        let win_main = self.cfg.gemma4.as_ref().map(|g| g.sliding_window as usize).unwrap_or(0);
1503        let n_rows = cache.kv.len() + 1;
1504
1505        let was_tracking = e.ctx().is_event_tracking();
1506        if was_tracking { unsafe { e.ctx().disable_event_tracking(); } }
1507        let r = self.gemma4_plain_graph_inner(e, cache, last, max_new, eos,
1508                                              RING, DRAIN, win_main, n_rows);
1509        if was_tracking { unsafe { e.ctx().enable_event_tracking(); } }
1510        r
1511    }
1512
1513    #[allow(clippy::too_many_arguments)]
1514    fn gemma4_plain_graph_inner(
1515        &self,
1516        e: &Engine,
1517        cache: &mut Cache,
1518        last: u32,
1519        max_new: usize,
1520        eos: &[u32],
1521        ring_cap: usize,
1522        drain: usize,
1523        win_main: usize,
1524        n_rows: usize,
1525    ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
1526        let mut scr = self.verify_stream_scratch(e, 1)?;
1527        let mut tok_d = e.stream().alloc_zeros::<u32>(1)?;
1528        e.u32_set_k(&mut tok_d, last, 0)?;
1529        let pos_ctr = e.htod_i32(&[cache.pos as i32])?;
1530        let mut pos_start_d = e.htod_i32(&[cache.pos as i32])?;
1531        let acc0 = e.stream().alloc_zeros::<u32>(2)?;          // acc[0] = 0 -> counters +1
1532        let mut ring = e.stream().alloc_zeros::<u32>(ring_cap)?;
1533        let ptrs = crate::round_stream::kv_len_ptr_table(e, cache, Some(&pos_ctr))?;
1534        for kvl in cache.kv.iter_mut().flatten() {
1535            e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
1536        }
1537        let ring_base = cache.pos;                             // baked into every capture
1538
1539        let mut graphs: std::collections::HashMap<
1540            (usize, bool, bool),
1541            (cudarc::driver::CudaGraph, Vec<Box<dyn std::any::Any + Send>>),
1542        > = Default::default();
1543
1544        let mut out: Vec<u32> = Vec::with_capacity(max_new);
1545        let mut drained = 0usize;                              // tokens read off the ring
1546
1547        // hint law (round-graph): the arm-gating bound must sit on the SAME side of every
1548        // crossover as the live lengths this capture serves, with the arms' own margins.
1549        let hint_for = |pos: usize| -> usize {
1550            if pos > win_main { pos + drain + 2 }
1551            else if pos + 1 >= crate::fa512_min_tkv() { win_main.saturating_sub(2) }
1552            else { crate::fa512_min_tkv().saturating_sub(5) }
1553        };
1554        let regime_key = |pos: usize| -> (usize, bool, bool) {
1555            let rung = (pos + drain + 2).next_power_of_two().max(512);
1556            (rung, pos > win_main, pos + 1 >= crate::fa512_min_tkv())
1557        };
1558        // the whole [pos, pos+n) stretch must share one regime for a captured replay run.
1559        let stable_for = |pos: usize, n: usize| -> bool {
1560            regime_key(pos) == regime_key(pos + n)
1561                && (pos > win_main || pos + n + 2 < win_main)
1562                && (pos + 1 >= crate::fa512_min_tkv()
1563                    || pos + n + 2 < crate::fa512_min_tkv())
1564        };
1565
1566        while out.len() < max_new {
1567            let pos = cache.pos;
1568            let hint = hint_for(pos);
1569            let scr_ptr: *mut crate::hybrid_forward::VerifyStreamScratch = &mut scr;
1570            let cache_ptr: *mut Cache = cache as *mut Cache;
1571            let tok_ptr: *mut CudaSlice<u32> = &mut tok_d;
1572            let ring_ptr: *mut CudaSlice<u32> = &mut ring;
1573            let start_ptr: *mut CudaSlice<i32> = &mut pos_start_d;
1574            let step = |e: &Engine| -> Result<(), Box<dyn std::error::Error>> {
1575                // SAFETY: single-threaded body; raw pointers alias the outer &mut only here.
1576                let (scr, cache, tok_d, ring, pos_start_d) = unsafe {
1577                    (&mut *scr_ptr, &mut *cache_ptr, &mut *tok_ptr,
1578                     &mut *ring_ptr, &mut *start_ptr) };
1579                e.i32_copy_add(&pos_ctr, pos_start_d, 0)?;
1580                let (vam, _hn) = self.gemma4_verify_t_am_stream(
1581                    e, tok_d, 1, &pos_ctr, hint, cache, scr)?;
1582                e.u32_copy(&vam, tok_d)?;
1583                e.plain_tok_ring(&vam, pos_start_d, ring_base, ring)?;
1584                e.spec_rollback_stream(&ptrs, pos_start_d, &acc0, 1, n_rows)?;
1585                Ok(())
1586            };
1587
1588            let n_left = max_new - out.len();
1589            let burst = drain.min(n_left);
1590            // MEMRA_G4PLAIN_EAGER=1: run the body eagerly every step (no capture/replay) —
1591            // splits "body semantics wrong" from "replay mechanics wrong" (round-graph law).
1592            let force_eager = std::env::var("MEMRA_G4PLAIN_EAGER").as_deref() == Ok("1");
1593            let steps_done = if !force_eager && burst >= 4 && stable_for(pos, burst + 3) {
1594                let key = regime_key(pos);
1595                if !graphs.contains_key(&key) {
1596                    // capture cost = 3 SERVED steps (2 warmups + the captured run itself):
1597                    // the loop is self-feeding, so they are real tokens in the ring.
1598                    let g = e.capture_graph_retained(step)?;
1599                    graphs.insert(key, g);
1600                    3
1601                } else {
1602                    let (g, _keep) = graphs.get(&key).unwrap();
1603                    for _ in 0..burst { g.launch()?; }
1604                    burst
1605                }
1606            } else {
1607                step(e)?;                                     // eager fallback (same body)
1608                1
1609            };
1610
1611            // host mirrors + drain
1612            cache.pos += steps_done;
1613            for kvl in cache.kv.iter_mut().flatten() { kvl.len = cache.pos; }
1614            e.stream().synchronize()?;
1615            let ringh = e.dtoh_u32(&ring)?;
1616            let total = cache.pos - ring_base;
1617            while drained < total && out.len() < max_new {
1618                let t = ringh[drained % ring_cap];
1619                out.push(t);
1620                drained += 1;
1621                if eos.contains(&t) { return Ok(out); }
1622            }
1623        }
1624        Ok(out)
1625    }
1626}