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::Engine;
12use crate::cache::Cache;
13use crate::hybrid::HybridModel;
14use crate::model::GpuTensor;
15use cudarc::driver::CudaSlice;
16use memra_gguf::GgufFile;
17use memra_gguf::source::{GgufSource, TensorSource};
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(
85 &mut self,
86 e: &Engine,
87 tok: u32,
88 head: &mut GpuTensor,
89 d2t: &mut [u32],
90 d2t_dev: &mut CudaSlice<u32>,
91 ) -> Result<bool, Box<dyn std::error::Error>> {
92 let t = tok as usize;
93 if t >= self.n_vocab || self.present[t] {
94 return Ok(false);
95 }
96 if self.used == self.n_spare {
97 if !self.logged_full {
98 self.logged_full = true;
99 eprintln!(
100 "[trim-adapt] spare slots exhausted ({}) — later escapes stay unproposable",
101 self.n_spare
102 );
103 }
104 return Ok(false);
105 }
106 let slot = self.spare_base + self.used;
107 self.used += 1;
108 self.present[t] = true;
109 if let GpuTensor::Quant { bytes, .. } = head {
110 e.htod_u8_into(
111 bytes,
112 slot * self.row_bytes,
113 &self.src_rows[t * self.row_bytes..(t + 1) * self.row_bytes],
114 )?;
115 }
116 d2t[slot] = tok;
117 e.u32_set_k(d2t_dev, tok, slot)?;
118 Ok(true)
119 }
120}
121
122/// Union `toks` into the adaptive trim set (no-op when the draft has no adaptive state).
123/// Split-borrow helper: the fields move together or not at all.
124fn trim_adapt_learn(
125 e: &Engine,
126 d: &mut GemmaDraft,
127 toks: &[u32],
128) -> Result<(), Box<dyn std::error::Error>> {
129 let GemmaDraft {
130 trim_adapt,
131 head,
132 d2t,
133 d2t_dev,
134 ..
135 } = d;
136 let (Some(ta), Some(d2t), Some(d2t_dev)) =
137 (trim_adapt.as_mut(), d2t.as_mut(), d2t_dev.as_mut())
138 else {
139 return Ok(());
140 };
141 for &tok in toks {
142 ta.maybe_add(e, tok, head, d2t, d2t_dev)?;
143 }
144 Ok(())
145}
146
147impl GemmaDraft {
148 /// Adaptive-trim stats: (slots used, slot budget). None when adaptation is off.
149 pub fn trim_adapt_stats(&self) -> Option<(usize, usize)> {
150 self.trim_adapt.as_ref().map(|ta| (ta.used, ta.n_spare))
151 }
152
153 /// Persist the learned trim rows: append ids not yet in the sidecar to
154 /// `<ranks>.learned` (the load path pre-fills spare slots from it, so a distribution's
155 /// escapes pay their first-miss round ONCE across the serve lifetime, not per request).
156 pub fn trim_adapt_save(&self) -> std::io::Result<usize> {
157 let (Some(ta), Some(d2t), Some(path)) = (
158 self.trim_adapt.as_ref(),
159 self.d2t.as_ref(),
160 self.trim_learned_path(),
161 ) else {
162 return Ok(0);
163 };
164 let prior: std::collections::HashSet<u32> = std::fs::read_to_string(&path)
165 .map(|t| t.lines().filter_map(|l| l.trim().parse().ok()).collect())
166 .unwrap_or_default();
167 let fresh: Vec<u32> = d2t[ta.spare_base..ta.spare_base + ta.used]
168 .iter()
169 .copied()
170 .filter(|id| !prior.contains(id))
171 .collect();
172 if !fresh.is_empty() {
173 use std::io::Write;
174 let mut f = std::fs::OpenOptions::new()
175 .create(true)
176 .append(true)
177 .open(&path)?;
178 for id in &fresh {
179 writeln!(f, "{id}")?;
180 }
181 }
182 Ok(fresh.len())
183 }
184
185 fn trim_learned_path(&self) -> Option<String> {
186 std::env::var("MEMRA_GEMMA_DRAFT_RANKS")
187 .ok()
188 .map(|p| format!("{p}.learned"))
189 }
190}
191
192fn load_t(
193 e: &Engine,
194 src: &dyn TensorSource,
195 name: &str,
196) -> Result<GpuTensor, Box<dyn std::error::Error>> {
197 GpuTensor::load_from_source(e, src, name)
198}
199
200impl GemmaDraft {
201 pub fn load(e: &Engine, g: &GgufFile) -> Result<Self, Box<dyn std::error::Error>> {
202 // two published spellings of the same arch: the 26B/31B drafters ship
203 // "gemma4-assistant", the E4B assistant ships "gemma4_assistant" — the metadata
204 // key prefix follows the arch string verbatim.
205 let arch = match g.arch() {
206 Some(a @ ("gemma4-assistant" | "gemma4_assistant")) => a.to_string(),
207 other => panic!("not a gemma4-assistant drafter (arch {other:?})"),
208 };
209 let src = GgufSource(g);
210 let meta_u = |k: &str| -> u32 {
211 g.metadata
212 .get(&format!("{arch}.{k}"))
213 .and_then(|v| v.as_u64())
214 .unwrap_or(0) as u32
215 };
216 let meta_f = |k: &str, d: f32| -> f32 {
217 match g.metadata.get(&format!("{arch}.{k}")) {
218 Some(memra_gguf::MetaValue::F32(v)) => *v,
219 Some(memra_gguf::MetaValue::F64(v)) => *v as f32,
220 _ => d,
221 }
222 };
223 let n_layer = meta_u("block_count") as usize;
224 let n_embd = meta_u("embedding_length") as usize;
225 // 26B/31B carry the target width as embedding_length_out; the E4B assistant as
226 // n_embd_backbone.
227 let n_backbone = match meta_u("embedding_length_out") as usize {
228 0 => meta_u("n_embd_backbone") as usize,
229 v => v,
230 };
231 let hd_g = meta_u("attention.key_length") as usize;
232 let hd_s = meta_u("attention.key_length_swa") as usize;
233 let swa_pat: Vec<bool> = match g
234 .metadata
235 .get(&format!("{arch}.attention.sliding_window_pattern"))
236 {
237 Some(memra_gguf::MetaValue::Array(a)) => a
238 .iter()
239 .filter_map(|v| v.as_u64().map(|x| x != 0))
240 .collect(),
241 _ => return Err("drafter missing sliding_window_pattern".into()),
242 };
243
244 let mut layers = Vec::with_capacity(n_layer);
245 #[allow(clippy::needless_range_loop)]
246 // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
247 for il in 0..n_layer {
248 let p = |n: &str| format!("blk.{il}.{n}");
249 let swa = swa_pat[il];
250 let out_scale = {
251 let t = src
252 .find(&p("layer_output_scale.weight"))
253 .ok_or("missing layer_output_scale")?;
254 memra_gguf::dequant::dequantize(t.ggml_type, &t.bytes, 1)[0]
255 };
256 let hd = if swa { hd_s } else { hd_g };
257 let wq = load_t(e, &src, &p("attn_q.weight"))?;
258 // heads per layer from the projection shape (the E4B assistant keeps 4 heads on
259 // BOTH classes — hd differs — while 26B/31B are uniform; the shape is the truth).
260 let nh = wq.out_features() / hd;
261 layers.push(GemmaDraftLayer {
262 attn_norm: load_t(e, &src, &p("attn_norm.weight"))?,
263 wq,
264 wo: load_t(e, &src, &p("attn_output.weight"))?,
265 q_norm: load_t(e, &src, &p("attn_q_norm.weight"))?,
266 post_attn_norm: load_t(e, &src, &p("post_attention_norm.weight"))?,
267 ffn_norm: load_t(e, &src, &p("ffn_norm.weight"))?,
268 ffn_gate: load_t(e, &src, &p("ffn_gate.weight"))?,
269 ffn_up: load_t(e, &src, &p("ffn_up.weight"))?,
270 ffn_down: load_t(e, &src, &p("ffn_down.weight"))?,
271 ffn_post_norm: load_t(e, &src, &p("post_ffw_norm.weight"))?,
272 out_scale,
273 swa,
274 hd,
275 nh,
276 });
277 }
278 let rope_freqs = {
279 let t = src
280 .find("rope_freqs.weight")
281 .ok_or("drafter missing rope_freqs")?;
282 e.htod(&memra_gguf::dequant::dequantize(
283 t.ggml_type,
284 &t.bytes,
285 t.ne.iter().product::<u64>() as usize,
286 ))?
287 };
288 // FR-Spec head trim (MEMRA_GEMMA_DRAFT_RANKS=<ids file, rank order>): gather the ranked
289 // rows of the drafter head + d2t map. (Top-N-IDS truncation measured NEGATIVE — id
290 // order is not frequency; the CORPUS-ranked gather is the real FR-Spec.)
291 // MEMRA_GEMMA_TRIM_ADAPT=<n> (default 512 when ranks are set, 0 = off) appends n spare
292 // rows the serve loop fills from prompt ids + verify corrections (see TrimAdapt).
293 let (head, d2t, trim_adapt) = {
294 let t = src
295 .find("token_embd.weight")
296 .ok_or("drafter missing token_embd")?;
297 let in_f = t.ne[0] as usize;
298 let n_vocab = t.ne[1] as usize;
299 match std::env::var("MEMRA_GEMMA_DRAFT_RANKS").ok() {
300 Some(path) => {
301 // row gather is layout-agnostic given the per-row byte stride: Q4_0 (26B
302 // drafter) and Q8_0 (31B drafter) both ship 32-elem blocks row-major.
303 // (qtype, elems/block, bytes/block) — the gather is stride-agnostic.
304 let (qtype, blk_e, blk_b) = match t.ggml_type {
305 memra_gguf::GgmlType::Q4_0 => (crate::QT_Q4_0, 32, 18),
306 memra_gguf::GgmlType::Q8_0 => (crate::QT_Q8_0, 32, 34),
307 memra_gguf::GgmlType::Q6_K => (crate::QT_Q6_K, 256, 210),
308 other => panic!("drafter head trim: unsupported head type {other:?}"),
309 };
310 let ids: Vec<u32> = std::fs::read_to_string(&path)?
311 .lines()
312 .filter_map(|l| l.trim().parse().ok())
313 .filter(|&id| (id as usize) < n_vocab)
314 .collect();
315 let n_spare: usize = std::env::var("MEMRA_GEMMA_TRIM_ADAPT")
316 .ok()
317 .and_then(|v| v.parse().ok())
318 .unwrap_or(512);
319 let row_bytes = in_f / blk_e * blk_b;
320 let mut gathered = Vec::with_capacity((ids.len() + n_spare) * row_bytes);
321 for &id in &ids {
322 let off = id as usize * row_bytes;
323 gathered.extend_from_slice(&t.bytes[off..off + row_bytes]);
324 }
325 // spare slots start as copies of row ids[0] mapping to ids[0] — a real,
326 // already-present token, so however the argmax resolves the duplicate-
327 // logit tie, the d2t translation lands on the same token id.
328 for _ in 0..n_spare {
329 let off = ids[0] as usize * row_bytes;
330 gathered.extend_from_slice(&t.bytes[off..off + row_bytes]);
331 }
332 eprintln!(
333 "[gemma-draft] FR head trim: {} rows + {} adaptive ({} MB vs {} MB full)",
334 ids.len(),
335 n_spare,
336 (ids.len() + n_spare) * row_bytes / 1_000_000,
337 n_vocab * row_bytes / 1_000_000
338 );
339 let mut trim_adapt = (n_spare > 0).then(|| {
340 let mut present = vec![false; n_vocab];
341 for &id in &ids {
342 present[id as usize] = true;
343 }
344 TrimAdapt {
345 src_rows: t.bytes.to_vec(),
346 row_bytes,
347 n_vocab,
348 present,
349 spare_base: ids.len(),
350 n_spare,
351 used: 0,
352 logged_full: false,
353 }
354 });
355 let mut d2t = ids;
356 let spare_fill = d2t[0];
357 d2t.extend(std::iter::repeat_n(spare_fill, n_spare));
358 // pre-fill spare slots from the learned sidecar (trim_adapt_save):
359 // prior serves' escapes are proposable from round 1 of THIS serve.
360 if let Some(ta) = trim_adapt.as_mut() {
361 let learned: Vec<u32> = std::fs::read_to_string(format!("{path}.learned"))
362 .map(|t| t.lines().filter_map(|l| l.trim().parse().ok()).collect())
363 .unwrap_or_default();
364 let mut n_pre = 0usize;
365 for id in learned {
366 let i = id as usize;
367 if i < n_vocab && !ta.present[i] && ta.used < ta.n_spare {
368 let slot = ta.spare_base + ta.used;
369 ta.used += 1;
370 ta.present[i] = true;
371 let off = i * row_bytes;
372 gathered[slot * row_bytes..(slot + 1) * row_bytes]
373 .copy_from_slice(&t.bytes[off..off + row_bytes]);
374 d2t[slot] = id;
375 n_pre += 1;
376 }
377 }
378 if n_pre > 0 {
379 eprintln!(
380 "[trim-adapt] {n_pre} learned rows pre-filled from {path}.learned"
381 );
382 }
383 }
384 // upload AFTER the sidecar pre-fill wrote its rows into `gathered`.
385 let bytes = e.htod_bytes(&gathered)?;
386 (
387 GpuTensor::Quant {
388 bytes,
389 qtype,
390 row_bytes,
391 ne: vec![in_f as u64, d2t.len() as u64],
392 scale: 1.0,
393 rp: false,
394 #[cfg(memra_cutlass)]
395 cutlass: None,
396 fp8: None,
397 blk: None,
398 rp4: None,
399 f16: None,
400 },
401 Some(d2t),
402 trim_adapt,
403 )
404 }
405 None => (load_t(e, &src, "token_embd.weight")?, None, None),
406 }
407 };
408 // Q4_0 split-plane decode mirrors (MEMRA_Q4RP, same as the main trunk — see hybrid.rs):
409 // the draft chain is 3 serial mmvq trips/round; the head alone is ~137MB/draft.
410 // projection tensor prefix: 26B/31B "nextn.", the E4B assistant "mtp.".
411 let proj_prefix = if src.find("nextn.pre_projection.weight").is_some() {
412 "nextn"
413 } else {
414 "mtp"
415 };
416 let (mut pre_proj, mut post_proj) = (
417 load_t(e, &src, &format!("{proj_prefix}.pre_projection.weight"))?,
418 load_t(e, &src, &format!("{proj_prefix}.post_projection.weight"))?,
419 );
420 let mut head = head;
421 let mut layers = layers;
422 if crate::Engine::q4rp_enabled() {
423 // adaptive-trim heads skip the split-plane mirror: the mmvq _rp twins read the
424 // MIRROR, so an in-place row learn on `bytes` would be invisible to the matmul.
425 let head_ws: &mut [&mut GpuTensor] = if trim_adapt.is_some() {
426 &mut [&mut pre_proj, &mut post_proj]
427 } else {
428 &mut [&mut pre_proj, &mut post_proj, &mut head]
429 };
430 for w in head_ws.iter_mut() {
431 e.build_q4_rp4(w)?;
432 }
433 for l in layers.iter_mut() {
434 for w in [
435 &mut l.wq,
436 &mut l.wo,
437 &mut l.ffn_gate,
438 &mut l.ffn_up,
439 &mut l.ffn_down,
440 ] {
441 e.build_q4_rp4(w)?;
442 }
443 }
444 }
445 let d2t_dev = match &d2t {
446 Some(m) => Some(e.stream().clone_htod(&m[..])?),
447 None => None,
448 };
449 Ok(GemmaDraft {
450 layers,
451 pre_proj,
452 post_proj,
453 output_norm: load_t(e, &src, "output_norm.weight")?,
454 head,
455 d2t,
456 d2t_dev,
457 trim_adapt,
458 rope_freqs,
459 ones: e.htod(&[1.0f32; 512])?,
460 n_embd,
461 n_backbone,
462 rope_base_global: meta_f("rope.freq_base", 1e6),
463 rope_base_swa: meta_f("rope.freq_base_swa", 1e4),
464 sliding_window: meta_u("attention.sliding_window") as usize,
465 })
466 }
467}
468
469impl HybridModel {
470 /// The MAIN layer whose KV cache a drafter layer attends (llama-model.cpp:2139):
471 /// the last OWN-KV layer of the class — `boundary - 2` windowed / `boundary - 1`
472 /// global, where boundary = n_layer - shared_kv_layers. Shared across every
473 /// gemma4-assistant drafter (26B/31B: boundary = n_layer; E4B: 24).
474 pub(crate) fn gemma4_draft_kv_target(&self, swa: bool) -> usize {
475 let shared = self
476 .cfg
477 .gemma4
478 .as_ref()
479 .map(|g| g.shared_kv_layers as usize)
480 .unwrap_or(0);
481 let boundary = self.layers.len() - shared;
482 boundary - if swa { 2 } else { 1 }
483 }
484
485 /// One drafter step: (token, h[2816 device]) at absolute position `pos` over the FROZEN main
486 /// cache. Returns (draft logits host [n_vocab], h_next [2816 device]).
487 pub fn gemma4_draft_step(
488 &self,
489 e: &Engine,
490 d: &GemmaDraft,
491 token: u32,
492 h: &CudaSlice<f32>,
493 pos: usize,
494 cache: &Cache,
495 ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
496 let (hn, h_next) = self.gemma4_draft_trunk(e, d, token, h, pos, cache)?;
497 let logits = e.dtoh(&e.matmul(&d.head, &hn, 1)?)?;
498 Ok((logits, h_next))
499 }
500
501 /// Drafter trunk with the token in DEVICE memory (a 1-elem view of the round's batch
502 /// buffer) — zero host traffic.
503 #[allow(clippy::too_many_arguments)] // allow: the parameter list mirrors the kernel/FFI/call contract; bundling into a struct is a refactor, not a lint fix
504 fn gemma4_draft_trunk_dev(
505 &self,
506 e: &Engine,
507 d: &GemmaDraft,
508 tok_v: &cudarc::driver::CudaView<u32>,
509 h: &CudaSlice<f32>,
510 pos_d: &CudaSlice<i32>,
511 cache: &Cache,
512 dc_bucket: Option<usize>,
513 ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
514 let nb = d.n_backbone;
515 let embd_gpu = self
516 .embd_gpu
517 .get_or_init(|| e.upload_u8(&self.embd.raw).expect("embed table upload"));
518 let (qt, rb) = self.embd.qt_and_row_bytes(nb);
519 let mut xs = e.embed_gather_device_tv(embd_gpu, tok_v, 1, nb, qt, rb)?;
520 e.scale_inplace(&mut xs, (nb as f32).sqrt(), nb)?;
521 self.gemma4_draft_trunk_from_x(e, d, &xs, h, pos_d, cache, dc_bucket)
522 }
523
524 /// Drafter trunk: returns (post-output_norm hidden [1024], h_next [2816]).
525 fn gemma4_draft_trunk(
526 &self,
527 e: &Engine,
528 d: &GemmaDraft,
529 token: u32,
530 h: &CudaSlice<f32>,
531 pos: usize,
532 cache: &Cache,
533 ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
534 let nb = d.n_backbone;
535 let mut xs = e.htod(&self.embd.try_gather(nb, &[token])?)?;
536 e.scale_inplace(&mut xs, (nb as f32).sqrt(), nb)?;
537 let pos_d = e.htod_i32(&[pos as i32])?;
538 self.gemma4_draft_trunk_from_x(e, d, &xs, h, &pos_d, cache, None)
539 }
540
541 /// Trunk body from the pre-scaled main-embed row.
542 #[allow(clippy::too_many_arguments)] // allow: the parameter list mirrors the kernel/FFI/call contract; bundling into a struct is a refactor, not a lint fix
543 fn gemma4_draft_trunk_from_x(
544 &self,
545 e: &Engine,
546 d: &GemmaDraft,
547 xs: &CudaSlice<f32>,
548 h: &CudaSlice<f32>,
549 pos_d: &CudaSlice<i32>,
550 cache: &Cache,
551 dc_bucket: Option<usize>,
552 ) -> Result<(CudaSlice<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
553 // pos rides a DEVICE slot (burst-arc step a, 2026-07-12): the round fills persistent
554 // slots via set_i32_one (kernel-arg stores — no per-step htod/alloc) and the chain
555 // becomes graph-capturable (an in-graph i32_copy_add can feed the slots later).
556 let eps = self.cfg.rms_eps;
557 let ne = d.n_embd;
558
559 // xh = concat(x, h) [2*n_backbone]
560 let nb = d.n_backbone;
561 let mut xh = e.uninit(2 * nb)?;
562 e.copy_into(&mut xh, 0, xs, nb)?;
563 e.copy_into(&mut xh, nb, h, nb)?;
564
565 let mut cur = e.matmul(&d.pre_proj, &xh, 1)?; // [1024]
566
567 for dl in d.layers.iter() {
568 // attention over the shared MAIN KV: swa -> the last OWN-KV windowed layer,
569 // global -> the last OWN-KV global layer (llama-model.cpp:2139 rule). Plain
570 // 26B/31B trunks have no shared tail, so this is n-2 / n-1 there; E4B's 18
571 // KV-shared tail layers move the boundary to 24 -> targets 22 (swa) / 23.
572 let main_il = self.gemma4_draft_kv_target(dl.swa);
573 let kvl = cache.kv[main_il].as_ref().unwrap();
574 let (hd, nhh) = (dl.hd, dl.nh);
575 let nkv = kvl.kv_dim_k / hd;
576 let base = if dl.swa {
577 d.rope_base_swa
578 } else {
579 d.rope_base_global
580 };
581
582 let mut hn = e.uninit(ne)?;
583 e.rms_norm(&cur, dl.attn_norm.float_data(), &mut hn, ne, 1, eps)?;
584 let q0 = e.matmul(&dl.wq, &hn, 1)?;
585 let mut q = e.uninit(nhh * hd)?;
586 e.rms_norm(&q0, dl.q_norm.float_data(), &mut q, hd, nhh, eps)?;
587 if dl.swa {
588 e.rope_neox(&mut q, pos_d, hd, hd, nhh, 1, base, 1.0)?;
589 } else {
590 e.rope_neox_ff(&mut q, pos_d, hd, hd, nhh, 1, base, 1.0, &d.rope_freqs)?;
591 }
592 let avail = kvl.len;
593 let win = d.sliding_window;
594 let mut attn = e.uninit(nhh * hd)?;
595 // drafter attends the MAIN cache — its format follows the main layer's class
596 // (windowed L28 = wkv arm, global L29 = gkv arm; gkv routing is hd-keyed inside).
597 // DEVICE-LEN arms (burst arc): the length rides the main layer's len_d counter
598 // so the chain is replay-correct across rounds. dc_bucket = the RUNG the round
599 // derived (power-of-2, shared by eager and captured replays — same n_splits,
600 // same combine order; the main graph arc's bucket lesson). None = host-len arm.
601 if let Some(bucket) = dc_bucket {
602 let k_view = e.view_u8(&kvl.k, kvl.k.len());
603 let v_view = e.view_u8(&kvl.v, kvl.v.len());
604 if dl.swa && avail > win {
605 e.fa_decode_rows_w(
606 &q,
607 &k_view,
608 &v_view,
609 &mut attn,
610 hd,
611 nhh,
612 nkv,
613 &kvl.len_d,
614 -1,
615 1,
616 1.0,
617 win,
618 kvl.k_tok_bytes,
619 kvl.v_tok_bytes,
620 None,
621 )?;
622 } else {
623 e.fa_decode_dc(
624 &q,
625 &k_view,
626 &v_view,
627 &mut attn,
628 hd,
629 nhh,
630 nkv,
631 &kvl.len_d,
632 bucket,
633 1.0,
634 kvl.k_tok_bytes,
635 kvl.v_tok_bytes,
636 dl.swa && crate::Engine::wkv_on(),
637 )?;
638 }
639 } else {
640 let (off_tok, t_kv) = if dl.swa && avail > win {
641 (avail - win, win)
642 } else {
643 (0, avail)
644 };
645 let k_view = e.view_u8_range(
646 &kvl.k,
647 off_tok * kvl.k_tok_bytes,
648 (off_tok + t_kv) * kvl.k_tok_bytes,
649 );
650 let v_view = e.view_u8_range(
651 &kvl.v,
652 off_tok * kvl.v_tok_bytes,
653 (off_tok + t_kv) * kvl.v_tok_bytes,
654 );
655 e.fa_decode_kvmod(
656 &q,
657 &k_view,
658 &v_view,
659 &mut attn,
660 hd,
661 nhh,
662 nkv,
663 t_kv,
664 1.0,
665 kvl.k_tok_bytes,
666 kvl.v_tok_bytes,
667 dl.swa && crate::Engine::wkv_on(),
668 )?;
669 }
670 let o = e.matmul(&dl.wo, &attn, 1)?;
671
672 let mut post = e.uninit(ne)?;
673 e.rms_norm(&o, dl.post_attn_norm.float_data(), &mut post, ne, 1, eps)?;
674 let mut attn_out = e.uninit(ne)?;
675 e.add(&post, &cur, &mut attn_out, ne)?;
676
677 let mut z = e.uninit(ne)?;
678 e.rms_norm(&attn_out, dl.ffn_norm.float_data(), &mut z, ne, 1, eps)?;
679 let n_ff = dl.ffn_gate.out_features();
680 let gate = e.matmul(&dl.ffn_gate, &z, 1)?;
681 let up = e.matmul(&dl.ffn_up, &z, 1)?;
682 let mut act = e.uninit(n_ff)?;
683 e.gelu_tanh_mul(&gate, &up, &mut act, n_ff)?;
684 let f0 = e.matmul(&dl.ffn_down, &act, 1)?;
685 let mut fpost = e.uninit(ne)?;
686 e.rms_norm(&f0, dl.ffn_post_norm.float_data(), &mut fpost, ne, 1, eps)?;
687 let mut xn = e.uninit(ne)?;
688 e.add_scale(&fpost, &attn_out, dl.out_scale, &mut xn, ne)?;
689 cur = xn;
690 }
691
692 let mut hn = e.uninit(ne)?;
693 e.rms_norm(&cur, d.output_norm.float_data(), &mut hn, ne, 1, eps)?;
694 let h_next = e.matmul(&d.post_proj, &hn, 1)?; // [2816]; head applied by callers (NO softcap)
695 Ok((hn, h_next))
696 }
697
698 /// Greedy draft step: like gemma4_draft_step but the token argmax stays on device —
699 /// host sees 4 bytes (no 1MB logits dtoh per draft). Returns (token, h_next).
700 pub fn gemma4_draft_step_greedy(
701 &self,
702 e: &Engine,
703 d: &GemmaDraft,
704 token: u32,
705 h: &CudaSlice<f32>,
706 pos: usize,
707 cache: &Cache,
708 ) -> Result<(u32, CudaSlice<f32>), Box<dyn std::error::Error>> {
709 let (hn, h_next) = self.gemma4_draft_trunk(e, d, token, h, pos, cache)?;
710 let ld = e.matmul(&d.head, &hn, 1)?;
711 let tok_d = e.argmax_token_device(&ld, d.head.out_features())?;
712 let idx = e.dtoh_u32(&tok_d)?[0];
713 let tok = match &d.d2t {
714 Some(map) => map[idx as usize],
715 None => idx,
716 };
717 Ok((tok, h_next))
718 }
719}
720
721impl HybridModel {
722 /// gemma4 MTP greedy spec loop: prime the prompt, then rounds of (chained K-token draft
723 /// over the frozen main cache) + (ONE batched verify) + longest-prefix accept + KV rollback.
724 /// Returns generated tokens; prints acceptance stats.
725 #[allow(clippy::too_many_arguments)]
726 #[allow(clippy::unnecessary_unwrap)] // allow: the Some-guards sit in multi-clause regime gates; if-let would reshape the arm structure
727 #[allow(clippy::map_entry)] // allow: the init bodies are fallible (`?`); Entry::or_insert_with cannot propagate errors
728 pub fn generate_spec_gemma(
729 &self,
730 e: &Engine,
731 d: &mut GemmaDraft,
732 prompt: &[u32],
733 max_new: usize,
734 k: usize,
735 eos: &[u32],
736 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
737 let n_embd = self.cfg.n_embd as usize;
738 let eps = self.cfg.rms_eps;
739 let mut cache = Cache::new(e, &self.cfg, prompt.len() + max_new + k + 8)?;
740
741 // Adaptive trim, learn point 1: the PROMPT's own ids — the measured escapees are the
742 // prompt's domain content words echoed back (▁oceans, clouds, Explain...), so the
743 // prompt is the cheapest predictor of what the trim is about to miss.
744 trim_adapt_learn(e, d, prompt)?;
745
746 let t_prime = std::time::Instant::now();
747 // short prompts fall below prime_cache's T floor — the batched verify IS a prime.
748 let (pl, h_seed) = if prompt.len() >= crate::hybrid_forward::PRIME_MIN_T {
749 let (l, hs, _hh) = self.prime_cache(e, prompt, &mut cache, 0)?;
750 (l, hs)
751 } else if self.is_gemma4_e4b() {
752 // E4B short-prompt prime: TOKENWISE — the batched e4b trunk at base_len==0
753 // rides the PRIME-FA f32 arm (a different numerics class from the plain arm's
754 // tokenwise prime), and the class skew flipped near-tie streams (3/64,
755 // 2026-07-13). decode_step_h is the same chain the plain arm primes with.
756 let n_embd_ = self.cfg.n_embd as usize;
757 let mut ll = Vec::new();
758 let mut hx = e.zeros(n_embd_)?;
759 for &tok in prompt {
760 let (l, hh) = self.gemma4_e4b_decode_step_h(e, tok, &mut cache)?;
761 ll = l;
762 hx = hh;
763 }
764 // decode_step_h returns the PRE-output_norm hidden; the short-prompt arm's
765 // h convention below is POST-norm — norm here.
766 let mut hp = e.uninit(n_embd_)?;
767 e.rms_norm(&hx, self.output_norm.float_data(), &mut hp, n_embd_, 1, eps)?;
768 (ll, hp)
769 } else {
770 let n_vocab = self.output.out_features();
771 let (lv, hv) = self.gemma4_decode_step_t_h(e, prompt, 0, &mut cache)?;
772 let t = prompt.len();
773 let last = lv[(t - 1) * n_vocab..t * n_vocab].to_vec();
774 // NOTE hv rows are POST-output_norm; h_seed convention below expects PRE-norm and
775 // re-norms — so recover a pre-norm-free path: use the post-norm row DIRECTLY.
776 let hvv = e.view(&hv, t * n_embd);
777 let row = hvv.slice((t - 1) * n_embd..t * n_embd);
778 let mut hrow = e.uninit(n_embd)?;
779 e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
780 // mark: already post-norm — skip the re-norm below via the flag
781 (last, hrow)
782 };
783 e.stream().synchronize()?;
784 crate::PRIME_NANOS.store(
785 t_prime.elapsed().as_nanos() as u64,
786 std::sync::atomic::Ordering::Relaxed,
787 );
788 // drafter h = POST-output_norm hidden (llama h_nextn); prime returns PRE-norm h_seed,
789 // the short-prompt verify path already returns post-norm rows.
790 let mut h = if prompt.len() >= crate::hybrid_forward::PRIME_MIN_T {
791 let mut hh = e.uninit(n_embd)?;
792 e.rms_norm(
793 &h_seed,
794 self.output_norm.float_data(),
795 &mut hh,
796 n_embd,
797 1,
798 eps,
799 )?;
800 hh
801 } else {
802 h_seed
803 };
804
805 let mut last = crate::forward::argmax(&pl) as u32;
806 // MEMRA_PROFILE_SPEC=2: capture starts at the ROUND LOOP (prime excluded) — pair
807 // with `nsys -c cudaProfilerApi` (the qwen loop's pattern, spec.rs).
808 if std::env::var("MEMRA_PROFILE_SPEC").as_deref() == Ok("2") {
809 unsafe extern "C" {
810 fn cudaProfilerStart() -> i32;
811 }
812 unsafe {
813 cudaProfilerStart();
814 }
815 }
816 let mut out: Vec<u32> = Vec::with_capacity(max_new);
817 let (mut drafted, mut accepted, mut rounds) = (0usize, 0usize, 0usize);
818 // per-position accept histogram (MEMRA_SPEC_STATS): [attempted, accepted] per slot —
819 // the depth-K policy statistic (deep slots' marginal accept decides fixed-cap vs deep).
820 let mut pos_att = [0usize; 16];
821 let mut pos_acc = [0usize; 16];
822
823 // ASYNC ROUND v2 (dc class): the whole draft chain + verify enqueue with ZERO host
824 // syncs — token seeds via kernel-arg store (u32_set_k, no host-memory transfer), draft
825 // argmaxes land in the batch buffer, verify argmaxes in vam_d; ONE pack + ONE dtoh of
826 // (k drafts + k+1 vam) closes the round. (v1 with memcpy_htod seeding measured
827 // NEGATIVE — the pageable-copy sync; this is the retry with the sync removed.)
828 let mut batch_d = e.stream().alloc_zeros::<u32>(k + 1)?;
829 let mut packed = e.stream().alloc_zeros::<u32>(2 * k + 1)?;
830 // confidence-adaptive depth (MEMRA_SPEC_PMIN, default 0 = off): per-draft probs.
831 let pmin: f32 = std::env::var("MEMRA_SPEC_PMIN")
832 .ok()
833 .and_then(|v| v.parse().ok())
834 .unwrap_or(0.0);
835 // IN-ROUND confidence cut (2026-07-28): llama's draft-mtp stops drafting the
836 // moment a draft's top-1 prob falls below p-min; our MEMRA_SPEC_PMIN is one round
837 // LATE by design (zero-sync round). This arm pays one small dtoh sync per draft
838 // step (steps ~150µs; sync ~15µs) to cut the chain mid-round and verify at the
839 // shrunk width. Eager arm only — burst/graph arms draft fixed depth.
840 // DEFAULT is SELF-KEYED: active at depth (pos >= floor_ctx) and only in rounds
841 // following a MISS — measured: depth cells with sub-0.9 acceptance win (26B
842 // +1.4-3.2% @ 0.868-0.882 accept, 31B +2% @ 0.845-0.883), chat cells and the
843 // 0.95-accept 12B depth lose under an ALWAYS-on cut (-0.9 to -6%) but their
844 // rounds are mostly full-accept so the self-key idles there. Explicit
845 // MEMRA_SPEC_PMIN_INROUND pins the cut at every position/round; =0 disables.
846 let pmin_ir_env: Option<f32> = std::env::var("MEMRA_SPEC_PMIN_INROUND")
847 .ok()
848 .and_then(|v| v.parse().ok());
849 const PMIN_IR_DEFAULT: f32 = 0.7;
850 let mut prev_full = true; // round 1: no miss evidence yet — draft at full depth
851 let mut p_d = e.stream().alloc_zeros::<f32>(k.max(1))?;
852
853 // ADAPTIVE DRAFT LENGTH (default ON 2026-07-10; MEMRA_SPEC_ADAPT=0 reverts): llama's
854 // draft-mtp reaches 0.64-0.70 acceptance on the SAME drafter (ours fixed-K: 0.52) by
855 // drafting fewer tokens when unconfident (p-min gate). Zero-sync host proxy: next
856 // round's depth = last round's accepted run + 1, clamped to [floor=1, k] — rounds
857 // after a miss shrink, streaks re-deepen. The round's ONE dtoh already carries the
858 // acceptance; no new syncs. Policy sweep (short chat, N=1 each): floor1/cap3 239.2
859 // vs fixed-K3 231.1 (+3.5%, accept .52->.58); floor2 and cap4/5 all worse.
860 let adapt = std::env::var("MEMRA_SPEC_ADAPT").as_deref() != Ok("0");
861 // ADAPTIVE FLOOR default is per-model (MEMRA_SPEC_ADAPT_FLOOR overrides): the floor-1
862 // policy collapses to shallow drafts after any miss and pays a slow re-deepen; on
863 // models with an expensive verify step the deep-draft upside dwarfs the wasted-draft
864 // cost. Measured 2026-07-25 (chat cell, own-gen trim; peak grids both models):
865 // 31B K=5 floor=4 120.2 vs floor=1 103.8 (+15.7%, N=3; floor 5-6 falls off);
866 // 12B K=4-5 floor=4 240.5-240.8 vs floor=1 200.6 (+20%, floor 5+ falls off).
867 // The floor clamps to k_cap, so shallow-K callers are unaffected.
868 // 26B tier (2026-07-26 re-sweep under the f16pv spec flip): floor=2 wins BOTH its
869 // cells — short 329.5 vs 307.0 floor1 (+7%, best at every K), depth 329.7 vs ~318
870 // (the 2026-07-10 "floor2 worse" verdict predates the flip and is superseded).
871 // E4B (n_embd < 2500) keeps floor=1 — unmeasured, cheap verify.
872 let adapt_floor_default: usize = if self.cfg.n_embd >= 3500 {
873 4
874 } else if self.cfg.n_embd >= 2500 {
875 2
876 } else {
877 1
878 };
879 // (stream-k spec key lives in HybridModel::load_from_source_impl — it must be set
880 // before the PRIME's GEMMs autotune, not here.)
881 let adapt_floor_env: Option<usize> = std::env::var("MEMRA_SPEC_ADAPT_FLOOR")
882 .ok()
883 .and_then(|v| v.parse().ok());
884 let adapt_floor: usize = adapt_floor_env.unwrap_or(adapt_floor_default);
885 // POSITION KEY (2026-07-26): the HIGH floor is a SHORT-CTX win. At depth the
886 // per-position acceptance is lower and FORCED-DEEP drafts turn net-negative:
887 // 31B d1736 floor4 99-101 and floor2 97.4-99.8 @ 0.758-0.778 vs floor1
888 // 103.8-104.2 @ 0.817 (two perf-ci batteries + flip-tree N=2 — floor2 is a REAL
889 // small loss there, not noise), while its chat cell holds +15-20% under floor4.
890 // The 26B is the opposite at depth: its mild floor2 WINS (304-305 vs ~297).
891 // Default: full floor while pos < floor_ctx; past it HIGH-floor models (>=4)
892 // relax to 1, MILD-floor models keep their floor. MEMRA_SPEC_FLOOR_CTX overrides
893 // the boundary; an explicit MEMRA_SPEC_ADAPT_FLOOR pins the floor everywhere.
894 let floor_ctx: usize = std::env::var("MEMRA_SPEC_FLOOR_CTX")
895 .ok()
896 .and_then(|v| v.parse().ok())
897 .unwrap_or(1024);
898 let floor_at = |pos: usize| -> usize {
899 if adapt_floor_env.is_some() || pos < floor_ctx {
900 adapt_floor
901 } else if adapt_floor >= 4 {
902 1
903 } else {
904 adapt_floor
905 }
906 };
907 // cap ceiling 7 by default; MEMRA_SPEC_CAPMAX opens the b16 verify tier (t=9..16).
908 // The historical cap>=8 "crash" was two host bugs, both fixed 2026-07-12: round 1
909 // ran UNCLAMPED (`kc = k` — verify t=K+1 entered the b16 tier while it was gated)
910 // and the b16 dispatch requested _r2 twins that were never compiled (mcols==16 now
911 // forces the base variant). Stream gates arbitrate any raised cap.
912 let cap_max: usize = std::env::var("MEMRA_SPEC_CAPMAX")
913 .ok()
914 .and_then(|v| v.parse().ok())
915 .unwrap_or(7);
916 let k_cap = k.min(cap_max).max(1);
917 // DRAFT-CHAIN GRAPHS (burst-arc step c, MEMRA_GEMMA_DRAFT_GRAPH=1): the whole k-step
918 // draft chain replays as ONE captured graph — position slots fill in-graph,
919 // the seed hidden rides the persistent g_seed buffer, KV lengths ride len_d (step b).
920 // Keyed on (kr, rung, over_win): a new depth/rung/window regime captures lazily.
921 let graph_on = std::env::var("MEMRA_GEMMA_DRAFT_GRAPH").as_deref() == Ok("1");
922 #[allow(clippy::type_complexity)]
923 // allow: one-shot composite type; naming it would hide the shape that matters at the call site
924 let mut draft_graphs: std::collections::HashMap<
925 (usize, usize, bool),
926 (
927 cudarc::driver::CudaGraph,
928 Vec<Box<dyn std::any::Any + Send>>,
929 ),
930 > = Default::default();
931 let mut g_seed = e.zeros(n_embd)?;
932 // seed len_d before round 1 (prime went through the host-len path).
933 for kvl in cache.kv.iter_mut().flatten() {
934 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
935 }
936 // persistent per-step rope-pos slots (device; filled by set_i32_one kernel-arg stores).
937 let mut pos_slots: Vec<CudaSlice<i32>> = (0..k_cap.max(1))
938 .map(|_| e.htod_i32(&[0]))
939 .collect::<Result<_, _>>()?;
940 // clamp round 1 too (the leak above).
941 let mut kc = k_cap;
942 // round-graph arm state (MEMRA_GEMMA_ROUND_GRAPH): stream buffers, the fill dummy, the
943 // kv-len pointer table and the verify scratch, allocated on first use.
944 let mut burst_state: Option<(
945 crate::round_stream::StreamBufs,
946 CudaSlice<f32>,
947 CudaSlice<u64>,
948 crate::hybrid_forward::VerifyStreamScratch,
949 )> = None;
950 let win_main = self
951 .cfg
952 .gemma4
953 .as_ref()
954 .map(|g| g.sliding_window as usize)
955 .unwrap_or(0);
956 let g4_shared = self
957 .cfg
958 .gemma4
959 .as_ref()
960 .map(|g| g.shared_kv_layers)
961 .unwrap_or(0);
962 'outer: while out.len() < max_new {
963 let mut kr = if adapt { kc } else { k_cap };
964 // power-of-2 rung bucket for the dc arms (shared by eager and captured replays);
965 // MEMRA_GEMMA_DRAFT_DC=0 reverts to the host-len kvmod arm.
966 let dc_bucket: Option<usize> = {
967 static DC: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
968 if *DC.get_or_init(|| std::env::var("MEMRA_GEMMA_DRAFT_DC").as_deref() != Ok("0")) {
969 let ml = cache
970 .kv
971 .iter()
972 .flatten()
973 .map(|kv| kv.len)
974 .max()
975 .unwrap_or(1);
976 Some((ml + k_cap + 2).next_power_of_two().max(512))
977 } else {
978 None
979 }
980 };
981 e.u32_set_k(&mut batch_d, last, 0)?;
982 e.copy_into(&mut g_seed, 0, &h, n_embd)?;
983 // the draft chain, step j: reads g_seed via the hc chain, pos from pos_slots[j]
984 // (eager: host-filled; graph: filled in-graph).
985 let run_chain = |e: &Engine,
986 d: &GemmaDraft,
987 batch_d: &mut CudaSlice<u32>,
988 p_d: &mut CudaSlice<f32>,
989 g_seed: &CudaSlice<f32>,
990 pos_slots: &Vec<CudaSlice<i32>>,
991 inround: f32|
992 -> Result<usize, Box<dyn std::error::Error>> {
993 // uninit+copy (NOT clone_dtod): clone_dtod's internal alloc bypasses the
994 // capture-retain hooks — its address got pool-reused between replays and the
995 // replayed chain read a corrupted seed (accept 0.52 vs 0.76).
996 let mut hc = e.uninit(n_embd)?;
997 e.copy_into(&mut hc, 0, g_seed, n_embd)?;
998 for j in 0..kr {
999 let tv = batch_d.slice(j..j + 1);
1000 let (hn, h_next) = self.gemma4_draft_trunk_dev(
1001 e,
1002 d,
1003 &tv,
1004 &hc,
1005 &pos_slots[j],
1006 &cache,
1007 dc_bucket,
1008 )?;
1009 let ld = e.matmul(&d.head, &hn, 1)?;
1010 e.argmax_token_device_col(&ld, 0, d.head.out_features(), batch_d, j + 1)?;
1011 // confidence-adaptive depth (MEMRA_SPEC_PMIN): TRIM-space prob before d2t.
1012 if pmin > 0.0 || inround > 0.0 {
1013 e.prob_of_token_device_col(
1014 &ld,
1015 batch_d,
1016 j + 1,
1017 p_d,
1018 j,
1019 d.head.out_features(),
1020 )?;
1021 }
1022 // FR-trimmed head: translate the trim-space argmax to the vocab id.
1023 if let Some(map) = &d.d2t_dev {
1024 e.u32_map_k(batch_d, map, j + 1)?;
1025 }
1026 hc = h_next;
1027 // IN-ROUND cut: one small dtoh sync per step; stop drafting the moment
1028 // confidence falls below the gate and verify at the shrunk width.
1029 // (A DSpark-class marginal-rate window — S_{j+1}*T(j) > E[tok](j)*t_d
1030 // with profiled t_draft/t_verify EMAs — measured FLAT here 2026-07-30:
1031 // never cuts at accept >= 0.8, par-to-noise on 26B/31B depth x3
1032 // interleaved; arm removed per flags doctrine, jsonl row is the record.)
1033 if inround > 0.0 && j + 1 < kr {
1034 let ph = e.dtoh(p_d)?;
1035 if ph[j] < inround {
1036 return Ok(j + 1);
1037 }
1038 }
1039 }
1040 Ok(kr)
1041 };
1042 let over_win = {
1043 let win = d.sliding_window;
1044 d.layers.iter().any(|dl| {
1045 dl.swa
1046 && cache.kv[self.gemma4_draft_kv_target(true)]
1047 .as_ref()
1048 .is_some_and(|kv| kv.len > win)
1049 })
1050 };
1051 // ---- ROUND-GRAPH ARM ---- (MEMRA_GEMMA_ROUND_GRAPH=1): the WHOLE round —
1052 // draft chain + stream verify + device accept/seed/rollback/commit + the
1053 // device adaptive-depth update — captured ONCE per (k_cap, rung, over_win)
1054 // regime and replayed as ONE graph launch per round (the llama round-cost
1055 // mechanism: ~600 per-round enqueues collapse to 1). The round is SELF-FEEDING
1056 // (pos_ctr/pend/brk/g_seed all advance in-graph), so the capture warmups are
1057 // simply two SERVED rounds — their tokens land in the ring and drain normally
1058 // (no snapshot/rollback needed, unlike the E4B token door).
1059 // Adaptive K rides brk[0] via spec_adapt_k: drafts always run k_cap deep (the
1060 // drafter is cheap) but the accept walk depth follows the host policy exactly.
1061 let round_graph_on = std::env::var("MEMRA_GEMMA_ROUND_GRAPH").as_deref() == Ok("1");
1062 if round_graph_on
1063 && dc_bucket.is_some()
1064 && pmin == 0.0
1065 && g4_shared == 0
1066 && !self.is_gemma4_e4b()
1067 && (cache.pos + 2 * (k_cap + 1) + k_cap + 4 < win_main || cache.pos > win_main)
1068 && (cache.pos + 2 * (k_cap + 1) + k_cap + 4 < crate::fa512_min_tkv()
1069 || cache.pos + 1 >= crate::fa512_min_tkv())
1070 && e.fa_rows_eligible(cache.pos, 256)
1071 && cache.pos + 2 * (k_cap + 1) + k_cap + 2 <= cache.max_ctx
1072 {
1073 if burst_state.is_none() {
1074 // ring sized for the capture warmups (2 rounds) + the live round.
1075 let bufs = crate::round_stream::StreamBufs::new(e, k_cap, 3)?;
1076 let fill_dummy = e.zeros(n_embd)?;
1077 let ptrs =
1078 crate::round_stream::kv_len_ptr_table(e, &cache, Some(&bufs.pos_ctr))?;
1079 let scr = self.verify_stream_scratch(e, k_cap + 1)?;
1080 burst_state = Some((bufs, fill_dummy, ptrs, scr));
1081 }
1082 // entry: `last` is the pending token (emitted at drain), h is the seed.
1083 let (bufs, fill_dummy, ptrs, scr) = burst_state.as_mut().unwrap();
1084 let n_rows = cache.kv.len() + 1;
1085 e.set_i32_one(&mut bufs.pos_ctr, cache.pos as i32)?;
1086 e.u32_set_k(&mut bufs.ring_d, 0, 0)?;
1087 e.u32_set_k(&mut bufs.pend_d, last, 0)?;
1088 e.u32_set_k(&mut bufs.brk_d, (if adapt { kc } else { k_cap }) as u32, 0)?;
1089 e.u32_set_k(&mut bufs.brk_d, 1, 1)?;
1090 e.copy_into(&mut g_seed, 0, &h, n_embd)?;
1091 // entry pend is emitted host-side (the ring only carries accepted drafts
1092 // + bonuses — the burst-arm contract).
1093 out.push(last);
1094 if eos.contains(&last) {
1095 break 'outer;
1096 }
1097 if out.len() >= max_new {
1098 break 'outer;
1099 }
1100 #[allow(clippy::unnecessary_unwrap)]
1101 // allow: the Some-guard sits in a multi-clause regime gate; if-let would reshape the arm structure
1102 let key = (usize::MAX - k_cap, dc_bucket.unwrap(), over_win);
1103 let mut fresh_rounds = 1usize; // rounds executed by this iteration
1104 // `hint` is the verify stream's ARM-GATING upper bound — it must sit on
1105 // the SAME side of every crossover as the live lengths this capture
1106 // serves, INCLUDING the arms' own margins (`hint + t < f512` gates the
1107 // global scalar arm; `hint + 1 >= win` gates rows_w), or the captured
1108 // verify bakes a different kernel class than the eager reference
1109 // (107-vs-106 / 4-64 drifts; the regime gate above guarantees the live
1110 // side with the same margins).
1111 let hint = if cache.pos > win_main {
1112 dc_bucket.unwrap() + k_cap + 2 // over-window: rows_w regime
1113 } else if cache.pos + 1 >= crate::fa512_min_tkv() {
1114 win_main - 2 // above f512, under window
1115 } else {
1116 crate::fa512_min_tkv().saturating_sub(k_cap + 5) // under both
1117 };
1118 let bufs_ptr: *mut crate::round_stream::StreamBufs = &mut *bufs;
1119 let scr_ptr: *mut crate::hybrid_forward::VerifyStreamScratch = &mut *scr;
1120 let cache_ptr: *mut Cache = &mut cache;
1121 let batch_ptr: *mut CudaSlice<u32> = &mut batch_d;
1122 let seed_ptr: *mut CudaSlice<f32> = &mut g_seed;
1123 let slots_ptr: *mut Vec<CudaSlice<i32>> = &mut pos_slots;
1124 let mut round_body = |e: &Engine| -> Result<(), Box<dyn std::error::Error>> {
1125 // SAFETY: single-threaded round body; the raw pointers alias the outer
1126 // &mut only within this closure (no overlapping borrows).
1127 let (bufs, scr, cache, batch_d, g_seed, pos_slots) = unsafe {
1128 (
1129 &mut *bufs_ptr,
1130 &mut *scr_ptr,
1131 &mut *cache_ptr,
1132 &mut *batch_ptr,
1133 &mut *seed_ptr,
1134 &mut *slots_ptr,
1135 )
1136 };
1137 e.i32_copy_add(&bufs.pos_ctr, &mut bufs.pos_start_d, 0)?;
1138 e.u32_copy(&bufs.pend_d, batch_d)?;
1139 for (j, slot) in pos_slots.iter_mut().take(k_cap).enumerate() {
1140 e.i32_copy_add(&bufs.pos_ctr, slot, j as i32)?;
1141 }
1142 let mut hc = e.uninit(n_embd)?;
1143 e.copy_into(&mut hc, 0, g_seed, n_embd)?;
1144 #[allow(clippy::needless_range_loop)]
1145 // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
1146 for j in 0..k_cap {
1147 let tv = batch_d.slice(j..j + 1);
1148 let (hn, h_next) = self.gemma4_draft_trunk_dev(
1149 e,
1150 d,
1151 &tv,
1152 &hc,
1153 &pos_slots[j],
1154 cache,
1155 dc_bucket,
1156 )?;
1157 let ld = e.matmul(&d.head, &hn, 1)?;
1158 e.argmax_token_device_col(&ld, 0, d.head.out_features(), batch_d, j + 1)?;
1159 if let Some(map) = &d.d2t_dev {
1160 e.u32_map_k(batch_d, map, j + 1)?;
1161 }
1162 hc = h_next;
1163 }
1164 let (vam_d, vh) = self.gemma4_verify_t_am_stream(
1165 e,
1166 batch_d,
1167 k_cap + 1,
1168 &bufs.pos_ctr,
1169 hint,
1170 cache,
1171 scr,
1172 )?;
1173 e.spec_accept_greedy_dc(
1174 &vam_d,
1175 batch_d,
1176 &bufs.last_pred_d,
1177 &bufs.brk_d,
1178 &mut bufs.acc_d,
1179 )?;
1180 if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1")
1181 && std::env::var("MEMRA_ROUND_GRAPH_CHECK").as_deref() == Ok("1")
1182 {
1183 let vhh = e.dtoh(&vh)?;
1184 let nrm = |r: usize| {
1185 vhh[r * n_embd..(r + 1) * n_embd]
1186 .iter()
1187 .map(|x| x * x)
1188 .sum::<f32>()
1189 .sqrt()
1190 };
1191 let vamh = e.dtoh_u32(&vam_d)?;
1192 eprintln!(
1193 "[rg-vh] |row0|={:.3} |row1|={:.3} |row2|={:.3} vam={:?}",
1194 nrm(0),
1195 nrm(1),
1196 nrm(2),
1197 &vamh[..(k_cap + 1).min(7)]
1198 );
1199 }
1200 e.spec_seed_gather(&vh, fill_dummy, &bufs.acc_d, g_seed, 1, n_embd)?;
1201 e.spec_rollback_stream(ptrs, &bufs.pos_start_d, &bufs.acc_d, 1, n_rows)?;
1202 e.spec_ring_commit(
1203 batch_d,
1204 &bufs.acc_d,
1205 &bufs.brk_d,
1206 &mut bufs.ring_d,
1207 &mut bufs.pend_d,
1208 )?;
1209 e.spec_adapt_k(&bufs.acc_d, &mut bufs.brk_d, floor_at(cache.pos), k_cap)?;
1210 Ok(())
1211 };
1212 // MEMRA_ROUND_GRAPH_CHECK=1: run the body EAGERLY (no capture/replay) —
1213 // splits "body semantics wrong" from "replay mechanics wrong".
1214 let body_check = std::env::var("MEMRA_ROUND_GRAPH_CHECK").as_deref() == Ok("1");
1215 if body_check {
1216 round_body(e)?;
1217 if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1218 let acc = e.dtoh_u32(&bufs.acc_d)?;
1219 let brk = e.dtoh_u32(&bufs.brk_d)?;
1220 let bt = e.dtoh_u32(&batch_d)?;
1221 let tgt = self.gemma4_draft_kv_target(true);
1222 let ld = e.dtoh_i32(&cache.kv[tgt].as_ref().unwrap().len_d)?[0];
1223 let gs = e.dtoh(&g_seed)?;
1224 let gn: f32 = gs.iter().map(|x| x * x).sum::<f32>().sqrt();
1225 eprintln!(
1226 "[rg-check] pos0={} batch={bt:?} n_acc={} bonus={} brk_next={:?} len_d[L{tgt}]={ld} |g_seed|={gn:.3}",
1227 cache.pos, acc[0], acc[1], brk
1228 );
1229 }
1230 } else {
1231 if !draft_graphs.contains_key(&key) {
1232 let g = e.capture_graph_retained(&mut round_body)?;
1233 draft_graphs.insert(key, g);
1234 fresh_rounds += 2; // the capture warmups were served rounds
1235 }
1236 draft_graphs.get(&key).unwrap().0.launch()?;
1237 }
1238 // drain: ONE host sync per iteration (warmup rounds included on capture).
1239 let toks = bufs.drain_ring(e)?;
1240 let posh = e.dtoh_i32(&bufs.pos_ctr)?[0] as usize;
1241 if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1242 eprintln!(
1243 "[round-graph] fresh={fresh_rounds} drained={} posh={posh} toks={:?}",
1244 toks.len(),
1245 &toks[..toks.len().min(12)]
1246 );
1247 }
1248 drafted += fresh_rounds * k_cap;
1249 rounds += fresh_rounds;
1250 accepted += toks.len().saturating_sub(fresh_rounds);
1251 let mut ended = false;
1252 for &tk in &toks[..toks.len() - 1] {
1253 out.push(tk);
1254 if eos.contains(&tk) || out.len() >= max_new {
1255 ended = true;
1256 break;
1257 }
1258 }
1259 last = *toks.last().unwrap();
1260 cache.pos = posh;
1261 for kvl in cache.kv.iter_mut().flatten() {
1262 kvl.len = posh;
1263 }
1264 // NO allocation between replays: a pool alloc here can land on a baked
1265 // transient address and corrupt the next replay (the draft-graph lesson).
1266 // g_seed already holds the next seed (in-graph gather); copy INTO the
1267 // existing h buffer for the (possible) eager-arm handoff.
1268 e.copy_into(&mut h, 0, &g_seed, n_embd)?;
1269 kc = k_cap; // device brk owns the walk depth; host kc only seeds entry
1270 // learn point 2 (round-graph drain): ring = accepted drafts + bonuses; only
1271 // bonuses can be escapes, and the present-bitmap check skips the rest cheap.
1272 trim_adapt_learn(e, d, &toks)?;
1273 if ended {
1274 break 'outer;
1275 }
1276 continue 'outer;
1277 }
1278 if graph_on && dc_bucket.is_some() {
1279 #[allow(clippy::unnecessary_unwrap)]
1280 // allow: the Some-guard sits in a multi-clause regime gate; if-let would reshape the arm structure
1281 let key = (kr, dc_bucket.unwrap(), over_win);
1282 if !draft_graphs.contains_key(&key) {
1283 // chain-only capture; pos slots are graph INPUTS (filled eagerly before
1284 // each launch, like g_seed — the in-graph copy_add fills replayed one
1285 // round stale, see jsonl).
1286 let g = e.capture_graph_retained(|e| {
1287 run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, 0.0)
1288 .map(|_| ())
1289 })?;
1290 draft_graphs.insert(key, g);
1291 }
1292 for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1293 e.set_i32_one(slot, (cache.pos + j) as i32)?;
1294 }
1295 draft_graphs.get(&key).unwrap().0.launch()?;
1296 // MEMRA_DRAFT_GRAPH_CHECK=1: re-run the chain eagerly from the same state and
1297 // diff the drafted slots (replay-vs-eager divergence bisect).
1298 if std::env::var("MEMRA_DRAFT_GRAPH_CHECK").as_deref() == Ok("1") {
1299 // NON-DESTRUCTIVE: compare, then restore the graph's tokens so the round
1300 // proceeds exactly as it would without the check.
1301 let gtoks = e.dtoh_u32(&batch_d)?;
1302 for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1303 e.set_i32_one(slot, (cache.pos + j) as i32)?;
1304 }
1305 run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, 0.0)?;
1306 let etoks = e.dtoh_u32(&batch_d)?;
1307 if gtoks[..=kr] != etoks[..=kr] {
1308 eprintln!(
1309 "[draft-graph] DIVERGE round={rounds} graph={:?} eager={:?}",
1310 >oks[..=kr],
1311 &etoks[..=kr]
1312 );
1313 }
1314 for (j, &t) in gtoks.iter().enumerate().take(kr + 1) {
1315 e.u32_set_k(&mut batch_d, t, j)?;
1316 }
1317 }
1318 } else {
1319 for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1320 e.set_i32_one(slot, (cache.pos + j) as i32)?;
1321 }
1322 let ir_now = match pmin_ir_env {
1323 Some(p) => p, // explicit pin (0 disables)
1324 None if cache.pos >= floor_ctx && !prev_full => PMIN_IR_DEFAULT,
1325 None => 0.0,
1326 };
1327 kr = run_chain(e, d, &mut batch_d, &mut p_d, &g_seed, &pos_slots, ir_now)?;
1328 }
1329 drafted += kr;
1330 rounds += 1;
1331 let pos0 = cache.pos;
1332 // MEMRA_BURST_VCHECK=1: run the STREAM verify first on the same batch/state and
1333 // diff its argmaxes against the eager verify (bisect harness — the stream append
1334 // writes the same rows the eager append then overwrites, so state is untouched).
1335 let vcheck = std::env::var("MEMRA_BURST_VCHECK").as_deref() == Ok("1");
1336 let kvsum = |e: &Engine,
1337 cache: &Cache|
1338 -> Result<Vec<(u64, u64)>, Box<dyn std::error::Error>> {
1339 let mut out = Vec::new();
1340 for kvl in cache.kv.iter().flatten() {
1341 let kb = e.dtoh_u8(&kvl.k)?;
1342 let vb = e.dtoh_u8(&kvl.v)?;
1343 let lo = pos0 * kvl.k_tok_bytes;
1344 let hi = (pos0 + kr + 1) * kvl.k_tok_bytes;
1345 let lov = pos0 * kvl.v_tok_bytes;
1346 let hiv = (pos0 + kr + 1) * kvl.v_tok_bytes;
1347 out.push((
1348 kb[lo..hi].iter().map(|&b| b as u64).sum(),
1349 vb[lov..hiv].iter().map(|&b| b as u64).sum(),
1350 ));
1351 }
1352 Ok(out)
1353 };
1354 let vam_s = if vcheck && !self.is_gemma4_e4b() {
1355 let mut ctr = e.htod_i32(&[pos0 as i32])?;
1356 e.set_i32_one(&mut ctr, pos0 as i32)?;
1357 let mut scr0 = self.verify_stream_scratch(e, kr + 1)?;
1358 let (vs, vhs) = self.gemma4_verify_t_am_stream(
1359 e,
1360 &batch_d,
1361 kr + 1,
1362 &ctr,
1363 pos0 + kr + 3,
1364 &mut cache,
1365 &mut scr0,
1366 )?;
1367 let ss = kvsum(e, &cache)?;
1368 Some((e.dtoh_u32(&vs)?, ss, e.dtoh(&vhs)?))
1369 } else {
1370 None
1371 };
1372 let (vam_d, vh) = if self.is_gemma4_e4b() {
1373 self.gemma4_e4b_decode_step_t_am_dev(e, &batch_d, kr + 1, pos0, &mut cache)?
1374 } else {
1375 self.gemma4_decode_step_t_am_dev(e, &batch_d, kr + 1, pos0, &mut cache)?
1376 };
1377 if let Some((vs, ss, vhs)) = vam_s {
1378 let vhe = e.dtoh(&vh)?;
1379 for r in 0..kr + 1 {
1380 let md = vhs[r * n_embd..(r + 1) * n_embd]
1381 .iter()
1382 .zip(&vhe[r * n_embd..(r + 1) * n_embd])
1383 .map(|(a, b)| (a - b).abs())
1384 .fold(0.0f32, f32::max);
1385 if md > 1e-3 {
1386 eprintln!("[vcheck-vh] round={rounds} row={r} maxdiff={md:.3e}");
1387 }
1388 }
1389 let se = kvsum(e, &cache)?;
1390 for (il, (a, b)) in ss.iter().zip(&se).enumerate() {
1391 if a != b {
1392 eprintln!("[vcheck-kv] round={rounds} il={il} stream={a:?} eager={b:?}");
1393 }
1394 }
1395 let ve = e.dtoh_u32(&vam_d)?;
1396 if vs[..kr + 1] != ve[..kr + 1] {
1397 eprintln!(
1398 "[vcheck] DIVERGE round={rounds} pos0={pos0} stream={:?} eager={:?}",
1399 &vs[..kr + 1],
1400 &ve[..kr + 1]
1401 );
1402 } else {
1403 eprintln!("[vcheck] match round={rounds} pos0={pos0}");
1404 }
1405 }
1406 e.u32_pack2(&batch_d, 1, kr, &vam_d, kr + 1, &mut packed)?;
1407 let host = e.dtoh_u32(&packed)?; // the round's ONE sync
1408 let k = kr;
1409 let dtoks: Vec<u32> = host[..k].to_vec();
1410 let vam: Vec<u32> = host[k..2 * k + 1].to_vec();
1411 // longest accepted prefix: d_i accepted iff d_i == argmax(verify[i-1])
1412 // (trimmed heads: batch_d slots were d2t-translated in the draft loop, so dtoks
1413 // are full-vocab ids here — the 2026-07-10 async rewrite silently dropped this
1414 // and the trim probes read accept=0.000 through it.)
1415 let mut m = 0usize;
1416 while m < k {
1417 if dtoks[m] == vam[m] {
1418 m += 1;
1419 } else {
1420 break;
1421 }
1422 }
1423 prev_full = m == k; // feeds the self-keyed in-round cut (miss → next round cuts)
1424 if std::env::var("MEMRA_DEBUG_SPEC").as_deref() == Ok("1") {
1425 let l0 = cache
1426 .kv
1427 .iter()
1428 .flatten()
1429 .next()
1430 .map(|kv| kv.len)
1431 .unwrap_or(0);
1432 let hh = e.dtoh(&h)?;
1433 let hn: f32 = hh.iter().map(|x| x * x).sum::<f32>().sqrt();
1434 eprintln!(
1435 "[round {rounds}] pos0={pos0} post_pos={} kv0_len={l0} last={last} dtoks={dtoks:?} vam={vam:?} m={m} |h_in|={hn:.3}",
1436 cache.pos
1437 );
1438 }
1439 accepted += m;
1440 for j in 0..k.min(16) {
1441 pos_att[j] += 1;
1442 if j < m {
1443 pos_acc[j] += 1;
1444 }
1445 }
1446 // emit last + accepted drafts; the correction token comes from verify row m.
1447 out.push(last);
1448 if eos.contains(&last) {
1449 break 'outer;
1450 }
1451 for &dt in &dtoks[..m] {
1452 out.push(dt);
1453 if eos.contains(&dt) {
1454 break 'outer;
1455 }
1456 if out.len() >= max_new {
1457 break 'outer;
1458 }
1459 }
1460 let next = vam[m];
1461 // roll back rejected rows: batch appended k+1 rows; keep m+1 (positions of
1462 // last + accepted drafts). SWA layers cap t_kv by the window view, so a plain
1463 // len rewind is safe for every layer.
1464 let keep = m + 1;
1465 for kvl in cache.kv.iter_mut().flatten() {
1466 kvl.len -= (k + 1) - keep;
1467 // keep len_d in lockstep: the drafter's device-len attention arms read it
1468 // (the gemma round appends via the HOST-len path, which doesn't maintain
1469 // the counter — stale len_d gutted acceptance to 0.059 on the dc probe).
1470 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
1471 }
1472 cache.pos -= (k + 1) - keep;
1473 // h for the next round = main hidden at the LAST KEPT position (verify row m).
1474 let hv = e.view(&vh, (k + 1) * n_embd);
1475 let row = hv.slice(m * n_embd..(m + 1) * n_embd);
1476 let mut hrow = e.uninit(n_embd)?;
1477 e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
1478 h = hrow;
1479 last = next;
1480 // Adaptive trim, learn point 2: ALL verify argmaxes — vam[m] is the emitted
1481 // correction (the only emitted token that can sit outside the trim set; accepted
1482 // drafts are trim members by construction), and vam[i>m] are main-model
1483 // predictions for positions never reached this round: next round usually wants
1484 // exactly those tokens, so learning them here lets the draft propose them
1485 // BEFORE any miss is paid (prose escapes are first-occurrence-dominated —
1486 // corrections-only learning measured +0.5 acceptance pts, jsonl 2026-07-19).
1487 trim_adapt_learn(e, d, &vam)?;
1488 if adapt {
1489 let fl_now = floor_at(cache.pos);
1490 kc = (m + 1).clamp(fl_now.min(k_cap), k_cap);
1491 // confidence cut (MEMRA_SPEC_PMIN > 0): next round drafts no deeper than one
1492 // past the first low-confidence draft of THIS round (llama's p-min class,
1493 // one round late — the zero-sync enqueue stays intact). One extra tiny dtoh.
1494 if pmin > 0.0 {
1495 let ph = e.dtoh(&p_d)?;
1496 if let Some(fl) = ph[..kr].iter().position(|&p| p < pmin) {
1497 kc = kc.min((fl + 1).max(fl_now.min(k_cap)));
1498 }
1499 }
1500 }
1501 }
1502 eprintln!(
1503 "[gemma-spec] rounds={rounds} drafted={drafted} accepted={accepted} accept-rate={:.3} tok/round={:.2}",
1504 accepted as f64 / drafted.max(1) as f64,
1505 out.len() as f64 / rounds.max(1) as f64
1506 );
1507 if let Some((used, budget)) = d.trim_adapt_stats() {
1508 eprintln!("[trim-adapt] {used}/{budget} spare slots learned");
1509 match d.trim_adapt_save() {
1510 Ok(n) if n > 0 => {
1511 eprintln!("[trim-adapt] {n} new ids appended to the .learned sidecar")
1512 }
1513 Ok(_) => {}
1514 Err(err) => eprintln!("[trim-adapt] sidecar save failed: {err}"),
1515 }
1516 }
1517 if std::env::var("MEMRA_SPEC_STATS").as_deref() == Ok("1") {
1518 let hist: Vec<String> = (0..16)
1519 .filter(|&j| pos_att[j] > 0)
1520 .map(|j| format!("p{j}:{}/{}", pos_acc[j], pos_att[j]))
1521 .collect();
1522 eprintln!("[gemma-spec] per-position accept: {}", hist.join(" "));
1523 }
1524 Ok(out)
1525 }
1526}
1527
1528/// BURST-SCOPED gemma4 spec session (lane/gemma-batched stage 1, 2026-08-16): the serve
1529/// twin of `generate_spec_gemma`. That function is GENERATION-scoped — it builds its own
1530/// cache, primes, loops to completion, and its `break 'outer` exits deliberately skip the
1531/// final round's rollback/h/pending updates (safe only because the cache dies with the
1532/// call). A served session must instead stop and RESUME across scheduler ticks, so this
1533/// type carries the exact cross-round state the eager loop threads through its locals:
1534///
1535/// * `cache` — the trunk Cache; rows = `committed` (prompt + emitted, INCL. overshoot).
1536/// * `h` — post-output_norm hidden of the LAST committed row (device; draft seed).
1537/// * `pending`— the `last` local: the predicted next token. Emitted as the FIRST token
1538/// of the next round and appended as verify col 0 there; it has NO cache
1539/// row while parked here (the Q38 `next_pred` convention).
1540/// * `kc_next`/`prev_full` — the adaptive-depth + self-keyed in-round-cut carries.
1541///
1542/// BOUNDARY LAW (the Q38 pending-carry/empty-suffix bug class, banked as gate cases in
1543/// gemma-spec-session-gate before this was written): a burst NEVER exits mid-round.
1544/// Every round runs to completion — emission, rollback to the accepted prefix, h/pending
1545/// update, trim-adapt learn — and only then does the burst-target check run. Overshoot
1546/// past `target` is committed and returned (the caller clamps VISIBLE emission; state
1547/// counts every row, exactly like Q38's `SpecSession::committed`). EOS ends the burst at
1548/// its round boundary with the same complete-state guarantee.
1549///
1550/// V1 scope (greedy serve): EAGER round arm only — the round-graph / burst-ring arms are
1551/// generation-scoped perf doors (their ring/pos-counter state does not checkpoint at
1552/// round boundaries) and the shipping bench receipts (154.9/176-179, ASSISTANT-ARM-
1553/// RESULTS.md) were measured on this same eager arm. Dense gemma4 only (E4B refused).
1554/// Fresh session per request: no prefix reuse, no multi-turn suffix — continuation
1555/// bursts are always empty-suffix by construction.
1556pub struct GemmaSpecSession {
1557 pub cache: Cache,
1558 /// Every token whose rows the cache holds, in order (prompt + emitted, incl. overshoot).
1559 pub committed: Vec<u32>,
1560 h: CudaSlice<f32>,
1561 pending: u32,
1562 kc_next: usize,
1563 prev_full: bool,
1564 pub prompt_len: usize,
1565 /// Session-lifetime spec telemetry (rounds / drafted / accepted).
1566 pub rounds: usize,
1567 pub drafted: usize,
1568 pub accepted: usize,
1569}
1570
1571impl GemmaSpecSession {
1572 /// Tokens the session has emitted (committed past the prompt). The pending token is
1573 /// NOT included — it has no cache row and the next burst emits it first.
1574 pub fn emitted_len(&self) -> usize {
1575 self.committed.len() - self.prompt_len
1576 }
1577 /// Context capacity of the session's cache (the server's ContextFull guard).
1578 pub fn cache_max_ctx(&self) -> usize {
1579 self.cache.max_ctx
1580 }
1581 /// DEMOTE HANDOFF (stage-2 seam, gated by the session gate's demote case): hand the
1582 /// trunk cache to the plain path. The cache rows are exactly `committed` (boundary
1583 /// law), and the pending token is returned as the plain path's device_next-equivalent
1584 /// — the plain loop feeds it as its first decode input. The draft side holds no
1585 /// per-session state (the assistant drafter reads the TRUNK's KV; trim-adapt is
1586 /// model-lifetime, not session), so dropping self is the whole handoff.
1587 pub fn into_demoted(self) -> (Cache, u32, Vec<u32>) {
1588 (self.cache, self.pending, self.committed)
1589 }
1590}
1591
1592impl HybridModel {
1593 /// Open a burst-scoped gemma spec session: prime the prompt, park the first predicted
1594 /// token as `pending`. Mirrors `generate_spec_gemma`'s entry verbatim (trim-adapt
1595 /// learn point 1, the PRIME_MIN_T split, the post-norm h convention).
1596 pub fn gemma_spec_session_new(
1597 &self,
1598 e: &Engine,
1599 d: &mut GemmaDraft,
1600 prompt: &[u32],
1601 max_ctx: usize,
1602 ) -> Result<GemmaSpecSession, Box<dyn std::error::Error>> {
1603 if self.is_gemma4_e4b() || !self.uses_gemma_program() {
1604 return Err(
1605 "gemma_spec_session_new: dense gemma4 only (E4B keeps its own arms)".into(),
1606 );
1607 }
1608 let n_embd = self.cfg.n_embd as usize;
1609 let eps = self.cfg.rms_eps;
1610 let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
1611 trim_adapt_learn(e, d, prompt)?;
1612 let (pl, h_seed, post_norm) = if prompt.len() >= crate::hybrid_forward::PRIME_MIN_T {
1613 let (l, hs, _hh) = self.prime_cache(e, prompt, &mut cache, 0)?;
1614 (l, hs, false)
1615 } else {
1616 let n_vocab = self.output.out_features();
1617 let (lv, hv) = self.gemma4_decode_step_t_h(e, prompt, 0, &mut cache)?;
1618 let t = prompt.len();
1619 let last = lv[(t - 1) * n_vocab..t * n_vocab].to_vec();
1620 let hvv = e.view(&hv, t * n_embd);
1621 let row = hvv.slice((t - 1) * n_embd..t * n_embd);
1622 let mut hrow = e.uninit(n_embd)?;
1623 e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
1624 (last, hrow, true)
1625 };
1626 // drafter h = POST-output_norm hidden; the prime returns PRE-norm h_seed.
1627 let h = if post_norm {
1628 h_seed
1629 } else {
1630 let mut hh = e.uninit(n_embd)?;
1631 e.rms_norm(
1632 &h_seed,
1633 self.output_norm.float_data(),
1634 &mut hh,
1635 n_embd,
1636 1,
1637 eps,
1638 )?;
1639 hh
1640 };
1641 let pending = crate::forward::argmax(&pl) as u32;
1642 Ok(GemmaSpecSession {
1643 cache,
1644 committed: prompt.to_vec(),
1645 h,
1646 pending,
1647 kc_next: usize::MAX, // clamped to the burst's k_cap at entry (one-shot: kc = k_cap)
1648 prev_full: true, // round 1: no miss evidence yet
1649 prompt_len: prompt.len(),
1650 rounds: 0,
1651 drafted: 0,
1652 accepted: 0,
1653 })
1654 }
1655
1656 /// SPEC-ON-CACHE-HIT restore (lane/spec-on-cache-hit, 2026-08-18): open a gemma spec
1657 /// session over a trunk cache the worker already restored from a WHOLE prefix-cache
1658 /// entry (rows `[0..prefix.len())` == `prefix`, `cache.pos == prefix.len()`), feeding
1659 /// only the prompt SUFFIX. The assistant drafter holds no per-session KV of its own —
1660 /// it attends the TRUNK's cache — so the restored rows already ARE the draft state;
1661 /// the only products a fresh prime supplied were the boundary logits (-> `pending`)
1662 /// and the post-norm hidden of the last prompt row (-> `h`, the drafter seed), and a
1663 /// non-empty suffix feed regenerates both. An empty suffix therefore REFUSES: there is
1664 /// no drafter seed hidden without feeding at least one row (the plain path serves that
1665 /// shape from the entry's boundary logits, as before).
1666 ///
1667 /// PROGRAM CHOICE (the splitiso two-programs law, 0b0ffa13c6): gemma4's monolithic
1668 /// prime refuses pos > 0, so the suffix rides `gemma4_decode_step_t_h` — the SAME
1669 /// verify-trunk program every spec round runs and the same arm the cold
1670 /// sub-PRIME_MIN_T session prime uses (banked byte-identical by
1671 /// gemma-spec-session-gate). The restored bytes below `prefix.len()` are never
1672 /// recomputed by construction.
1673 pub fn gemma_spec_session_from_restored(
1674 &self,
1675 e: &Engine,
1676 d: &mut GemmaDraft,
1677 mut cache: Cache,
1678 prefix: &[u32],
1679 suffix: &[u32],
1680 ) -> Result<GemmaSpecSession, Box<dyn std::error::Error>> {
1681 if self.is_gemma4_e4b() || !self.uses_gemma_program() {
1682 return Err(
1683 "gemma_spec_session_from_restored: dense gemma4 only (E4B keeps its own arms)"
1684 .into(),
1685 );
1686 }
1687 if prefix.is_empty() {
1688 return Err("gemma_spec_session_from_restored: empty restored prefix".into());
1689 }
1690 if suffix.is_empty() {
1691 return Err(
1692 "gemma_spec_session_from_restored: empty suffix — the drafter seed \
1693 hidden only exists after feeding at least one row (plain path owns \
1694 the whole-prompt hit)"
1695 .into(),
1696 );
1697 }
1698 if cache.pos != prefix.len() {
1699 return Err(format!(
1700 "gemma_spec_session_from_restored: restored cache pos {} != prefix len {}",
1701 cache.pos,
1702 prefix.len(),
1703 )
1704 .into());
1705 }
1706 let n_embd = self.cfg.n_embd as usize;
1707 let n_vocab = self.output.out_features();
1708 // trim-adapt learning is model-lifetime (not session state); feed the full logical
1709 // prompt so restored traffic teaches the head trim exactly what cold traffic does.
1710 let full: Vec<u32> = prefix.iter().chain(suffix.iter()).copied().collect();
1711 trim_adapt_learn(e, d, &full)?;
1712 let base = cache.pos;
1713 let (lv, hv) = self.gemma4_decode_step_t_h(e, suffix, base, &mut cache)?;
1714 let t = suffix.len();
1715 let last = lv[(t - 1) * n_vocab..t * n_vocab].to_vec();
1716 // gemma4_decode_step_t_h returns POST-output_norm hiddens (the drafter's h
1717 // convention — same arm gemma_spec_session_new uses below PRIME_MIN_T).
1718 let hvv = e.view(&hv, t * n_embd);
1719 let row = hvv.slice((t - 1) * n_embd..t * n_embd);
1720 let mut h = e.uninit(n_embd)?;
1721 e.copy_view_into(&mut h, 0, &row, n_embd)?;
1722 let pending = crate::forward::argmax(&last) as u32;
1723 let prompt_len = full.len();
1724 Ok(GemmaSpecSession {
1725 cache,
1726 committed: full,
1727 h,
1728 pending,
1729 kc_next: usize::MAX,
1730 prev_full: true,
1731 prompt_len,
1732 rounds: 0,
1733 drafted: 0,
1734 accepted: 0,
1735 })
1736 }
1737
1738 /// One serve burst: run complete spec rounds until >= `target` NEW tokens have been
1739 /// emitted this burst (overshoot committed and returned) or EOS lands. Returns
1740 /// (tokens emitted this burst in order, drafted, accepted). The round body is the
1741 /// EAGER arm of `generate_spec_gemma`, kept behaviorally identical under default env
1742 /// (adapt/floor/pmin/in-round-cut logic verbatim) — gemma-spec-session-gate enforces
1743 /// byte-equality of the emitted stream against the one-shot at every burst width.
1744 pub fn gemma_spec_session_burst(
1745 &self,
1746 e: &Engine,
1747 d: &mut GemmaDraft,
1748 sess: &mut GemmaSpecSession,
1749 target: usize,
1750 k: usize,
1751 eos: &[u32],
1752 ) -> Result<(Vec<u32>, usize, usize), Box<dyn std::error::Error>> {
1753 let n_embd = self.cfg.n_embd as usize;
1754 if target == 0 {
1755 return Ok((Vec::new(), 0, 0));
1756 }
1757 let pmin: f32 = std::env::var("MEMRA_SPEC_PMIN")
1758 .ok()
1759 .and_then(|v| v.parse().ok())
1760 .unwrap_or(0.0);
1761 let pmin_ir_env: Option<f32> = std::env::var("MEMRA_SPEC_PMIN_INROUND")
1762 .ok()
1763 .and_then(|v| v.parse().ok());
1764 const PMIN_IR_DEFAULT: f32 = 0.7;
1765 let adapt = std::env::var("MEMRA_SPEC_ADAPT").as_deref() != Ok("0");
1766 let adapt_floor_default: usize = if self.cfg.n_embd >= 3500 {
1767 4
1768 } else if self.cfg.n_embd >= 2500 {
1769 2
1770 } else {
1771 1
1772 };
1773 let adapt_floor_env: Option<usize> = std::env::var("MEMRA_SPEC_ADAPT_FLOOR")
1774 .ok()
1775 .and_then(|v| v.parse().ok());
1776 let adapt_floor: usize = adapt_floor_env.unwrap_or(adapt_floor_default);
1777 let floor_ctx: usize = std::env::var("MEMRA_SPEC_FLOOR_CTX")
1778 .ok()
1779 .and_then(|v| v.parse().ok())
1780 .unwrap_or(1024);
1781 let floor_at = |pos: usize| -> usize {
1782 if adapt_floor_env.is_some() || pos < floor_ctx {
1783 adapt_floor
1784 } else if adapt_floor >= 4 {
1785 1
1786 } else {
1787 adapt_floor
1788 }
1789 };
1790 let cap_max: usize = std::env::var("MEMRA_SPEC_CAPMAX")
1791 .ok()
1792 .and_then(|v| v.parse().ok())
1793 .unwrap_or(7);
1794 let k_cap = k.min(cap_max).max(1);
1795 let mut kc = sess.kc_next.min(k_cap);
1796 let mut prev_full = sess.prev_full;
1797
1798 // per-burst device scratch (the one-shot allocates these per generation; per-burst
1799 // re-allocation is micro against a >= (K+1)-token round).
1800 let mut batch_d = e.stream().alloc_zeros::<u32>(k_cap + 1)?;
1801 let mut packed = e.stream().alloc_zeros::<u32>(2 * k_cap + 1)?;
1802 let mut p_d = e.stream().alloc_zeros::<f32>(k_cap.max(1))?;
1803 let mut pos_slots: Vec<CudaSlice<i32>> = (0..k_cap.max(1))
1804 .map(|_| e.htod_i32(&[0]))
1805 .collect::<Result<_, _>>()?;
1806 let mut g_seed = e.zeros(n_embd)?;
1807 // len_d lockstep at burst entry: the drafter's device-len arms read it, and the
1808 // previous burst's rollback set it — a fresh session's prime went host-len.
1809 for kvl in sess.cache.kv.iter_mut().flatten() {
1810 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
1811 }
1812
1813 let mut burst_out: Vec<u32> = Vec::with_capacity(target + k_cap + 1);
1814 let (mut drafted, mut accepted) = (0usize, 0usize);
1815 let mut ended = false;
1816 while burst_out.len() < target && !ended {
1817 let mut kr = if adapt { kc } else { k_cap };
1818 let dc_bucket: Option<usize> = {
1819 static DC: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1820 if *DC.get_or_init(|| std::env::var("MEMRA_GEMMA_DRAFT_DC").as_deref() != Ok("0")) {
1821 let ml = sess
1822 .cache
1823 .kv
1824 .iter()
1825 .flatten()
1826 .map(|kv| kv.len)
1827 .max()
1828 .unwrap_or(1);
1829 Some((ml + k_cap + 2).next_power_of_two().max(512))
1830 } else {
1831 None
1832 }
1833 };
1834 e.u32_set_k(&mut batch_d, sess.pending, 0)?;
1835 e.copy_into(&mut g_seed, 0, &sess.h, n_embd)?;
1836 for (j, slot) in pos_slots.iter_mut().take(kr).enumerate() {
1837 e.set_i32_one(slot, (sess.cache.pos + j) as i32)?;
1838 }
1839 let ir_now = match pmin_ir_env {
1840 Some(p) => p,
1841 None if sess.cache.pos >= floor_ctx && !prev_full => PMIN_IR_DEFAULT,
1842 None => 0.0,
1843 };
1844 // draft chain (the one-shot's run_chain, eager): reads g_seed, seeds batch_d.
1845 {
1846 let mut hc = e.uninit(n_embd)?;
1847 e.copy_into(&mut hc, 0, &g_seed, n_embd)?;
1848 let mut j = 0usize;
1849 while j < kr {
1850 let tv = batch_d.slice(j..j + 1);
1851 let (hn, h_next) = self.gemma4_draft_trunk_dev(
1852 e,
1853 d,
1854 &tv,
1855 &hc,
1856 &pos_slots[j],
1857 &sess.cache,
1858 dc_bucket,
1859 )?;
1860 let ld = e.matmul(&d.head, &hn, 1)?;
1861 e.argmax_token_device_col(&ld, 0, d.head.out_features(), &mut batch_d, j + 1)?;
1862 if pmin > 0.0 || ir_now > 0.0 {
1863 e.prob_of_token_device_col(
1864 &ld,
1865 &batch_d,
1866 j + 1,
1867 &mut p_d,
1868 j,
1869 d.head.out_features(),
1870 )?;
1871 }
1872 if let Some(map) = &d.d2t_dev {
1873 e.u32_map_k(&mut batch_d, map, j + 1)?;
1874 }
1875 hc = h_next;
1876 if ir_now > 0.0 && j + 1 < kr {
1877 let ph = e.dtoh(&p_d)?;
1878 if ph[j] < ir_now {
1879 kr = j + 1;
1880 break;
1881 }
1882 }
1883 j += 1;
1884 }
1885 }
1886 drafted += kr;
1887 sess.rounds += 1;
1888 let pos0 = sess.cache.pos;
1889 let (vam_d, vh) =
1890 self.gemma4_decode_step_t_am_dev(e, &batch_d, kr + 1, pos0, &mut sess.cache)?;
1891 e.u32_pack2(&batch_d, 1, kr, &vam_d, kr + 1, &mut packed)?;
1892 let host = e.dtoh_u32(&packed)?; // the round's ONE sync
1893 let dtoks: Vec<u32> = host[..kr].to_vec();
1894 let vam: Vec<u32> = host[kr..2 * kr + 1].to_vec();
1895 let mut m = 0usize;
1896 while m < kr {
1897 if dtoks[m] == vam[m] {
1898 m += 1;
1899 } else {
1900 break;
1901 }
1902 }
1903 prev_full = m == kr;
1904 accepted += m;
1905 // ---- ROUND COMPLETES UNCONDITIONALLY (the boundary law) ----
1906 // rollback rejected rows FIRST, then emit — an EOS mid-emission must still
1907 // leave cache rows == committed tokens.
1908 let keep = m + 1;
1909 for kvl in sess.cache.kv.iter_mut().flatten() {
1910 kvl.len -= (kr + 1) - keep;
1911 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
1912 }
1913 sess.cache.pos -= (kr + 1) - keep;
1914 // h for the next round = main hidden at the LAST KEPT position (verify row m).
1915 let hv2 = e.view(&vh, (kr + 1) * n_embd);
1916 let row = hv2.slice(m * n_embd..(m + 1) * n_embd);
1917 let mut hrow = e.uninit(n_embd)?;
1918 e.copy_view_into(&mut hrow, 0, &row, n_embd)?;
1919 sess.h = hrow;
1920 // emit: pending + accepted drafts. VISIBLE emission stops at the first EOS
1921 // (the one-shot's exact stream); COMMIT accounting continues — every kept row
1922 // must appear in `committed` or the cache-rows == committed invariant breaks.
1923 sess.committed.push(sess.pending);
1924 burst_out.push(sess.pending);
1925 if eos.contains(&sess.pending) {
1926 ended = true;
1927 }
1928 for &dt in &dtoks[..m] {
1929 sess.committed.push(dt);
1930 if !ended {
1931 burst_out.push(dt);
1932 if eos.contains(&dt) {
1933 ended = true;
1934 }
1935 }
1936 }
1937 sess.pending = vam[m];
1938 trim_adapt_learn(e, d, &vam)?;
1939 if adapt {
1940 let fl_now = floor_at(sess.cache.pos);
1941 kc = (m + 1).clamp(fl_now.min(k_cap), k_cap);
1942 if pmin > 0.0 {
1943 let ph = e.dtoh(&p_d)?;
1944 if let Some(fl) = ph[..kr].iter().position(|&p| p < pmin) {
1945 kc = kc.min((fl + 1).max(fl_now.min(k_cap)));
1946 }
1947 }
1948 }
1949 }
1950 sess.kc_next = kc;
1951 sess.prev_full = prev_full;
1952 sess.drafted += drafted;
1953 sess.accepted += accepted;
1954 Ok((burst_out, drafted, accepted))
1955 }
1956}
1957
1958impl HybridModel {
1959 /// PLAIN-DECODE CUDA-GRAPH loop (gemma4, greedy): one captured verify-trunk step
1960 /// (t=1, device tokens/pos/lens) replayed per token — the launch-gap eraser the
1961 /// decode decomposition demanded (2026-07-23: ~2.3ms/token idle at 128 launches).
1962 /// Self-feeding: argmax -> tok_d -> next embed; counters advance in-graph via
1963 /// spec_rollback_stream(base=1, acc=0). Tokens land in a device ring; ONE host sync
1964 /// per drain window. Captures are keyed on the (rung, window-side, f512-side) regime
1965 /// (the round-graph hint law); regime-crossing stretches run the same body eagerly.
1966 /// Caller guarantees: gemma4, greedy, shared_kv_layers == 0, prompt already primed
1967 /// (cache.pos = prompt len, host kvl.len mirrors set).
1968 pub fn gemma4_generate_plain_graph(
1969 &self,
1970 e: &Engine,
1971 cache: &mut Cache,
1972 last: u32,
1973 max_new: usize,
1974 eos: &[u32],
1975 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
1976 const RING: usize = 64;
1977 const DRAIN: usize = 32; // replays per host sync
1978 let win_main = self
1979 .cfg
1980 .gemma4
1981 .as_ref()
1982 .map(|g| g.sliding_window as usize)
1983 .unwrap_or(0);
1984 let n_rows = cache.kv.len() + 1;
1985
1986 let was_tracking = e.ctx().is_event_tracking();
1987 if was_tracking {
1988 unsafe {
1989 e.ctx().disable_event_tracking();
1990 }
1991 }
1992 let r = self
1993 .gemma4_plain_graph_inner(e, cache, last, max_new, eos, RING, DRAIN, win_main, n_rows);
1994 if was_tracking {
1995 unsafe {
1996 e.ctx().enable_event_tracking();
1997 }
1998 }
1999 r
2000 }
2001
2002 #[allow(clippy::too_many_arguments)]
2003 #[allow(clippy::map_entry)] // allow: the init body is fallible (`?`); Entry::or_insert_with cannot propagate errors
2004 fn gemma4_plain_graph_inner(
2005 &self,
2006 e: &Engine,
2007 cache: &mut Cache,
2008 last: u32,
2009 max_new: usize,
2010 eos: &[u32],
2011 ring_cap: usize,
2012 drain: usize,
2013 win_main: usize,
2014 n_rows: usize,
2015 ) -> Result<Vec<u32>, Box<dyn std::error::Error>> {
2016 let mut scr = self.verify_stream_scratch(e, 1)?;
2017 let mut tok_d = e.stream().alloc_zeros::<u32>(1)?;
2018 e.u32_set_k(&mut tok_d, last, 0)?;
2019 let pos_ctr = e.htod_i32(&[cache.pos as i32])?;
2020 let mut pos_start_d = e.htod_i32(&[cache.pos as i32])?;
2021 let acc0 = e.stream().alloc_zeros::<u32>(2)?; // acc[0] = 0 -> counters +1
2022 let mut ring = e.stream().alloc_zeros::<u32>(ring_cap)?;
2023 let ptrs = crate::round_stream::kv_len_ptr_table(e, cache, Some(&pos_ctr))?;
2024 for kvl in cache.kv.iter_mut().flatten() {
2025 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
2026 }
2027 let ring_base = cache.pos; // baked into every capture
2028
2029 #[allow(clippy::type_complexity)]
2030 // allow: one-shot composite type; naming it would hide the shape that matters at the call site
2031 let mut graphs: std::collections::HashMap<
2032 (usize, bool, bool),
2033 (
2034 cudarc::driver::CudaGraph,
2035 Vec<Box<dyn std::any::Any + Send>>,
2036 ),
2037 > = Default::default();
2038
2039 let mut out: Vec<u32> = Vec::with_capacity(max_new);
2040 let mut drained = 0usize; // tokens read off the ring
2041
2042 // hint law (round-graph): the arm-gating bound must sit on the SAME side of every
2043 // crossover as the live lengths this capture serves, with the arms' own margins.
2044 let hint_for = |pos: usize| -> usize {
2045 if pos > win_main {
2046 pos + drain + 2
2047 } else if pos + 1 >= crate::fa512_min_tkv() {
2048 win_main.saturating_sub(2)
2049 } else {
2050 crate::fa512_min_tkv().saturating_sub(5)
2051 }
2052 };
2053 let regime_key = |pos: usize| -> (usize, bool, bool) {
2054 let rung = (pos + drain + 2).next_power_of_two().max(512);
2055 (rung, pos > win_main, pos + 1 >= crate::fa512_min_tkv())
2056 };
2057 // the whole [pos, pos+n) stretch must share one regime for a captured replay run.
2058 let stable_for = |pos: usize, n: usize| -> bool {
2059 regime_key(pos) == regime_key(pos + n)
2060 && (pos > win_main || pos + n + 2 < win_main)
2061 && (pos + 1 >= crate::fa512_min_tkv() || pos + n + 2 < crate::fa512_min_tkv())
2062 };
2063
2064 while out.len() < max_new {
2065 let pos = cache.pos;
2066 let hint = hint_for(pos);
2067 let scr_ptr: *mut crate::hybrid_forward::VerifyStreamScratch = &mut scr;
2068 let cache_ptr: *mut Cache = cache as *mut Cache;
2069 let tok_ptr: *mut CudaSlice<u32> = &mut tok_d;
2070 let ring_ptr: *mut CudaSlice<u32> = &mut ring;
2071 let start_ptr: *mut CudaSlice<i32> = &mut pos_start_d;
2072 let step = |e: &Engine| -> Result<(), Box<dyn std::error::Error>> {
2073 // SAFETY: single-threaded body; raw pointers alias the outer &mut only here.
2074 let (scr, cache, tok_d, ring, pos_start_d) = unsafe {
2075 (
2076 &mut *scr_ptr,
2077 &mut *cache_ptr,
2078 &mut *tok_ptr,
2079 &mut *ring_ptr,
2080 &mut *start_ptr,
2081 )
2082 };
2083 e.i32_copy_add(&pos_ctr, pos_start_d, 0)?;
2084 let (vam, _hn) =
2085 self.gemma4_verify_t_am_stream(e, tok_d, 1, &pos_ctr, hint, cache, scr)?;
2086 e.u32_copy(&vam, tok_d)?;
2087 e.plain_tok_ring(&vam, pos_start_d, ring_base, ring)?;
2088 e.spec_rollback_stream(&ptrs, pos_start_d, &acc0, 1, n_rows)?;
2089 Ok(())
2090 };
2091
2092 let n_left = max_new - out.len();
2093 let burst = drain.min(n_left);
2094 // MEMRA_G4PLAIN_EAGER=1: run the body eagerly every step (no capture/replay) —
2095 // splits "body semantics wrong" from "replay mechanics wrong" (round-graph law).
2096 let force_eager = std::env::var("MEMRA_G4PLAIN_EAGER").as_deref() == Ok("1");
2097 let steps_done = if !force_eager && burst >= 4 && stable_for(pos, burst + 3) {
2098 let key = regime_key(pos);
2099 if !graphs.contains_key(&key) {
2100 // capture cost = 3 SERVED steps (2 warmups + the captured run itself):
2101 // the loop is self-feeding, so they are real tokens in the ring.
2102 let g = e.capture_graph_retained(step)?;
2103 graphs.insert(key, g);
2104 3
2105 } else {
2106 let (g, _keep) = graphs.get(&key).unwrap();
2107 for _ in 0..burst {
2108 g.launch()?;
2109 }
2110 burst
2111 }
2112 } else {
2113 step(e)?; // eager fallback (same body)
2114 1
2115 };
2116
2117 // host mirrors + drain
2118 cache.pos += steps_done;
2119 for kvl in cache.kv.iter_mut().flatten() {
2120 kvl.len = cache.pos;
2121 }
2122 e.stream().synchronize()?;
2123 let ringh = e.dtoh_u32(&ring)?;
2124 let total = cache.pos - ring_base;
2125 while drained < total && out.len() < max_new {
2126 let t = ringh[drained % ring_cap];
2127 out.push(t);
2128 drained += 1;
2129 if eos.contains(&t) {
2130 return Ok(out);
2131 }
2132 }
2133 }
2134 Ok(out)
2135 }
2136}