Skip to main content

lattice_inference/forward/
cpu_f16.rs

1//! F16-weight forward pass for Qwen3.5-2B.
2//!
3//! This module mirrors `qwen35_model::forward_step` and `gated_delta_net_fused::gated_delta_net_step_fused`
4//! but uses `F16ModelWeights` (packed `u16` half-precision) for all large projection matrices.
5//! Activations, norms, and recurrent state remain in `f32`.
6//!
7//! The purpose is to halve memory bandwidth for weight-bound inference while preserving numerical
8//! accuracy in the accumulation path (all dot products widen f16 elements on the fly).
9
10use crate::attention::gdn::{GatedDeltaNetState, sigmoid, softplus};
11use crate::attention::gdn_fused::{
12    GatedDeltaNetFusedScratch, conv1d_silu_fused, simd_decay_and_rank1_update, simd_gated_rms_norm,
13    simd_l2_normalize, simd_matvec_transpose,
14};
15use crate::forward::cpu::{elementwise_mul, silu_inplace};
16use crate::model::qwen35::{
17    ForwardScratch, GenerationEntryContract, GenerationPlan, GenerationPreparation, KvCache,
18    decode_tokens, prepare_generation, qwen35_rms_norm, resize, sample_token, should_stop_token,
19};
20use crate::model::qwen35_config::{GenerateConfig, GenerateOutput, Qwen35Config};
21use crate::rope::RopeTable;
22use crate::stop_reason::StopReason;
23use crate::tokenizer::bpe::BpeTokenizer;
24use crate::tokenizer::common::Tokenizer;
25use crate::vision::multimodal::Qwen35VisionRequest;
26use crate::weights::f16_weights::{
27    F16AttentionWeights, F16FeedForwardWeights, F16FullAttentionLayerWeights,
28    F16GatedDeltaNetWeights, F16ModelWeights, F16MoeLayerWeights, f16_to_f32_slice, matmul_bt_f16,
29};
30
31// ---------------------------------------------------------------------------
32// GatedDeltaNet step (f16 weights)
33// ---------------------------------------------------------------------------
34
35/// **Unstable**: f16-weight GatedDeltaNet step; kernel interface evolving with quantization strategy.
36///
37/// Process a single token through the GatedDeltaNet layer using f16 weight matrices.
38///
39/// Numerically equivalent to `gated_delta_net_step_fused` within f16 quantization tolerance.
40/// All five large projections (in_proj_qkv, in_proj_z, in_proj_b, in_proj_a, out_proj) use
41/// `matmul_bt_f16`. Small vectors (a_log, dt_bias, conv1d_weight, norm_weight) remain f32.
42///
43/// `input`: hidden state `[hidden_size]`
44/// `state`: mutable recurrent state for this layer
45/// `weights`: layer weights with f16 projection matrices
46/// `cfg`: model config
47/// `scratch`: reusable fused scratch buffers
48/// `output`: output buffer `[hidden_size]`, written in-place
49#[inline]
50pub fn gated_delta_net_step_fused_f16(
51    input: &[f32],
52    state: &mut GatedDeltaNetState,
53    weights: &F16GatedDeltaNetWeights,
54    cfg: &Qwen35Config,
55    scratch: &mut GatedDeltaNetFusedScratch,
56    output: &mut [f32],
57) {
58    let hidden = cfg.hidden_size;
59    let num_heads = cfg.linear_num_key_heads;
60    let value_heads = cfg.linear_num_value_heads();
61    let ratio = value_heads / num_heads;
62    let key_dim = cfg.linear_key_head_dim;
63    let value_dim = cfg.linear_value_head_dim;
64    let qkv_dim = cfg.linear_qkv_dim();
65    let output_dim = cfg.linear_output_dim();
66    let kernel_size = cfg.linear_conv_kernel_dim;
67
68    debug_assert!(input.len() >= hidden);
69    debug_assert!(output.len() >= hidden);
70
71    scratch.ensure_capacity(qkv_dim, output_dim, value_heads, key_dim, value_dim);
72
73    // 1. Projections (f16 weights)
74    matmul_bt_f16(
75        input,
76        &weights.in_proj_qkv,
77        &mut scratch.qkv_proj[..qkv_dim],
78        1,
79        hidden,
80        qkv_dim,
81    );
82
83    matmul_bt_f16(
84        input,
85        &weights.in_proj_z,
86        &mut scratch.z_proj[..output_dim],
87        1,
88        hidden,
89        output_dim,
90    );
91
92    matmul_bt_f16(
93        input,
94        &weights.in_proj_b,
95        &mut scratch.beta_proj[..value_heads],
96        1,
97        hidden,
98        value_heads,
99    );
100
101    matmul_bt_f16(
102        input,
103        &weights.in_proj_a,
104        &mut scratch.alpha_proj[..value_heads],
105        1,
106        hidden,
107        value_heads,
108    );
109
110    // sigmoid(beta)
111    for b in &mut scratch.beta_proj[..value_heads] {
112        *b = sigmoid(*b);
113    }
114
115    // 2. Fused conv1d + SiLU (f32 conv weights)
116    conv1d_silu_fused(
117        &scratch.qkv_proj[..qkv_dim],
118        &mut state.conv_buffer,
119        &weights.conv1d_weight,
120        &mut scratch.conv_output[..qkv_dim],
121        qkv_dim,
122        kernel_size,
123    );
124
125    // 3-7. Per-head processing
126    let q_total = num_heads * key_dim;
127    let k_total = num_heads * key_dim;
128    let v_offset = q_total + k_total;
129    let scale = 1.0 / (key_dim as f32).sqrt();
130
131    for h in 0..value_heads {
132        let k_head = h / ratio;
133        let q_start = k_head * key_dim;
134        let k_start = q_total + k_head * key_dim;
135        let v_start = v_offset + h * value_dim;
136
137        scratch.q_head[..key_dim].copy_from_slice(&scratch.conv_output[q_start..q_start + key_dim]);
138        scratch.k_head[..key_dim].copy_from_slice(&scratch.conv_output[k_start..k_start + key_dim]);
139        let v = &scratch.conv_output[v_start..v_start + value_dim];
140
141        // L2-normalize Q and K (SIMD-accelerated)
142        simd_l2_normalize(&mut scratch.q_head[..key_dim]);
143        simd_l2_normalize(&mut scratch.k_head[..key_dim]);
144
145        // Decay gate (f32 weights: a_log, dt_bias). Clamp exp(a_log) to finite
146        // (mirror gdn.rs compute_decay_gate): a_log>~88 -> +inf, inf*0 = NaN poisons state.
147        let a = weights.a_log[h].exp().min(f32::MAX);
148        let sp = softplus(scratch.alpha_proj[h] + weights.dt_bias[h]);
149        let g = (-a * sp).exp();
150
151        let s_offset = h * key_dim * value_dim;
152        let s = &mut state.s_matrices[s_offset..s_offset + key_dim * value_dim];
153
154        // Retrieve: kv_mem = S^T @ k (SIMD-accelerated)
155        simd_matvec_transpose(
156            s,
157            &scratch.k_head[..key_dim],
158            &mut scratch.kv_mem[..value_dim],
159            key_dim,
160            value_dim,
161        );
162
163        // Delta: (v - g * kv_mem) * beta
164        let beta_h = scratch.beta_proj[h];
165        for ((d, &vj), &mem) in scratch.delta[..value_dim]
166            .iter_mut()
167            .zip(&v[..value_dim])
168            .zip(&scratch.kv_mem[..value_dim])
169        {
170            *d = (vj - mem * g) * beta_h;
171        }
172
173        // Fused decay + rank-1 update: S = g*S + outer(k, delta) (SIMD-accelerated)
174        simd_decay_and_rank1_update(
175            s,
176            &scratch.k_head[..key_dim],
177            &scratch.delta[..value_dim],
178            g,
179            key_dim,
180            value_dim,
181        );
182
183        // Output: o = S^T @ q / sqrt(key_dim) (SIMD-accelerated)
184        let out_start = h * value_dim;
185        let out_head = &mut scratch.output_heads[out_start..out_start + value_dim];
186        simd_matvec_transpose(s, &scratch.q_head[..key_dim], out_head, key_dim, value_dim);
187        for val in out_head.iter_mut() {
188            *val *= scale;
189        }
190    }
191
192    // 8. Gated RMSNorm + output projection
193    // norm_weight is [value_dim] per-head, applied to each head independently.
194    let gamma = &weights.norm_weight[..value_dim];
195    debug_assert_eq!(gamma.len(), value_dim);
196
197    for h in 0..value_heads {
198        let start = h * value_dim;
199        let end = start + value_dim;
200        simd_gated_rms_norm(
201            &scratch.output_heads[start..end],
202            &scratch.z_proj[start..end],
203            gamma,
204            &mut scratch.gated_norm_buf[start..end],
205            cfg.rms_norm_eps,
206        );
207    }
208
209    // Output projection (f16 weights)
210    matmul_bt_f16(
211        &scratch.gated_norm_buf[..output_dim],
212        &weights.out_proj,
213        &mut output[..hidden],
214        1,
215        output_dim,
216        hidden,
217    );
218}
219
220// ---------------------------------------------------------------------------
221// Full attention step (f16 weights)
222// ---------------------------------------------------------------------------
223
224/// Full GQA attention for a single token using f16 weight matrices.
225///
226/// Input is read from `scratch.attn_out[..hidden]`, output written back to
227/// `scratch.attn_out[..hidden]`.
228fn full_attention_step_f16(
229    weights: &F16FullAttentionLayerWeights,
230    cache_idx: usize,
231    position: usize,
232    kv_cache: &mut KvCache,
233    scratch: &mut ForwardScratch,
234    cfg: &Qwen35Config,
235    rope: &RopeTable,
236    hidden: usize,
237    mrope_cos_sin: Option<(&[f32], &[f32])>,
238) {
239    // Read input from attn_out (where caller placed it)
240    let input: Vec<f32> = scratch.attn_out[..hidden].to_vec();
241    let q_dim = cfg.full_q_dim();
242    let kv_dim = cfg.full_kv_dim();
243    let head_dim = cfg.head_dim;
244    let num_q_heads = cfg.num_attention_heads;
245    let num_kv_heads = cfg.num_key_value_heads;
246    let rope_dim = cfg.rope_dim();
247
248    // Q projection produces [Q, gate] interleaved per head:
249    // view(num_heads, head_dim*2) -> chunk(2) -> Q[num_heads, head_dim], gate[num_heads, head_dim]
250    let q_proj_dim = 2 * q_dim;
251    let mut q_and_gate = vec![0.0f32; q_proj_dim];
252    matmul_bt_f16(
253        &input,
254        &weights.q_proj,
255        &mut q_and_gate,
256        1,
257        hidden,
258        q_proj_dim,
259    );
260    // Scatter per-head: each head has [Q_h, gate_h] of size head_dim*2
261    let mut gate_z = vec![0.0f32; q_dim];
262    for h in 0..num_q_heads {
263        let src = h * head_dim * 2;
264        let dst = h * head_dim;
265        scratch.q_buf[dst..dst + head_dim].copy_from_slice(&q_and_gate[src..src + head_dim]);
266        gate_z[dst..dst + head_dim]
267            .copy_from_slice(&q_and_gate[src + head_dim..src + head_dim * 2]);
268    }
269    matmul_bt_f16(
270        &input,
271        &weights.k_proj,
272        &mut scratch.k_buf[..kv_dim],
273        1,
274        hidden,
275        kv_dim,
276    );
277    matmul_bt_f16(
278        &input,
279        &weights.v_proj,
280        &mut scratch.v_buf[..kv_dim],
281        1,
282        hidden,
283        kv_dim,
284    );
285
286    // Per-head QK-norm (Qwen3.5 RMSNorm: 1 + gamma, f32 norms)
287    for h in 0..num_q_heads {
288        let start = h * head_dim;
289        qwen35_rms_norm(
290            &mut scratch.q_buf[start..start + head_dim],
291            &weights.q_norm,
292            head_dim,
293            cfg.rms_norm_eps,
294        );
295    }
296    for h in 0..num_kv_heads {
297        let start = h * head_dim;
298        qwen35_rms_norm(
299            &mut scratch.k_buf[start..start + head_dim],
300            &weights.k_norm,
301            head_dim,
302            cfg.rms_norm_eps,
303        );
304    }
305
306    // Partial RoPE: stride-half pairing (i, half+i) — matches apply_partial_rope / HF rotate_half.
307    // When `mrope_cos_sin` is supplied (Qwen3.5 vision M-RoPE, ADR-069 S5b), the per-token
308    // interleaved-axis cos/sin row replaces the 1-D `rope` table lookup; the rotation formula
309    // is identical either way, so text-only decode (mrope_cos_sin=None) is untouched.
310    let half = rope_dim / 2;
311    for h in 0..num_q_heads {
312        let start = h * head_dim;
313        if let Some((cos_row, sin_row)) = mrope_cos_sin {
314            for i in 0..half {
315                let cos_val = cos_row[i];
316                let sin_val = sin_row[i];
317                let x0 = scratch.q_buf[start + i];
318                let x1 = scratch.q_buf[start + half + i];
319                scratch.q_buf[start + i] = x0 * cos_val - x1 * sin_val;
320                scratch.q_buf[start + half + i] = x0 * sin_val + x1 * cos_val;
321            }
322        } else {
323            let base = position * half;
324            for i in 0..half {
325                let cos_val = rope.cos_at(base + i);
326                let sin_val = rope.sin_at(base + i);
327                let x0 = scratch.q_buf[start + i];
328                let x1 = scratch.q_buf[start + half + i];
329                scratch.q_buf[start + i] = x0 * cos_val - x1 * sin_val;
330                scratch.q_buf[start + half + i] = x0 * sin_val + x1 * cos_val;
331            }
332        }
333    }
334    for h in 0..num_kv_heads {
335        let start = h * head_dim;
336        if let Some((cos_row, sin_row)) = mrope_cos_sin {
337            for i in 0..half {
338                let cos_val = cos_row[i];
339                let sin_val = sin_row[i];
340                let x0 = scratch.k_buf[start + i];
341                let x1 = scratch.k_buf[start + half + i];
342                scratch.k_buf[start + i] = x0 * cos_val - x1 * sin_val;
343                scratch.k_buf[start + half + i] = x0 * sin_val + x1 * cos_val;
344            }
345        } else {
346            let base = position * half;
347            for i in 0..half {
348                let cos_val = rope.cos_at(base + i);
349                let sin_val = rope.sin_at(base + i);
350                let x0 = scratch.k_buf[start + i];
351                let x1 = scratch.k_buf[start + half + i];
352                scratch.k_buf[start + i] = x0 * cos_val - x1 * sin_val;
353                scratch.k_buf[start + half + i] = x0 * sin_val + x1 * cos_val;
354            }
355        }
356    }
357
358    // Append to KV cache
359    kv_cache.append_kv(
360        cache_idx,
361        &scratch.k_buf[..kv_dim],
362        &scratch.v_buf[..kv_dim],
363    );
364    let cur_seq_len = kv_cache.seq_len + 1; // including current token
365
366    // Compute attention: for each Q head, find its KV head, compute scaled dot-product
367    let groups = num_q_heads / num_kv_heads;
368    let scale = 1.0 / (head_dim as f32).sqrt();
369
370    let k_cache = &kv_cache.k[cache_idx];
371    let v_cache = &kv_cache.v[cache_idx];
372
373    for qh in 0..num_q_heads {
374        let kvh = qh / groups;
375        let q_off = qh * head_dim;
376        let q = &scratch.q_buf[q_off..q_off + head_dim];
377
378        // Compute scores against all cached K vectors
379        let scores_start = qh * cur_seq_len;
380
381        for t in 0..cur_seq_len {
382            let k_off = t * kv_dim + kvh * head_dim;
383            let mut dot = 0.0f32;
384            for d in 0..head_dim {
385                dot += q[d] * k_cache[k_off + d];
386            }
387            scratch.scores[scores_start + t] = dot * scale;
388        }
389
390        // ADR-080 C1: route the fail-closed final decision through the
391        // shared row-finalizer contract (#780). RED before the fix: the
392        // bare `1.0 / sum_exp` had no guard, so a NaN/`+inf` cached score
393        // propagated NaN into the context output instead of failing the
394        // row closed. Mirrors the byte-identical duplicate in
395        // `model::qwen35::forward::compute_attention_context`.
396        let row = &mut scratch.scores[scores_start..scores_start + cur_seq_len];
397        let (max_score, any_nan) = crate::attention::softmax_row::row_max_and_any_nan(row);
398        if crate::attention::softmax_row::row_fails_closed_pre_exp(max_score, any_nan) {
399            row.fill(0.0);
400        } else {
401            let mut sum_exp = 0.0f32;
402            for v in row.iter_mut() {
403                *v = (*v - max_score).exp();
404                sum_exp += *v;
405            }
406            crate::attention::softmax_row::finalize_row(row, sum_exp);
407        }
408
409        // Weighted sum of V
410        let ctx_off = qh * head_dim;
411        for d in 0..head_dim {
412            let mut sum = 0.0f32;
413            for t in 0..cur_seq_len {
414                let v_off = t * kv_dim + kvh * head_dim;
415                sum += scratch.scores[scores_start + t] * v_cache[v_off + d];
416            }
417            scratch.context[ctx_off + d] = sum;
418        }
419    }
420
421    // Output gating: attn_output *= sigmoid(gate)
422    for (ctx, &gz) in scratch.context[..q_dim].iter_mut().zip(&gate_z[..q_dim]) {
423        let sig = 1.0 / (1.0 + (-gz).exp());
424        *ctx *= sig;
425    }
426
427    // Output projection: context [1, q_dim] @ o_proj^T [hidden, q_dim] (f16 weights)
428    matmul_bt_f16(
429        &scratch.context[..q_dim],
430        &weights.o_proj,
431        &mut scratch.attn_out[..hidden],
432        1,
433        q_dim,
434        hidden,
435    );
436}
437
438// ---------------------------------------------------------------------------
439// FFN step (f16 weights)
440// ---------------------------------------------------------------------------
441
442/// Dense SwiGLU FFN step using f16 weight matrices.
443///
444/// Input is read from `scratch.ffn_out[..hidden]`, output written back to
445/// `scratch.ffn_out[..hidden]`.
446#[inline]
447fn ffn_step_f16(
448    gate_proj: &[u16],
449    up_proj: &[u16],
450    down_proj: &[u16],
451    scratch: &mut ForwardScratch,
452    inter: usize,
453    hidden: usize,
454) {
455    scratch.input_tmp[..hidden].copy_from_slice(&scratch.ffn_out[..hidden]);
456
457    matmul_bt_f16(
458        &scratch.input_tmp[..hidden],
459        gate_proj,
460        &mut scratch.gate_buf[..inter],
461        1,
462        hidden,
463        inter,
464    );
465    matmul_bt_f16(
466        &scratch.input_tmp[..hidden],
467        up_proj,
468        &mut scratch.up_buf[..inter],
469        1,
470        hidden,
471        inter,
472    );
473
474    silu_inplace(&mut scratch.gate_buf[..inter]);
475    elementwise_mul(&mut scratch.gate_buf[..inter], &scratch.up_buf[..inter]);
476
477    matmul_bt_f16(
478        &scratch.gate_buf[..inter],
479        down_proj,
480        &mut scratch.ffn_out[..hidden],
481        1,
482        inter,
483        hidden,
484    );
485}
486
487/// MoE FFN step using f16 weight matrices.
488///
489/// Mirrors `moe_ffn_step` in `qwen35.rs`.
490/// Input is read from `scratch.ffn_out[..hidden]`, output written back to
491/// `scratch.ffn_out[..hidden]`.
492#[inline]
493fn moe_ffn_step_f16(moe: &F16MoeLayerWeights, scratch: &mut ForwardScratch, hidden: usize) {
494    let inter = moe.experts.intermediate_size;
495    let shared_inter = moe.shared_expert.intermediate_size;
496    let num_experts = moe.router.num_experts;
497    let top_k = moe.router.num_experts_per_tok;
498
499    debug_assert_eq!(moe.router.hidden_size, hidden);
500    debug_assert_eq!(moe.experts.num_experts, num_experts);
501    debug_assert_eq!(moe.experts.hidden_size, hidden);
502    debug_assert_eq!(moe.shared_expert.hidden_size, hidden);
503
504    scratch.input_tmp[..hidden].copy_from_slice(&scratch.ffn_out[..hidden]);
505
506    if scratch.router_logits.len() < num_experts {
507        scratch.router_logits.resize(num_experts, 0.0);
508    }
509    if scratch.router_selected.len() < top_k {
510        scratch.router_selected.resize(top_k, (usize::MAX, 0.0));
511    }
512
513    // Router logits: input [1, hidden] x gate^T [hidden, num_experts] -> [num_experts].
514    matmul_bt_f16(
515        &scratch.input_tmp[..hidden],
516        &moe.router.gate,
517        &mut scratch.router_logits[..num_experts],
518        1,
519        hidden,
520        num_experts,
521    );
522
523    // Stable softmax over f32 router logits.
524    let max_logit = scratch.router_logits[..num_experts]
525        .iter()
526        .copied()
527        .fold(f32::NEG_INFINITY, f32::max);
528    let mut denom = 0.0f32;
529    for v in &mut scratch.router_logits[..num_experts] {
530        *v = (*v - max_logit).exp();
531        denom += *v;
532    }
533    if denom > 0.0 {
534        for v in &mut scratch.router_logits[..num_experts] {
535            *v /= denom;
536        }
537    } else {
538        // Fail closed on a non-finite denom (NaN/±inf router logit from a corrupt
539        // f16 router gate weight or an upstream activation overflow), mirroring
540        // the f32 router fix in qwen35/moe.rs::compute_router_probs and the
541        // shared attention row contract (#409/#410). `max_logit` can stay finite
542        // when only one lane is NaN (Rust `f32::max` ignores a single NaN), so
543        // the NaN propagates into `denom` here and `denom > 0.0` is false.
544        // Without this the router would leave un-normalized raw `exp` values and,
545        // worse, an all-NaN row selects nothing below (`NaN > NEG_INF` is false),
546        // leaving a `usize::MAX` sentinel that overflows expert indexing. Zeroing
547        // drops the routed path (the shared expert still runs).
548        scratch.router_logits[..num_experts].fill(0.0);
549    }
550
551    // Insertion-sort top-k selection.
552    for slot in &mut scratch.router_selected[..top_k] {
553        *slot = (usize::MAX, f32::NEG_INFINITY);
554    }
555    for (expert_id, prob) in scratch.router_logits[..num_experts]
556        .iter()
557        .copied()
558        .enumerate()
559    {
560        for rank in 0..top_k {
561            if prob > scratch.router_selected[rank].1 {
562                for shift in (rank + 1..top_k).rev() {
563                    scratch.router_selected[shift] = scratch.router_selected[shift - 1];
564                }
565                scratch.router_selected[rank] = (expert_id, prob);
566                break;
567            }
568        }
569    }
570
571    let top_sum: f32 = scratch.router_selected[..top_k]
572        .iter()
573        .map(|(_, p)| *p)
574        .sum();
575    if top_sum > 0.0 {
576        for (_, prob) in &mut scratch.router_selected[..top_k] {
577            *prob /= top_sum;
578        }
579    }
580
581    scratch.expert_out[..hidden].fill(0.0);
582
583    for idx in 0..top_k {
584        let (expert_id, weight) = scratch.router_selected[idx];
585        // Defense in depth: never index expert weights with an unfilled sentinel
586        // slot or a router-only expert id. A degenerate router row can leave
587        // `(usize::MAX, _)` for a rank it could not fill, and a public f16 weight
588        // set may declare more router experts than routed-expert storage; either
589        // way `expert_id * gate_up_stride` would overflow / OOB the `moe.experts`
590        // slices below. Bound on the storage count `moe.experts.num_experts`,
591        // matching the f32 sibling (qwen35/moe.rs::accumulate_routed_experts).
592        if expert_id >= moe.experts.num_experts {
593            continue;
594        }
595        debug_assert_ne!(expert_id, usize::MAX);
596
597        let gate_up_stride = 2 * inter * hidden;
598        let gate_up_start = expert_id * gate_up_stride;
599        let down_start = expert_id * hidden * inter;
600
601        // `gate_up_proj` is [num_experts, 2 * inter, hidden]; first half is gate, second is up.
602        let gate_w = &moe.experts.gate_up_proj[gate_up_start..gate_up_start + inter * hidden];
603        let up_w = &moe.experts.gate_up_proj
604            [gate_up_start + inter * hidden..gate_up_start + 2 * inter * hidden];
605        let down_w = &moe.experts.down_proj[down_start..down_start + hidden * inter];
606
607        matmul_bt_f16(
608            &scratch.input_tmp[..hidden],
609            gate_w,
610            &mut scratch.gate_buf[..inter],
611            1,
612            hidden,
613            inter,
614        );
615        matmul_bt_f16(
616            &scratch.input_tmp[..hidden],
617            up_w,
618            &mut scratch.up_buf[..inter],
619            1,
620            hidden,
621            inter,
622        );
623
624        silu_inplace(&mut scratch.gate_buf[..inter]);
625        elementwise_mul(&mut scratch.gate_buf[..inter], &scratch.up_buf[..inter]);
626
627        scratch.down_input[..inter].copy_from_slice(&scratch.gate_buf[..inter]);
628        matmul_bt_f16(
629            &scratch.down_input[..inter],
630            down_w,
631            &mut scratch.ffn_out[..hidden],
632            1,
633            inter,
634            hidden,
635        );
636
637        for i in 0..hidden {
638            scratch.expert_out[i] += weight * scratch.ffn_out[i];
639        }
640    }
641
642    let shared = &moe.shared_expert;
643
644    // Shared gate: input [1, hidden] x shared_expert_gate^T [hidden, 1] -> [1].
645    let mut shared_gate_logit = [0.0f32; 1];
646    matmul_bt_f16(
647        &scratch.input_tmp[..hidden],
648        &shared.shared_expert_gate,
649        &mut shared_gate_logit,
650        1,
651        hidden,
652        1,
653    );
654    let shared_gate = sigmoid(shared_gate_logit[0]);
655
656    matmul_bt_f16(
657        &scratch.input_tmp[..hidden],
658        &shared.gate_proj,
659        &mut scratch.gate_buf[..shared_inter],
660        1,
661        hidden,
662        shared_inter,
663    );
664    matmul_bt_f16(
665        &scratch.input_tmp[..hidden],
666        &shared.up_proj,
667        &mut scratch.up_buf[..shared_inter],
668        1,
669        hidden,
670        shared_inter,
671    );
672
673    silu_inplace(&mut scratch.gate_buf[..shared_inter]);
674    elementwise_mul(
675        &mut scratch.gate_buf[..shared_inter],
676        &scratch.up_buf[..shared_inter],
677    );
678
679    scratch.down_input[..shared_inter].copy_from_slice(&scratch.gate_buf[..shared_inter]);
680    matmul_bt_f16(
681        &scratch.down_input[..shared_inter],
682        &shared.down_proj,
683        &mut scratch.ffn_out[..hidden],
684        1,
685        shared_inter,
686        hidden,
687    );
688
689    for i in 0..hidden {
690        scratch.expert_out[i] += shared_gate * scratch.ffn_out[i];
691    }
692
693    scratch.ffn_out[..hidden].copy_from_slice(&scratch.expert_out[..hidden]);
694}
695
696// ---------------------------------------------------------------------------
697// Forward step (f16 weights)
698// ---------------------------------------------------------------------------
699
700/// Single-token forward pass using f16 weight matrices.
701///
702/// Equivalent to `Qwen35Model::forward_step` but all large projection matrices
703/// (embeddings, QKV, FFN gate/up/down, output projections) use `matmul_bt_f16`.
704/// Norms, recurrent state, and activations remain in `f32`.
705///
706/// Writes logits into `scratch.logits`.
707pub(crate) fn forward_step_f16(
708    weights: &F16ModelWeights,
709    cfg: &Qwen35Config,
710    rope: &RopeTable,
711    token_id: u32,
712    position: usize,
713    gdn_states: &mut [GatedDeltaNetState],
714    kv_cache: &mut KvCache,
715    scratch: &mut ForwardScratch,
716    injected_embedding: Option<&[f32]>,
717    mrope_cos_sin: Option<(&[f32], &[f32])>,
718) -> Result<(), crate::error::InferenceError> {
719    let hidden = cfg.hidden_size;
720
721    scratch.ensure_capacity(cfg, kv_cache.seq_len + 1);
722
723    match injected_embedding {
724        // Qwen3.5 vision M-RoPE (ADR-069 S5b): REPLACE the token-embedding lookup with the
725        // caller-supplied post-merger visual row at an `<|image_pad|>` slot (HF's
726        // `masked_scatter` contract). Fail closed rather than poison the KV state with a
727        // wrong-shape or non-finite row.
728        Some(row) => {
729            if row.len() != hidden {
730                return Err(crate::error::InferenceError::InvalidInput(format!(
731                    "injected_embedding length {} does not match hidden_size {hidden}",
732                    row.len()
733                )));
734            }
735            if let Some(bad) = row.iter().find(|v| !v.is_finite()) {
736                return Err(crate::error::InferenceError::InvalidInput(format!(
737                    "injected_embedding contains a non-finite value: {bad}"
738                )));
739            }
740            scratch.hidden[..hidden].copy_from_slice(row);
741        }
742        None => {
743            // Embedding lookup: f16 embed_tokens -> f32 hidden
744            let embed_start = token_id as usize * hidden;
745            f16_to_f32_slice(
746                &weights.embed_tokens[embed_start..embed_start + hidden],
747                &mut scratch.hidden[..hidden],
748            );
749        }
750    }
751
752    let mut linear_idx = 0usize;
753    let mut full_idx = 0usize;
754
755    for layer_i in 0..cfg.num_hidden_layers {
756        let (attn_weights, common) = &weights.layers[layer_i];
757
758        // Save residual
759        scratch.residual[..hidden].copy_from_slice(&scratch.hidden[..hidden]);
760
761        // Pre-attention RMSNorm (Qwen3.5: 1 + gamma, f32 norms)
762        qwen35_rms_norm(
763            &mut scratch.hidden[..hidden],
764            &common.input_layernorm,
765            hidden,
766            cfg.rms_norm_eps,
767        );
768
769        // Attention
770        match attn_weights {
771            F16AttentionWeights::Linear(gdn_w) => {
772                gated_delta_net_step_fused_f16(
773                    &scratch.hidden[..hidden],
774                    &mut gdn_states[linear_idx],
775                    gdn_w,
776                    cfg,
777                    &mut scratch.gdn_scratch,
778                    &mut scratch.attn_out[..hidden],
779                );
780                linear_idx += 1;
781            }
782            F16AttentionWeights::Full(full_w) => {
783                // Copy hidden to attn_out as temp input to avoid borrow conflict
784                scratch.attn_out[..hidden].copy_from_slice(&scratch.hidden[..hidden]);
785                full_attention_step_f16(
786                    full_w,
787                    cache_idx_of(full_idx),
788                    position,
789                    kv_cache,
790                    scratch,
791                    cfg,
792                    rope,
793                    hidden,
794                    mrope_cos_sin,
795                );
796                full_idx += 1;
797            }
798        }
799
800        // Residual connection
801        for i in 0..hidden {
802            scratch.hidden[i] = scratch.residual[i] + scratch.attn_out[i];
803        }
804
805        // Save residual for FFN
806        scratch.residual[..hidden].copy_from_slice(&scratch.hidden[..hidden]);
807
808        // Post-attention RMSNorm (Qwen3.5: 1 + gamma, f32 norms)
809        qwen35_rms_norm(
810            &mut scratch.hidden[..hidden],
811            &common.post_attention_layernorm,
812            hidden,
813            cfg.rms_norm_eps,
814        );
815
816        // FFN: copy hidden into ffn_out as temp input to avoid borrow conflict
817        scratch.ffn_out[..hidden].copy_from_slice(&scratch.hidden[..hidden]);
818        match &common.ffn {
819            F16FeedForwardWeights::Dense {
820                gate_proj,
821                up_proj,
822                down_proj,
823            } => {
824                ffn_step_f16(
825                    gate_proj,
826                    up_proj,
827                    down_proj,
828                    scratch,
829                    cfg.intermediate_size,
830                    hidden,
831                );
832            }
833            F16FeedForwardWeights::Moe(moe) => {
834                moe_ffn_step_f16(moe, scratch, hidden);
835            }
836        }
837
838        // Residual connection
839        for i in 0..hidden {
840            scratch.hidden[i] = scratch.residual[i] + scratch.ffn_out[i];
841        }
842    }
843
844    // Final RMSNorm (Qwen3.5: 1 + gamma, f32 norm)
845    qwen35_rms_norm(
846        &mut scratch.hidden[..hidden],
847        &weights.final_norm,
848        hidden,
849        cfg.rms_norm_eps,
850    );
851
852    // Logits: hidden @ embed_tokens^T (tied weights, f16)
853    // hidden [1, hidden] @ embed_tokens^T [hidden, vocab] = logits [1, vocab]
854    // embed_tokens is [vocab, hidden] in row-major f16, so matmul_bt_f16 computes
855    // hidden @ embed_tokens^T correctly.
856    resize(&mut scratch.logits, cfg.vocab_size);
857    matmul_bt_f16(
858        &scratch.hidden[..hidden],
859        &weights.embed_tokens,
860        &mut scratch.logits[..cfg.vocab_size],
861        1,
862        hidden,
863        cfg.vocab_size,
864    );
865
866    Ok(())
867}
868
869/// Identity function for cache index -- full_idx IS the cache index.
870#[inline(always)]
871fn cache_idx_of(full_idx: usize) -> usize {
872    full_idx
873}
874
875// ---------------------------------------------------------------------------
876// Generate (f16 weights)
877// ---------------------------------------------------------------------------
878
879/// **Unstable**: f16-weight generate; function signature will likely merge with model struct API.
880///
881/// Generate text from a prompt using f16 weight matrices.
882///
883/// Equivalent to `Qwen35Model::generate` but calls `forward_step_f16` for all
884/// forward passes. The tokenizer, RoPE table, and generate config are passed
885/// explicitly since we operate as standalone functions rather than methods on
886/// the model struct.
887pub fn generate_f16(
888    weights: &F16ModelWeights,
889    cfg: &Qwen35Config,
890    tokenizer: &BpeTokenizer,
891    rope: &RopeTable,
892    prompt: &str,
893    gen_cfg: &GenerateConfig,
894) -> Result<GenerateOutput, crate::error::InferenceError> {
895    let plan = match prepare_generation(
896        tokenizer,
897        prompt,
898        gen_cfg,
899        cfg.vocab_size,
900        rope.max_positions(),
901        GenerationEntryContract::StandaloneCpu,
902    )? {
903        GenerationPreparation::Ready(plan) => plan,
904        GenerationPreparation::Complete(output) => return Ok(output),
905    };
906    let GenerationPlan {
907        mut rng_state,
908        prompt_ids,
909        prompt_len,
910        ..
911    } = plan;
912
913    // Initialize states
914    let num_linear = cfg.num_linear_attention_layers();
915    let num_full = cfg.num_full_attention_layers();
916    let mut gdn_states: Vec<GatedDeltaNetState> = (0..num_linear)
917        .map(|_| GatedDeltaNetState::new(cfg))
918        .collect();
919    let mut kv_cache = KvCache::new(num_full);
920    let mut scratch = ForwardScratch::new();
921
922    let mut generated_ids: Vec<u32> = Vec::with_capacity(gen_cfg.max_new_tokens);
923    let mut all_ids = prompt_ids.clone();
924
925    // Prefill: process prompt tokens one at a time through the recurrence
926    for (pos, &token_id) in prompt_ids.iter().enumerate() {
927        forward_step_f16(
928            weights,
929            cfg,
930            rope,
931            token_id,
932            pos,
933            &mut gdn_states,
934            &mut kv_cache,
935            &mut scratch,
936            None,
937            None,
938        )?;
939        if pos < prompt_len - 1 {
940            kv_cache.seq_len += 1;
941        }
942    }
943    kv_cache.seq_len = prompt_len;
944
945    // Sample from last prefill logits
946    let next_id = sample_token(
947        &scratch.logits[..cfg.vocab_size],
948        gen_cfg,
949        &all_ids,
950        &mut rng_state,
951    );
952
953    if should_stop_token(cfg, gen_cfg, next_id) {
954        return Ok(GenerateOutput {
955            text: String::new(),
956            token_ids: vec![],
957            prompt_tokens: prompt_len,
958            generated_tokens: 0,
959            stopped: true,
960            stop_reason: Some(StopReason::Eos),
961            token_logprobs: vec![],
962        });
963    }
964
965    generated_ids.push(next_id);
966    all_ids.push(next_id);
967
968    let mut stopped = false;
969    let mut stop_reason = StopReason::Length;
970    // Autoregressive decode
971    for _ in 1..gen_cfg.max_new_tokens {
972        let pos = kv_cache.seq_len;
973        let last_token = *all_ids
974            .last()
975            .expect("invariant: prompt or previous sample populated all_ids");
976
977        forward_step_f16(
978            weights,
979            cfg,
980            rope,
981            last_token,
982            pos,
983            &mut gdn_states,
984            &mut kv_cache,
985            &mut scratch,
986            None,
987            None,
988        )?;
989        kv_cache.seq_len += 1;
990
991        let next_id = sample_token(
992            &scratch.logits[..cfg.vocab_size],
993            gen_cfg,
994            &all_ids,
995            &mut rng_state,
996        );
997
998        if should_stop_token(cfg, gen_cfg, next_id) {
999            stopped = true;
1000            stop_reason = StopReason::Eos;
1001            break;
1002        }
1003
1004        generated_ids.push(next_id);
1005        all_ids.push(next_id);
1006    }
1007
1008    // Detokenize
1009    let text = decode_tokens(tokenizer, &generated_ids);
1010
1011    Ok(GenerateOutput {
1012        text,
1013        token_ids: generated_ids.clone(),
1014        prompt_tokens: prompt_len,
1015        generated_tokens: generated_ids.len(),
1016        stopped,
1017        stop_reason: Some(stop_reason),
1018        token_logprobs: vec![],
1019    })
1020}
1021
1022// ---------------------------------------------------------------------------
1023// Generate multimodal (f16 weights, ADR-069 Stage 5b)
1024// ---------------------------------------------------------------------------
1025
1026/// Greedy-decode a Qwen3.5 vision-language prompt through the CPU f16 forward
1027/// path (ADR-069 Stage 5b): the decoder splice on top of [`generate_f16`].
1028///
1029/// Mirrors `generate_f16`'s prefill/decode loop, but drives it from
1030/// [`crate::vision::multimodal::Qwen35VisionRequest`]'s already-expanded
1031/// `input_ids` instead of a tokenizer call, injects each post-merger visual
1032/// row at its `<|image_pad|>` slot (masked REPLACE, not add), and threads a
1033/// per-token M-RoPE cos/sin row into every full-attention (GQA) layer in
1034/// place of the 1-D `RopeTable`. GDN layers are untouched — they never
1035/// receive rope. Fails closed via `request.validate()` before any decoder
1036/// work begins.
1037///
1038/// No tokenizer is available here (the request already carries expanded
1039/// token ids), so `GenerateOutput.text` is always empty; callers that need
1040/// decoded text detokenize `token_ids` themselves.
1041pub fn generate_multimodal_f16(
1042    weights: &F16ModelWeights,
1043    cfg: &Qwen35Config,
1044    request: &crate::vision::multimodal::Qwen35VisionRequest,
1045    gen_cfg: &GenerateConfig,
1046) -> Result<GenerateOutput, crate::error::InferenceError> {
1047    request.validate().map_err(|e| {
1048        crate::error::InferenceError::InvalidInput(format!(
1049            "multimodal request failed validation: {e}"
1050        ))
1051    })?;
1052
1053    // Caller-supplied `input_ids` must be bounded against the
1054    // checkpoint vocabulary before any decoder allocation/work begins — an
1055    // out-of-range id would otherwise panic in `forward_step_f16`'s embedding-table
1056    // slice (`token_id * hidden`) instead of failing closed.
1057    if let Some(&bad_id) = request
1058        .input_ids
1059        .iter()
1060        .find(|&&id| id as usize >= cfg.vocab_size)
1061    {
1062        return Err(crate::error::InferenceError::InvalidInput(format!(
1063            "input_ids contains out-of-vocabulary token id {bad_id} (vocab_size={})",
1064            cfg.vocab_size
1065        )));
1066    }
1067
1068    let has_image = !request.image_grids.is_empty();
1069
1070    // `request.validate()` proves only internal consistency —
1071    // bind the request to the *loaded checkpoint's* vision metadata before
1072    // selecting image slots or building M-RoPE tables, otherwise an internally
1073    // consistent request targeting the wrong checkpoint silently injects rows at
1074    // the wrong slots / applies image M-RoPE where HF would treat them as text.
1075    if has_image {
1076        let cfg_image_token_id = cfg.image_token_id.ok_or_else(|| {
1077            crate::error::InferenceError::InvalidInput(
1078                "multimodal request supplied but checkpoint has no image_token_id".to_string(),
1079            )
1080        })?;
1081        if cfg_image_token_id != request.image_token_id {
1082            return Err(crate::error::InferenceError::InvalidInput(format!(
1083                "request image_token_id {} does not match checkpoint image_token_id {cfg_image_token_id}",
1084                request.image_token_id
1085            )));
1086        }
1087        let vision_cfg = cfg.vision_config.as_ref().ok_or_else(|| {
1088            crate::error::InferenceError::InvalidInput(
1089                "multimodal request supplied but checkpoint has no vision_config".to_string(),
1090            )
1091        })?;
1092        if vision_cfg.spatial_merge_size != request.spatial_merge_size {
1093            return Err(crate::error::InferenceError::InvalidInput(format!(
1094                "request spatial_merge_size {} does not match checkpoint \
1095                 vision_config.spatial_merge_size {}",
1096                request.spatial_merge_size, vision_cfg.spatial_merge_size
1097            )));
1098        }
1099        if request.decoder_hidden_size != cfg.hidden_size {
1100            return Err(crate::error::InferenceError::InvalidInput(format!(
1101                "request decoder_hidden_size {} does not match checkpoint hidden_size {}",
1102                request.decoder_hidden_size, cfg.hidden_size
1103            )));
1104        }
1105        if vision_cfg.out_hidden_size != cfg.hidden_size {
1106            return Err(crate::error::InferenceError::InvalidInput(format!(
1107                "checkpoint vision_config.out_hidden_size {} does not match decoder \
1108                 hidden_size {}",
1109                vision_cfg.out_hidden_size, cfg.hidden_size
1110            )));
1111        }
1112    }
1113
1114    let (positions, tables) = request.build_mrope_tables(cfg)?;
1115
1116    // The M-RoPE table builder resolves `partial_rotary_factor`
1117    // from `cfg.rope_parameters`, while the attention loop derives its rotary
1118    // half-width from the separately public `cfg.rope_dim()`
1119    // (`cfg.partial_rotary_factor`). A constructible config where these diverge
1120    // must fail closed here, before the first forward pass indexes `cos_row`/
1121    // `sin_row` past the table's actual row width.
1122    let expected_rope_half = cfg.rope_dim() / 2;
1123    if tables.cos.iter().any(|row| row.len() != expected_rope_half)
1124        || tables.sin.iter().any(|row| row.len() != expected_rope_half)
1125    {
1126        return Err(crate::error::InferenceError::InvalidInput(format!(
1127            "M-RoPE table row width does not match decoder rotary half-width: expected \
1128             {expected_rope_half}"
1129        )));
1130    }
1131
1132    let prompt_ids = &request.input_ids;
1133    let prompt_len = prompt_ids.len();
1134    crate::model::qwen35::check_prompt_not_empty(prompt_len)?;
1135
1136    if gen_cfg.max_new_tokens == 0 {
1137        return Ok(GenerateOutput {
1138            text: String::new(),
1139            token_ids: vec![],
1140            prompt_tokens: prompt_len,
1141            generated_tokens: 0,
1142            stopped: false,
1143            stop_reason: Some(StopReason::Length),
1144            token_logprobs: vec![],
1145        });
1146    }
1147
1148    crate::model::qwen35::check_grammar_not_set(gen_cfg)?;
1149    crate::model::qwen35::check_logprobs_not_set(gen_cfg)?;
1150    crate::model::qwen35::check_stop_strings_not_set(gen_cfg)?;
1151    crate::model::qwen35::check_reasoning_budget_not_set(gen_cfg)?;
1152
1153    let max_context = cfg.max_position_embeddings;
1154    if prompt_len.saturating_add(gen_cfg.max_new_tokens) > max_context {
1155        return Err(crate::error::InferenceError::Inference(format!(
1156            "prompt ({prompt_len} tokens) plus max_new_tokens ({}) exceeds \
1157             model context window ({max_context})",
1158            gen_cfg.max_new_tokens
1159        )));
1160    }
1161
1162    let mut rng_state = match gen_cfg.seed {
1163        Some(s) => {
1164            if s == 0 {
1165                1
1166            } else {
1167                s
1168            }
1169        }
1170        None => {
1171            use std::time::SystemTime;
1172            let t = SystemTime::now()
1173                .duration_since(SystemTime::UNIX_EPOCH)
1174                .map(|d| d.as_nanos() as u64)
1175                .unwrap_or(0x12345678_9abcdef0);
1176            if t == 0 { 1 } else { t }
1177        }
1178    };
1179
1180    let num_linear = cfg.num_linear_attention_layers();
1181    let num_full = cfg.num_full_attention_layers();
1182    let mut gdn_states: Vec<GatedDeltaNetState> = (0..num_linear)
1183        .map(|_| GatedDeltaNetState::new(cfg))
1184        .collect();
1185    let mut kv_cache = KvCache::new(num_full);
1186    let mut scratch = ForwardScratch::new();
1187
1188    // A request with no image runs needs no M-RoPE divergence: every position's 3 axes are
1189    // trivially equal to the sequential index (rope_delta=0), so the plain 1-D `RopeTable`
1190    // reproduces `build_cos_sin`'s output exactly in the f64-precomputed-table sense —
1191    // `generate_f16`'s own path — rather than the fresh f32 per-call computation, which is
1192    // only mathematically (not bit-) equivalent. Route text-only requests through the
1193    // unchanged 1-D table so they are bit-identical to `generate_f16`, and reserve the M-RoPE
1194    // table for requests that actually contain an image (where axes genuinely diverge).
1195    // `has_image` was already computed above (before `build_mrope_tables`) for the
1196    // checkpoint-binding guard; reused here rather than recomputed.
1197    let rope = RopeTable::new(cfg.rope_dim(), max_context, cfg.rope_theta);
1198
1199    let mut generated_ids: Vec<u32> = Vec::with_capacity(gen_cfg.max_new_tokens);
1200    let mut all_ids = prompt_ids.clone();
1201
1202    // Prefill: inject a post-merger visual row at each `<|image_pad|>` slot, in the same
1203    // sequential image-token order the request already concatenated `post_merger_rows` in
1204    // (HF's masked_scatter contract, ADR-069 RECON sec. 1).
1205    let mut visual_row = 0usize;
1206    for (pos, &token_id) in prompt_ids.iter().enumerate() {
1207        let injected = if token_id == request.image_token_id {
1208            let start = visual_row * request.decoder_hidden_size;
1209            let end = start + request.decoder_hidden_size;
1210            visual_row += 1;
1211            Some(&request.post_merger_rows[start..end])
1212        } else {
1213            None
1214        };
1215        let cos_sin = if has_image {
1216            Some((tables.cos[pos].as_slice(), tables.sin[pos].as_slice()))
1217        } else {
1218            None
1219        };
1220
1221        forward_step_f16(
1222            weights,
1223            cfg,
1224            &rope,
1225            token_id,
1226            pos,
1227            &mut gdn_states,
1228            &mut kv_cache,
1229            &mut scratch,
1230            injected,
1231            cos_sin,
1232        )?;
1233        if pos < prompt_len - 1 {
1234            kv_cache.seq_len += 1;
1235        }
1236    }
1237    kv_cache.seq_len = prompt_len;
1238
1239    let next_id = sample_token(
1240        &scratch.logits[..cfg.vocab_size],
1241        gen_cfg,
1242        &all_ids,
1243        &mut rng_state,
1244    );
1245
1246    if should_stop_token(cfg, gen_cfg, next_id) {
1247        return Ok(GenerateOutput {
1248            text: String::new(),
1249            token_ids: vec![],
1250            prompt_tokens: prompt_len,
1251            generated_tokens: 0,
1252            stopped: true,
1253            stop_reason: Some(StopReason::Eos),
1254            token_logprobs: vec![],
1255        });
1256    }
1257
1258    generated_ids.push(next_id);
1259    all_ids.push(next_id);
1260
1261    let mut stopped = false;
1262    let mut stop_reason = StopReason::Length;
1263    // Autoregressive decode: the KV-cache index (`physical_pos`) stays contiguous/physical,
1264    // while the M-RoPE coordinate is `physical_cache_len + rope_delta` (ADR-069 RECON sec. 3) —
1265    // they diverge whenever the prompt contained an image.
1266    for _ in 1..gen_cfg.max_new_tokens {
1267        let physical_pos = kv_cache.seq_len;
1268        let last_token = *all_ids
1269            .last()
1270            .expect("invariant: prompt or previous sample populated all_ids");
1271
1272        // Owns the M-RoPE row buffers for this iteration so both branches below can borrow
1273        // from a value with the same lifetime as the `forward_step_f16` call.
1274        let decode_cos_sin;
1275        let mrope_cos_sin = if has_image {
1276            let decode_axis =
1277                crate::vision::qwen35_mrope::decode_position(physical_pos, positions.rope_delta)?;
1278            decode_cos_sin = request.build_decode_cos_sin(cfg, decode_axis)?;
1279            // Decode-time sibling of the prefill row-width guard: the prefill table's row-width
1280            // guard has no effect on this independently-built single-row decode
1281            // table — check it here too, before it reaches the attention loop's
1282            // `cos_row[i]`/`sin_row[i]` indexing.
1283            if decode_cos_sin.0.len() != expected_rope_half
1284                || decode_cos_sin.1.len() != expected_rope_half
1285            {
1286                return Err(crate::error::InferenceError::InvalidInput(format!(
1287                    "decode-time M-RoPE row width does not match decoder rotary \
1288                     half-width: expected {expected_rope_half}"
1289                )));
1290            }
1291            Some((decode_cos_sin.0.as_slice(), decode_cos_sin.1.as_slice()))
1292        } else {
1293            None
1294        };
1295
1296        forward_step_f16(
1297            weights,
1298            cfg,
1299            &rope,
1300            last_token,
1301            physical_pos,
1302            &mut gdn_states,
1303            &mut kv_cache,
1304            &mut scratch,
1305            None,
1306            mrope_cos_sin,
1307        )?;
1308        kv_cache.seq_len += 1;
1309
1310        let next_id = sample_token(
1311            &scratch.logits[..cfg.vocab_size],
1312            gen_cfg,
1313            &all_ids,
1314            &mut rng_state,
1315        );
1316
1317        if should_stop_token(cfg, gen_cfg, next_id) {
1318            stopped = true;
1319            stop_reason = StopReason::Eos;
1320            break;
1321        }
1322
1323        generated_ids.push(next_id);
1324        all_ids.push(next_id);
1325    }
1326
1327    Ok(GenerateOutput {
1328        text: String::new(),
1329        token_ids: generated_ids.clone(),
1330        prompt_tokens: prompt_len,
1331        generated_tokens: generated_ids.len(),
1332        stopped,
1333        stop_reason: Some(stop_reason),
1334        token_logprobs: vec![],
1335    })
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Pooled embedding extraction (vision-embed-pooling): image + text, same
1340// decoder + same pooling, so both land in the same vector space (GME-style).
1341// ---------------------------------------------------------------------------
1342
1343/// How to collapse a prefill's per-position hidden states into one
1344/// fixed-size embedding vector.
1345///
1346/// **Retrieval quality with the base Qwen3.5-0.8B *instruct* checkpoint is
1347/// unvalidated.** GME-style pooled embeddings normally come from a
1348/// checkpoint that has been contrastively fine-tuned for retrieval
1349/// (image-text matching, hard-negative mining); the base instruct checkpoint
1350/// was never trained for that objective. What this module provides — and
1351/// what is tested — is the extraction *machinery*: pooling over the
1352/// verifiably correct positions, deterministically, into a unit-norm
1353/// vector. Picking (or fine-tuning) a checkpoint for retrieval quality is a
1354/// separate, later decision.
1355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1356pub enum PoolingStrategy {
1357    /// Mean over the hidden states at the request's `<|image_pad|>`
1358    /// positions (the visual tokens). For a text-only request — no image
1359    /// runs, so no pad tokens are present — this degrades to a mean over
1360    /// every position, i.e. an ordinary mean-pooled text embedding rather
1361    /// than an error.
1362    MeanVisualTokens,
1363    /// The hidden state at the last physical position — GME's
1364    /// text-embedding convention. Well-defined for both image and
1365    /// text-only requests.
1366    LastToken,
1367}
1368
1369/// Run prefill ONLY (no sampling, no decode loop) over `request.input_ids`,
1370/// injecting each post-merger visual row at its `<|image_pad|>` slot exactly
1371/// as [`generate_multimodal_f16`] does, and return every position's final
1372/// hidden state (post-final-norm, pre-lm_head projection) as a flat
1373/// row-major `[seq_len * cfg.hidden_size]` buffer.
1374///
1375/// Shares `generate_multimodal_f16`'s request validation, checkpoint-binding
1376/// checks, and M-RoPE/injection wiring; the only behavioral difference is
1377/// that this never samples a token, so it takes no [`GenerateConfig`].
1378///
1379/// # Errors
1380///
1381/// See [`generate_multimodal_f16`]'s error conditions — the same
1382/// `request.validate()`, out-of-vocabulary, checkpoint-binding, and M-RoPE
1383/// table checks apply here, minus the generation-only checks (grammar,
1384/// logprobs, stop strings, reasoning budget) that do not apply to a
1385/// prefill-only call.
1386pub fn prefill_hidden_states_f16(
1387    weights: &F16ModelWeights,
1388    cfg: &Qwen35Config,
1389    request: &Qwen35VisionRequest,
1390) -> Result<Vec<f32>, crate::error::InferenceError> {
1391    request.validate().map_err(|e| {
1392        crate::error::InferenceError::InvalidInput(format!(
1393            "multimodal request failed validation: {e}"
1394        ))
1395    })?;
1396
1397    if let Some(&bad_id) = request
1398        .input_ids
1399        .iter()
1400        .find(|&&id| id as usize >= cfg.vocab_size)
1401    {
1402        return Err(crate::error::InferenceError::InvalidInput(format!(
1403            "input_ids contains out-of-vocabulary token id {bad_id} (vocab_size={})",
1404            cfg.vocab_size
1405        )));
1406    }
1407
1408    let has_image = !request.image_grids.is_empty();
1409
1410    // Mirrors generate_multimodal_f16's checkpoint-binding guard: an
1411    // internally consistent request can still target the wrong checkpoint.
1412    if has_image {
1413        let cfg_image_token_id = cfg.image_token_id.ok_or_else(|| {
1414            crate::error::InferenceError::InvalidInput(
1415                "multimodal request supplied but checkpoint has no image_token_id".to_string(),
1416            )
1417        })?;
1418        if cfg_image_token_id != request.image_token_id {
1419            return Err(crate::error::InferenceError::InvalidInput(format!(
1420                "request image_token_id {} does not match checkpoint image_token_id {cfg_image_token_id}",
1421                request.image_token_id
1422            )));
1423        }
1424        let vision_cfg = cfg.vision_config.as_ref().ok_or_else(|| {
1425            crate::error::InferenceError::InvalidInput(
1426                "multimodal request supplied but checkpoint has no vision_config".to_string(),
1427            )
1428        })?;
1429        if vision_cfg.spatial_merge_size != request.spatial_merge_size {
1430            return Err(crate::error::InferenceError::InvalidInput(format!(
1431                "request spatial_merge_size {} does not match checkpoint \
1432                 vision_config.spatial_merge_size {}",
1433                request.spatial_merge_size, vision_cfg.spatial_merge_size
1434            )));
1435        }
1436        if request.decoder_hidden_size != cfg.hidden_size {
1437            return Err(crate::error::InferenceError::InvalidInput(format!(
1438                "request decoder_hidden_size {} does not match checkpoint hidden_size {}",
1439                request.decoder_hidden_size, cfg.hidden_size
1440            )));
1441        }
1442        if vision_cfg.out_hidden_size != cfg.hidden_size {
1443            return Err(crate::error::InferenceError::InvalidInput(format!(
1444                "checkpoint vision_config.out_hidden_size {} does not match decoder \
1445                 hidden_size {}",
1446                vision_cfg.out_hidden_size, cfg.hidden_size
1447            )));
1448        }
1449    }
1450
1451    // Reject empty and over-context prompts before build_mrope_tables, which
1452    // otherwise materializes a position entry plus cos/sin rows per supplied
1453    // token — unbounded input must fail cheaply, not after that allocation.
1454    let prompt_len = request.input_ids.len();
1455    crate::model::qwen35::check_prompt_not_empty(prompt_len)?;
1456    let max_context = cfg.max_position_embeddings;
1457    if prompt_len > max_context {
1458        return Err(crate::error::InferenceError::Inference(format!(
1459            "prompt ({prompt_len} tokens) exceeds model context window ({max_context})"
1460        )));
1461    }
1462
1463    let (_positions, tables) = request.build_mrope_tables(cfg)?;
1464
1465    let expected_rope_half = cfg.rope_dim() / 2;
1466    if tables.cos.iter().any(|row| row.len() != expected_rope_half)
1467        || tables.sin.iter().any(|row| row.len() != expected_rope_half)
1468    {
1469        return Err(crate::error::InferenceError::InvalidInput(format!(
1470            "M-RoPE table row width does not match decoder rotary half-width: expected \
1471             {expected_rope_half}"
1472        )));
1473    }
1474
1475    let prompt_ids = &request.input_ids;
1476
1477    let num_linear = cfg.num_linear_attention_layers();
1478    let num_full = cfg.num_full_attention_layers();
1479    let mut gdn_states: Vec<GatedDeltaNetState> = (0..num_linear)
1480        .map(|_| GatedDeltaNetState::new(cfg))
1481        .collect();
1482    let mut kv_cache = KvCache::new(num_full);
1483    let mut scratch = ForwardScratch::new();
1484
1485    // Text-only requests route through the plain 1-D RopeTable (cos_sin =
1486    // None below), bit-identical to generate_f16/generate_multimodal_f16;
1487    // only image-bearing requests use the M-RoPE table.
1488    let rope = RopeTable::new(cfg.rope_dim(), max_context, cfg.rope_theta);
1489
1490    let hidden = cfg.hidden_size;
1491    let mut hidden_states: Vec<f32> = Vec::with_capacity(prompt_len * hidden);
1492
1493    let mut visual_row = 0usize;
1494    for (pos, &token_id) in prompt_ids.iter().enumerate() {
1495        let injected = if token_id == request.image_token_id {
1496            let start = visual_row * request.decoder_hidden_size;
1497            let end = start + request.decoder_hidden_size;
1498            visual_row += 1;
1499            Some(&request.post_merger_rows[start..end])
1500        } else {
1501            None
1502        };
1503        let cos_sin = if has_image {
1504            Some((tables.cos[pos].as_slice(), tables.sin[pos].as_slice()))
1505        } else {
1506            None
1507        };
1508
1509        forward_step_f16(
1510            weights,
1511            cfg,
1512            &rope,
1513            token_id,
1514            pos,
1515            &mut gdn_states,
1516            &mut kv_cache,
1517            &mut scratch,
1518            injected,
1519            cos_sin,
1520        )?;
1521
1522        hidden_states.extend_from_slice(&scratch.hidden[..hidden]);
1523
1524        if pos < prompt_len - 1 {
1525            kv_cache.seq_len += 1;
1526        }
1527    }
1528    kv_cache.seq_len = prompt_len;
1529
1530    Ok(hidden_states)
1531}
1532
1533/// Mean-pool `hidden_states` (flat row-major `[seq_len, hidden_size]`) over
1534/// `positions`. Panics only on internal misuse (empty `positions` or an
1535/// out-of-range index), never on caller input — callers of this private
1536/// helper always derive `positions` from a validated request.
1537fn mean_pool_rows(hidden_states: &[f32], hidden_size: usize, positions: &[usize]) -> Vec<f32> {
1538    debug_assert!(!positions.is_empty());
1539    let mut out = vec![0.0f32; hidden_size];
1540    for &p in positions {
1541        let row = &hidden_states[p * hidden_size..(p + 1) * hidden_size];
1542        for (o, &v) in out.iter_mut().zip(row) {
1543            *o += v;
1544        }
1545    }
1546    let n = positions.len() as f32;
1547    for o in &mut out {
1548        *o /= n;
1549    }
1550    out
1551}
1552
1553/// L2-normalize `v` in place; a zero or non-finite norm leaves `v`
1554/// unchanged rather than dividing by zero/NaN.
1555fn l2_normalize_owned(mut v: Vec<f32>) -> Vec<f32> {
1556    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1557    if norm > 0.0 && norm.is_finite() {
1558        for x in &mut v {
1559            *x /= norm;
1560        }
1561    }
1562    v
1563}
1564
1565/// Collapse `hidden_states` into one `[hidden_size]` vector per
1566/// [`PoolingStrategy`]. `image_pad_positions` is the (possibly empty) list
1567/// of physical positions holding an `<|image_pad|>` token, in ascending
1568/// order.
1569fn pool_hidden_states(
1570    hidden_states: &[f32],
1571    hidden_size: usize,
1572    seq_len: usize,
1573    image_pad_positions: &[usize],
1574    pooling: PoolingStrategy,
1575) -> Vec<f32> {
1576    match pooling {
1577        PoolingStrategy::LastToken => {
1578            hidden_states[(seq_len - 1) * hidden_size..seq_len * hidden_size].to_vec()
1579        }
1580        PoolingStrategy::MeanVisualTokens => {
1581            if image_pad_positions.is_empty() {
1582                let all: Vec<usize> = (0..seq_len).collect();
1583                mean_pool_rows(hidden_states, hidden_size, &all)
1584            } else {
1585                mean_pool_rows(hidden_states, hidden_size, image_pad_positions)
1586            }
1587        }
1588    }
1589}
1590
1591/// Run prefill over an image + text [`Qwen35VisionRequest`] and return a
1592/// pooled, L2-normalized embedding of length `cfg.hidden_size` (2048 for the
1593/// Qwen3.5-0.8B checkpoint).
1594///
1595/// See [`PoolingStrategy`] for the honesty note on retrieval quality: this
1596/// function is the extraction machinery (correct positions, deterministic,
1597/// unit-norm output), not a claim about embedding quality.
1598///
1599/// # Errors
1600///
1601/// See [`prefill_hidden_states_f16`].
1602pub fn embed_image_f16(
1603    weights: &F16ModelWeights,
1604    cfg: &Qwen35Config,
1605    request: &Qwen35VisionRequest,
1606    pooling: PoolingStrategy,
1607) -> Result<Vec<f32>, crate::error::InferenceError> {
1608    let hidden_states = prefill_hidden_states_f16(weights, cfg, request)?;
1609    let seq_len = request.input_ids.len();
1610    let image_pad_positions: Vec<usize> = request
1611        .input_ids
1612        .iter()
1613        .enumerate()
1614        .filter(|&(_, &id)| id == request.image_token_id)
1615        .map(|(i, _)| i)
1616        .collect();
1617    let pooled = pool_hidden_states(
1618        &hidden_states,
1619        cfg.hidden_size,
1620        seq_len,
1621        &image_pad_positions,
1622        pooling,
1623    );
1624    Ok(l2_normalize_owned(pooled))
1625}
1626
1627/// Tokenize `prompt` and run it through the same decoder + pooling path as
1628/// [`embed_image_f16`] (as a text-only [`Qwen35VisionRequest`] with no image
1629/// runs), so text and image embeddings from the same checkpoint land in the
1630/// same vector space. Returns a pooled, L2-normalized embedding of length
1631/// `cfg.hidden_size`.
1632///
1633/// Requires a vision-language checkpoint: `cfg.rope_parameters` must carry
1634/// an `mrope_section` (only vision-language configs set one) even though no
1635/// image is present, because this routes through the same
1636/// [`Qwen35VisionRequest`]-shaped prefill as the image path rather than a
1637/// separate code path — that shared path is the whole point (same decoder,
1638/// same pooling, same space).
1639///
1640/// # Errors
1641///
1642/// Returns [`crate::error::InferenceError::InvalidInput`] if the tokenized
1643/// prompt is empty or contains an out-of-vocabulary or (surprisingly) an
1644/// `image_token_id` token. See [`prefill_hidden_states_f16`] for the
1645/// remaining error conditions.
1646pub fn embed_text_vlm_f16(
1647    weights: &F16ModelWeights,
1648    cfg: &Qwen35Config,
1649    tokenizer: &BpeTokenizer,
1650    prompt: &str,
1651    pooling: PoolingStrategy,
1652) -> Result<Vec<f32>, crate::error::InferenceError> {
1653    let input = tokenizer.tokenize(prompt);
1654    let prompt_ids: Vec<u32> = input.input_ids[..input.real_length].to_vec();
1655    crate::model::qwen35::check_prompt_not_empty(prompt_ids.len())?;
1656
1657    if let Some(&bad_id) = prompt_ids.iter().find(|&&id| id as usize >= cfg.vocab_size) {
1658        return Err(crate::error::InferenceError::InvalidInput(format!(
1659            "prompt contains out-of-vocabulary token id {bad_id} (vocab_size={})",
1660            cfg.vocab_size
1661        )));
1662    }
1663
1664    // image_token_id is only needed here to shape a well-formed (imageless)
1665    // Qwen35VisionRequest; u32::MAX is an unreachable sentinel when the
1666    // checkpoint has no vision config at all (in which case the request
1667    // below will simply never see that id).
1668    let image_token_id = cfg.image_token_id.unwrap_or(u32::MAX);
1669    if prompt_ids.contains(&image_token_id) {
1670        return Err(crate::error::InferenceError::InvalidInput(
1671            "tokenized prompt unexpectedly contains the checkpoint's image_token_id".to_string(),
1672        ));
1673    }
1674
1675    let request = Qwen35VisionRequest {
1676        input_ids: prompt_ids,
1677        image_grids: vec![],
1678        post_merger_rows: vec![],
1679        image_token_id,
1680        spatial_merge_size: cfg
1681            .vision_config
1682            .as_ref()
1683            .map(|v| v.spatial_merge_size)
1684            .unwrap_or(2),
1685        decoder_hidden_size: cfg.hidden_size,
1686    };
1687
1688    embed_image_f16(weights, cfg, &request, pooling)
1689}
1690
1691// ---------------------------------------------------------------------------
1692// Tests
1693// ---------------------------------------------------------------------------
1694
1695#[cfg(test)]
1696mod tests {
1697    use super::*;
1698
1699    #[test]
1700    #[allow(clippy::type_complexity)]
1701    fn test_f16_forward_compiles() {
1702        // Verify the function signatures are correct by constructing the types
1703        // and calling the functions with a trivial (1-layer, tiny) config.
1704        let cfg = Qwen35Config::qwen35_2b();
1705
1706        // Verify forward_step_f16 signature (pub(crate))
1707        let _fn_ptr: fn(
1708            &F16ModelWeights,
1709            &Qwen35Config,
1710            &RopeTable,
1711            u32,
1712            usize,
1713            &mut [GatedDeltaNetState],
1714            &mut KvCache,
1715            &mut ForwardScratch,
1716            Option<&[f32]>,
1717            Option<(&[f32], &[f32])>,
1718        ) -> Result<(), crate::error::InferenceError> = forward_step_f16;
1719
1720        // Verify gated_delta_net_step_fused_f16 signature
1721        let _gdn_fn_ptr: fn(
1722            &[f32],
1723            &mut GatedDeltaNetState,
1724            &F16GatedDeltaNetWeights,
1725            &Qwen35Config,
1726            &mut GatedDeltaNetFusedScratch,
1727            &mut [f32],
1728        ) = gated_delta_net_step_fused_f16;
1729
1730        // Verify generate_f16 returns the right type
1731        let _gen_fn_ptr: fn(
1732            &F16ModelWeights,
1733            &Qwen35Config,
1734            &BpeTokenizer,
1735            &RopeTable,
1736            &str,
1737            &GenerateConfig,
1738        ) -> Result<GenerateOutput, crate::error::InferenceError> = generate_f16;
1739
1740        // Verify the config helpers work
1741        assert!(cfg.num_full_attention_layers() > 0);
1742        assert!(cfg.num_linear_attention_layers() > 0);
1743        assert_eq!(
1744            cfg.num_full_attention_layers() + cfg.num_linear_attention_layers(),
1745            cfg.num_hidden_layers
1746        );
1747    }
1748
1749    /// Regression test for #392: cpu F16 RoPE must use stride-half pairing (i, half+i), not
1750    /// interleaved (2i, 2i+1).
1751    ///
1752    /// Design: call `full_attention_step_f16` with an identity K-projection so k_buf equals
1753    /// the input exactly (1.0 in f16 is exact, no rounding error on the diagonal).
1754    /// Independently reproduce the same matmul + QK-norm + stride-half RoPE in the test body
1755    /// and compare against the post-call KV-cache. The two paths agree to <1e-4 when the
1756    /// production loops are correct; reverting either loop to 2*i interleaved produces
1757    /// max_diff ~0.9 (observed during mutation verification).
1758    #[test]
1759    fn test_full_attn_step_f16_rope_stride_half_parity() {
1760        use crate::model::qwen35_config::LayerType;
1761        use crate::weights::f16_weights::f32_to_f16_slice;
1762
1763        let head_dim: usize = 32;
1764        let num_q_heads: usize = 1;
1765        let num_kv_heads: usize = 1;
1766        let hidden: usize = 64;
1767        let q_dim = num_q_heads * head_dim;
1768        let kv_dim = num_kv_heads * head_dim;
1769        let position: usize = 3;
1770
1771        let cfg = Qwen35Config {
1772            hidden_size: hidden,
1773            num_hidden_layers: 2,
1774            vocab_size: 128,
1775            intermediate_size: 128,
1776            rms_norm_eps: 1e-6,
1777            num_attention_heads: num_q_heads,
1778            num_key_value_heads: num_kv_heads,
1779            head_dim,
1780            rope_theta: 10_000.0,
1781            partial_rotary_factor: 0.5, // rope_dim = 16, half = 8
1782            rope_parameters: None,
1783            linear_num_key_heads: 2,
1784            linear_num_value_heads: Some(2),
1785            linear_key_head_dim: 32,
1786            linear_value_head_dim: 32,
1787            linear_conv_kernel_dim: 4,
1788            num_experts: None,
1789            num_experts_per_tok: None,
1790            moe_intermediate_size: None,
1791            shared_expert_intermediate_size: None,
1792            output_router_logits: false,
1793            router_aux_loss_coef: None,
1794            tie_word_embeddings: true,
1795            full_attention_interval: 2,
1796            layer_types: vec![LayerType::LinearAttention, LayerType::FullAttention],
1797            layer_mask: vec![true; 2],
1798            eos_token_id: 127,
1799            max_position_embeddings: 512,
1800            mtp_num_hidden_layers: 0,
1801            mtp_use_dedicated_embeddings: false,
1802            quarot_rotation_seed: None,
1803            vision_config: None,
1804            image_token_id: None,
1805            video_token_id: None,
1806            vision_start_token_id: None,
1807            vision_end_token_id: None,
1808        };
1809
1810        let rope_dim = cfg.rope_dim(); // = 16
1811        let half = rope_dim / 2; // = 8
1812        let rope = RopeTable::new(rope_dim, 512, cfg.rope_theta);
1813
1814        // Helper: convert f32 slice to packed f16 Vec<u16>.
1815        let to_f16 = |src: &[f32]| -> Vec<u16> {
1816            let mut dst = vec![0u16; src.len()];
1817            f32_to_f16_slice(src, &mut dst);
1818            dst
1819        };
1820
1821        // W_k = identity [kv_dim, hidden]: row j selects input[j] exactly (1.0 in f16 is exact).
1822        let mut k_proj_f32 = vec![0.0f32; kv_dim * hidden];
1823        for j in 0..kv_dim {
1824            k_proj_f32[j * hidden + j] = 1.0;
1825        }
1826
1827        // W_q = identity for first q_dim rows (Q part), zeros for next q_dim rows (gate part).
1828        // Row j selects input[j] exactly so scratch.q_buf is non-trivial and Q-loop mutation
1829        // changes the assertion result.
1830        let mut q_proj_f32 = vec![0.0f32; 2 * q_dim * hidden];
1831        for j in 0..q_dim {
1832            q_proj_f32[j * hidden + j] = 1.0;
1833        }
1834
1835        let weights = F16FullAttentionLayerWeights {
1836            q_proj: to_f16(&q_proj_f32),
1837            k_proj: to_f16(&k_proj_f32),
1838            v_proj: to_f16(&vec![0.0f32; kv_dim * hidden]),
1839            o_proj: to_f16(&vec![0.0f32; hidden * q_dim]),
1840            q_norm: vec![0.0f32; head_dim],
1841            k_norm: vec![0.0f32; head_dim],
1842        };
1843
1844        // Distinct non-trivial input values (positions 0..64 scaled to small floats).
1845        let input: Vec<f32> = (0..hidden).map(|i| (i as f32 + 1.0) * 0.07).collect();
1846
1847        let mut scratch = ForwardScratch::new();
1848        scratch.ensure_capacity(&cfg, 2);
1849        scratch.attn_out[..hidden].copy_from_slice(&input);
1850
1851        let mut kv_cache = KvCache::new(1);
1852        full_attention_step_f16(
1853            &weights,
1854            0,
1855            position,
1856            &mut kv_cache,
1857            &mut scratch,
1858            &cfg,
1859            &rope,
1860            hidden,
1861            None,
1862        );
1863
1864        // Reference: reproduce the same matmul + QK-norm + stride-half RoPE.
1865        // Using the same production matmul and norm keeps f16 rounding error identical on
1866        // both sides, so the only source of divergence under mutation is the RoPE pairing.
1867
1868        // --- K reference ---
1869        let mut k_ref = vec![0.0f32; kv_dim];
1870        matmul_bt_f16(&input, &weights.k_proj, &mut k_ref, 1, hidden, kv_dim);
1871        qwen35_rms_norm(&mut k_ref, &weights.k_norm, head_dim, cfg.rms_norm_eps);
1872
1873        // Stride-half reference (correct pairing).
1874        let base = position * half;
1875        for i in 0..half {
1876            let cos_val = rope.cos_at(base + i);
1877            let sin_val = rope.sin_at(base + i);
1878            let x0 = k_ref[i];
1879            let x1 = k_ref[half + i];
1880            k_ref[i] = x0 * cos_val - x1 * sin_val;
1881            k_ref[half + i] = x0 * sin_val + x1 * cos_val;
1882        }
1883
1884        let k_cached = &kv_cache.k[0][..kv_dim];
1885        let max_k_diff = k_cached
1886            .iter()
1887            .zip(k_ref.iter())
1888            .map(|(a, b)| (a - b).abs())
1889            .fold(0.0f32, f32::max);
1890
1891        assert!(
1892            max_k_diff < 1e-4,
1893            "cpu F16 K-loop stride-half RoPE diverges from reference: max_k_diff = {max_k_diff:.6}. \
1894             With interleaved pairing the diff is O(0.1-1). Bug: #392."
1895        );
1896
1897        // --- Q reference (guards the Q loop mutation) ---
1898        // The production scatter copies q_and_gate[0..q_dim] → scratch.q_buf[0..q_dim]
1899        // for head 0 (num_q_heads=1).
1900        let mut q_and_gate_ref = vec![0.0f32; 2 * q_dim];
1901        matmul_bt_f16(
1902            &input,
1903            &weights.q_proj,
1904            &mut q_and_gate_ref,
1905            1,
1906            hidden,
1907            2 * q_dim,
1908        );
1909        let mut q_ref = q_and_gate_ref[..q_dim].to_vec();
1910        qwen35_rms_norm(&mut q_ref, &weights.q_norm, head_dim, cfg.rms_norm_eps);
1911
1912        for i in 0..half {
1913            let cos_val = rope.cos_at(base + i);
1914            let sin_val = rope.sin_at(base + i);
1915            let x0 = q_ref[i];
1916            let x1 = q_ref[half + i];
1917            q_ref[i] = x0 * cos_val - x1 * sin_val;
1918            q_ref[half + i] = x0 * sin_val + x1 * cos_val;
1919        }
1920
1921        let max_q_diff = scratch.q_buf[..q_dim]
1922            .iter()
1923            .zip(q_ref.iter())
1924            .map(|(a, b)| (a - b).abs())
1925            .fold(0.0f32, f32::max);
1926
1927        assert!(
1928            max_q_diff < 1e-4,
1929            "cpu F16 Q-loop stride-half RoPE diverges from reference: max_q_diff = {max_q_diff:.6}. \
1930             With interleaved pairing the diff is O(0.1-1). Bug: #392."
1931        );
1932    }
1933
1934    /// ADR-080 C1 (#780): `full_attention_step_f16`'s decode-attention row
1935    /// finalizer must fail closed on a NaN score, not propagate the bare
1936    /// `1.0 / sum_exp` into the context output. A prior cached position is
1937    /// poisoned (NaN K); the current position's own K/V stay finite. RED
1938    /// before the fix: the poisoned row's NaN leaked into every context lane.
1939    #[test]
1940    fn test_full_attn_step_f16_nan_cached_score_fails_closed() {
1941        use crate::model::qwen35_config::LayerType;
1942        use crate::weights::f16_weights::f32_to_f16_slice;
1943
1944        let head_dim: usize = 32;
1945        let num_q_heads: usize = 1;
1946        let num_kv_heads: usize = 1;
1947        let hidden: usize = 64;
1948        let q_dim = num_q_heads * head_dim;
1949        let kv_dim = num_kv_heads * head_dim;
1950        let position: usize = 1;
1951
1952        let cfg = Qwen35Config {
1953            hidden_size: hidden,
1954            num_hidden_layers: 2,
1955            vocab_size: 128,
1956            intermediate_size: 128,
1957            rms_norm_eps: 1e-6,
1958            num_attention_heads: num_q_heads,
1959            num_key_value_heads: num_kv_heads,
1960            head_dim,
1961            rope_theta: 10_000.0,
1962            partial_rotary_factor: 0.5,
1963            rope_parameters: None,
1964            linear_num_key_heads: 2,
1965            linear_num_value_heads: Some(2),
1966            linear_key_head_dim: 32,
1967            linear_value_head_dim: 32,
1968            linear_conv_kernel_dim: 4,
1969            num_experts: None,
1970            num_experts_per_tok: None,
1971            moe_intermediate_size: None,
1972            shared_expert_intermediate_size: None,
1973            output_router_logits: false,
1974            router_aux_loss_coef: None,
1975            tie_word_embeddings: true,
1976            full_attention_interval: 2,
1977            layer_types: vec![LayerType::LinearAttention, LayerType::FullAttention],
1978            layer_mask: vec![true; 2],
1979            eos_token_id: 127,
1980            max_position_embeddings: 512,
1981            mtp_num_hidden_layers: 0,
1982            mtp_use_dedicated_embeddings: false,
1983            quarot_rotation_seed: None,
1984            vision_config: None,
1985            image_token_id: None,
1986            video_token_id: None,
1987            vision_start_token_id: None,
1988            vision_end_token_id: None,
1989        };
1990
1991        let rope = RopeTable::new(cfg.rope_dim(), 512, cfg.rope_theta);
1992
1993        let to_f16 = |src: &[f32]| -> Vec<u16> {
1994            let mut dst = vec![0u16; src.len()];
1995            f32_to_f16_slice(src, &mut dst);
1996            dst
1997        };
1998
1999        // W_k = identity, W_q = identity for the Q half (gate half zero), W_v
2000        // = identity too (so the CURRENT token's V is a distinct, finite,
2001        // predictable vector rather than zero -- confirms the fail-closed
2002        // path isn't trivially passing because every V is zero anyway).
2003        let mut k_proj_f32 = vec![0.0f32; kv_dim * hidden];
2004        for j in 0..kv_dim {
2005            k_proj_f32[j * hidden + j] = 1.0;
2006        }
2007        let mut v_proj_f32 = vec![0.0f32; kv_dim * hidden];
2008        for j in 0..kv_dim {
2009            v_proj_f32[j * hidden + j] = 1.0;
2010        }
2011        let mut q_proj_f32 = vec![0.0f32; 2 * q_dim * hidden];
2012        for j in 0..q_dim {
2013            q_proj_f32[j * hidden + j] = 1.0;
2014        }
2015
2016        let weights = F16FullAttentionLayerWeights {
2017            q_proj: to_f16(&q_proj_f32),
2018            k_proj: to_f16(&k_proj_f32),
2019            v_proj: to_f16(&v_proj_f32),
2020            o_proj: to_f16(&vec![0.0f32; hidden * q_dim]),
2021            q_norm: vec![0.0f32; head_dim],
2022            k_norm: vec![0.0f32; head_dim],
2023        };
2024
2025        let input: Vec<f32> = (0..hidden).map(|i| (i as f32 + 1.0) * 0.07).collect();
2026
2027        let mut scratch = ForwardScratch::new();
2028        scratch.ensure_capacity(&cfg, 2);
2029        scratch.attn_out[..hidden].copy_from_slice(&input);
2030
2031        // Pre-load one poisoned cached position (position 0): NaN K, finite V.
2032        let mut kv_cache = KvCache::new(1);
2033        let mut poisoned_k = vec![0.0f32; kv_dim];
2034        poisoned_k[0] = f32::NAN;
2035        let finite_v = vec![5.0f32; kv_dim];
2036        kv_cache.append_kv(0, &poisoned_k, &finite_v);
2037        kv_cache.seq_len = 1;
2038
2039        full_attention_step_f16(
2040            &weights,
2041            0,
2042            position,
2043            &mut kv_cache,
2044            &mut scratch,
2045            &cfg,
2046            &rope,
2047            hidden,
2048            None,
2049        );
2050
2051        assert!(
2052            scratch.context[..head_dim].iter().all(|&v| v == 0.0),
2053            "expected exact-zero context for a NaN-poisoned cached score, \
2054             got {:?}",
2055            &scratch.context[..head_dim]
2056        );
2057    }
2058
2059    #[test]
2060    fn test_gdn_f16_step_with_zeros() {
2061        // Run the GDN f16 step with zero weights/inputs to verify it doesn't crash.
2062        let cfg = Qwen35Config::qwen35_2b();
2063        let hidden = cfg.hidden_size;
2064        let qkv_dim = cfg.linear_qkv_dim();
2065        let output_dim = cfg.linear_output_dim();
2066        let num_heads = cfg.linear_num_key_heads;
2067        let kernel_size = cfg.linear_conv_kernel_dim;
2068
2069        let weights = F16GatedDeltaNetWeights {
2070            in_proj_qkv: vec![0u16; qkv_dim * hidden],
2071            in_proj_qkv_rows: qkv_dim,
2072            in_proj_qkv_cols: hidden,
2073            in_proj_z: vec![0u16; output_dim * hidden],
2074            in_proj_z_rows: output_dim,
2075            in_proj_z_cols: hidden,
2076            in_proj_b: vec![0u16; num_heads * hidden],
2077            in_proj_b_rows: num_heads,
2078            in_proj_b_cols: hidden,
2079            in_proj_a: vec![0u16; num_heads * hidden],
2080            in_proj_a_rows: num_heads,
2081            in_proj_a_cols: hidden,
2082            a_log: vec![0.0f32; num_heads],
2083            dt_bias: vec![0.0f32; num_heads],
2084            conv1d_weight: vec![0.0f32; qkv_dim * kernel_size],
2085            conv_dim: qkv_dim,
2086            kernel_size,
2087            norm_weight: vec![0.0f32; output_dim],
2088            out_proj: vec![0u16; hidden * output_dim],
2089            out_proj_rows: hidden,
2090            out_proj_cols: output_dim,
2091        };
2092
2093        let mut state = GatedDeltaNetState::new(&cfg);
2094        let mut scratch = GatedDeltaNetFusedScratch::default();
2095        let input = vec![0.0f32; hidden];
2096        let mut output = vec![0.0f32; hidden];
2097
2098        gated_delta_net_step_fused_f16(
2099            &input,
2100            &mut state,
2101            &weights,
2102            &cfg,
2103            &mut scratch,
2104            &mut output,
2105        );
2106
2107        // With all-zero weights and input, output should be all zeros
2108        for &v in &output[..hidden] {
2109            assert_eq!(
2110                v, 0.0,
2111                "zero weights + zero input should produce zero output"
2112            );
2113        }
2114    }
2115
2116    /// A NaN reaching the f16 MoE router (corrupt f16 router gate weight or an
2117    /// upstream activation overflow) makes every router logit NaN. Before the
2118    /// fail-closed guards `moe_ffn_step_f16` left NaN probabilities (the
2119    /// `denom > 0.0` path was skipped), top-k selected nothing (`NaN > NEG_INF`
2120    /// is false), and the routed-expert loop indexed expert weights at
2121    /// `usize::MAX * stride` → overflow/OOB panic on the (bench-only public)
2122    /// `generate_f16` path. This is the f16 sibling of the f32 fix in
2123    /// qwen35/moe.rs (#410). The router must fail closed and no `usize::MAX`
2124    /// sentinel may reach accumulation.
2125    #[test]
2126    fn test_moe_ffn_step_f16_nan_router_fails_closed_no_panic() {
2127        use crate::weights::f16_weights::{
2128            F16, F16MoeLayerWeights, F16MoeRouter, F16RoutedExperts, F16SharedExpert,
2129        };
2130        let num_experts = 4usize;
2131        let hidden = 4usize;
2132        let inter = 2usize;
2133        let shared_inter = 2usize;
2134        let top_k = 2usize;
2135
2136        let nan16 = F16::from_f32(f32::NAN).0;
2137        let zeros = |n: usize| vec![F16::from_f32(0.0).0; n];
2138
2139        // Every router gate weight is NaN → every router logit is NaN.
2140        let router = F16MoeRouter::new(
2141            vec![nan16; num_experts * hidden],
2142            num_experts,
2143            top_k,
2144            hidden,
2145        )
2146        .unwrap();
2147        let experts = F16RoutedExperts::new(
2148            zeros(num_experts * 2 * inter * hidden),
2149            zeros(num_experts * hidden * inter),
2150            num_experts,
2151            hidden,
2152            inter,
2153        )
2154        .unwrap();
2155        let shared = F16SharedExpert::new(
2156            zeros(shared_inter * hidden),
2157            zeros(shared_inter * hidden),
2158            zeros(hidden * shared_inter),
2159            zeros(hidden),
2160            hidden,
2161            shared_inter,
2162        )
2163        .unwrap();
2164        let moe = F16MoeLayerWeights {
2165            router,
2166            experts,
2167            shared_expert: shared,
2168        };
2169
2170        let mut scratch = ForwardScratch::new();
2171        let buf = inter.max(shared_inter);
2172        scratch.ffn_out.resize(hidden, 1.0);
2173        scratch.input_tmp.resize(hidden, 0.0);
2174        scratch.expert_out.resize(hidden, 0.0);
2175        scratch.gate_buf.resize(buf, 0.0);
2176        scratch.up_buf.resize(buf, 0.0);
2177        scratch.down_input.resize(buf, 0.0);
2178        scratch.router_logits.resize(num_experts, 0.0);
2179        scratch.router_selected.resize(top_k, (usize::MAX, 0.0));
2180
2181        // Must not panic (was: usize::MAX expert_id → OOB slice / overflow).
2182        moe_ffn_step_f16(&moe, &mut scratch, hidden);
2183
2184        assert!(
2185            scratch.router_selected[..top_k]
2186                .iter()
2187                .all(|(id, _)| *id < num_experts),
2188            "degenerate f16 router must not leave a usize::MAX sentinel selected"
2189        );
2190    }
2191
2192    /// The non-obvious case the denom-else (not a max-only guard) is meant to
2193    /// catch: ONE router row is NaN while the rest are finite, so `max_logit`
2194    /// stays finite (Rust `f32::max` ignores a single NaN) but the NaN still
2195    /// lands in `denom`. A max-only guard (`if !max_logit.is_finite()`) would
2196    /// pass this and leave un-normalized raw `exp` mass; the denom-else fills
2197    /// the row with 0.0. f16 analogue of qwen35/moe.rs
2198    /// `test_moe_router_finite_max_nan_tail_fails_closed`.
2199    #[test]
2200    fn test_moe_ffn_step_f16_finite_max_nan_tail_fails_closed() {
2201        use crate::weights::f16_weights::{
2202            F16, F16MoeLayerWeights, F16MoeRouter, F16RoutedExperts, F16SharedExpert,
2203        };
2204        let num_experts = 4usize;
2205        let hidden = 4usize;
2206        let inter = 2usize;
2207        let shared_inter = 2usize;
2208        let top_k = 2usize;
2209
2210        let nan16 = F16::from_f32(f32::NAN).0;
2211        let zeros = |n: usize| vec![F16::from_f32(0.0).0; n];
2212
2213        // Only expert 0's gate row is NaN → logit[0] = NaN, logit[1..] finite,
2214        // so `max_logit` is finite but `denom` is NaN.
2215        let mut gate = zeros(num_experts * hidden);
2216        for w in &mut gate[..hidden] {
2217            *w = nan16;
2218        }
2219        let router = F16MoeRouter::new(gate, num_experts, top_k, hidden).unwrap();
2220        let experts = F16RoutedExperts::new(
2221            zeros(num_experts * 2 * inter * hidden),
2222            zeros(num_experts * hidden * inter),
2223            num_experts,
2224            hidden,
2225            inter,
2226        )
2227        .unwrap();
2228        let shared = F16SharedExpert::new(
2229            zeros(shared_inter * hidden),
2230            zeros(shared_inter * hidden),
2231            zeros(hidden * shared_inter),
2232            zeros(hidden),
2233            hidden,
2234            shared_inter,
2235        )
2236        .unwrap();
2237        let moe = F16MoeLayerWeights {
2238            router,
2239            experts,
2240            shared_expert: shared,
2241        };
2242
2243        let mut scratch = ForwardScratch::new();
2244        let buf = inter.max(shared_inter);
2245        scratch.ffn_out.resize(hidden, 1.0);
2246        scratch.input_tmp.resize(hidden, 0.0);
2247        scratch.expert_out.resize(hidden, 0.0);
2248        scratch.gate_buf.resize(buf, 0.0);
2249        scratch.up_buf.resize(buf, 0.0);
2250        scratch.down_input.resize(buf, 0.0);
2251        scratch.router_logits.resize(num_experts, 0.0);
2252        scratch.router_selected.resize(top_k, (usize::MAX, 0.0));
2253
2254        moe_ffn_step_f16(&moe, &mut scratch, hidden);
2255
2256        assert!(
2257            scratch.router_logits[..num_experts]
2258                .iter()
2259                .all(|p| *p == 0.0),
2260            "finite-max + NaN-tail router row must fail closed to all-zero probs \
2261             (a max-only guard would miss this)"
2262        );
2263    }
2264
2265    // NOTE: the storage-bound guard (`expert_id >= moe.experts.num_experts`) is
2266    // release-only defense-in-depth — in debug the `debug_assert_eq!(moe.experts
2267    // .num_experts, num_experts)` at the top of `moe_ffn_step_f16` fires first on
2268    // a router/expert count mismatch, so the guard cannot be exercised by a debug
2269    // unit test. It mirrors the f32 sibling guard (qwen35/moe.rs) and costs one
2270    // comparison; it hardens the bench-only public `generate_f16` path against a
2271    // manually constructed f16 weight set whose router declares more experts than
2272    // the routed-expert storage holds.
2273
2274    /// Build a zero-layer F16 model fixture for generate_f16 unit tests.
2275    ///
2276    /// All-zero u16 (= f16 zero) embeddings → logits all 0 → greedy picks token 0.
2277    /// eos_token_id = 5 so that greedy token 0 is NOT eos, making stop_token_ids=[0]
2278    /// detectable as a distinct stop path.
2279    fn zero_layer_f16_fixture() -> (Qwen35Config, F16ModelWeights, RopeTable, BpeTokenizer) {
2280        use std::collections::HashMap;
2281
2282        let hidden = 4usize;
2283        let vocab = 8usize;
2284
2285        let cfg = Qwen35Config {
2286            hidden_size: hidden,
2287            num_hidden_layers: 0,
2288            vocab_size: vocab,
2289            intermediate_size: 4,
2290            rms_norm_eps: 1e-6,
2291            num_attention_heads: 1,
2292            num_key_value_heads: 1,
2293            head_dim: 4,
2294            rope_theta: 10_000.0,
2295            partial_rotary_factor: 0.5,
2296            rope_parameters: None,
2297            linear_num_key_heads: 1,
2298            linear_num_value_heads: Some(1),
2299            linear_key_head_dim: 4,
2300            linear_value_head_dim: 4,
2301            linear_conv_kernel_dim: 4,
2302            num_experts: None,
2303            num_experts_per_tok: None,
2304            moe_intermediate_size: None,
2305            shared_expert_intermediate_size: None,
2306            output_router_logits: false,
2307            router_aux_loss_coef: None,
2308            tie_word_embeddings: true,
2309            full_attention_interval: 2,
2310            layer_types: vec![],
2311            layer_mask: vec![],
2312            // eos is 5 so that greedy token 0 is NOT eos — allows stop_token_ids=[0]
2313            // to be a distinct, detectable stop signal.
2314            eos_token_id: 5,
2315            max_position_embeddings: 512,
2316            mtp_num_hidden_layers: 0,
2317            mtp_use_dedicated_embeddings: false,
2318            quarot_rotation_seed: None,
2319            vision_config: None,
2320            image_token_id: None,
2321            video_token_id: None,
2322            vision_start_token_id: None,
2323            vision_end_token_id: None,
2324        };
2325
2326        // embed_tokens is [vocab * hidden] packed u16 (f16 zeros = 0u16).
2327        // All zeros → logits all 0 → greedy always picks token 0.
2328        let weights = F16ModelWeights {
2329            embed_tokens: vec![0u16; vocab * hidden],
2330            final_norm: vec![0.0f32; hidden],
2331            layers: vec![],
2332        };
2333
2334        // rope_dim = head_dim * partial_rotary_factor = 4 * 0.5 = 2.
2335        let rope = RopeTable::new(2, 64, 10_000.0);
2336
2337        let mut vocab_map: HashMap<String, u32> = HashMap::new();
2338        for (i, c) in ["h", "e", "l", "o", "w", "r", "d", "!"].iter().enumerate() {
2339            vocab_map.insert((*c).to_string(), i as u32);
2340        }
2341        let merges = vec![
2342            ("h".to_string(), "e".to_string()),
2343            ("he".to_string(), "l".to_string()),
2344        ];
2345        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab_map, merges).unwrap();
2346
2347        (cfg, weights, rope, tokenizer)
2348    }
2349
2350    /// `generate_f16` must reject a request whose prompt + max_new_tokens exceeds
2351    /// the RoPE table capacity with a clean error, not an out-of-bounds RoPE index
2352    /// (in a real model) or a runaway allocation. The preflight returns before any
2353    /// forward pass, so the zero-layer fixture is sufficient. Mutation check:
2354    /// removing the preflight lets the zero-layer model run the decode to
2355    /// completion and return Ok (it has no RoPE-indexing attention layer), which
2356    /// trips the `expect_err` below — changing the test from PASS to FAIL. Mirrors
2357    /// `generate_q8`'s `test_generate_q8_rejects_context_overflow`.
2358    #[test]
2359    fn test_generate_f16_rejects_context_overflow() {
2360        let (cfg, weights, rope, tokenizer) = zero_layer_f16_fixture();
2361        let max_context = rope.max_positions(); // 64 from the fixture
2362        // "hello" is >= 1 token, so prompt_len + max_context > max_context.
2363        let gen_cfg = GenerateConfig {
2364            max_new_tokens: max_context,
2365            ..Default::default()
2366        };
2367        let err = generate_f16(&weights, &cfg, &tokenizer, &rope, "hello", &gen_cfg)
2368            .expect_err("request beyond context window must error, not panic");
2369        let msg = format!("{err}");
2370        assert!(
2371            msg.contains("context window"),
2372            "error must name the context window; got: {msg}"
2373        );
2374    }
2375
2376    /// `generate_f16` must stop on a token in `stop_token_ids` even when that
2377    /// token differs from `eos_token_id`.
2378    ///
2379    /// Setup: all-zero f16 weights → greedy sampling always picks token 0.
2380    /// Config has eos_token_id=5 (not 0) and stop_token_ids=[0].
2381    /// With the fix the first sampled token (0) hits the stop list and the
2382    /// function returns 0 generated tokens.
2383    ///
2384    /// Mutation check: reverting `should_stop_token` back to
2385    /// `next_id == cfg.eos_token_id` in either check causes `0 == 5` to be false,
2386    /// so token 0 is pushed to output and `generated_tokens` becomes ≥ 1.
2387    #[test]
2388    fn test_generate_f16_honors_stop_token_ids() {
2389        let (cfg, weights, rope, tokenizer) = zero_layer_f16_fixture();
2390
2391        let gen_cfg = GenerateConfig {
2392            max_new_tokens: 4,
2393            stop_token_ids: vec![0], // token 0 is the stop signal, NOT eos (5)
2394            temperature: 0.0,        // greedy: all-zero logits always yield token 0
2395            ..Default::default()
2396        };
2397
2398        let out = generate_f16(&weights, &cfg, &tokenizer, &rope, "h", &gen_cfg)
2399            .expect("generate_f16 must succeed with valid stop_token_ids");
2400
2401        assert_eq!(
2402            out.generated_tokens, 0,
2403            "generate_f16 must stop immediately when the first greedy token (0) \
2404             is in stop_token_ids — got {} generated tokens instead",
2405            out.generated_tokens
2406        );
2407    }
2408
2409    /// `generate_f16` must also stop when the stop token first appears in the
2410    /// **decode loop**, not only at the post-prefill check.
2411    ///
2412    /// Fixture: a "bouncing" 0-layer f16 model.
2413    ///   embed[0] = [-1, 1, 0, 0]  (token 0, f16)
2414    ///   embed[1] = [ 1, 1, 0, 0]  (token 1, f16)
2415    ///   final_norm gamma = [-2, 0, 0, 0]
2416    ///
2417    /// The negative gamma at dim-0 flips the sign of that component after RMSNorm,
2418    /// creating a "bounce" between tokens 0 and 1:
2419    ///   from token 1: hidden = [-√2, +√2, 0, 0] → logit[0] = 2√2 wins → generates 0
2420    ///   from token 0: hidden = [+√2, +√2, 0, 0] → logit[1] = 2√2 wins → generates 1
2421    ///
2422    /// Greedy sequence from prompt "e" (→ token 1, eos_token_id=5):
2423    ///   post-prefill  → token 0  (not stop=1)
2424    ///   decode step 1 → token 1  (stop) → decode-loop fires
2425    ///
2426    /// Mutation proof: reverting ONLY the decode-loop `should_stop_token` check
2427    /// (line 946 at time of writing) to `next_id == cfg.eos_token_id` leaves
2428    /// token 1 uncaught (1 ≠ eos=5), the sequence continues, and generated_tokens
2429    /// becomes ≥ 2 — failing the assertion below.
2430    #[test]
2431    fn test_generate_f16_honors_stop_token_ids_decode_loop() {
2432        use crate::weights::f16_weights::f32_to_f16_slice;
2433        use std::collections::HashMap;
2434
2435        let hidden = 4usize;
2436        let vocab = 8usize;
2437
2438        let cfg = Qwen35Config {
2439            hidden_size: hidden,
2440            num_hidden_layers: 0,
2441            vocab_size: vocab,
2442            intermediate_size: 4,
2443            rms_norm_eps: 1e-6,
2444            num_attention_heads: 1,
2445            num_key_value_heads: 1,
2446            head_dim: 4,
2447            rope_theta: 10_000.0,
2448            partial_rotary_factor: 0.5,
2449            rope_parameters: None,
2450            linear_num_key_heads: 1,
2451            linear_num_value_heads: Some(1),
2452            linear_key_head_dim: 4,
2453            linear_value_head_dim: 4,
2454            linear_conv_kernel_dim: 4,
2455            num_experts: None,
2456            num_experts_per_tok: None,
2457            moe_intermediate_size: None,
2458            shared_expert_intermediate_size: None,
2459            output_router_logits: false,
2460            router_aux_loss_coef: None,
2461            tie_word_embeddings: true,
2462            full_attention_interval: 2,
2463            layer_types: vec![],
2464            layer_mask: vec![],
2465            // eos=5 so the stop at token 1 is detectable only via stop_token_ids.
2466            eos_token_id: 5,
2467            max_position_embeddings: 512,
2468            mtp_num_hidden_layers: 0,
2469            mtp_use_dedicated_embeddings: false,
2470            quarot_rotation_seed: None,
2471            vision_config: None,
2472            image_token_id: None,
2473            video_token_id: None,
2474            vision_start_token_id: None,
2475            vision_end_token_id: None,
2476        };
2477
2478        // The negative gamma at dim-0 flips the sign of that component after
2479        // RMSNorm, creating a deterministic "bounce" between tokens 0 and 1.
2480        // from embed[1]=[1,1,0,0]: hidden→[-√2,+√2,0,0] → dot(embed[0]=[-1,1,..]) = 2√2 > 0
2481        // from embed[0]=[-1,1,0,0]: hidden→[+√2,+√2,0,0] → dot(embed[1]=[1,1,..]) = 2√2 > 0
2482        let embed_f32: Vec<f32> = {
2483            let mut v = vec![0.0f32; vocab * hidden];
2484            v[0] = -1.0; // token 0, dim 0
2485            v[1] = 1.0; // token 0, dim 1
2486            v[hidden] = 1.0; // token 1, dim 0
2487            v[hidden + 1] = 1.0; // token 1, dim 1
2488            v
2489        };
2490        let mut embed_f16 = vec![0u16; vocab * hidden];
2491        f32_to_f16_slice(&embed_f32, &mut embed_f16);
2492
2493        let weights = F16ModelWeights {
2494            embed_tokens: embed_f16,
2495            final_norm: vec![-2.0f32, 0.0, 0.0, 0.0],
2496            layers: vec![],
2497        };
2498
2499        let rope = RopeTable::new(2, 64, 10_000.0);
2500
2501        let mut vocab_map: HashMap<String, u32> = HashMap::new();
2502        for (i, c) in ["h", "e", "l", "o", "w", "r", "d", "!"].iter().enumerate() {
2503            vocab_map.insert((*c).to_string(), i as u32);
2504        }
2505        let merges = vec![
2506            ("h".to_string(), "e".to_string()),
2507            ("he".to_string(), "l".to_string()),
2508        ];
2509        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab_map, merges).unwrap();
2510
2511        let gen_cfg = GenerateConfig {
2512            max_new_tokens: 10,
2513            stop_token_ids: vec![1], // stop on token 1 mid-decode-loop; eos_token_id=5≠1
2514            temperature: 0.0,        // greedy: deterministic bouncing sequence
2515            ..Default::default()
2516        };
2517
2518        // Prompt "e" → token 1.
2519        // Post-prefill generates token 0 (not stop=1).
2520        // Decode step 1 generates token 1 → decode-loop stop fires.
2521        let out = generate_f16(&weights, &cfg, &tokenizer, &rope, "e", &gen_cfg)
2522            .expect("generate_f16 must succeed");
2523
2524        assert_eq!(
2525            out.generated_tokens, 1,
2526            "generate_f16 must stop at decode-loop step 1 when token 1 is in \
2527             stop_token_ids — got {} tokens; reverting only the decode-loop check \
2528             lets token 1 through and produces ≥ 2 tokens",
2529            out.generated_tokens
2530        );
2531        assert!(
2532            out.stopped,
2533            "generate_f16 must set stopped=true when the decode-loop stop fires"
2534        );
2535    }
2536
2537    /// `generate_f16` must reject an empty prompt with a typed
2538    /// `Err(Inference("empty prompt"))` before any weight dereference or
2539    /// state allocation (#856): this is one of the three CPU forward paths
2540    /// the shared `check_prompt_not_empty` preflight unifies with the four
2541    /// Metal paths, which used to silently accept an empty prompt and
2542    /// return an empty `Ok`. See docs/generation-entrypoint-matrix.md row 2.
2543    ///
2544    /// The guard fires before any weight dereference, so empty weight vecs
2545    /// are sufficient (mirrors `generate_f16_rejects_grammar_config_before_sampling`
2546    /// below).
2547    ///
2548    /// Mutation sensitivity: bypassing the shared preparation at this entry
2549    /// point makes the function proceed past the guard with a
2550    /// zero-length prompt, either panicking in the prefill/decode loop
2551    /// (`all_ids.last()` on an empty vec) or producing a non-`Inference`
2552    /// error — this assert fails either way.
2553    #[test]
2554    fn generate_f16_rejects_empty_prompt() {
2555        use crate::error::InferenceError;
2556        use std::collections::HashMap;
2557
2558        let mut vocab: HashMap<String, u32> = HashMap::new();
2559        for (i, c) in ["h", "e", "l", "o"].iter().enumerate() {
2560            vocab.insert((*c).to_string(), i as u32);
2561        }
2562        let merges = vec![
2563            ("h".to_string(), "e".to_string()),
2564            ("he".to_string(), "l".to_string()),
2565        ];
2566        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
2567
2568        let cfg = Qwen35Config::qwen35_2b();
2569        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2570        let weights = F16ModelWeights {
2571            embed_tokens: vec![],
2572            final_norm: vec![],
2573            layers: vec![],
2574        };
2575        let gen_cfg = GenerateConfig::default();
2576
2577        let result = generate_f16(&weights, &cfg, &tokenizer, &rope, "", &gen_cfg);
2578        assert!(
2579            matches!(result, Err(InferenceError::Inference(ref msg)) if msg.contains("empty prompt")),
2580            "generate_f16 must reject an empty prompt with Err(Inference(\"empty \
2581             prompt\")) (#856); got {result:?}"
2582        );
2583    }
2584
2585    /// `generate_f16` must reject a `GenerateConfig` that sets `grammar` with a
2586    /// typed `InvalidInput` error before sampling any token (#397/#398).
2587    ///
2588    /// Before the fix, grammar was silently ignored and unconstrained output was
2589    /// produced. The guard fires before any weight dereference or state allocation,
2590    /// so empty weight vecs are sufficient.
2591    ///
2592    /// Mutation sensitivity: removing the `check_grammar_not_set` call makes the
2593    /// function proceed past the guard and attempt to forward with empty weights,
2594    /// producing a panic or a non-`InvalidInput` error — this assert fails either way.
2595    #[test]
2596    fn generate_f16_rejects_grammar_config_before_sampling() {
2597        use crate::error::InferenceError;
2598        use crate::grammar::{GrammarEngine, GrammarSpec};
2599        use std::collections::HashMap;
2600        use std::sync::Arc;
2601
2602        let mut vocab: HashMap<String, u32> = HashMap::new();
2603        for (i, c) in ["h", "e", "l", "o"].iter().enumerate() {
2604            vocab.insert((*c).to_string(), i as u32);
2605        }
2606        let merges = vec![
2607            ("h".to_string(), "e".to_string()),
2608            ("he".to_string(), "l".to_string()),
2609        ];
2610        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
2611
2612        let cfg = Qwen35Config::qwen35_2b();
2613        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2614        let weights = F16ModelWeights {
2615            embed_tokens: vec![],
2616            final_norm: vec![],
2617            layers: vec![],
2618        };
2619
2620        let spec = GrammarSpec::Gbnf("root ::= \"t\" | \"f\"\n".to_string());
2621        let grammar_vocab = vec![b"t".to_vec(), b"f".to_vec()];
2622        let engine =
2623            GrammarEngine::new(&spec, grammar_vocab).expect("trivial grammar must compile");
2624
2625        let gen_cfg = GenerateConfig {
2626            grammar: Some(Arc::new(engine)),
2627            ..Default::default()
2628        };
2629
2630        let result = generate_f16(&weights, &cfg, &tokenizer, &rope, "hello", &gen_cfg);
2631        assert!(
2632            matches!(result, Err(InferenceError::InvalidInput(_))),
2633            "generate_f16 must fail closed with InvalidInput when grammar is set (#397/#398); \
2634             got {result:?}"
2635        );
2636    }
2637
2638    /// `generate_f16` must reject a `GenerateConfig` that sets `stop_strings` with a
2639    /// typed `InvalidInput` error before sampling any token (ADR-080 C3, #783).
2640    ///
2641    /// Mutation sensitivity: removing the `check_stop_strings_not_set` call makes the
2642    /// function proceed past the guard and attempt to forward with empty weights,
2643    /// producing a panic or a non-`InvalidInput` error — this assert fails either way.
2644    #[test]
2645    fn generate_f16_rejects_stop_strings_config_before_sampling() {
2646        use crate::error::InferenceError;
2647        use std::collections::HashMap;
2648
2649        let mut vocab: HashMap<String, u32> = HashMap::new();
2650        for (i, c) in ["h", "e", "l", "o"].iter().enumerate() {
2651            vocab.insert((*c).to_string(), i as u32);
2652        }
2653        let merges = vec![
2654            ("h".to_string(), "e".to_string()),
2655            ("he".to_string(), "l".to_string()),
2656        ];
2657        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
2658
2659        let cfg = Qwen35Config::qwen35_2b();
2660        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2661        let weights = F16ModelWeights {
2662            embed_tokens: vec![],
2663            final_norm: vec![],
2664            layers: vec![],
2665        };
2666
2667        let gen_cfg = GenerateConfig {
2668            stop_strings: vec!["</s>".to_string()],
2669            ..Default::default()
2670        };
2671
2672        let result = generate_f16(&weights, &cfg, &tokenizer, &rope, "hello", &gen_cfg);
2673        assert!(
2674            matches!(result, Err(InferenceError::InvalidInput(_))),
2675            "generate_f16 must fail closed with InvalidInput when stop_strings is set \
2676             (ADR-080 C3, #783); got {result:?}"
2677        );
2678    }
2679
2680    /// `generate_f16` must reject a `GenerateConfig` that sets `reasoning_budget` with
2681    /// a typed `InvalidInput` error before sampling any token (ADR-080 C3, #783).
2682    ///
2683    /// Mutation sensitivity: removing the `check_reasoning_budget_not_set` call makes
2684    /// the function proceed past the guard and attempt to forward with empty weights,
2685    /// producing a panic or a non-`InvalidInput` error — this assert fails either way.
2686    #[test]
2687    fn generate_f16_rejects_reasoning_budget_config_before_sampling() {
2688        use crate::error::InferenceError;
2689        use std::collections::HashMap;
2690
2691        let mut vocab: HashMap<String, u32> = HashMap::new();
2692        for (i, c) in ["h", "e", "l", "o"].iter().enumerate() {
2693            vocab.insert((*c).to_string(), i as u32);
2694        }
2695        let merges = vec![
2696            ("h".to_string(), "e".to_string()),
2697            ("he".to_string(), "l".to_string()),
2698        ];
2699        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
2700
2701        let cfg = Qwen35Config::qwen35_2b();
2702        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2703        let weights = F16ModelWeights {
2704            embed_tokens: vec![],
2705            final_norm: vec![],
2706            layers: vec![],
2707        };
2708
2709        let gen_cfg = GenerateConfig {
2710            reasoning_budget: Some(16),
2711            ..Default::default()
2712        };
2713
2714        let result = generate_f16(&weights, &cfg, &tokenizer, &rope, "hello", &gen_cfg);
2715        assert!(
2716            matches!(result, Err(InferenceError::InvalidInput(_))),
2717            "generate_f16 must fail closed with InvalidInput when reasoning_budget is set \
2718             (ADR-080 C3, #783); got {result:?}"
2719        );
2720    }
2721
2722    /// `generate_f16` with `max_new_tokens == 0` must return zero generated tokens
2723    /// without running prefill or sampling anything (#612, 3rd recurrence of the
2724    /// #226/#456 bug class).
2725    ///
2726    /// The guard fires before any weight dereference or state allocation, so
2727    /// empty weight vecs are sufficient — mirrors the grammar-guard test above.
2728    ///
2729    /// Mutation sensitivity: removing the `max_new_tokens == 0` early return
2730    /// causes the function to run prefill (against empty weight vecs, which
2731    /// would panic) and sample one token, so `generated_tokens` becomes 1
2732    /// instead of 0 and the assertion below fails.
2733    #[test]
2734    fn generate_f16_max_new_tokens_zero_returns_empty() {
2735        use std::collections::HashMap;
2736
2737        let mut vocab: HashMap<String, u32> = HashMap::new();
2738        for (i, c) in ["h", "e", "l", "o"].iter().enumerate() {
2739            vocab.insert((*c).to_string(), i as u32);
2740        }
2741        let merges = vec![
2742            ("h".to_string(), "e".to_string()),
2743            ("he".to_string(), "l".to_string()),
2744        ];
2745        let tokenizer = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
2746
2747        let cfg = Qwen35Config::qwen35_2b();
2748        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2749        let weights = F16ModelWeights {
2750            embed_tokens: vec![],
2751            final_norm: vec![],
2752            layers: vec![],
2753        };
2754
2755        let gen_cfg = GenerateConfig {
2756            max_new_tokens: 0,
2757            ..Default::default()
2758        };
2759
2760        let out = generate_f16(&weights, &cfg, &tokenizer, &rope, "hello", &gen_cfg)
2761            .expect("max_new_tokens=0 must succeed, not error");
2762
2763        assert_eq!(
2764            out.generated_tokens, 0,
2765            "max_new_tokens=0 must produce zero generated tokens"
2766        );
2767        assert!(
2768            out.token_ids.is_empty(),
2769            "max_new_tokens=0 must produce an empty token list"
2770        );
2771        assert_eq!(
2772            out.stop_reason,
2773            Some(StopReason::Length),
2774            "max_new_tokens=0 must report stop_reason=Length"
2775        );
2776    }
2777
2778    // -----------------------------------------------------------------
2779    // ADR-069 Stage 5b: visual injection + M-RoPE splice (pure CPU, no
2780    // checkpoint required).
2781    // -----------------------------------------------------------------
2782
2783    use crate::model::qwen35_config::{LayerType, RopeParams};
2784    use crate::weights::f16_weights::{
2785        F16CommonLayerWeights, F16FullAttentionLayerWeights, f32_to_f16_slice,
2786    };
2787
2788    /// A minimal one-layer, full-attention-only (no GDN) config + f16 weight
2789    /// set: small enough to hand-construct, but with non-trivial (identity)
2790    /// Q/K/V/O projections so RoPE and embedding-source mutations actually
2791    /// move the logits, unlike an all-zero model.
2792    fn tiny_vision_splice_model() -> (Qwen35Config, F16ModelWeights) {
2793        let hidden = 8usize;
2794        let vocab = 4usize;
2795
2796        let cfg = Qwen35Config {
2797            hidden_size: hidden,
2798            num_hidden_layers: 1,
2799            vocab_size: vocab,
2800            intermediate_size: 4,
2801            rms_norm_eps: 1e-6,
2802            num_attention_heads: 1,
2803            num_key_value_heads: 1,
2804            head_dim: hidden,
2805            rope_theta: 1.0e7,
2806            partial_rotary_factor: 1.0, // rope_dim=8, half=4 (matches production theta scale)
2807            rope_parameters: Some(RopeParams {
2808                rope_theta: 1.0e7,
2809                partial_rotary_factor: Some(1.0),
2810                mrope_section: Some(vec![2, 1, 1]),
2811                mrope_interleaved: Some(true),
2812            }),
2813            linear_num_key_heads: 2,
2814            linear_num_value_heads: Some(2),
2815            linear_key_head_dim: 32,
2816            linear_value_head_dim: 32,
2817            linear_conv_kernel_dim: 4,
2818            num_experts: None,
2819            num_experts_per_tok: None,
2820            moe_intermediate_size: None,
2821            shared_expert_intermediate_size: None,
2822            output_router_logits: false,
2823            router_aux_loss_coef: None,
2824            tie_word_embeddings: true,
2825            full_attention_interval: 1,
2826            layer_types: vec![LayerType::FullAttention],
2827            layer_mask: vec![true],
2828            eos_token_id: 999,
2829            max_position_embeddings: 512,
2830            mtp_num_hidden_layers: 0,
2831            mtp_use_dedicated_embeddings: false,
2832            quarot_rotation_seed: None,
2833            vision_config: None,
2834            image_token_id: Some(3),
2835            video_token_id: None,
2836            vision_start_token_id: None,
2837            vision_end_token_id: None,
2838        };
2839
2840        let to_f16 = |src: &[f32]| -> Vec<u16> {
2841            let mut dst = vec![0u16; src.len()];
2842            f32_to_f16_slice(src, &mut dst);
2843            dst
2844        };
2845        let identity = |rows: usize, cols: usize| -> Vec<f32> {
2846            let mut m = vec![0.0f32; rows * cols];
2847            for i in 0..rows.min(cols) {
2848                m[i * cols + i] = 1.0;
2849            }
2850            m
2851        };
2852
2853        let embed_tokens_f32: Vec<f32> = (0..vocab * hidden)
2854            .map(|k| ((k % 11) as f32) * 0.05 - 0.2)
2855            .collect();
2856
2857        let q_dim = hidden;
2858        let mut q_proj_f32 = vec![0.0f32; 2 * q_dim * hidden];
2859        q_proj_f32[..q_dim * hidden].copy_from_slice(&identity(q_dim, hidden));
2860
2861        let full_weights = F16FullAttentionLayerWeights {
2862            q_proj: to_f16(&q_proj_f32),
2863            k_proj: to_f16(&identity(hidden, hidden)),
2864            v_proj: to_f16(&identity(hidden, hidden)),
2865            o_proj: to_f16(&identity(hidden, q_dim)),
2866            q_norm: vec![0.0f32; hidden],
2867            k_norm: vec![0.0f32; hidden],
2868        };
2869
2870        let common = F16CommonLayerWeights {
2871            input_layernorm: vec![0.0f32; hidden],
2872            post_attention_layernorm: vec![0.0f32; hidden],
2873            ffn: F16FeedForwardWeights::Dense {
2874                gate_proj: to_f16(&vec![0.0f32; 4 * hidden]),
2875                up_proj: to_f16(&vec![0.0f32; 4 * hidden]),
2876                down_proj: to_f16(&vec![0.0f32; hidden * 4]),
2877            },
2878        };
2879
2880        let weights = F16ModelWeights {
2881            embed_tokens: to_f16(&embed_tokens_f32),
2882            final_norm: vec![0.0f32; hidden],
2883            layers: vec![(F16AttentionWeights::Full(full_weights), common)],
2884        };
2885
2886        (cfg, weights)
2887    }
2888
2889    /// Injection reaches the decoder: a supplied embedding replaces the
2890    /// token-id lookup (not adds to it), mutating the supplied row changes
2891    /// the resulting logits, and mutating an unrelated vocab row leaves the
2892    /// injected-slot output unchanged (proves the None-path embedding table
2893    /// isn't consulted when `injected_embedding` is `Some`).
2894    #[test]
2895    fn injection_replaces_lookup_and_is_mutation_sensitive() {
2896        let (cfg, mut weights) = tiny_vision_splice_model();
2897        let hidden = cfg.hidden_size;
2898
2899        // Compares the pre-lm_head hidden state (not `scratch.logits`): `embed_tokens` is
2900        // tied to the output projection too, so a logits comparison would show every vocab
2901        // row mutation regardless of whether the *input* lookup was ever consulted. The
2902        // decoder's final hidden state isolates exactly the quantity injection controls.
2903        let run = |weights: &F16ModelWeights, injected: Option<&[f32]>| -> Vec<f32> {
2904            let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2905            let mut gdn_states: Vec<GatedDeltaNetState> = vec![];
2906            let mut kv_cache = KvCache::new(cfg.num_full_attention_layers());
2907            let mut scratch = ForwardScratch::new();
2908            forward_step_f16(
2909                weights,
2910                &cfg,
2911                &rope,
2912                0,
2913                0,
2914                &mut gdn_states,
2915                &mut kv_cache,
2916                &mut scratch,
2917                injected,
2918                None,
2919            )
2920            .expect("forward step succeeds");
2921            scratch.hidden[..hidden].to_vec()
2922        };
2923
2924        let baseline = run(&weights, None);
2925
2926        let mut visual_row = vec![0.9f32, -0.4, 0.2, 0.6, -0.1, 0.3, 0.7, -0.8];
2927        let injected_hidden = run(&weights, Some(&visual_row));
2928        assert_ne!(
2929            injected_hidden, baseline,
2930            "an injected embedding must produce a different hidden state than the token-id lookup"
2931        );
2932
2933        // Mutate the supplied visual scalar: the image-pad-slot output must change.
2934        visual_row[0] += 1.0;
2935        let mutated_visual_hidden = run(&weights, Some(&visual_row));
2936        assert_ne!(
2937            mutated_visual_hidden, injected_hidden,
2938            "mutating the supplied visual row must change the injected-slot hidden state"
2939        );
2940        visual_row[0] -= 1.0; // restore
2941
2942        // Mutate a non-pad vocab row in the embedding table: the injected-slot output
2943        // (still using the ORIGINAL visual_row) must be unchanged.
2944        let embed_start = hidden; // token id 1's row
2945        let mutated_row_f32 = vec![1.0f32; hidden];
2946        f32_to_f16_slice(
2947            &mutated_row_f32,
2948            &mut weights.embed_tokens[embed_start..embed_start + hidden],
2949        );
2950        let after_table_mutation = run(&weights, Some(&visual_row));
2951        assert_eq!(
2952            after_table_mutation, injected_hidden,
2953            "mutating an unrelated embedding-table row must not affect the injected slot"
2954        );
2955
2956        // Sanity: length mismatch and non-finite values must fail closed.
2957        let mut gdn_states: Vec<GatedDeltaNetState> = vec![];
2958        let mut kv_cache = KvCache::new(cfg.num_full_attention_layers());
2959        let mut scratch = ForwardScratch::new();
2960        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
2961        let short_row = vec![0.0f32; hidden - 1];
2962        assert!(
2963            forward_step_f16(
2964                &weights,
2965                &cfg,
2966                &rope,
2967                0,
2968                0,
2969                &mut gdn_states,
2970                &mut kv_cache,
2971                &mut scratch,
2972                Some(&short_row),
2973                None,
2974            )
2975            .is_err(),
2976            "wrong-length injected_embedding must be rejected"
2977        );
2978        let mut nan_row = vec![0.0f32; hidden];
2979        nan_row[2] = f32::NAN;
2980        assert!(
2981            forward_step_f16(
2982                &weights,
2983                &cfg,
2984                &rope,
2985                0,
2986                0,
2987                &mut gdn_states,
2988                &mut kv_cache,
2989                &mut scratch,
2990                Some(&nan_row),
2991                None,
2992            )
2993            .is_err(),
2994            "non-finite injected_embedding must be rejected"
2995        );
2996    }
2997
2998    /// The cos/sin actually applied inside the wired GQA path
2999    /// (`full_attention_step_f16` via `forward_step_f16`) at an image-pad
3000    /// token matches `build_cos_sin`'s output at that same (t,h,w) position —
3001    /// exercised through the wired forward, not just the S5a unit builder.
3002    #[test]
3003    fn mrope_cos_sin_applied_in_wired_forward_matches_builder() {
3004        use crate::vision::qwen35_mrope::{MRopePositions, build_cos_sin};
3005
3006        let (cfg, weights) = tiny_vision_splice_model();
3007        let hidden = cfg.hidden_size;
3008        let rope_params = cfg.rope_parameters.as_ref().unwrap();
3009        let mrope_section = rope_params.mrope_section.as_ref().unwrap();
3010
3011        let positions = MRopePositions {
3012            positions: vec![(2, 3, 5)],
3013            rope_delta: 0,
3014        };
3015        let tables = build_cos_sin(
3016            &positions,
3017            cfg.head_dim,
3018            rope_params.partial_rotary_factor.unwrap(),
3019            rope_params.rope_theta as f32,
3020            mrope_section,
3021        )
3022        .expect("builds tables");
3023        let (cos_row, sin_row) = (tables.cos[0].as_slice(), tables.sin[0].as_slice());
3024
3025        let rope = RopeTable::new(cfg.rope_dim(), 8, cfg.rope_theta);
3026        let mut gdn_states: Vec<GatedDeltaNetState> = vec![];
3027        let mut kv_cache = KvCache::new(cfg.num_full_attention_layers());
3028        let mut scratch = ForwardScratch::new();
3029
3030        forward_step_f16(
3031            &weights,
3032            &cfg,
3033            &rope,
3034            1,
3035            0,
3036            &mut gdn_states,
3037            &mut kv_cache,
3038            &mut scratch,
3039            None,
3040            Some((cos_row, sin_row)),
3041        )
3042        .expect("forward step succeeds");
3043
3044        // Independently reproduce the K rotation using the SAME cos/sin row (identity
3045        // k_proj means k_buf before rotation equals the RMSNorm'd embedding row).
3046        let mut k_ref = vec![0.0f32; hidden];
3047        f16_to_f32_slice(&weights.embed_tokens[hidden..2 * hidden], &mut k_ref);
3048        let zero_norm = vec![0.0f32; hidden];
3049        qwen35_rms_norm(&mut k_ref, &zero_norm, hidden, cfg.rms_norm_eps);
3050        let half = cfg.rope_dim() / 2;
3051        for i in 0..half {
3052            let x0 = k_ref[i];
3053            let x1 = k_ref[half + i];
3054            k_ref[i] = x0 * cos_row[i] - x1 * sin_row[i];
3055            k_ref[half + i] = x0 * sin_row[i] + x1 * cos_row[i];
3056        }
3057
3058        let k_cached = &kv_cache.k[0][..hidden];
3059        let max_diff = k_cached
3060            .iter()
3061            .zip(k_ref.iter())
3062            .map(|(a, b)| (a - b).abs())
3063            .fold(0.0f32, f32::max);
3064        assert!(
3065            max_diff < 1e-3,
3066            "wired M-RoPE rotation diverges from build_cos_sin's own row: max_diff={max_diff}"
3067        );
3068    }
3069
3070    /// A text-only `Qwen35VisionRequest` (no image runs) driven through
3071    /// `generate_multimodal_f16` must be bit-identical to the same token
3072    /// sequence driven through `forward_step_f16` directly with the plain
3073    /// 1-D `RopeTable` (mirroring `generate_f16`'s own prefill+decode loop) —
3074    /// proving `generate_multimodal_f16`'s text-only path never falls
3075    /// through to the M-RoPE table when the request has no image.
3076    #[test]
3077    fn generate_multimodal_text_only_matches_plain_forward_bit_identical() {
3078        use crate::vision::multimodal::Qwen35VisionRequest;
3079
3080        let (cfg, weights) = tiny_vision_splice_model();
3081        let input_ids: Vec<u32> = vec![0, 1, 2, 0];
3082
3083        let request = Qwen35VisionRequest {
3084            input_ids: input_ids.clone(),
3085            image_grids: vec![],
3086            post_merger_rows: vec![],
3087            image_token_id: 3,
3088            spatial_merge_size: 2,
3089            decoder_hidden_size: cfg.hidden_size,
3090        };
3091
3092        let gen_cfg = GenerateConfig {
3093            max_new_tokens: 2,
3094            temperature: 0.0,
3095            seed: Some(1),
3096            stop_token_ids: vec![],
3097            ..Default::default()
3098        };
3099
3100        let multimodal_out = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg)
3101            .expect("text-only multimodal generate succeeds");
3102
3103        // Reference: hand-drive forward_step_f16 exactly like generate_f16's own loop,
3104        // with the same seed and the plain 1-D RopeTable.
3105        let rope = RopeTable::new(cfg.rope_dim(), 512, cfg.rope_theta);
3106        let mut gdn_states: Vec<GatedDeltaNetState> = vec![];
3107        let mut kv_cache = KvCache::new(cfg.num_full_attention_layers());
3108        let mut scratch = ForwardScratch::new();
3109        for (pos, &token_id) in input_ids.iter().enumerate() {
3110            forward_step_f16(
3111                &weights,
3112                &cfg,
3113                &rope,
3114                token_id,
3115                pos,
3116                &mut gdn_states,
3117                &mut kv_cache,
3118                &mut scratch,
3119                None,
3120                None,
3121            )
3122            .expect("reference forward step succeeds");
3123            if pos < input_ids.len() - 1 {
3124                kv_cache.seq_len += 1;
3125            }
3126        }
3127        kv_cache.seq_len = input_ids.len();
3128
3129        let mut rng_state = 1u64;
3130        let mut all_ids = input_ids.clone();
3131        let mut ref_ids = Vec::new();
3132
3133        let next_id = sample_token(
3134            &scratch.logits[..cfg.vocab_size],
3135            &gen_cfg,
3136            &all_ids,
3137            &mut rng_state,
3138        );
3139        ref_ids.push(next_id);
3140        all_ids.push(next_id);
3141
3142        for _ in 1..gen_cfg.max_new_tokens {
3143            let pos = kv_cache.seq_len;
3144            let last_token = *all_ids.last().unwrap();
3145            forward_step_f16(
3146                &weights,
3147                &cfg,
3148                &rope,
3149                last_token,
3150                pos,
3151                &mut gdn_states,
3152                &mut kv_cache,
3153                &mut scratch,
3154                None,
3155                None,
3156            )
3157            .expect("reference decode step succeeds");
3158            kv_cache.seq_len += 1;
3159            let next_id = sample_token(
3160                &scratch.logits[..cfg.vocab_size],
3161                &gen_cfg,
3162                &all_ids,
3163                &mut rng_state,
3164            );
3165            ref_ids.push(next_id);
3166            all_ids.push(next_id);
3167        }
3168
3169        assert_eq!(
3170            multimodal_out.token_ids, ref_ids,
3171            "text-only generate_multimodal_f16 token ids must match the plain forward_step_f16 \
3172             reference bit-for-bit"
3173        );
3174    }
3175
3176    /// `generate_multimodal_f16` fails closed on an invalid request (mismatched
3177    /// image-pad count vs grid) before any decoder work begins — it delegates to
3178    /// `Qwen35VisionRequest::validate()` rather than re-deriving the checks.
3179    #[test]
3180    fn generate_multimodal_f16_rejects_invalid_request() {
3181        use crate::vision::multimodal::Qwen35VisionRequest;
3182
3183        let (cfg, weights) = tiny_vision_splice_model();
3184        let request = Qwen35VisionRequest {
3185            input_ids: vec![0, 3, 3, 1], // 2 image-pad tokens
3186            image_grids: vec![crate::vision::qwen35_vit::GridThw { t: 1, h: 4, w: 4 }], // needs 4
3187            post_merger_rows: vec![0.0f32; 2 * cfg.hidden_size],
3188            image_token_id: 3,
3189            spatial_merge_size: 2,
3190            decoder_hidden_size: cfg.hidden_size,
3191        };
3192        let gen_cfg = GenerateConfig {
3193            max_new_tokens: 1,
3194            ..Default::default()
3195        };
3196        assert!(
3197            generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg).is_err(),
3198            "a request whose image-pad count does not match its grid must be rejected"
3199        );
3200    }
3201
3202    /// `generate_multimodal_f16` fails closed when the prompt plus
3203    /// `max_new_tokens` would exceed the model's context window, mirroring
3204    /// `generate_f16`'s own preflight.
3205    #[test]
3206    fn generate_multimodal_f16_rejects_context_overflow() {
3207        use crate::vision::multimodal::Qwen35VisionRequest;
3208
3209        let (mut cfg, weights) = tiny_vision_splice_model();
3210        cfg.max_position_embeddings = 3;
3211        let request = Qwen35VisionRequest {
3212            input_ids: vec![0, 1, 2],
3213            image_grids: vec![],
3214            post_merger_rows: vec![],
3215            image_token_id: 3,
3216            spatial_merge_size: 2,
3217            decoder_hidden_size: cfg.hidden_size,
3218        };
3219        let gen_cfg = GenerateConfig {
3220            max_new_tokens: 5,
3221            ..Default::default()
3222        };
3223        assert!(
3224            generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg).is_err(),
3225            "prompt_len + max_new_tokens exceeding max_position_embeddings must be rejected"
3226        );
3227    }
3228
3229    /// A caller-supplied `input_ids` entry at or past
3230    /// `cfg.vocab_size` must be rejected with `InvalidInput` before any decoder
3231    /// allocation/work, not panic in `forward_step_f16`'s embedding-table slice.
3232    /// Mutation check: removing the guard lets the request reach the None-branch
3233    /// embedding lookup, which indexes `embed_tokens[token_id * hidden..]` past
3234    /// the table's end and panics, turning this `expect_err` into a test-binary
3235    /// abort rather than a clean assertion failure — either way the test fails.
3236    #[test]
3237    fn generate_multimodal_f16_rejects_out_of_vocab_input_id() {
3238        use crate::vision::multimodal::Qwen35VisionRequest;
3239
3240        let (cfg, weights) = tiny_vision_splice_model();
3241        let request = Qwen35VisionRequest {
3242            input_ids: vec![0, 1, cfg.vocab_size as u32], // last id == vocab_size (OOV)
3243            image_grids: vec![],
3244            post_merger_rows: vec![],
3245            image_token_id: 3,
3246            spatial_merge_size: 2,
3247            decoder_hidden_size: cfg.hidden_size,
3248        };
3249        let gen_cfg = GenerateConfig {
3250            max_new_tokens: 1,
3251            ..Default::default()
3252        };
3253        let err = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg)
3254            .expect_err("an out-of-vocabulary input_id must be rejected, not panic");
3255        assert!(
3256            matches!(err, crate::error::InferenceError::InvalidInput(_)),
3257            "expected InvalidInput, got {err:?}"
3258        );
3259    }
3260
3261    /// Sibling path of the input-id guard: `generate_f16` accepts `cfg` and
3262    /// `tokenizer` as independent parameters, so a mismatched pair can still
3263    /// tokenize a prompt into an id at or past `cfg.vocab_size`. Simulates that
3264    /// mismatch directly (a tokenizer vocab entry the fixture's 8-row embedding
3265    /// table cannot cover) rather than relying on tokenizer internals to produce
3266    /// an OOV id by accident.
3267    /// Mutation check: removing the guard lets `forward_step_f16`'s prefill call
3268    /// index `embed_tokens[8 * hidden..]` on a `vocab=8` table — out of bounds —
3269    /// so the panic (or, pre-guard, a wrong-but-silent read) replaces this clean
3270    /// `expect_err`, failing the test either way.
3271    #[test]
3272    fn test_generate_f16_rejects_out_of_vocab_prompt_id() {
3273        use std::collections::HashMap;
3274
3275        let (cfg, weights, rope, _tokenizer) = zero_layer_f16_fixture();
3276
3277        let mut vocab_map: HashMap<String, u32> = HashMap::new();
3278        for (i, c) in ["h", "e", "l", "o", "w", "r", "d", "!"].iter().enumerate() {
3279            vocab_map.insert((*c).to_string(), i as u32);
3280        }
3281        // OOV against the fixture's cfg.vocab_size=8 embedding table -- a
3282        // mismatched cfg/tokenizer pair, exactly the scenario the guard covers.
3283        vocab_map.insert("z".to_string(), cfg.vocab_size as u32);
3284        let mismatched_tokenizer = BpeTokenizer::from_vocab_and_merges(vocab_map, vec![])
3285            .expect("tokenizer with an OOV vocab entry still constructs");
3286
3287        let gen_cfg = GenerateConfig {
3288            max_new_tokens: 1,
3289            ..Default::default()
3290        };
3291        let err = generate_f16(&weights, &cfg, &mismatched_tokenizer, &rope, "z", &gen_cfg)
3292            .expect_err("an out-of-vocabulary prompt token id must be rejected, not panic");
3293        assert!(
3294            matches!(err, crate::error::InferenceError::InvalidInput(_)),
3295            "expected InvalidInput, got {err:?}"
3296        );
3297    }
3298
3299    /// Build the [`tiny_vision_splice_model`] fixture plus a populated
3300    /// `vision_config` (spatial_merge_size=2, out_hidden_size == decoder
3301    /// hidden_size) so checkpoint-binding mismatches are
3302    /// testable against a checkpoint that genuinely carries vision metadata.
3303    fn tiny_vision_splice_model_with_vision_cfg() -> (Qwen35Config, F16ModelWeights) {
3304        use crate::model::qwen35_config::VisionModelConfig;
3305
3306        let (mut cfg, weights) = tiny_vision_splice_model();
3307        cfg.vision_config = Some(VisionModelConfig {
3308            depth: 1,
3309            hidden_size: 8,
3310            num_heads: 1,
3311            patch_size: 1,
3312            spatial_merge_size: 2,
3313            out_hidden_size: cfg.hidden_size,
3314            temporal_patch_size: 1,
3315            num_position_embeddings: 1,
3316            in_channels: 3,
3317            deepstack_visual_indexes: vec![],
3318            intermediate_size: None,
3319        });
3320        (cfg, weights)
3321    }
3322
3323    /// An internally-consistent multimodal request whose
3324    /// `image_token_id` does not match the loaded checkpoint's must be rejected
3325    /// up front, before image slots are selected -- otherwise it silently
3326    /// injects/rotates at the wrong slots against a real checkpoint.
3327    /// Mutation check: removing just this guard branch (leaving the other FIX-3
3328    /// checks in place) lets the request reach the decoder unmodified; since
3329    /// `generate_multimodal_f16`'s own injection loop keys off the request's
3330    /// (not the checkpoint's) `image_token_id`, the run completes with `Ok`,
3331    /// flipping this `expect_err` to a failing assertion.
3332    #[test]
3333    fn generate_multimodal_f16_rejects_mismatched_image_token_id() {
3334        use crate::vision::multimodal::Qwen35VisionRequest;
3335        use crate::vision::qwen35_vit::GridThw;
3336
3337        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3338        assert_eq!(cfg.image_token_id, Some(3));
3339
3340        // Internally consistent (validate() passes): pad id 2, matching grid/rows,
3341        // but 2 != the checkpoint's image_token_id (3).
3342        let request = Qwen35VisionRequest {
3343            input_ids: vec![0, 2, 2, 2, 2, 1],
3344            image_grids: vec![GridThw { t: 1, h: 4, w: 4 }],
3345            post_merger_rows: vec![0.1f32; 4 * cfg.hidden_size],
3346            image_token_id: 2,
3347            spatial_merge_size: 2,
3348            decoder_hidden_size: cfg.hidden_size,
3349        };
3350        let gen_cfg = GenerateConfig {
3351            max_new_tokens: 1,
3352            ..Default::default()
3353        };
3354        let err = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg)
3355            .expect_err("mismatched image_token_id must be rejected, not silently run");
3356        let msg = format!("{err}");
3357        assert!(
3358            msg.contains("image_token_id"),
3359            "error must name image_token_id; got: {msg}"
3360        );
3361    }
3362
3363    /// A request whose `spatial_merge_size` does not match the
3364    /// checkpoint's `vision_config.spatial_merge_size` must be rejected up front.
3365    /// Mutation check: removing just this guard branch lets an internally
3366    /// consistent (but checkpoint-mismatched) request run to completion (`Ok`),
3367    /// since nothing downstream re-derives merge size from `cfg.vision_config`.
3368    #[test]
3369    fn generate_multimodal_f16_rejects_mismatched_spatial_merge_size() {
3370        use crate::vision::multimodal::Qwen35VisionRequest;
3371        use crate::vision::qwen35_vit::GridThw;
3372
3373        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3374        assert_eq!(cfg.vision_config.as_ref().unwrap().spatial_merge_size, 2);
3375
3376        // merge_size=1 (checkpoint says 2): 1*4*4/1^2 = 16 post-merger rows,
3377        // internally consistent with the request's own spatial_merge_size.
3378        let mut input_ids = vec![0u32];
3379        input_ids.extend(std::iter::repeat_n(3u32, 16));
3380        input_ids.push(1);
3381        let request = Qwen35VisionRequest {
3382            input_ids,
3383            image_grids: vec![GridThw { t: 1, h: 4, w: 4 }],
3384            post_merger_rows: vec![0.1f32; 16 * cfg.hidden_size],
3385            image_token_id: 3,
3386            spatial_merge_size: 1,
3387            decoder_hidden_size: cfg.hidden_size,
3388        };
3389        let gen_cfg = GenerateConfig {
3390            max_new_tokens: 1,
3391            ..Default::default()
3392        };
3393        let err = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg)
3394            .expect_err("mismatched spatial_merge_size must be rejected, not silently run");
3395        let msg = format!("{err}");
3396        assert!(
3397            msg.contains("spatial_merge_size"),
3398            "error must name spatial_merge_size; got: {msg}"
3399        );
3400    }
3401
3402    /// A request whose `decoder_hidden_size` does not match
3403    /// the checkpoint's `hidden_size` must be rejected up front by name, before
3404    /// image slots are selected -- not only by the unrelated, later
3405    /// `forward_step_f16` injected-row-length guard that happens to catch this
3406    /// specific case too. Asserting the error text names `decoder_hidden_size`
3407    /// keeps this test sensitive to *this* guard specifically.
3408    /// Mutation check: removing just this guard branch still leaves the request
3409    /// failing (via `forward_step_f16`'s unrelated length check deep in the
3410    /// prefill loop), but the error text no longer mentions
3411    /// `decoder_hidden_size` -- flipping the `contains` assertion to failing.
3412    #[test]
3413    fn generate_multimodal_f16_rejects_mismatched_decoder_hidden_size() {
3414        use crate::vision::multimodal::Qwen35VisionRequest;
3415        use crate::vision::qwen35_vit::GridThw;
3416
3417        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3418        assert_eq!(cfg.hidden_size, 8);
3419
3420        // decoder_hidden_size=4 (checkpoint hidden_size is 8): internally
3421        // consistent request (post_merger_rows sized to 4 rows * 4), but wrong
3422        // against this checkpoint.
3423        let request = Qwen35VisionRequest {
3424            input_ids: vec![0, 3, 3, 3, 3, 1],
3425            image_grids: vec![GridThw { t: 1, h: 4, w: 4 }],
3426            post_merger_rows: vec![0.1f32; 4 * 4],
3427            image_token_id: 3,
3428            spatial_merge_size: 2,
3429            decoder_hidden_size: 4,
3430        };
3431        let gen_cfg = GenerateConfig {
3432            max_new_tokens: 1,
3433            ..Default::default()
3434        };
3435        let err = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg)
3436            .expect_err("mismatched decoder_hidden_size must be rejected, not silently run");
3437        let msg = format!("{err}");
3438        assert!(
3439            msg.contains("decoder_hidden_size"),
3440            "error must name decoder_hidden_size (not just the unrelated downstream \
3441             injected-row-length message); got: {msg}"
3442        );
3443    }
3444
3445    /// The M-RoPE table builder resolves
3446    /// `partial_rotary_factor` from `cfg.rope_parameters`, while the attention
3447    /// loop derives its rotary half-width from the separately public
3448    /// `cfg.rope_dim()` (`cfg.partial_rotary_factor`). A constructible config
3449    /// where these diverge (`head_dim=256`, `cfg.partial_rotary_factor=0.5` ->
3450    /// decoder half=64, but `rope_parameters.partial_rotary_factor=Some(0.25)`
3451    /// with section `[11,11,10]` -> table half=32) must fail closed here, before
3452    /// the first forward pass indexes `cos_row[32]`/`sin_row[32]` past the
3453    /// table's actual 32-lane row and panics.
3454    /// Mutation check: removing the guard lets `full_attention_step_f16` index
3455    /// the 32-lane row at `half=64`, panicking mid-attention instead of
3456    /// returning the clean `InvalidInput` this test expects.
3457    #[test]
3458    fn generate_multimodal_f16_rejects_mismatched_mrope_row_width() {
3459        use crate::model::qwen35_config::RopeParams;
3460        use crate::vision::multimodal::Qwen35VisionRequest;
3461
3462        let (mut cfg, weights) = tiny_vision_splice_model();
3463        cfg.head_dim = 256;
3464        cfg.partial_rotary_factor = 0.5; // decoder rotary half = 64
3465        cfg.rope_parameters = Some(RopeParams {
3466            rope_theta: 1.0e7,
3467            partial_rotary_factor: Some(0.25), // table half = 32 (diverges from 64)
3468            mrope_section: Some(vec![11, 11, 10]),
3469            mrope_interleaved: Some(true),
3470        });
3471
3472        // Text-only request: no image, so this exercises the guard on the
3473        // prefill table path alone (the decode-time sibling guard covers the
3474        // per-token `build_decode_cos_sin` path independently).
3475        let request = Qwen35VisionRequest {
3476            input_ids: vec![0, 1, 2],
3477            image_grids: vec![],
3478            post_merger_rows: vec![],
3479            image_token_id: 3,
3480            spatial_merge_size: 2,
3481            decoder_hidden_size: cfg.hidden_size,
3482        };
3483        let gen_cfg = GenerateConfig {
3484            max_new_tokens: 1,
3485            ..Default::default()
3486        };
3487        let err = generate_multimodal_f16(&weights, &cfg, &request, &gen_cfg).expect_err(
3488            "a config whose rope_parameters and rope_dim() disagree on rotary width must be \
3489             rejected, not panic at attention-lane indexing",
3490        );
3491        assert!(
3492            matches!(err, crate::error::InferenceError::InvalidInput(_)),
3493            "expected InvalidInput, got {err:?}"
3494        );
3495    }
3496
3497    // -----------------------------------------------------------------
3498    // Pooled embedding extraction (vision-embed-pooling)
3499    // -----------------------------------------------------------------
3500
3501    fn cosine(a: &[f32], b: &[f32]) -> f32 {
3502        assert_eq!(a.len(), b.len());
3503        let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
3504        let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
3505        let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
3506        if na == 0.0 || nb == 0.0 {
3507            return 0.0;
3508        }
3509        dot / (na * nb)
3510    }
3511
3512    /// A one-image request over [`tiny_vision_splice_model_with_vision_cfg`]:
3513    /// grid (1,4,4), merge_size=2 -> 4 post-merger rows, embedded in a short
3514    /// text scaffold `[0, <pad>x4, 1]`. `visual_rows` lets callers vary the
3515    /// "image content" while keeping every shape/id fixed.
3516    fn one_image_request(visual_rows: Vec<f32>) -> Qwen35VisionRequest {
3517        let mut input_ids = vec![0u32];
3518        input_ids.extend(std::iter::repeat_n(3u32, 4));
3519        input_ids.push(1);
3520        Qwen35VisionRequest {
3521            input_ids,
3522            image_grids: vec![crate::vision::qwen35_vit::GridThw { t: 1, h: 4, w: 4 }],
3523            post_merger_rows: visual_rows,
3524            image_token_id: 3,
3525            spatial_merge_size: 2,
3526            decoder_hidden_size: 8,
3527        }
3528    }
3529
3530    #[test]
3531    fn embed_image_f16_is_deterministic() {
3532        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3533        let request = one_image_request(vec![0.3f32; 4 * cfg.hidden_size]);
3534
3535        let v1 = embed_image_f16(&weights, &cfg, &request, PoolingStrategy::MeanVisualTokens)
3536            .expect("embed_image_f16 succeeds");
3537        let v2 = embed_image_f16(&weights, &cfg, &request, PoolingStrategy::MeanVisualTokens)
3538            .expect("embed_image_f16 succeeds");
3539        assert_eq!(v1, v2, "same input must produce an identical vector");
3540
3541        let v3 = embed_image_f16(&weights, &cfg, &request, PoolingStrategy::LastToken)
3542            .expect("embed_image_f16 succeeds");
3543        let v4 = embed_image_f16(&weights, &cfg, &request, PoolingStrategy::LastToken)
3544            .expect("embed_image_f16 succeeds");
3545        assert_eq!(
3546            v3, v4,
3547            "same input must produce an identical vector (LastToken)"
3548        );
3549    }
3550
3551    #[test]
3552    fn embed_image_f16_is_finite_and_unit_norm() {
3553        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3554        for pooling in [
3555            PoolingStrategy::MeanVisualTokens,
3556            PoolingStrategy::LastToken,
3557        ] {
3558            let request = one_image_request(vec![0.4f32; 4 * cfg.hidden_size]);
3559            let v = embed_image_f16(&weights, &cfg, &request, pooling)
3560                .expect("embed_image_f16 succeeds");
3561            assert_eq!(v.len(), cfg.hidden_size);
3562            assert!(
3563                v.iter().all(|x| x.is_finite()),
3564                "{pooling:?}: non-finite output"
3565            );
3566            let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3567            assert!(
3568                (norm - 1.0).abs() < 1e-4,
3569                "{pooling:?}: expected unit norm, got {norm}"
3570            );
3571        }
3572    }
3573
3574    #[test]
3575    fn embed_image_f16_discriminates_different_images_but_matches_itself() {
3576        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3577
3578        let request_a = one_image_request(
3579            (0..4 * cfg.hidden_size)
3580                .map(|i| (i as f32) * 0.05 - 0.8)
3581                .collect(),
3582        );
3583        let request_b = one_image_request(
3584            (0..4 * cfg.hidden_size)
3585                .map(|i| -(i as f32) * 0.03 + 0.5)
3586                .collect(),
3587        );
3588
3589        let emb_a = embed_image_f16(
3590            &weights,
3591            &cfg,
3592            &request_a,
3593            PoolingStrategy::MeanVisualTokens,
3594        )
3595        .expect("embed_image_f16 succeeds");
3596        let emb_a_again = embed_image_f16(
3597            &weights,
3598            &cfg,
3599            &request_a,
3600            PoolingStrategy::MeanVisualTokens,
3601        )
3602        .expect("embed_image_f16 succeeds");
3603        let emb_b = embed_image_f16(
3604            &weights,
3605            &cfg,
3606            &request_b,
3607            PoolingStrategy::MeanVisualTokens,
3608        )
3609        .expect("embed_image_f16 succeeds");
3610
3611        let self_cos = cosine(&emb_a, &emb_a_again);
3612        assert!(
3613            (self_cos - 1.0).abs() < 1e-5,
3614            "an image embedded against itself must have cosine ~1.0, got {self_cos}"
3615        );
3616
3617        let cross_cos = cosine(&emb_a, &emb_b);
3618        assert!(
3619            cross_cos < 0.999,
3620            "two different images must not collapse to near-identical embeddings, got cosine {cross_cos}"
3621        );
3622    }
3623
3624    /// Direct unit test on the pooling primitive: pooling over the correct
3625    /// image-pad window vs. an off-by-one-shifted window over the SAME
3626    /// hidden-state matrix must produce different vectors. This is the
3627    /// mutation-sensitivity gate for the position-selection logic
3628    /// `embed_image_f16` relies on — an off-by-one bug in
3629    /// `image_pad_positions` (or a copy-pasted sibling that reintroduces
3630    /// one) changes the output instead of silently passing.
3631    #[test]
3632    fn pool_hidden_states_wrong_positions_change_the_output() {
3633        let hidden_size = 4;
3634        let seq_len = 6;
3635        // Row i is a constant-i vector, so shifting the pooled window by one
3636        // position is guaranteed to change the mean.
3637        let hidden_states: Vec<f32> = (0..seq_len)
3638            .flat_map(|i| std::iter::repeat_n(i as f32, hidden_size))
3639            .collect();
3640
3641        let correct_positions = [1usize, 2, 3, 4];
3642        let off_by_one_positions = [2usize, 3, 4, 5];
3643
3644        let correct = pool_hidden_states(
3645            &hidden_states,
3646            hidden_size,
3647            seq_len,
3648            &correct_positions,
3649            PoolingStrategy::MeanVisualTokens,
3650        );
3651        let wrong = pool_hidden_states(
3652            &hidden_states,
3653            hidden_size,
3654            seq_len,
3655            &off_by_one_positions,
3656            PoolingStrategy::MeanVisualTokens,
3657        );
3658
3659        assert_ne!(
3660            correct, wrong,
3661            "pooling over an off-by-one-shifted position window must change the output"
3662        );
3663    }
3664
3665    #[test]
3666    fn embed_image_f16_wrong_pad_run_placement_changes_embedding() {
3667        // Same checkpoint, same post-merger rows, but the image-pad run sits
3668        // at a different physical offset in input_ids (shifted by one text
3669        // token) -- the practical shape of a real "wrong positions" bug
3670        // (e.g. an off-by-one in scaffold assembly). The resulting pooled
3671        // embedding must differ: different M-RoPE coordinates and different
3672        // neighboring context both feed into it.
3673        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3674        let visual_rows = vec![0.25f32; 4 * cfg.hidden_size];
3675
3676        let correct = one_image_request(visual_rows.clone());
3677        let mut shifted_ids = vec![0u32, 2]; // extra leading text token
3678        shifted_ids.extend(std::iter::repeat_n(3u32, 4));
3679        shifted_ids.push(1);
3680        let shifted = Qwen35VisionRequest {
3681            input_ids: shifted_ids,
3682            ..one_image_request(visual_rows)
3683        };
3684
3685        let emb_correct =
3686            embed_image_f16(&weights, &cfg, &correct, PoolingStrategy::MeanVisualTokens)
3687                .expect("embed_image_f16 succeeds");
3688        let emb_shifted =
3689            embed_image_f16(&weights, &cfg, &shifted, PoolingStrategy::MeanVisualTokens)
3690                .expect("embed_image_f16 succeeds");
3691
3692        assert_ne!(
3693            emb_correct, emb_shifted,
3694            "shifting the image-pad run's position in input_ids must change the pooled embedding"
3695        );
3696    }
3697
3698    #[test]
3699    fn embed_text_vlm_f16_is_deterministic_and_unit_norm() {
3700        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3701        let mut vocab_map = std::collections::HashMap::new();
3702        for (i, c) in ["a", "b", "c"].iter().enumerate() {
3703            vocab_map.insert((*c).to_string(), i as u32);
3704        }
3705        let tokenizer =
3706            BpeTokenizer::from_vocab_and_merges(vocab_map, vec![]).expect("tokenizer constructs");
3707
3708        for pooling in [
3709            PoolingStrategy::MeanVisualTokens,
3710            PoolingStrategy::LastToken,
3711        ] {
3712            let v1 = embed_text_vlm_f16(&weights, &cfg, &tokenizer, "abc", pooling)
3713                .expect("embed_text_vlm_f16 succeeds");
3714            let v2 = embed_text_vlm_f16(&weights, &cfg, &tokenizer, "abc", pooling)
3715                .expect("embed_text_vlm_f16 succeeds");
3716            assert_eq!(
3717                v1, v2,
3718                "{pooling:?}: same prompt must produce an identical vector"
3719            );
3720            assert_eq!(v1.len(), cfg.hidden_size);
3721            let norm: f32 = v1.iter().map(|x| x * x).sum::<f32>().sqrt();
3722            assert!(
3723                (norm - 1.0).abs() < 1e-4,
3724                "{pooling:?}: expected unit norm, got {norm}"
3725            );
3726        }
3727    }
3728
3729    #[test]
3730    fn embed_text_vlm_f16_and_embed_image_f16_share_the_same_space() {
3731        // Not a quality claim (see PoolingStrategy's doc comment) -- just
3732        // proves both entry points route through the same decoder + pooling
3733        // call so their outputs are directly comparable vectors of the same
3734        // dimension, which is the structural property retrieval depends on.
3735        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3736        let mut vocab_map = std::collections::HashMap::new();
3737        vocab_map.insert("a".to_string(), 0u32);
3738        let tokenizer =
3739            BpeTokenizer::from_vocab_and_merges(vocab_map, vec![]).expect("tokenizer constructs");
3740
3741        let text_emb =
3742            embed_text_vlm_f16(&weights, &cfg, &tokenizer, "a", PoolingStrategy::LastToken)
3743                .expect("embed_text_vlm_f16 succeeds");
3744        let image_emb = embed_image_f16(
3745            &weights,
3746            &cfg,
3747            &one_image_request(vec![0.1f32; 4 * cfg.hidden_size]),
3748            PoolingStrategy::LastToken,
3749        )
3750        .expect("embed_image_f16 succeeds");
3751
3752        assert_eq!(text_emb.len(), image_emb.len());
3753        let cos = cosine(&text_emb, &image_emb);
3754        assert!(cos.is_finite());
3755    }
3756
3757    #[test]
3758    fn embed_text_vlm_f16_rejects_prompt_colliding_with_image_token_id() {
3759        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3760        assert_eq!(cfg.image_token_id, Some(3));
3761        // Vocab entry "z" is deliberately assigned id 3, the checkpoint's
3762        // image_token_id -- a tokenized prompt must never silently contain it.
3763        let mut vocab_map = std::collections::HashMap::new();
3764        vocab_map.insert("z".to_string(), 3u32);
3765        let tokenizer =
3766            BpeTokenizer::from_vocab_and_merges(vocab_map, vec![]).expect("tokenizer constructs");
3767
3768        let err = embed_text_vlm_f16(&weights, &cfg, &tokenizer, "z", PoolingStrategy::LastToken)
3769            .expect_err("a prompt colliding with image_token_id must be rejected");
3770        assert!(matches!(err, crate::error::InferenceError::InvalidInput(_)));
3771    }
3772
3773    #[test]
3774    fn embed_image_f16_rejects_invalid_request() {
3775        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3776        let mut request = one_image_request(vec![0.1f32; 4 * cfg.hidden_size]);
3777        request.post_merger_rows.pop(); // now the wrong length
3778        assert!(
3779            embed_image_f16(&weights, &cfg, &request, PoolingStrategy::MeanVisualTokens).is_err()
3780        );
3781    }
3782
3783    /// The context-window limit must be evaluated BEFORE `build_mrope_tables`
3784    /// materializes per-token position/cos/sin rows: an over-context request
3785    /// must fail cheaply, not after unbounded allocation work. The request
3786    /// here passes `Qwen35VisionRequest::validate` (run count, TOTAL pad
3787    /// count, and row-buffer length all line up) but carries per-run lengths
3788    /// [1, 3] against grids expecting [2, 2] — a mismatch only the M-RoPE
3789    /// builder detects — so getting the context-window error (not the
3790    /// builder's run-length error) proves the ordering.
3791    #[test]
3792    fn prefill_rejects_over_context_before_mrope_table_construction() {
3793        let (mut cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3794        let base = one_image_request(vec![0.1f32; 4 * cfg.hidden_size]);
3795        let request = Qwen35VisionRequest {
3796            // Two pad runs of lengths 1 and 3 (total 4, matching the grids'
3797            // total merged rows), while each grid below expects a run of 2.
3798            input_ids: vec![0u32, 3, 1, 3, 3, 3, 1],
3799            image_grids: vec![
3800                crate::vision::qwen35_vit::GridThw { t: 1, h: 2, w: 4 },
3801                crate::vision::qwen35_vit::GridThw { t: 1, h: 2, w: 4 },
3802            ],
3803            ..base
3804        };
3805        request
3806            .validate()
3807            .expect("request must pass validation so only the builder would catch it");
3808        cfg.max_position_embeddings = request.input_ids.len() - 1;
3809
3810        let err = prefill_hidden_states_f16(&weights, &cfg, &request)
3811            .expect_err("over-context request must be rejected");
3812        let msg = err.to_string();
3813        assert!(
3814            msg.contains("context window"),
3815            "must fail on the context-window check, before M-RoPE table \
3816             construction; got: {msg}"
3817        );
3818    }
3819
3820    /// Golden-reference check for `embed_image_f16`'s own image-pad
3821    /// position-selection wiring (not just the generic `pool_hidden_states`
3822    /// primitive, which `pool_hidden_states_wrong_positions_change_the_output`
3823    /// already covers in isolation): `one_image_request`'s fixed layout
3824    /// `[0, <pad>x4, 1]` puts the pad run at physical positions `[1,2,3,4]`
3825    /// by construction. This test independently derives the expected
3826    /// pooled/normalized vector from `prefill_hidden_states_f16` using that
3827    /// hand-known-correct window and asserts `embed_image_f16` matches it
3828    /// exactly.
3829    ///
3830    /// Mutation-sensitive: an off-by-one in `embed_image_f16`'s
3831    /// `image_pad_positions` computation (e.g. `.map(|(i, _)| i + 1)`) still
3832    /// passes `embed_image_f16_is_deterministic`,
3833    /// `_discriminates_different_images_but_matches_itself`, and
3834    /// `_wrong_pad_run_placement_changes_embedding` (all of them assert only
3835    /// relative properties -- determinism, discrimination, "differs from a
3836    /// differently-shaped request" -- that remain true under a consistently
3837    /// applied shift), but fails this golden check because the reference
3838    /// value is computed independently of that internal computation.
3839    #[test]
3840    fn embed_image_f16_matches_independently_computed_golden_pool() {
3841        let (cfg, weights) = tiny_vision_splice_model_with_vision_cfg();
3842        let request = one_image_request(vec![0.37f32; 4 * cfg.hidden_size]);
3843
3844        let hidden_states = prefill_hidden_states_f16(&weights, &cfg, &request)
3845            .expect("prefill_hidden_states_f16 succeeds");
3846        let known_correct_pad_positions = [1usize, 2, 3, 4]; // by construction of one_image_request
3847        let golden = l2_normalize_owned(mean_pool_rows(
3848            &hidden_states,
3849            cfg.hidden_size,
3850            &known_correct_pad_positions,
3851        ));
3852
3853        let got = embed_image_f16(&weights, &cfg, &request, PoolingStrategy::MeanVisualTokens)
3854            .expect("embed_image_f16 succeeds");
3855
3856        assert_eq!(
3857            got, golden,
3858            "embed_image_f16 must pool over exactly the known-correct image-pad positions"
3859        );
3860    }
3861}