Skip to main content

ferrox_models/
loader.rs

1//! Loads a real `Decoder` from an on-disk GGUF file, using the
2//! llama.cpp-style tensor naming convention
3//! (`token_embd.weight`, `blk.N.attn_q.weight`, `blk.N.ffn_gate.weight`
4//! or, for MoE, `blk.N.ffn_gate_exps.weight`, `output_norm.weight`,
5//! `output.weight`). Until this module existed, ferrox could only run
6//! correctly-shaped *random* weights.
7//!
8//! Quantized tensors (Q8_0 / Q4_0) are loaded as `WeightMatrix::Quantized`
9//! backed by `WeightBytes::Mapped` -- a zero-copy view into the same
10//! mmap `GgufFile` already holds, with no intermediate heap copy of the
11//! tensor's bytes at all. So a checkpoint's resident memory is the
12//! mmap page cache, not the mmap plus a second in-process copy of every
13//! weight. `WeightMatrix::apply` dispatches to ferrox-quant's fused
14//! dequant+dot kernels directly against those mapped bytes at inference
15//! time. F32 tensors (norms, embeddings, and any weight not natively
16//! quantized) still copy into an owned `Tensor`, since they're small
17//! relative to the quantized weight matrices and need per-element
18//! access patterns a raw byte view doesn't support as cleanly.
19//!
20//! Verified end to end (see `crates/ferrox-models/tests/gguf_roundtrip.rs`)
21//! against a genuinely Q8_0-quantized, generated on-disk GGUF fixture
22//! for the dense (single-expert) case, and against real OLMoE / Qwen2-MoE
23//! checkpoints for the multi-expert 3D-packed-tensor path.
24
25use ferrox_core::expert_store::{ExpertKey, ExpertSource, ExpertStore};
26use ferrox_core::tensor::Tensor;
27use ferrox_core::weight_matrix::quant_kind_for;
28use ferrox_core::weight_matrix::{QuantKind, WeightBytes, WeightMatrix};
29use ferrox_gguf::{GgmlType, GgufError, GgufValue, ShardedGguf, TensorInfo, TensorSource};
30use ferrox_moe::{ExpertWeights, GatingFunction, MoeLayerConfig};
31use std::sync::Arc;
32use thiserror::Error;
33
34use crate::config::ModelConfig;
35#[cfg(feature = "metal")]
36use crate::decoder::MoePackedQ4Planes;
37use crate::decoder::{AttnWeights, Decoder, ExpertBacking, LayerWeights, MoeWeights};
38
39#[derive(Debug, Error)]
40pub enum LoadError {
41    #[error(transparent)]
42    Gguf(#[from] GgufError),
43    #[error(transparent)]
44    Shard(#[from] ferrox_gguf::ShardError),
45    #[error("tensor '{0}' has unsupported dtype {1:?}")]
46    UnsupportedDtype(String, GgmlType),
47    #[error(
48        "MoE tensor '{0}' is not 3D or its expert count {1} does not match config n_experts {2}"
49    )]
50    ExpertCountMismatch(String, usize, usize),
51    #[error("GGUF file is missing required hparam metadata key '{0}'")]
52    MissingHparam(String),
53    /// `general.architecture` is not in the capability registry -- refuse
54    /// to guess RoPE/gating rather than emit fluent-but-wrong logits.
55    #[error(
56        "unsupported GGUF architecture '{0}': not in ferrox's capability registry \
57         (unknown required features fail closed; see ferrox_models::capability)"
58    )]
59    UnsupportedArchitecture(String),
60    /// Architecture exists but must not use the generic GQA decoder.
61    #[error("architecture '{0}' cannot use the generic Decoder: {1}")]
62    DedicatedArchitectureRequired(String, &'static str),
63    /// Metadata advertises a feature the generic decoder does not implement.
64    #[error("architecture '{0}' requires unimplemented feature: {1}")]
65    UnsupportedFeature(String, String),
66    #[error(
67        "architecture '{0}' has never been verified against llama.cpp. It would run on \
68         ferrox's shared generic-GQA path, which ASSUMES plain GQA with {1:?} RoPE and no \
69         ALiBi, no learned position embeddings and no per-layer rope skipping. That \
70         assumption has already been wrong for gpt2, mpt, refact, bloom and jais, each of \
71         which loaded clean and answered as a different model. {2} Set \
72         FERROX_ALLOW_UNAUDITED_ARCH=1 to run it anyway and compare the output against \
73         llama.cpp yourself"
74    )]
75    UnauditedArchitecture(String, crate::config::RopeLayout, String),
76    /// The checkpoint carries per-block tensors this build never reads,
77    /// i.e. weights that contribute to the real graph and would simply
78    /// be missing from ours. See [`assert_every_tensor_consumed`].
79    #[error(
80        "checkpoint carries {0} tensor(s) this build never reads, so its graph is not the one \
81         ferrox would run: {1}. This is a missing feature, not a corrupt file. Override with \
82         FERROX_ALLOW_UNKNOWN_TENSORS=1 to load anyway and accept wrong output."
83    )]
84    UnconsumedTensors(usize, String),
85    /// `FERROX_STRICT_KERNELS=1` and the model has weights with no
86    /// kernel on the selected accelerator, i.e. it would run, correctly,
87    /// on a silently slower path. Refusing is the point: a benchmark or
88    /// CI run must not be able to publish a number taken off the
89    /// backend it claims. See [`ferrox_core::kernel_registry`].
90    #[error("{0}")]
91    StrictKernels(String),
92}
93
94/// Architecture-family name strings (GGUF's `general.architecture` value)
95/// known, from reading ik_llama.cpp's `llama-hparams.cpp`
96/// (`LLM_ARCH_DEEPSEEK2`, `LLM_ARCH_GLM4_MOE` cases), to default to
97/// sigmoid MoE gating with post-selection renormalization rather than
98/// softmax. Every member's citation is inline here; `docs/MODELS.md`
99/// carries none and the pointer that used to send readers there was
100/// dangling.
101/// `afmoe`, `laguna` and `step35` added 2026-09-01 by the
102/// unaudited-refusal triage's gating sweep. Each reads
103/// `LLM_KV_EXPERT_GATING_FUNC` as OPTIONAL and then, when the key is
104/// absent, sets `LLAMA_EXPERT_GATING_FUNC_TYPE_SIGMOID`
105/// (`afmoe.cpp:29-30`, `laguna.cpp:55-56`, `step35.cpp:19-20`). Ferrox
106/// fell back to softmax for all three.
107///
108/// This is the `deepseek` shape a third, fourth and fifth time: a
109/// default that is right for most architectures and silently wrong for
110/// one, where the GGUF carries no key to correct it. Nothing is live
111/// today -- all three are `NewCode` for other reasons and refuse before
112/// reaching here -- but the list is what a later admission would trust.
113const SIGMOID_GATING_ARCHITECTURES: &[&str] =
114    &["afmoe", "deepseek2", "glm4moe", "laguna", "step35"];
115
116/// Names that appear in a behaviour table above but are `DedicatedOnly`
117/// or `Deferred`, together with the module that actually applies the
118/// behaviour for them.
119///
120/// Two true things were in conflict here, and deleting either would
121/// have lost one. `SIGMOID_GATING_ARCHITECTURES` records a fact about
122/// llama.cpp (these architectures default to sigmoid when the GGUF
123/// carries no `expert_gating_func`), and a test pins it as such. The
124/// cross-table test records a different fact: an entry for an
125/// architecture that never reaches THIS loader cannot fire, and a gate
126/// that cannot fire is worse than no gate because it reads as coverage.
127///
128/// Both hold. `deepseek2` and `glm4moe` are genuinely sigmoid-gated and
129/// genuinely never arrive here. So the resolution is not to drop a name
130/// from either place, it is to say out loud who owns it instead, and to
131/// make an unexplained dead entry still fail.
132///
133/// Adding a name here is a claim that the named module applies the
134/// behaviour. It is checked no further than that, so it is the one line
135/// in this file to be suspicious of.
136/// Test-only: it asserts a relationship rather than driving one, and a
137/// production reader would have to be told that.
138#[cfg(test)]
139const DEDICATED_OWNS_ITS_BEHAVIOUR: &[(&str, &str)] = &[
140    // `mla_gguf_loader` reads `expert_gating_func` and falls back to
141    // Sigmoid itself, so deepseek2's gating is decided there.
142    ("deepseek2", "mla_gguf_loader"),
143    // glm4moe is refused today (it needs gpt-oss's norm slot, see its
144    // refusal text). The entry stays because the fact about llama.cpp
145    // stays true, and it becomes live the moment the refusal lifts.
146    ("glm4moe", "refused today, see capability::unaudited_triage"),
147];
148
149/// Architecture-family names whose real reference implementation skips
150/// renormalizing top-k softmax routing weights after selection (GGUF
151/// carries no metadata key for this -- it's hardcoded per-architecture in
152/// both the real HF `transformers` model code and llama.cpp's
153/// `build_moe_ffn` call sites, not read from the file). Confirmed for
154/// `olmoe` against `OlmoeTopKRouter.forward` in
155/// `transformers/models/olmoe/modeling_olmoe.py` (`config.norm_topk_prob`
156/// is `false` in the real published config.json) and llama.cpp's
157/// `src/models/olmoe.cpp` (`build_moe_ffn(..., false, ...,
158/// LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX, ...)`). See
159/// `MoeLayerConfig::norm_topk_prob`'s doc comment for why this matters:
160/// getting it wrong silently produces wrong generation output even
161/// though the file loads and shape-validates fine.
162// Architectures whose reference graphs pass `norm_w=false` to
163// `build_moe_ffn` (llama.cpp) / `norm_topk_prob=false` in HF config.
164// Qwen2-MoE: `.scratch/llama.cpp/src/models/qwen2moe.cpp` -- Softmax +
165// `false` for the norm_topk slot. Renormalizing top-k weights made
166// Qwen1.5-MoE greedy decode emit garbage despite shared-expert load.
167// `deepseek` (V1) added 2026-09-01 by the unaudited-refusal triage.
168// `src/models/deepseek.cpp:145-155` passes `norm_w=false`, and
169// `conversion/deepseek.py`'s `DeepseekModel` never writes
170// `{arch}.expert_weights_norm` -- only `DeepseekV2Model` does -- so no
171// real `deepseek` GGUF carries the key to override the default with.
172// Ferrox therefore renormalised where llama.cpp does not. Same class of
173// bug as the OLMoE one above, and latent only because `deepseek` is
174// unaudited and refuses first.
175const NO_TOPK_RENORMALIZE_ARCHITECTURES: &[&str] = &["deepseek", "olmoe", "qwen2moe"];
176
177/// Architectures that store their **pre-FFN** norm under the tensor name
178/// `blk.N.post_attention_norm.weight` and carry no `blk.N.ffn_norm`.
179///
180/// Gemma writes the same tensor name for a genuinely different norm: it
181/// is applied to the attention output *inside* the attention residual,
182/// and Gemma also carries `ffn_norm`. Reading one file's tensor with the
183/// other's meaning silently moves a whole RMSNorm to the wrong side of a
184/// residual add, so the meaning is decided by architecture, not by which
185/// tensors happen to be present.
186///
187/// - `gpt-oss`: `openai-moe.cpp` norms `ffn_inp` with `attn_post_norm`.
188/// - `seed_oss`: `src/models/seed-oss.cpp:36-37` creates `attn_norm` and
189///   `attn_post_norm` and **no** `ffn_norm`, and `:113-115` norms
190///   `ffn_inp` -- the post-attention residual -- with `attn_post_norm`.
191///
192/// This is deliberately NOT `arch == "gpt-oss"`, which is what it used
193/// to be. That one flag also gated gpt-oss's five extra per-layer
194/// tensors (sinks, biases, the SwiGLU clamp), and widening it would have
195/// handed `seed_oss` attention sinks it does not have. Two facts, two
196/// predicates. `the_norm_slot_list_and_the_audit_list_agree` pins that a
197/// name added here is a name somebody actually read a graph for.
198const PRE_FFN_NORM_IS_POST_ATTENTION_NORM: &[&str] = &["gpt-oss", "seed_oss"];
199
200/// Does this architecture keep its pre-FFN norm in the
201/// `post_attention_norm` slot? See
202/// [`PRE_FFN_NORM_IS_POST_ATTENTION_NORM`].
203fn pre_ffn_norm_is_post_attention_norm(arch: &str) -> bool {
204    PRE_FFN_NORM_IS_POST_ATTENTION_NORM.contains(&arch)
205}
206
207/// Architectures whose checkpoints carry `{arch}.leading_dense_block_count`
208/// while their reference graph never branches on it: **every** layer is
209/// MoE regardless of what the key says.
210///
211/// `bailingmoe` is the case this list exists for.
212/// `src/models/bailingmoe.cpp:5` reads
213/// `LLM_KV_LEADING_DENSE_BLOCK_COUNT` into `n_layer_dense_lead` and then
214/// `load_arch_tensors` creates `ffn_gate_inp`, the expert tensors and
215/// the shared-expert tensors unconditionally for every layer (:39-54 --
216/// there is no `if (i < n_layer_dense_lead)` anywhere in the file) and
217/// the graph has no dense branch either (:119-152). Meanwhile
218/// `conversion/bailingmoe.py:27` writes `first_k_dense_replace` into the
219/// key verbatim, so real Ling checkpoints DO carry a nonzero value.
220///
221/// Ferrox's `ModelConfig::layer_is_dense` does branch on it, so without
222/// this list ferrox looks for `blk.0.ffn_gate.weight` on a layer that
223/// only ships experts and dies on a missing tensor. That is a load
224/// failure rather than wrong logits, which is why it stayed latent.
225///
226/// Do not read this as "the key is meaningless": for `deepseek`,
227/// `dots1`, `glm4moe` and every other leading-dense architecture the key
228/// is load-bearing and must be honoured. Membership here is a statement
229/// about ONE architecture's graph, checked in that graph.
230const LEADING_DENSE_KEY_IS_INERT: &[&str] = &["bailingmoe"];
231
232/// Architectures whose reference graph applies `attn_q_norm` /
233/// `attn_k_norm` AFTER `ggml_rope_ext`, not before it.
234///
235/// There is no GGUF key for this. llama.cpp writes the order into each
236/// hand-written graph, so the only place it can come from is the
237/// architecture string, and getting it wrong changes every layer's
238/// attention scores without changing a single tensor shape.
239///
240/// - `maincoder`: `src/models/maincoder.cpp:78-90` ropes Q and K, then
241///   norms them at `:92` and `:95`.
242/// - `hunyuan-moe`: `src/models/hunyuan-moe.cpp:93,104` rope, `:110,115`
243///   norm.
244///
245/// - `hunyuan-dense`: it has no graph of its own --
246///   `src/models/models.h:1830-1834` derives `llama_model_hunyuan_dense`
247///   from `llama_model_hunyuan_vl` and reuses its graph -- so the lines
248///   are `hunyuan-vl.cpp:56-66` (rope) then `:73-81` (norm). Its second
249///   blocker, the NTK-alpha RoPE base rescale, is implemented too; see
250///   [`crate::rope_ntk_alpha`].
251///
252/// The audited majority is the other way round -- `qwen3moe.cpp:99,108`
253/// and `bailingmoe2.cpp:123-135` both norm first -- which is why the
254/// decoder's default is "before" and this list is the exception.
255const QK_NORM_AFTER_ROPE_ARCHITECTURES: &[&str] = &["hunyuan-dense", "hunyuan-moe", "maincoder"];
256
257fn metadata_u64_any(file: &impl TensorSource, keys: &[String]) -> Option<u64> {
258    keys.iter().find_map(|k| file.metadata_u64(k))
259}
260
261fn metadata_f32_any(file: &impl TensorSource, keys: &[String]) -> Option<f32> {
262    keys.iter()
263        .find_map(|k| file.metadata(k).and_then(GgufValue::as_f32))
264}
265
266impl ModelConfig {
267    /// Derives a `ModelConfig` from a real GGUF file's own hyperparameter
268    /// metadata, following llama.cpp's `general.architecture`-prefixed key
269    /// convention (`{arch}.block_count`, `{arch}.embedding_length`,
270    /// `{arch}.attention.head_count`, `{arch}.expert_count`, ...) rather
271    /// than requiring a hand-written preset to already match the file's
272    /// shape exactly. This is what lets `ferrox-server` (and `ferrox
273    /// run-real`) load an arbitrary checkpoint, not just the three
274    /// hand-tuned presets in `config.rs`.
275    ///
276    /// Fields with no corresponding metadata key fall back to widely-used
277    /// llama.cpp defaults (documented inline) and are listed in the
278    /// returned config's `best_effort_fields`, following the same
279    /// confirmed-vs-estimated discipline as the hand-written presets.
280    pub fn from_gguf(file: &impl TensorSource) -> Result<Self, LoadError> {
281        let arch = file
282            .metadata_str("general.architecture")
283            .ok_or_else(|| LoadError::MissingHparam("general.architecture".to_string()))?
284            .to_string();
285        let arch_profile = crate::capability::resolve_profile(&arch)
286            .ok_or_else(|| LoadError::UnsupportedArchitecture(arch.clone()))?;
287        let rope_layout = match arch_profile.path {
288            crate::capability::ArchPath::GenericGqa { rope }
289            | crate::capability::ArchPath::TestFixture { rope } => rope,
290            crate::capability::ArchPath::DedicatedOnly { reason } => {
291                return Err(LoadError::DedicatedArchitectureRequired(
292                    arch.clone(),
293                    reason,
294                ));
295            }
296            crate::capability::ArchPath::Deferred { reason } => {
297                return Err(LoadError::UnsupportedFeature(
298                    arch.clone(),
299                    format!("architecture deferred from Ferrox text-generation scope: {reason}"),
300                ));
301            }
302        };
303        let qk_norm_style = arch_profile.qk_norm;
304        for (meta_key, feature) in crate::capability::unsupported_feature_keys(&arch) {
305            if let Some(v) = metadata_f32_any(file, std::slice::from_ref(&meta_key)) {
306                if v > 0.0 {
307                    return Err(LoadError::UnsupportedFeature(
308                        arch.clone(),
309                        format!("{feature} (metadata {meta_key}={v})"),
310                    ));
311                }
312            }
313            if let Some(v) = metadata_u64_any(file, std::slice::from_ref(&meta_key)) {
314                if v > 0 {
315                    return Err(LoadError::UnsupportedFeature(
316                        arch.clone(),
317                        feature.to_string(),
318                    ));
319                }
320            }
321        }
322        // Metadata-declared multipliers the generic decoder does not
323        // apply. Unlike the tensor-consumption gate, nothing about these
324        // is visible in the weights, so a Granite checkpoint would load
325        // and answer at the wrong scale. See
326        // `capability::unsupported_scaling_keys`.
327        for (meta_key, feature, no_op) in crate::capability::unsupported_scaling_keys(&arch) {
328            if let Some(v) = metadata_f32_any(file, std::slice::from_ref(&meta_key)) {
329                if (v - no_op).abs() > 1e-6 {
330                    return Err(LoadError::UnsupportedFeature(
331                        arch.clone(),
332                        format!("{feature} (metadata {meta_key}={v})"),
333                    ));
334                }
335            }
336        }
337        let key = |suffix: &str| format!("{arch}.{suffix}");
338
339        let name: &'static str = Box::leak(
340            file.metadata_str("general.name")
341                .unwrap_or(&arch)
342                .to_string()
343                .into_boxed_str(),
344        );
345
346        let n_layers =
347            file.metadata_u64(&key("block_count"))
348                .ok_or_else(|| LoadError::MissingHparam(key("block_count")))? as usize;
349        // Baichuan is one architecture string covering two positional
350        // schemes: 7B rotates, 13B uses ALiBi and no RoPE at all
351        // (`src/models/baichuan.cpp:11-14`, `:57-58`, where `inp_pos` is
352        // `nullptr` for 13B, so `ggml_rope_ext` is never reached).
353        // llama.cpp decides that on the layer count and says so in a
354        // comment: "TODO: become GGUF KV parameter". There is therefore
355        // no key for `capability::unsupported_feature_keys` to test and
356        // no tensor for `assert_every_tensor_consumed` to miss. A
357        // Baichuan-13B checkpoint loads clean and is rotated anyway.
358        // Refuse it here, where the layer count is known.
359        if arch == "baichuan" && n_layers == 40 {
360            return Err(LoadError::UnsupportedFeature(
361                arch.clone(),
362                "Baichuan-13B (block_count=40) uses ALiBi and no RoPE, decided by layer \
363                 count with no GGUF key to declare it; the generic decoder would rotate \
364                 every Q/K head instead. Baichuan-7B (block_count=32) is unaffected"
365                    .to_string(),
366            ));
367        }
368        let hidden_dim = file
369            .metadata_u64(&key("embedding_length"))
370            .ok_or_else(|| LoadError::MissingHparam(key("embedding_length")))?
371            as usize;
372        let n_heads = file
373            .metadata_u64(&key("attention.head_count"))
374            .ok_or_else(|| LoadError::MissingHparam(key("attention.head_count")))?
375            as usize;
376
377        let mut best_effort_fields: Vec<&'static str> = Vec::new();
378
379        let n_kv_heads = file
380            .metadata_u64(&key("attention.head_count_kv"))
381            .map(|v| v as usize)
382            .unwrap_or_else(|| {
383                best_effort_fields.push("n_kv_heads (no attention.head_count_kv key; assumed equal to n_heads, i.e. plain MHA)");
384                n_heads
385            });
386        let head_dim = file
387            .metadata_u64(&key("attention.key_length"))
388            .map(|v| v as usize)
389            .unwrap_or_else(|| {
390                best_effort_fields.push(
391                    "head_dim (no attention.key_length key; derived as hidden_dim / n_heads)",
392                );
393                hidden_dim / n_heads
394            });
395        let v_head_dim = file
396            .metadata_u64(&key("attention.value_length"))
397            .map(|v| v as usize)
398            .unwrap_or(head_dim);
399        if v_head_dim != head_dim {
400            return Err(LoadError::UnsupportedFeature(
401                arch.clone(),
402                format!(
403                    "split K/V head dims (key_length={head_dim}, value_length={v_head_dim}); \
404                     generic decoder requires equal head dims"
405                ),
406            ));
407        }
408        let vocab_size = file
409            .metadata("tokenizer.ggml.tokens")
410            .and_then(|v| match v {
411                GgufValue::Array(items) => Some(items.len()),
412                _ => None,
413            })
414            .or_else(|| file.metadata_u64(&key("vocab_size")).map(|v| v as usize))
415            .unwrap_or_else(|| {
416                best_effort_fields.push("vocab_size (no tokenizer.ggml.tokens array or {arch}.vocab_size key; fell back to output.weight's own row count)");
417                // `output.weight`'s real raw shape is `[hidden_dim,
418                // vocab_size]` (ggml's fastest-first `ne[]` order --
419                // see `load_weight_matrix`'s doc comment), so vocab_size
420                // is the *last* element, not the first.
421                file.find_tensor("output.weight")
422                    .and_then(|t| t.shape.last().copied())
423                    .unwrap_or(0) as usize
424            });
425        let rope_theta = metadata_f32_any(file, &[key("rope.freq_base")]).unwrap_or_else(|| {
426            best_effort_fields.push("rope_theta (no rope.freq_base key; defaulted to 10000.0)");
427            10000.0
428        });
429        // NTK-alpha: `{arch}.rope.scaling.alpha` is read for every
430        // architecture (llama-model.cpp:1186) and APPLIED by two
431        // (`hunyuan-vl.cpp:8-12`, inherited by `hunyuan-dense`). The
432        // list and the arithmetic live together in one module so the
433        // key's readers and its appliers cannot drift apart -- see
434        // `crate::rope_ntk_alpha`, which also records why a converted
435        // `hunyuan-dense` file carries the already-scaled base instead.
436        let rope_theta = crate::rope_ntk_alpha::ntk_alpha_scaled_rope_base(
437            &arch,
438            rope_theta,
439            head_dim,
440            metadata_f32_any(file, &[key("rope.scaling.alpha")]),
441        );
442        let rms_norm_eps = metadata_f32_any(
443            file,
444            &[
445                key("attention.layer_norm_rms_epsilon"),
446                key("attention.layer_norm_epsilon"),
447            ],
448        )
449        .unwrap_or_else(|| {
450            best_effort_fields
451                .push("rms_norm_eps (no layer_norm_rms_epsilon key; defaulted to 1e-5)");
452            1e-5
453        });
454
455        let n_experts = metadata_u64_any(file, &[key("expert_count")]).unwrap_or(0) as usize;
456        let is_moe = n_experts > 1;
457
458        let n_experts_active = if is_moe {
459            metadata_u64_any(file, &[key("expert_used_count")]).unwrap_or_else(|| {
460                best_effort_fields
461                    .push("moe.n_experts_active (no expert_used_count key; defaulted to 2)");
462                2
463            }) as usize
464        } else {
465            1
466        };
467        // Prefer the GGUF hparam when present. Qwen2MoE (and some other
468        // HF→GGUF exports) omit `expert_shared_count` but still ship
469        // `blk.N.ffn_{gate,up,down}_shexp.weight` -- without a tensor-
470        // presence fallback those weights are silently dropped and the
471        // model runs with a large chunk of active FFN missing.
472        let n_shared_experts = match metadata_u64_any(file, &[key("expert_shared_count")]) {
473            Some(n) => n as usize,
474            None if is_moe && file.find_tensor("blk.0.ffn_gate_shexp.weight").is_some() => {
475                best_effort_fields.push(
476                    "moe.n_shared_experts (no expert_shared_count; inferred 1 from blk.0.ffn_gate_shexp.weight)",
477                );
478                1
479            }
480            None => 0,
481        };
482        // MoE GGUFs often only set `feed_forward_length` (OLMoE=1024,
483        // Qwen2-MoE=5632 for the shared expert). `expert_feed_forward_length`
484        // is optional. llama.cpp `qwen2moe.cpp` uses
485        // `n_ff_exp = n_ff_exp ? n_ff_exp : n_ff / n_expert_used` (1408 for
486        // Qwen1.5-MoE); the shared expert keeps the full `n_ff` (5632).
487        let feed_forward_length = metadata_u64_any(file, &[key("feed_forward_length")]);
488        let expert_ffn_dim = metadata_u64_any(file, &[key("expert_feed_forward_length")])
489            .or_else(|| {
490                feed_forward_length.map(|ff| {
491                    if is_moe && n_experts_active > 0 {
492                        ff / n_experts_active as u64
493                    } else {
494                        ff
495                    }
496                })
497            })
498            .unwrap_or_else(|| {
499                best_effort_fields.push(
500                    "moe.expert_ffn_dim (no expert_feed_forward_length/feed_forward_length; defaulted to 4x hidden_dim)",
501                );
502                (hidden_dim * 4) as u64
503            }) as usize;
504        let n_dense_leading_layers = if LEADING_DENSE_KEY_IS_INERT.contains(&arch.as_str()) {
505            0
506        } else {
507            metadata_u64_any(file, &[key("leading_dense_block_count")]).unwrap_or(0) as usize
508        };
509        // The OTHER half of llama.cpp's dense-vs-MoE rule.
510        // `ModelConfig::layer_is_dense` implements the leading-dense
511        // prefix and not the `(il + 1) % n_moe_layer_step == 0` at
512        // `src/models/ernie4-5-moe.cpp:64`, so a file whose step would
513        // change the answer stops here rather than looking for expert
514        // tensors on a layer that stores dense ones. `moe_interleave`
515        // records what building the fixture found: llama.cpp cannot load
516        // such a file either, because its own tensor loader has no step
517        // in it.
518        if let Some(reason) = crate::moe_interleave::interleave_step_refusal(
519            &arch,
520            metadata_u64_any(file, &[key("interleave_moe_layer_step")]),
521        ) {
522            return Err(LoadError::UnsupportedFeature(arch.clone(), reason));
523        }
524
525        // ik_llama.cpp's real gating-function hparam
526        // (LLM_KV_EXPERT_GATING_FUNC: 1=softmax, 2=sigmoid) if the file
527        // carries it; otherwise fall back to the same architecture-name
528        // convention the hand-written presets in config.rs use (see
529        // docs/MODELS.md for the citations behind that list).
530        let gating = match metadata_u64_any(file, &[key("expert_gating_func")]) {
531            Some(2) => GatingFunction::Sigmoid,
532            Some(1) => GatingFunction::Softmax,
533            _ => {
534                if SIGMOID_GATING_ARCHITECTURES.contains(&arch.as_str()) {
535                    GatingFunction::Sigmoid
536                } else {
537                    if is_moe {
538                        best_effort_fields.push(
539                            "moe.gating (no expert_gating_func key and architecture not in the known-sigmoid list; defaulted to softmax)",
540                        );
541                    }
542                    GatingFunction::Softmax
543                }
544            }
545        };
546
547        // `{arch}.expert_weights_norm` (llama.cpp
548        // `LLM_KV_EXPERT_WEIGHTS_NORM`) is the real metadata key for
549        // whether the selected experts' weights are renormalised. Most
550        // checkpoints do not carry it, which is why the fallback below
551        // exists at all -- but when one does, the file's own answer wins
552        // over an architecture-name guess.
553        let norm_topk_prob = match file.metadata_bool(&key("expert_weights_norm")) {
554            Some(v) => v,
555            None => {
556                // See `NO_TOPK_RENORMALIZE_ARCHITECTURES`'s doc comment:
557                // an architecture-name lookup, the same convention
558                // `gating`'s fallback above uses.
559                if is_moe && matches!(gating, GatingFunction::Softmax) {
560                    best_effort_fields.push(
561                        "moe.norm_topk_prob (no expert_weights_norm key; defaulted by architecture-name lookup against NO_TOPK_RENORMALIZE_ARCHITECTURES)",
562                    );
563                }
564                !NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&arch.as_str())
565            }
566        };
567
568        // `{arch}.expert_weights_scale` (`LLM_KV_EXPERT_WEIGHTS_SCALE`).
569        // llama.cpp's `build_moe_ffn` skips the multiply for both 0.0 and
570        // 1.0, so both mean "no scaling" and both land on 1.0 here.
571        let expert_weights_scale = metadata_f32_any(file, &[key("expert_weights_scale")])
572            .filter(|s| *s != 0.0)
573            .unwrap_or(1.0);
574
575        // Real GGUF key (`{arch}.attention.sliding_window`, confirmed
576        // against `gguf-py/gguf/constants.py`'s real
577        // `LLM_KV_ATTENTION_SLIDING_WINDOW`). Some checkpoints
578        // (confirmed for real published Qwen1.5-MoE/Qwen2-MoE GGUFs)
579        // carry a nonzero window value even when the model's own
580        // config disables sliding-window attention entirely
581        // (`use_sliding_window: false`) -- llama.cpp's own convention
582        // is that a window of 0 means "unused," so only a real nonzero
583        // value here is treated as active.
584        let sliding_window = metadata_u64_any(file, &[key("attention.sliding_window")])
585            .map(|v| v as usize)
586            .filter(|&w| w > 0)
587            // `phi3` declares a window that llama.cpp deliberately does
588            // NOT honour -- see `capability::swa_disabled_by_arch`. This
589            // has to drop the window rather than pick a period, because
590            // upstream is declining to use the file's value, not
591            // choosing a different one.
592            .filter(|_| !crate::capability::swa_disabled_by_arch(&arch));
593
594        // Gemma alternating SWA period (`attention.sliding_window_pattern`).
595        // llama.cpp: gemma2 defaults period=2, gemma3 defaults period=6 when
596        // the pattern key is absent. A missing key must NOT mean "all SWA".
597        //
598        // The metadata key overrides the PERIOD only. The phase is a
599        // property of the architecture in llama.cpp -- `dense_first` is
600        // an argument to `set_swa_pattern`, not a GGUF key -- so it
601        // comes from the registry either way.
602        let swa_layout = crate::capability::default_swa_layout(&arch);
603        let swa_dense_first = swa_layout.is_some_and(|p| p.dense_first);
604        // llama.cpp reads this with `ml.get_key_or_arr`, so the value is
605        // a scalar period OR an n_layer-long per-layer array. ferrox
606        // carries one scalar `swa_pattern`, and `metadata_u64_any`
607        // simply returns `None` for an array -- which silently
608        // substituted `default_swa_layout`'s period for the layout the
609        // file actually declared. Present-but-unreadable is the case to
610        // refuse; presence alone is not, because the pattern itself is
611        // implemented (`ModelConfig::layer_sliding_window`, both
612        // phases). `capability::unsupported_feature_keys` used to refuse
613        // presence alone, and its comment says why that was wrong.
614        let swa_pattern_key = key("attention.sliding_window_pattern");
615        if file.metadata(&swa_pattern_key).is_some()
616            && metadata_u64_any(file, std::slice::from_ref(&swa_pattern_key)).is_none()
617        {
618            return Err(LoadError::UnsupportedFeature(
619                arch.clone(),
620                format!(
621                    "{swa_pattern_key} is not a scalar period; llama.cpp accepts a \
622                     per-layer array here (ml.get_key_or_arr) and ferrox carries one \
623                     period for the whole model, so honouring it would mean substituting \
624                     a different layout for the file's"
625                ),
626            ));
627        }
628        let swa_pattern = metadata_u64_any(file, &[swa_pattern_key])
629            .map(|v| v as usize)
630            .or_else(|| {
631                sliding_window?;
632                // llama.cpp hardcodes the period per architecture and
633                // only lets the metadata key override it, so a missing
634                // key is *not* "every layer windowed" -- see
635                // `capability::default_swa_layout`.
636                swa_layout.map(|p| p.period).or(
637                    // Any Gemma variant not named in the table keeps the
638                    // gemma3+ period rather than going uniform.
639                    match arch_profile.family {
640                        crate::capability::DecoderFamily::GemmaFamily => Some(6),
641                        _ => None,
642                    },
643                )
644            });
645
646        let attn_logit_softcap = metadata_f32_any(
647            file,
648            &[
649                key("attention.logit_softcapping"),
650                key("attn_logit_softcapping"),
651            ],
652        )
653        .filter(|&v| v > 0.0);
654        let final_logit_softcap =
655            metadata_f32_any(file, &[key("final_logit_softcapping")]).filter(|&v| v > 0.0);
656
657        // Gemma: embeddings are scaled by sqrt(hidden_dim) at input.
658        let embedding_scale = if matches!(
659            arch_profile.family,
660            crate::capability::DecoderFamily::GemmaFamily
661        ) {
662            Some((hidden_dim as f32).sqrt())
663        } else {
664            None
665        };
666
667        // llama.cpp's `f_attention_scale`, and ONLY where it differs from
668        // the `1/sqrt(head_dim)` ferrox's attention kernels already
669        // apply -- `Some` here means "pre-scale Q", so restating the
670        // kernels' own scale would double-scale every score.
671        //
672        // For Gemma-2 and Gemma-3 that difference is real at 27B and
673        // nowhere else (`capability::attention_scale_override` carries
674        // the llama.cpp lines). This used to be a hardcoded `None` under
675        // a comment that NAMED the 27B exception without implementing
676        // it, so Gemma-2-27B scored 1.061x and Gemma-3-27B 1.146x too
677        // large on every layer: a sharper softmax than the trained one,
678        // fluent and wrong, with no error.
679        let attention_scale = crate::capability::attention_scale_override(
680            &arch, n_layers, hidden_dim, n_heads, head_dim,
681        );
682
683        // SWA-layer RoPE base. `llama_hparams` defaults it to 10000 and
684        // the Gemma-3 lineage relies on that default; the architectures
685        // in `swa_rope_base_follows_model` instead seed it from the
686        // model's own base before the key can override.
687        let rope_theta_swa = if sliding_window.is_some() {
688            let fallback = if crate::capability::swa_rope_base_follows_model(&arch) {
689                rope_theta
690            } else {
691                10_000.0
692            };
693            Some(
694                metadata_f32_any(
695                    file,
696                    &[key("rope.freq_base_swa"), key("rope_freq_base_swa")],
697                )
698                .unwrap_or(fallback),
699            )
700        } else {
701            None
702        };
703
704        let ffn_activation = match arch_profile.family {
705            // Per-ARCHITECTURE first, because llama.cpp's choice is per
706            // architecture and the family partition does not match it:
707            // `grok` is StandardGqa and passes `LLM_FFN_GELU`.
708            _ if crate::capability::uses_geglu(&arch) => crate::config::FfnActivation::Gelu,
709            crate::capability::DecoderFamily::GemmaFamily => crate::config::FfnActivation::Gelu,
710            crate::capability::DecoderFamily::PhiFamily => {
711                crate::config::FfnActivation::SwigluFused
712            }
713            _ => crate::config::FfnActivation::Swiglu,
714        };
715
716        // Llama 3/3.1/3.2's real per-band RoPE frequency correction: one
717        // model-level tensor (`TENSOR_NOT_REQUIRED`, `TENSOR_DUPLICATED`
718        // for every layer but the first in the real llama.cpp source --
719        // i.e. every layer shares this same array), not per-layer. See
720        // `ferrox_core::attention::apply_rope_with_freq_factors`'s doc
721        // comment for why this matters.
722        let rope_freqs = load_f32_vec_optional(file, "rope_freqs.weight")?;
723
724        // Phi-3/Phi-4 LongRoPE: two per-band factor tensors instead of
725        // Llama's single `rope_freqs.weight`, selected by context size
726        // (llama.cpp `llama_model::get_rope_factors`: `rope_freqs` wins if
727        // present, else `rope_long` when the run's context exceeds
728        // `rope.scaling.original_context_length`, else `rope_short`).
729        //
730        // Provisional pick from the checkpoint's advertised context length
731        // (llama.cpp's default `n_ctx`). The definitive pick happens in
732        // `ModelConfig::apply_runtime_context`, called from `ferrox run`
733        // (`--ctx-size`) and from `verify_engine::load_and_tokenize`
734        // (`n_tokens + 8`, matching `tools/llama_logits.c`).
735        let rope_orig_ctx = metadata_u64_any(file, &[key("rope.scaling.original_context_length")])
736            .map(|v| v as usize);
737        // `rope_freqs.weight` outranks the LongRoPE pair (llama.cpp
738        // `get_rope_factors` checks it first), so a checkpoint carrying
739        // it never populates these and the runtime re-pick below cannot
740        // overwrite a Llama-3 correction with a Phi one.
741        let (rope_freqs_long, rope_freqs_short) = if rope_freqs.is_some() {
742            (None, None)
743        } else {
744            (
745                load_f32_vec_optional(file, "rope_factors_long.weight")?,
746                load_f32_vec_optional(file, "rope_factors_short.weight")?,
747            )
748        };
749        // Provisional pick from the checkpoint's own advertised context;
750        // `ModelConfig::apply_runtime_context` re-picks once the run's
751        // `--ctx-size` is known, which is the number llama.cpp decides on.
752        let rope_freqs = match (rope_freqs, rope_orig_ctx) {
753            (Some(f), _) => Some(f),
754            (None, Some(orig)) => {
755                let model_ctx = metadata_u64_any(file, &[key("context_length")])
756                    .unwrap_or(orig as u64) as usize;
757                if model_ctx > orig {
758                    rope_freqs_long.clone().or_else(|| rope_freqs_short.clone())
759                } else {
760                    rope_freqs_short.clone().or_else(|| rope_freqs_long.clone())
761                }
762            }
763            (None, None) => None,
764        };
765
766        // Partial rotary: only when the file says the rotary width is
767        // narrower than a head. Equal values mean "whole head", which is
768        // the same thing as `None` and stays `None` so nothing downstream
769        // has to special-case it.
770        let rope_dim = metadata_u64_any(file, &[key("rope.dimension_count")])
771            .map(|d| d as usize)
772            .filter(|d| *d > 0 && *d < head_dim);
773
774        // See `ModelConfig::rope_attn_factor`.
775        let rope_attn_factor = metadata_f32_any(file, &[key("rope.scaling.attn_factor")])
776            .filter(|f| f.is_finite() && *f > 0.0)
777            .unwrap_or(1.0);
778
779        // YaRN long-context scaling. `rope.scaling.attn_factor` above is
780        // only YaRN's *magnitude* term (ggml `rope_yarn`'s `mscale`); the
781        // frequency half -- which bands get interpolated toward the
782        // trained context and which stay extrapolated -- lives in
783        // `rope.scaling.type` + `rope.scaling.factor`, and ferrox read
784        // neither before this. A YaRN checkpoint was therefore roped as
785        // if it declared no scaling at all: right near position 0 and
786        // progressively wrong further in, i.e. the failure that reads as
787        // long-prompt quality decay rather than as a bug.
788        //
789        // The rewrite is folded into `rope_freqs`, the same per-band
790        // divisor array Llama-3's `rope_freqs.weight` supplies (ggml
791        // divides each band's theta by it), so it rides the existing CPU
792        // and Metal RoPE paths unchanged. When a file carries both, the
793        // two corrections compose by multiplication, as they do in
794        // llama.cpp (`ggml_rope_cache_init` divides by `freq_factors`
795        // *and then* runs `rope_yarn`).
796        // Linear scaling, which was silently DROPPED before this.
797        //
798        // `rope.scaling.type = "linear"` with factor s means rotating
799        // position `p/s` instead of `p`. Since the angle is `p * freq`,
800        // that is exactly `p * (freq / s)`, and `rope_freqs` already
801        // divides each band's frequency. So a uniform vector of `s`
802        // expresses it exactly and rides the existing CPU and Metal RoPE
803        // paths unchanged, the same way YaRN does below.
804        //
805        // Before this, the type was compared against "yarn" and anything
806        // else returned None, so a checkpoint declaring linear scaling
807        // with factor 4 loaded and roped at UNSCALED positions where
808        // llama.cpp divides them by 4. It answered as a different model
809        // with no error. Affects the long-context community rescales
810        // (`*-16k`, `*-32k` Llama-2 derivatives).
811
812        // The file's own per-band factors, BEFORE any position-scaling
813        // fold. That is what a sliding layer uses on an architecture
814        // whose SWA layers do not inherit the trained scale -- llama.cpp
815        // keeps the two apart as `freq_factors` (a tensor, the same for
816        // every layer) and `freq_scale` (per layer,
817        // `llama-model.cpp:2033`), while ferrox folds them into one
818        // vector. See `config::RopeFreqs`.
819        let rope_freqs_unscaled = rope_freqs.clone();
820
821        let rope_freqs = match linear_scaling_from_gguf(file, &arch) {
822            None => rope_freqs,
823            Some(factor) => {
824                let rotary_dim = rope_dim.unwrap_or(head_dim);
825                if rotary_dim == 0 || !rotary_dim.is_multiple_of(2) {
826                    best_effort_fields.push(
827                        "rope_freqs (linear scaling declared but the rotary width is odd; \
828                         scaling not applied)",
829                    );
830                    rope_freqs
831                } else {
832                    let linear = vec![factor; rotary_dim / 2];
833                    match rope_freqs {
834                        None => Some(linear),
835                        // Compose by multiplication, as a file carrying
836                        // its own `rope_freqs.weight` tensor and a
837                        // declared linear factor means both.
838                        Some(own) if own.len() == linear.len() => {
839                            Some(own.iter().zip(linear.iter()).map(|(a, b)| a * b).collect())
840                        }
841                        Some(own) => {
842                            best_effort_fields.push(
843                                "rope_freqs (linear scaling declared but the file's own \
844                                 rope_freqs tensor has a different width; scaling not applied)",
845                            );
846                            Some(own)
847                        }
848                    }
849                }
850            }
851        };
852        let rope_freqs = match yarn_scaling_from_gguf(file, &arch, rope_orig_ctx) {
853            None => rope_freqs,
854            Some(scaling) => {
855                let rotary_dim = rope_dim.unwrap_or(head_dim);
856                if rotary_dim == 0 || !rotary_dim.is_multiple_of(2) {
857                    best_effort_fields.push(
858                        "rope_freqs (YaRN declared but the rotary width is odd; scaling not applied)",
859                    );
860                    rope_freqs
861                } else {
862                    let yarn =
863                        ferrox_core::attention::yarn_freq_factors(scaling, rotary_dim, rope_theta);
864                    match rope_freqs {
865                        None => Some(yarn),
866                        Some(own) if own.len() == yarn.len() => {
867                            Some(own.iter().zip(yarn.iter()).map(|(a, b)| a * b).collect())
868                        }
869                        Some(own) => {
870                            best_effort_fields.push(
871                                "rope_freqs (YaRN declared alongside a per-band factor tensor of a \
872                                 different width; the file's own tensor is used unscaled)",
873                            );
874                            Some(own)
875                        }
876                    }
877                }
878            }
879        };
880
881        // The SWA half of the split. llama.cpp defaults
882        // `rope_freq_scale_train_swa` to `1.0f`
883        // (`src/llama-hparams.h:129`) and only the architectures in
884        // `swa_rope_scale_follows_model` assign it from
885        // `rope_freq_scale_train`; `get_rope_freq_scale`
886        // (`llama-model.cpp:2033-2035`) then picks between them per
887        // layer. `gemma3.cpp` is not on that list and its converter
888        // writes the FULL-ATTENTION factor
889        // (`conversion/base.py:1222-1230`), so a Gemma-3 4B/12B/27B was
890        // rotating five layers in six at `p/8` where llama.cpp rotates
891        // at `p`.
892        //
893        // "No scaling" is spelled as an all-ones divisor vector, which
894        // is what dividing by nothing is, so the sliding layers need no
895        // second code path anywhere downstream.
896        let rope_freqs = rope_freqs.map(|full| {
897            let swa = (sliding_window.is_some()
898                && !crate::capability::swa_rope_scale_follows_model(&arch))
899            .then(|| rope_freqs_unscaled.unwrap_or_else(|| vec![1.0; full.len()]))
900            .filter(|swa| *swa != full);
901            crate::config::RopeFreqs { full, swa }
902        });
903
904        // RoPE layout comes from the capability registry above (fail-
905        // closed). Getting this wrong for `llama` (needs Norm) was the
906        // real root cause of the Llama-3.1-8B early-stop/wrong-logits bug.
907
908        if best_effort_fields.is_empty() {
909            best_effort_fields.push(
910                "none -- every field above was read directly from this file's own GGUF metadata",
911            );
912        }
913
914        // LAST, deliberately. The generic path is a GUESS, so it has to
915        // be opted into rather than fallen onto: it assumes plain GQA
916        // with no ALiBi, no learned position embeddings and no
917        // per-layer rope skipping, and that assumption was already
918        // wrong for gpt2, mpt, refact, bloom and jais.
919        //
920        // But it runs AFTER every architecture-specific refusal, so a
921        // checkpoint with a NAMED problem still reports that problem.
922        // Checking first would have replaced "this uses ALiBi" with
923        // "this is unaudited", which is true and much less useful.
924        if matches!(
925            arch_profile.path,
926            crate::capability::ArchPath::GenericGqa { .. }
927        ) && !crate::capability::is_audited_generic(&arch)
928            && !matches!(
929                std::env::var("FERROX_ALLOW_UNAUDITED_ARCH").ok().as_deref(),
930                Some("1") | Some("true") | Some("on")
931            )
932        {
933            return Err(LoadError::UnauditedArchitecture(
934                arch.clone(),
935                rope_layout,
936                crate::capability::unaudited_refusal_detail(&arch),
937            ));
938        }
939
940        Ok(ModelConfig {
941            name,
942            n_layers,
943            hidden_dim,
944            n_heads,
945            n_kv_heads,
946            head_dim,
947            vocab_size,
948            rope_theta,
949            rms_norm_eps,
950            // No GGUF file encodes a hybrid KDA/Gated-MLA attention
951            // topology today; every real checkpoint loaded this way
952            // runs the standard Gqa path.
953            attention: crate::config::AttentionKind::Gqa,
954            sliding_window,
955            swa_pattern,
956            swa_dense_first,
957            moe: MoeLayerConfig {
958                n_experts: n_experts.max(1),
959                n_experts_active,
960                n_shared_experts,
961                hidden_dim,
962                expert_ffn_dim,
963                gating,
964                norm_topk_prob,
965                expert_group_count: metadata_u64_any(file, &[key("expert_group_count")])
966                    .map(|v| v as usize)
967                    .filter(|&c| c > 1),
968                expert_group_used_count: metadata_u64_any(file, &[key("expert_group_used_count")])
969                    .map(|v| v as usize)
970                    .filter(|&c| c > 0),
971                expert_weights_scale,
972            },
973            n_dense_leading_layers,
974            rope_freqs,
975            rope_layout,
976            qk_norm_style,
977            attn_logit_softcap,
978            final_logit_softcap,
979            embedding_scale,
980            attention_scale,
981            rope_attn_factor,
982            rope_dim,
983            rope_freqs_long,
984            rope_freqs_short,
985            rope_orig_ctx,
986            rope_theta_swa,
987            ffn_activation,
988            best_effort_fields: Box::leak(best_effort_fields.into_boxed_slice()),
989        })
990    }
991}
992
993impl crate::sampling::RecommendedSampling {
994    /// The sampling a GGUF recommends for itself, from the
995    /// `general.sampling.*` metadata keys llama.cpp's converter writes
996    /// when the source checkpoint carried a `generation_config.json`.
997    ///
998    /// This is the GGUF half of FreeToken's `load_generation_sampling`
999    /// (`python/freetoken/utils/hf.py:92`), which checks the GGUF
1000    /// metadata *first* and only falls back to a `generation_config.json`
1001    /// sidecar for non-GGUF checkpoints -- a GGUF is a single file and
1002    /// has no sidecar to read.
1003    ///
1004    /// Key names are llama.cpp's own (`general.sampling.temp`, not
1005    /// `temperature`). Each key is independent: a file that names only
1006    /// `top_k` recommends only `top_k`, and the two fields it did not
1007    /// mention stay `None` so the server's own defaults keep speaking
1008    /// for them.
1009    ///
1010    /// `temp` / `top_p` are read as float *or* integer, because a
1011    /// converter that wrote `temp = 1` stores a GGUF integer and
1012    /// dropping that value would silently serve the checkpoint greedy --
1013    /// the exact repetition-loop failure the recommendation exists to
1014    /// prevent.
1015    pub fn from_gguf(file: &impl TensorSource) -> Self {
1016        let number = |k: &str| -> Option<f32> {
1017            file.metadata(k)
1018                .and_then(|v| v.as_f32().or_else(|| v.as_u64().map(|u| u as f32)))
1019        };
1020        crate::sampling::RecommendedSampling {
1021            temperature: number("general.sampling.temp"),
1022            top_p: number("general.sampling.top_p"),
1023            top_k: file
1024                .metadata("general.sampling.top_k")
1025                .and_then(|v| v.as_u64())
1026                .map(|v| v as usize),
1027        }
1028    }
1029}
1030
1031/// The `linear` RoPE scaling factor, if this file declares one.
1032///
1033/// Deliberately separate from [`yarn_scaling_from_gguf`]: YaRN needs an
1034/// original context length and per-band betas, and linear needs neither.
1035/// Any factor at or below one is not a correction, and is treated as
1036/// absent rather than applied as a no-op.
1037fn linear_scaling_from_gguf(file: &impl TensorSource, arch: &str) -> Option<f32> {
1038    let key = |suffix: &str| format!("{arch}.{suffix}");
1039    let scaling_type = file.metadata_str(&key("rope.scaling.type"))?;
1040    if !scaling_type.eq_ignore_ascii_case("linear") {
1041        return None;
1042    }
1043    metadata_f32_any(file, &[key("rope.scaling.factor")]).filter(|f| f.is_finite() && *f > 1.0)
1044}
1045
1046/// The YaRN RoPE scaling a GGUF declares, or `None` when this file
1047/// declares none that changes the rotation.
1048///
1049/// llama.cpp's key names (`llama-arch.cpp`
1050/// `LLM_KV_ROPE_SCALING_TYPE` / `_FACTOR`): `<arch>.rope.scaling.type`
1051/// is a string (`"none"`, `"linear"`, `"yarn"`, `"longrope"`) and
1052/// `<arch>.rope.scaling.factor` the ratio of served to trained context.
1053/// `beta_fast` / `beta_slow` are read from both the plain and the
1054/// `yarn_`-prefixed spelling and otherwise fall back to the reference's
1055/// own defaults (32.0 / 1.0), which is what a real checkpoint relies on
1056/// -- almost none of them write those two keys.
1057///
1058/// `None` is returned for every case where applying YaRN would be a
1059/// guess or a no-op rather than a correction, so that no checkpoint's
1060/// rotation moves without the file having asked for it:
1061///
1062/// * a scaling type other than `yarn` (`linear` divides positions,
1063///   `longrope` rides the `rope_factors_long`/`_short` tensors this
1064///   loader already reads -- neither is this rewrite, and treating them
1065///   as YaRN would rope them wrong in a *new* way instead of leaving
1066///   them as they are),
1067/// * a missing, non-finite or `<= 1.0` factor (the reference's own
1068///   `get_mscale` treats `scale <= 1` as unscaled, and a factor of 1.0
1069///   makes every band's divisor exactly 1.0 anyway),
1070/// * a missing `rope.scaling.original_context_length` -- the trained
1071///   context is what the correction range is measured against, and
1072///   inventing one (say, from `context_length`, which on a YaRN file is
1073///   the *extended* length) would put the ramp in the wrong place and
1074///   quietly rope the checkpoint at frequencies nobody trained.
1075fn yarn_scaling_from_gguf(
1076    file: &impl TensorSource,
1077    arch: &str,
1078    orig_ctx: Option<usize>,
1079) -> Option<ferrox_core::attention::YarnScaling> {
1080    let key = |suffix: &str| format!("{arch}.{suffix}");
1081    let scaling_type = file.metadata_str(&key("rope.scaling.type"))?;
1082    if !scaling_type.eq_ignore_ascii_case("yarn") {
1083        return None;
1084    }
1085    let factor = metadata_f32_any(file, &[key("rope.scaling.factor")])
1086        .filter(|f| f.is_finite() && *f > 1.0)?;
1087    let orig_max_pos = orig_ctx?;
1088    let beta = |suffix: &str, default: f32| -> f32 {
1089        metadata_f32_any(
1090            file,
1091            &[
1092                key(&format!("rope.scaling.{suffix}")),
1093                key(&format!("rope.scaling.yarn_{suffix}")),
1094            ],
1095        )
1096        .filter(|v| v.is_finite() && *v > 0.0)
1097        .unwrap_or(default)
1098    };
1099    Some(ferrox_core::attention::YarnScaling {
1100        factor,
1101        beta_fast: beta("beta_fast", 32.0),
1102        beta_slow: beta("beta_slow", 1.0),
1103        orig_max_pos,
1104        // No GGUF key carries the reference's `truncate` flag, and its
1105        // default is `true`; a file that wanted the fractional range
1106        // would have no way to say so here.
1107        truncate: true,
1108    })
1109}
1110
1111pub(crate) fn find_info<'a>(
1112    file: &'a impl TensorSource,
1113    name: &str,
1114) -> Result<&'a TensorInfo, LoadError> {
1115    file.find_tensor(name)
1116        .ok_or_else(|| LoadError::Gguf(GgufError::TensorNotFound(name.to_string())))
1117}
1118
1119/// Like `load_f32_vec`, but for tensors that only exist on some
1120/// checkpoints (e.g. `attn_q_norm`/`attn_k_norm` -- OLMoE-style
1121/// per-projection QK-RMSNorm applied to the full q_proj/k_proj output
1122/// before RoPE, confirmed against `OlmoeAttention.forward` in
1123/// `transformers/models/olmoe/modeling_olmoe.py`: `q_norm(q_proj(x))`,
1124/// `k_norm(k_proj(x))`, both plain RMSNorm over the whole projected
1125/// width, not per-head). Absent for every other preset/fixture this
1126/// loader already handles -- `None` there is correct, not a missing
1127/// feature.
1128/// Loads the five gpt-oss-only tensors for one layer.
1129///
1130/// Every one of them is **required**: a gpt-oss checkpoint that is
1131/// missing any of these is not a gpt-oss checkpoint ferrox can run, and
1132/// quietly substituting zeros would reintroduce exactly the
1133/// silently-wrong-graph failure this path exists to remove. The lengths
1134/// are asserted against the config for the same reason -- a bias of the
1135/// wrong width would otherwise be applied to a `zip`-truncated prefix
1136/// and produce a plausible, wrong answer.
1137///
1138/// Shapes follow `src/models/openai-moe.cpp::load_arch_tensors`:
1139/// `attn_sinks {n_head}`, `attn_output.bias {n_embd}`,
1140/// `ffn_gate_inp.bias {n_expert}`, `ffn_{gate,up}_exps.bias
1141/// {n_ff_exp, n_expert}`, `ffn_down_exps.bias {n_embd, n_expert}`.
1142/// GGUF stores the fastest dimension first, so the 2-D bias tensors
1143/// arrive expert-major and split by simple chunking.
1144fn load_gpt_oss_layer(
1145    file: &impl TensorSource,
1146    l: usize,
1147    config: &ModelConfig,
1148) -> Result<crate::decoder::GptOssLayer, LoadError> {
1149    let n_experts = config.moe.n_experts;
1150    let ff = config.moe.expert_ffn_dim;
1151
1152    let want = |name: &str, got: usize, expect: usize| -> Result<(), LoadError> {
1153        if got == expect {
1154            Ok(())
1155        } else {
1156            Err(LoadError::UnsupportedFeature(
1157                config.name.to_string(),
1158                format!("{name} has {got} elements, expected {expect}"),
1159            ))
1160        }
1161    };
1162
1163    let attn_sinks = load_f32_vec(file, &format!("blk.{l}.attn_sinks.weight"))?;
1164    want(
1165        &format!("blk.{l}.attn_sinks.weight"),
1166        attn_sinks.len(),
1167        config.n_heads,
1168    )?;
1169    let o_bias = load_f32_vec(file, &format!("blk.{l}.attn_output.bias"))?;
1170    want(
1171        &format!("blk.{l}.attn_output.bias"),
1172        o_bias.len(),
1173        config.hidden_dim,
1174    )?;
1175    let router_bias = load_f32_vec(file, &format!("blk.{l}.ffn_gate_inp.bias"))?;
1176    want(
1177        &format!("blk.{l}.ffn_gate_inp.bias"),
1178        router_bias.len(),
1179        n_experts,
1180    )?;
1181
1182    let gate_b = load_f32_vec(file, &format!("blk.{l}.ffn_gate_exps.bias"))?;
1183    want(
1184        &format!("blk.{l}.ffn_gate_exps.bias"),
1185        gate_b.len(),
1186        n_experts * ff,
1187    )?;
1188    let up_b = load_f32_vec(file, &format!("blk.{l}.ffn_up_exps.bias"))?;
1189    want(
1190        &format!("blk.{l}.ffn_up_exps.bias"),
1191        up_b.len(),
1192        n_experts * ff,
1193    )?;
1194    let down_b = load_f32_vec(file, &format!("blk.{l}.ffn_down_exps.bias"))?;
1195    want(
1196        &format!("blk.{l}.ffn_down_exps.bias"),
1197        down_b.len(),
1198        n_experts * config.hidden_dim,
1199    )?;
1200
1201    let expert_bias = (0..n_experts)
1202        .map(|e| ferrox_moe::ExpertBias {
1203            gate: gate_b[e * ff..(e + 1) * ff].to_vec(),
1204            up: up_b[e * ff..(e + 1) * ff].to_vec(),
1205            down: down_b[e * config.hidden_dim..(e + 1) * config.hidden_dim].to_vec(),
1206        })
1207        .collect();
1208
1209    Ok(crate::decoder::GptOssLayer {
1210        attn_sinks,
1211        o_bias,
1212        router_bias,
1213        expert_bias,
1214    })
1215}
1216
1217pub(crate) fn load_f32_vec_optional(
1218    file: &impl TensorSource,
1219    name: &str,
1220) -> Result<Option<Vec<f32>>, LoadError> {
1221    if file.find_tensor(name).is_none() {
1222        return Ok(None);
1223    }
1224    Ok(Some(load_f32_vec(file, name)?))
1225}
1226
1227/// A norm weight that some converters spell `<base>.weight` and others
1228/// spell just `<base>`.
1229///
1230/// This exists for exactly one pair of tensors, `post_attention_norm`
1231/// and `post_ffw_norm`, and it is this repo's dominant bug shape in
1232/// llama.cpp's own trees: TWO SPELLINGS OF ONE NAME, WITH NOTHING
1233/// ENFORCING AGREEMENT.
1234///
1235/// `LLM_TN` appends `.weight` only when it is given a suffix
1236/// (`src/llama-arch.cpp:898-910`). Every architecture that creates these
1237/// two tensors passes one -- `tn(LLM_TENSOR_ATTN_POST_NORM, "weight",
1238/// i)` in gemma2, gemma3, glm4, exaone4, afmoe and the rest -- EXCEPT
1239/// `plamo3`, which uses the two-argument overload
1240/// (`src/models/plamo3.cpp:52,55`) and therefore asks for
1241/// `blk.N.post_attention_norm` with no suffix at all.
1242///
1243/// The converter agrees with it, by a second accident that happens to
1244/// line up: `gguf-py/gguf/tensor_mapping.py:368,434` give the PLaMo
1245/// entries as `model.layers.layers.{bid}.post_mixer_norm.weight` and
1246/// `...post_mlp_norm.weight` -- keys that already END in `.weight`.
1247/// `TensorNameMap.get_type_and_name` (:2585-2594) tries an exact match
1248/// FIRST and only falls back to stripping a suffix, so those two match
1249/// exactly and the mapped name is emitted with nothing appended.
1250///
1251/// So a real PLaMo-3 GGUF carries `blk.N.post_attention_norm` and
1252/// `blk.N.post_ffw_norm`, and every Gemma-lineage GGUF carries the same
1253/// two names with `.weight`. Reading only one spelling means one of the
1254/// two families always fails on a missing tensor. ferrox read only
1255/// `.weight`, which is why `plamo3` could not have loaded a real
1256/// checkpoint -- fail-closed rather than wrong, but not "a fixture
1257/// away", which is what its triage verdict said.
1258///
1259/// Both spellings are accepted rather than one being chosen per
1260/// architecture, because the choice is a property of the file and
1261/// nothing in the metadata declares it. Neither present is still
1262/// `None`.
1263pub(crate) fn load_norm_vec_either_spelling(
1264    file: &impl TensorSource,
1265    base: &str,
1266) -> Result<Option<Vec<f32>>, LoadError> {
1267    let suffixed = format!("{base}.weight");
1268    if file.find_tensor(&suffixed).is_some() {
1269        return load_f32_vec_optional(file, &suffixed);
1270    }
1271    load_f32_vec_optional(file, base)
1272}
1273
1274/// Slice `n` rows starting at `start` out of a quantized matrix without
1275/// dequantizing: every `Quantized` kind stores one interleaved block
1276/// buffer per row (fixed `row_bytes`), so a row range is a contiguous
1277/// byte range. Mapped sources stay zero-copy (sub-range of the same
1278/// mmap); other backings get an owned copy. Returns `None` for non-
1279/// quantized matrices (F32 / MXFP4) -- callers fall back to dequant.
1280fn slice_quantized_rows(m: &WeightMatrix, start: usize, n: usize) -> Option<WeightMatrix> {
1281    let WeightMatrix::Quantized {
1282        data,
1283        rows,
1284        cols,
1285        kind,
1286    } = m
1287    else {
1288        return None;
1289    };
1290    let total = data.len();
1291    if *rows == 0 || total % *rows != 0 || start + n > *rows {
1292        return None;
1293    }
1294    let row_bytes = total / *rows;
1295    let (b0, b1) = (start * row_bytes, (start + n) * row_bytes);
1296    let bytes = match data {
1297        WeightBytes::Mapped { mmap, range } => WeightBytes::Mapped {
1298            mmap: mmap.clone(),
1299            range: range.start + b0..range.start + b1,
1300        },
1301        other => WeightBytes::Owned(other.as_slice()[b0..b1].to_vec()),
1302    };
1303    Some(WeightMatrix::Quantized {
1304        data: bytes,
1305        rows: n,
1306        cols: *cols,
1307        kind: *kind,
1308    })
1309}
1310
1311/// Loads Q/K/V projections: prefers split `attn_{q,k,v}.weight`, falls
1312/// back to fused `attn_qkv.weight` (Phi-3 / some Qwen GGUFs) by
1313/// slicing quantized rows (zero-copy for mmapped GGUFs; dequant only
1314/// for non-quantized storage). Mirrors llama.cpp `create_tensor_qkv`.
1315fn load_qkv_projections(
1316    file: &impl TensorSource,
1317    layer: usize,
1318    config: &ModelConfig,
1319) -> Result<(WeightMatrix, WeightMatrix, WeightMatrix), LoadError> {
1320    let q_name = format!("blk.{layer}.attn_q.weight");
1321    let k_name = format!("blk.{layer}.attn_k.weight");
1322    let v_name = format!("blk.{layer}.attn_v.weight");
1323    let fused_name = format!("blk.{layer}.attn_qkv.weight");
1324
1325    if file.find_tensor(&q_name).is_some() {
1326        return Ok((
1327            load_weight_matrix(file, &q_name)?,
1328            load_weight_matrix(file, &k_name)?,
1329            load_weight_matrix(file, &v_name)?,
1330        ));
1331    }
1332    if file.find_tensor(&fused_name).is_none() {
1333        return Err(LoadError::Gguf(GgufError::TensorNotFound(q_name)));
1334    }
1335
1336    let fused = load_weight_matrix(file, &fused_name)?;
1337    let q_rows = config.n_heads * config.head_dim;
1338    let kv_rows = config.n_kv_heads * config.head_dim;
1339    let expected = q_rows + 2 * kv_rows;
1340    if fused.rows() != expected {
1341        // Phi-3 sometimes stores Q as full n_embd (== q_rows when MHA).
1342        return Err(LoadError::UnsupportedFeature(
1343            config.name.to_string(),
1344            format!(
1345                "{fused_name} has {} rows; expected q+k+v = {} \
1346                 (n_heads*head_dim + 2*n_kv_heads*head_dim)",
1347                fused.rows(),
1348                expected
1349            ),
1350        ));
1351    }
1352    let cols = fused.cols();
1353    // Quantized fused tensor: split by row ranges without dequantizing,
1354    // keeping Q/K/V on the quantized (Metal-capable) matvec path.
1355    if let (Some(q), Some(k), Some(v)) = (
1356        slice_quantized_rows(&fused, 0, q_rows),
1357        slice_quantized_rows(&fused, q_rows, kv_rows),
1358        slice_quantized_rows(&fused, q_rows + kv_rows, kv_rows),
1359    ) {
1360        return Ok((q, k, v));
1361    }
1362    // Non-quantized storage: dequant once and split.
1363    let mut full = Vec::with_capacity(fused.rows() * cols);
1364    for r in 0..fused.rows() {
1365        full.extend_from_slice(&fused.dequant_row(r));
1366    }
1367    let q = WeightMatrix::F32(Tensor::new(
1368        full[..q_rows * cols].to_vec(),
1369        vec![q_rows, cols],
1370    ));
1371    let k = WeightMatrix::F32(Tensor::new(
1372        full[q_rows * cols..(q_rows + kv_rows) * cols].to_vec(),
1373        vec![kv_rows, cols],
1374    ));
1375    let v = WeightMatrix::F32(Tensor::new(
1376        full[(q_rows + kv_rows) * cols..].to_vec(),
1377        vec![kv_rows, cols],
1378    ));
1379    Ok((q, k, v))
1380}
1381
1382/// Dense-layer FFN tensors: standard gate/up/down, or Phi-3 fused
1383/// `ffn_up` with `2 * expert_ffn_dim` rows and no separate gate.
1384fn load_dense_expert(
1385    file: &impl TensorSource,
1386    layer: usize,
1387    config: &ModelConfig,
1388) -> Result<ExpertWeights, LoadError> {
1389    let gate_name = format!("blk.{layer}.ffn_gate.weight");
1390    let up_name = format!("blk.{layer}.ffn_up.weight");
1391    let down_name = format!("blk.{layer}.ffn_down.weight");
1392    if file.find_tensor(&gate_name).is_some() {
1393        return Ok(ExpertWeights {
1394            gate: load_weight_matrix(file, &gate_name)?,
1395            up: load_weight_matrix(file, &up_name)?,
1396            down: load_weight_matrix(file, &down_name)?,
1397        });
1398    }
1399    // Phi-3 fused SwiGLU: up is [hidden, 2*ff], first half gate, second up.
1400    let fused = load_weight_matrix(file, &up_name)?;
1401    let ff = config.moe.expert_ffn_dim;
1402    if fused.rows() != 2 * ff {
1403        return Err(LoadError::UnsupportedFeature(
1404            config.name.to_string(),
1405            format!(
1406                "{up_name} has {} rows without a companion ffn_gate; \
1407                 expected fused SwiGLU with 2*ffn_dim = {} rows",
1408                fused.rows(),
1409                2 * ff
1410            ),
1411        ));
1412    }
1413    let cols = fused.cols();
1414    // Quantized fused gate+up: split by rows, no dequant (Metal-capable).
1415    if let (Some(gate), Some(up)) = (
1416        slice_quantized_rows(&fused, 0, ff),
1417        slice_quantized_rows(&fused, ff, ff),
1418    ) {
1419        return Ok(ExpertWeights {
1420            gate,
1421            up,
1422            down: load_weight_matrix(file, &down_name)?,
1423        });
1424    }
1425    let mut full = Vec::with_capacity(fused.rows() * cols);
1426    for r in 0..fused.rows() {
1427        full.extend_from_slice(&fused.dequant_row(r));
1428    }
1429    let gate = WeightMatrix::F32(Tensor::new(full[..ff * cols].to_vec(), vec![ff, cols]));
1430    let up = WeightMatrix::F32(Tensor::new(full[ff * cols..].to_vec(), vec![ff, cols]));
1431    Ok(ExpertWeights {
1432        gate,
1433        up,
1434        down: load_weight_matrix(file, &down_name)?,
1435    })
1436}
1437
1438/// Widen a raw plain-float tensor (`F32` / `F16` / `BF16`) to `f32`.
1439///
1440/// The three unquantized element types are handled identically at every
1441/// call site (eager widening to an owned buffer -- none of them has a
1442/// block structure a fused dot kernel could exploit), and each of the
1443/// seven GGUF loaders used to inline the same two-way match. F16 had no
1444/// arm in any of them, which made every `*-f16.gguf` a hard
1445/// `UnsupportedDtype` even though the type was parsed and sized.
1446pub(crate) fn widen_plain_float(
1447    dtype: GgmlType,
1448    raw: &[u8],
1449    name: &str,
1450) -> Result<Vec<f32>, LoadError> {
1451    match dtype {
1452        GgmlType::F32 => {
1453            let mut out = Vec::with_capacity(raw.len() / 4);
1454            for chunk in raw.as_chunks::<4>().0 {
1455                out.push(f32::from_le_bytes(*chunk));
1456            }
1457            Ok(out)
1458        }
1459        GgmlType::F16 => ferrox_quant::dequant_f16(raw)
1460            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::F16)),
1461        GgmlType::BF16 => ferrox_quant::dequant_bf16(raw)
1462            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::BF16)),
1463        // MXFP4 is accepted as a weight matrix and as an MoE expert
1464        // tensor, and `WeightMatrix::dequant` already calls this
1465        // dequantizer, so refusing it here made a 1-D MXFP4 norm or
1466        // bias a hard load error on a checkpoint whose 2-D tensors of
1467        // the same type load fine. That contradicted this function's
1468        // own contract, which is to widen whatever the loaders accept.
1469        GgmlType::MXFP4 => ferrox_quant::dequant_mxfp4_gguf(raw)
1470            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::MXFP4)),
1471        other => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
1472    }
1473}
1474
1475pub(crate) fn load_f32_vec(file: &impl TensorSource, name: &str) -> Result<Vec<f32>, LoadError> {
1476    let info = find_info(file, name)?;
1477    let raw = file.tensor_bytes(name)?;
1478    match info.dtype {
1479        // MXFP4 rides with the plain floats because `widen_plain_float`
1480        // is where its arm already lives -- routing it here rather than
1481        // giving this table its own `dequant_mxfp4_gguf` call keeps ONE
1482        // MXFP4 arm in this file instead of two that can drift.
1483        //
1484        // It has to be in *both* tables' reach, and it was in neither's:
1485        // `load_weight_matrix` accepts MXFP4 as a 2-D weight and
1486        // `load_moe_expert_matrices` accepts it as an expert tensor, so
1487        // a checkpoint whose norms happen to be MXFP4 failed here with
1488        // `UnsupportedDtype` while its far larger tensors of the exact
1489        // same dtype loaded fine.
1490        GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 | GgmlType::MXFP4 => {
1491            widen_plain_float(info.dtype, raw, name)
1492        }
1493        GgmlType::Q8_0 => ferrox_quant::dequant_q8_0(raw)
1494            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q8_0)),
1495        GgmlType::Q4_0 => ferrox_quant::dequant_q4_0(raw)
1496            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4_0)),
1497        GgmlType::Q4K => ferrox_quant::dequant_q4_k(raw)
1498            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4K)),
1499        GgmlType::Q5K => ferrox_quant::dequant_q5_k(raw)
1500            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5K)),
1501        GgmlType::Q6K => ferrox_quant::dequant_q6_k(raw)
1502            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q6K)),
1503        GgmlType::Q2K => ferrox_quant::dequant_q2_k(raw)
1504            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q2K)),
1505        GgmlType::Q3K => ferrox_quant::dequant_q3_k(raw)
1506            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q3K)),
1507        GgmlType::Q4_1 => ferrox_quant::dequant_q4_1(raw)
1508            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4_1)),
1509        GgmlType::Q5_0 => ferrox_quant::dequant_q5_0(raw)
1510            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5_0)),
1511        GgmlType::Q5_1 => ferrox_quant::dequant_q5_1(raw)
1512            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5_1)),
1513        GgmlType::Q8_1 => ferrox_quant::dequant_q8_1(raw)
1514            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q8_1)),
1515        GgmlType::IQ4NL => ferrox_quant::dequant_iq4_nl(raw)
1516            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ4NL)),
1517        GgmlType::IQ4XS => ferrox_quant::dequant_iq4_xs(raw)
1518            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ4XS)),
1519        // The codebook-grid tiers. Rare on the 1-D tensors this
1520        // function widens (norms and biases are almost always F32),
1521        // but a dtype ferrox can decode should never be rejected here
1522        // just because the *other* dispatch table below knows it --
1523        // that split is how a supported format turns into a load
1524        // failure on the one checkpoint that uses it.
1525        GgmlType::IQ1S => ferrox_quant::dequant_iq1_s(raw)
1526            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ1S)),
1527        GgmlType::IQ1M => ferrox_quant::dequant_iq1_m(raw)
1528            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ1M)),
1529        GgmlType::IQ2XXS => ferrox_quant::dequant_iq2_xxs(raw)
1530            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2XXS)),
1531        GgmlType::IQ2XS => ferrox_quant::dequant_iq2_xs(raw)
1532            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2XS)),
1533        GgmlType::IQ2S => ferrox_quant::dequant_iq2_s(raw)
1534            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2S)),
1535        GgmlType::IQ3XXS => ferrox_quant::dequant_iq3_xxs(raw)
1536            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ3XXS)),
1537        GgmlType::IQ3S => ferrox_quant::dequant_iq3_s(raw)
1538            .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ3S)),
1539        other => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
1540    }
1541}
1542
1543/// Loads a 2D weight matrix, keeping Q8_0/Q4_0 tensors quantized (raw
1544/// bytes copied out, never dequantized) and only expanding truly F32
1545/// tensors. This is the memory- and bandwidth-saving path: for a
1546/// multi-billion-parameter checkpoint the difference between this and
1547/// "dequant everything on load" is the difference between fitting in
1548/// RAM and not.
1549pub(crate) fn load_weight_matrix(
1550    file: &impl TensorSource,
1551    name: &str,
1552) -> Result<WeightMatrix, LoadError> {
1553    let info = find_info(file, name)?;
1554    // GGUF's on-disk `ne[]` shape array is fastest-varying-dimension-first
1555    // (ggml convention), i.e. `[in_features, out_features]` for a 2D
1556    // weight matrix -- the *reverse* of the row-major `[rows, cols]` =
1557    // `[out_features, in_features]` order `WeightMatrix`/`matmul_f32`
1558    // need. Reversed here once so every consumer below gets the correct
1559    // orientation. Before this reversal existed, every 2D tensor in an
1560    // externally-produced GGUF file was silently loaded transposed -- a
1561    // real bug found by running a real downloaded checkpoint
1562    // (TinyLlama-1.1B-Chat, e.g. `attn_k.weight`'s real raw shape is
1563    // `[2048, 256]` = `[hidden_dim, kv_dim]` = `[in, out]`) -- found
1564    // as a real transposition bug affecting every externally-produced
1565    // GGUF file, caught by serving a real downloaded checkpoint.
1566    let shape: Vec<usize> = info.shape.iter().rev().map(|&d| d as usize).collect();
1567    // A ggml tensor's `ne[]` is always four long and trailing 1s are
1568    // implicit, so a GGUF writer is free to store a `[in, 1]` matrix
1569    // with `n_dims = 1`. llama.cpp reads it back as a matrix anyway --
1570    // `check_tensor_dims` compares each requested dimension against
1571    // `cur->ne[i]` and requires 1 for the dimensions the file does not
1572    // carry -- so a single-output projection is a 2-D weight there and
1573    // must be one here. Refusing it instead made a real checkpoint
1574    // unloadable: `cross-encoder/ms-marco-MiniLM-L6-v2` writes
1575    // `cls.output.weight` as `[384]`, i.e. the 1x384 relevance head
1576    // that `/v1/rerank` exists to run, and the whole route died at load
1577    // with "expected 2D".
1578    let (rows, cols) = match shape.as_slice() {
1579        [r, c] => (*r, *c),
1580        [c] => (1, *c),
1581        other => {
1582            return Err(LoadError::UnsupportedDtype(
1583                format!("{name} (expected 2D, got shape {other:?})"),
1584                info.dtype,
1585            ))
1586        }
1587    };
1588    // `shape` is what the file said; `[rows, cols]` is what the matrix
1589    // is. They differ exactly in the 1-D case above, and the `Tensor`
1590    // must carry the matrix shape or `apply` reads it as a vector.
1591    let shape = vec![rows, cols];
1592
1593    match info.dtype {
1594        // BF16 has no block/scale structure to keep quantized-in-place
1595        // the way Q4_0/Q8_0/K-quants do -- there's no fused dot kernel
1596        // that would make sense for a plain narrowed float, so it's
1597        // eagerly widened to an owned f32 Tensor exactly like F32
1598        // tensors already are.
1599        GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
1600            let data = load_f32_vec(file, name)?;
1601            Ok(WeightMatrix::F32(Tensor::new(data, shape)))
1602        }
1603        other => match quant_kind_for(other) {
1604            Some(kind) => {
1605                let (mmap, range) = file.tensor_mapped_range(name)?;
1606                #[cfg(feature = "metal")]
1607                ferrox_metal::gpu::register_weight_mmap(Arc::clone(&mmap));
1608                Ok(WeightMatrix::Quantized {
1609                    data: WeightBytes::Mapped { mmap, range },
1610                    rows,
1611                    cols,
1612                    kind,
1613                })
1614            }
1615            None => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
1616        },
1617    }
1618}
1619
1620/// Splits a packed 3D MoE expert tensor `blk.N.ffn_{gate,up,down}_exps.weight`
1621/// (shape `[n_experts, out_dim, in_dim]`) into per-expert `WeightMatrix`es,
1622/// slicing raw bytes directly (quantized tensors stay quantized; block
1623/// boundaries never cross expert boundaries since `in_dim` is a whole
1624/// number of quantization blocks). Matches llama.cpp/ik_llama.cpp layout
1625/// confirmed on real OLMoE and Qwen2-MoE GGUF checkpoints.
1626pub(crate) fn split_expert_tensor(
1627    file: &impl TensorSource,
1628    name: &str,
1629    n_experts: usize,
1630) -> Result<Vec<WeightMatrix>, LoadError> {
1631    let info = find_info(file, name)?;
1632    // Real raw shape is `[in_dim, out_dim, n_experts]` (ggml's
1633    // fastest-first `ne[]` order -- see `load_weight_matrix`'s doc
1634    // comment for the confirmed 2D case this generalizes from). `n_experts`
1635    // is the slowest-varying (last, i.e. outermost/most-major) dimension,
1636    // so each expert's `out_dim*in_dim` block is contiguous with experts
1637    // back-to-back in the mmap.
1638    if info.shape.len() != 3 || info.shape[2] as usize != n_experts {
1639        let file_experts = info.shape.last().map(|&d| d as usize).unwrap_or(0);
1640        return Err(LoadError::ExpertCountMismatch(
1641            name.to_string(),
1642            file_experts,
1643            n_experts,
1644        ));
1645    }
1646    let out_dim = info.shape[1] as usize;
1647    let in_dim = info.shape[0] as usize;
1648    let raw = file.tensor_bytes(name)?;
1649
1650    match info.dtype {
1651        GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
1652            let all = crate::loader::widen_plain_float(info.dtype, raw, name)?;
1653            let per_expert = out_dim * in_dim;
1654            Ok((0..n_experts)
1655                .map(|e| {
1656                    WeightMatrix::F32(Tensor::new(
1657                        all[e * per_expert..(e + 1) * per_expert].to_vec(),
1658                        vec![out_dim, in_dim],
1659                    ))
1660                })
1661                .collect())
1662        }
1663        other => match quant_kind_for(other) {
1664            Some(kind) => {
1665                let (mmap, full_range) = file.tensor_mapped_range(name)?;
1666                #[cfg(feature = "metal")]
1667                ferrox_metal::gpu::register_weight_mmap(Arc::clone(&mmap));
1668                let bytes_per_expert = raw.len() / n_experts;
1669                Ok((0..n_experts)
1670                    .map(|e| WeightMatrix::Quantized {
1671                        data: WeightBytes::Mapped {
1672                            mmap: Arc::clone(&mmap),
1673                            range: (full_range.start + e * bytes_per_expert)
1674                                ..(full_range.start + (e + 1) * bytes_per_expert),
1675                        },
1676                        rows: out_dim,
1677                        cols: in_dim,
1678                        kind,
1679                    })
1680                    .collect())
1681            }
1682            None => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
1683        },
1684    }
1685}
1686
1687/// When every routed expert is mmap-backed with a Metal simdgroup-GEMM
1688/// kind and back-to-back slices, record the combined gate/up/down planes
1689/// for Metal packed MoE. Gate/up/down may differ in kind (e.g. Q4_K /
1690/// Q4_K / Q8_0) but must be uniform across experts per role.
1691#[cfg(feature = "metal")]
1692fn try_build_moe_packed_q4_planes(experts: &[ExpertWeights]) -> Option<MoePackedQ4Planes> {
1693    use ferrox_core::weight_matrix::{QuantKind, WeightBytes};
1694    use std::sync::Arc;
1695
1696    if experts.is_empty() {
1697        return None;
1698    }
1699
1700    fn mapped_sg(m: &WeightMatrix) -> Option<(WeightBytes, usize, &'static str)> {
1701        match m {
1702            WeightMatrix::Quantized {
1703                data: WeightBytes::Mapped { mmap, range },
1704                rows,
1705                kind,
1706                ..
1707            } => {
1708                let kind_str = match kind {
1709                    QuantKind::Q4_0 => "Q4_0",
1710                    QuantKind::Q5_0 => "Q5_0",
1711                    QuantKind::Q4K => "Q4_K",
1712                    QuantKind::Q5K => "Q5_K",
1713                    QuantKind::Q6K => "Q6_K",
1714                    QuantKind::Q8_0 => "Q8_0",
1715                    QuantKind::IQ4XS => "IQ4_XS",
1716                    _ => return None,
1717                };
1718                let _ = ferrox_metal::gpu::mul_mm_sg_meta(kind_str)?;
1719                Some((
1720                    WeightBytes::Mapped {
1721                        mmap: Arc::clone(mmap),
1722                        range: range.clone(),
1723                    },
1724                    *rows,
1725                    kind_str,
1726                ))
1727            }
1728            _ => None,
1729        }
1730    }
1731
1732    let (gate0, ffn_rows, gate_kind) = mapped_sg(&experts[0].gate)?;
1733    let (up0, up_rows, up_kind) = mapped_sg(&experts[0].up)?;
1734    let (down0, hidden_rows, down_kind) = mapped_sg(&experts[0].down)?;
1735    if up_rows != ffn_rows {
1736        return None;
1737    }
1738    let WeightBytes::Mapped {
1739        mmap: gate_mmap,
1740        range: gate0_range,
1741    } = &gate0
1742    else {
1743        return None;
1744    };
1745    let WeightBytes::Mapped {
1746        mmap: up_mmap,
1747        range: up0_range,
1748    } = &up0
1749    else {
1750        return None;
1751    };
1752    let WeightBytes::Mapped {
1753        mmap: down_mmap,
1754        range: down0_range,
1755    } = &down0
1756    else {
1757        return None;
1758    };
1759
1760    let gate_stride = gate0_range.len();
1761    let up_stride = up0_range.len();
1762    let down_stride = down0_range.len();
1763    if gate_stride == 0 || up_stride == 0 || down_stride == 0 {
1764        return None;
1765    }
1766
1767    let n = experts.len();
1768    for (i, ex) in experts.iter().enumerate().skip(1) {
1769        let (g, fr, gk) = mapped_sg(&ex.gate)?;
1770        let (u, ur, uk) = mapped_sg(&ex.up)?;
1771        let (d, hr, dk) = mapped_sg(&ex.down)?;
1772        if gk != gate_kind || uk != up_kind || dk != down_kind {
1773            return None;
1774        }
1775        let WeightBytes::Mapped { mmap, range } = &g else {
1776            return None;
1777        };
1778        if fr != ffn_rows {
1779            return None;
1780        }
1781        if !Arc::ptr_eq(mmap, gate_mmap)
1782            || range.len() != gate_stride
1783            || range.start != gate0_range.start + i * gate_stride
1784        {
1785            return None;
1786        }
1787        let WeightBytes::Mapped { mmap, range } = &u else {
1788            return None;
1789        };
1790        if ur != ffn_rows
1791            || !Arc::ptr_eq(mmap, up_mmap)
1792            || range.len() != up_stride
1793            || range.start != up0_range.start + i * up_stride
1794        {
1795            return None;
1796        }
1797        let WeightBytes::Mapped { mmap, range } = &d else {
1798            return None;
1799        };
1800        if hr != hidden_rows
1801            || !Arc::ptr_eq(mmap, down_mmap)
1802            || range.len() != down_stride
1803            || range.start != down0_range.start + i * down_stride
1804        {
1805            return None;
1806        }
1807    }
1808
1809    Some(MoePackedQ4Planes::new(
1810        WeightBytes::Mapped {
1811            mmap: Arc::clone(gate_mmap),
1812            range: gate0_range.start..gate0_range.start + n * gate_stride,
1813        },
1814        WeightBytes::Mapped {
1815            mmap: Arc::clone(up_mmap),
1816            range: up0_range.start..up0_range.start + n * up_stride,
1817        },
1818        WeightBytes::Mapped {
1819            mmap: Arc::clone(down_mmap),
1820            range: down0_range.start..down0_range.start + n * down_stride,
1821        },
1822        gate_stride,
1823        up_stride,
1824        down_stride,
1825        n,
1826        ffn_rows,
1827        hidden_rows,
1828        gate_kind,
1829        up_kind,
1830        down_kind,
1831    ))
1832}
1833
1834/// One matrix's place inside a store-backed expert's combined byte
1835/// buffer (gate bytes, then up, then down, concatenated by
1836/// `GgufExpertSource::read_expert`).
1837#[derive(Debug, Clone, Copy)]
1838pub struct StoredMatrixSpec {
1839    pub offset: usize,
1840    pub len: usize,
1841    pub rows: usize,
1842    pub cols: usize,
1843    pub kind: QuantKind,
1844}
1845
1846/// Byte-range layout of one store-backed routed expert.
1847#[derive(Debug, Clone, Copy)]
1848pub struct StoredExpertLayout {
1849    pub gate: StoredMatrixSpec,
1850    pub up: StoredMatrixSpec,
1851    pub down: StoredMatrixSpec,
1852}
1853
1854impl StoredExpertLayout {
1855    pub fn total_bytes(&self) -> usize {
1856        self.gate.len + self.up.len + self.down.len
1857    }
1858
1859    /// Builds temporary zero-copy `WeightMatrix` views over a leased
1860    /// buffer. Each view's `WeightBytes::Shared` clone of the lease's
1861    /// `Arc` keeps the cache entry pinned for the view's lifetime.
1862    pub fn materialize(&self, lease: &ferrox_core::expert_store::ExpertLease) -> ExpertWeights {
1863        let mk = |spec: &StoredMatrixSpec| WeightMatrix::Quantized {
1864            data: WeightBytes::Shared {
1865                buf: lease.shared_buf(),
1866                range: spec.offset..spec.offset + spec.len,
1867            },
1868            rows: spec.rows,
1869            cols: spec.cols,
1870            kind: spec.kind,
1871        };
1872        ExpertWeights {
1873            gate: mk(&self.gate),
1874            up: mk(&self.up),
1875            down: mk(&self.down),
1876        }
1877    }
1878}
1879
1880/// [`ExpertSource`] over a (possibly sharded) GGUF checkpoint: each
1881/// expert's gate/up/down byte ranges are read positionally from the
1882/// owning shard file and concatenated, so a store miss touches exactly
1883/// that expert's bytes -- no mmap of the expert region, no shared seek
1884/// cursor.
1885pub struct GgufExpertSource {
1886    files: Vec<std::fs::File>,
1887    /// (layer, expert) -> the three (file index, offset, len) segments
1888    /// in gate/up/down order.
1889    segments: std::collections::HashMap<ExpertKey, [(usize, u64, usize); 3]>,
1890}
1891
1892impl ExpertSource for GgufExpertSource {
1893    fn expert_len(&self, key: ExpertKey) -> Option<usize> {
1894        self.segments
1895            .get(&key)
1896            .map(|segs| segs.iter().map(|&(_, _, len)| len).sum())
1897    }
1898
1899    fn read_expert(&self, key: ExpertKey) -> std::io::Result<Vec<u8>> {
1900        let segs = self
1901            .segments
1902            .get(&key)
1903            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{key:?}")))?;
1904        let total: usize = segs.iter().map(|&(_, _, len)| len).sum();
1905        let mut buf = vec![0u8; total];
1906        let mut written = 0;
1907        for &(fi, offset, len) in segs {
1908            let dst = &mut buf[written..written + len];
1909            #[cfg(unix)]
1910            {
1911                use std::os::unix::fs::FileExt;
1912                self.files[fi].read_exact_at(dst, offset)?;
1913            }
1914            #[cfg(not(unix))]
1915            {
1916                use std::io::{Read, Seek, SeekFrom};
1917                let mut f = &self.files[fi];
1918                f.seek(SeekFrom::Start(offset))?;
1919                f.read_exact(dst)?;
1920            }
1921            written += len;
1922        }
1923        Ok(buf)
1924    }
1925}
1926
1927/// Collects the per-expert `(file, offset, len)` segments and layout
1928/// for one packed 3D expert tensor -- the store-backed counterpart of
1929/// `split_expert_tensor`, sharing its shape/offset math. Only
1930/// quantized dtypes are supported (an F32/BF16 expert tensor keeps the
1931/// resident path; the store exists for the quantized multi-hundred-GB
1932/// case).
1933/// One packed 3D expert tensor's store-backed description: the owning
1934/// shard index, each expert's `(offset, len)` within that shard file,
1935/// and the matrix spec shared by every expert's slice.
1936struct StoredTensorSpecs {
1937    shard: usize,
1938    per_expert: Vec<(u64, usize)>,
1939    spec: StoredMatrixSpec,
1940}
1941
1942fn stored_expert_specs(
1943    file: &ShardedGguf,
1944    name: &str,
1945    n_experts: usize,
1946) -> Result<Option<StoredTensorSpecs>, LoadError> {
1947    let info = find_info(file, name)?;
1948    if info.shape.len() != 3 || info.shape[2] as usize != n_experts {
1949        let file_experts = info.shape.last().map(|&d| d as usize).unwrap_or(0);
1950        return Err(LoadError::ExpertCountMismatch(
1951            name.to_string(),
1952            file_experts,
1953            n_experts,
1954        ));
1955    }
1956    let out_dim = info.shape[1] as usize;
1957    let in_dim = info.shape[0] as usize;
1958    let Some(kind) = quant_kind_for(info.dtype) else {
1959        return Ok(None); // F32/BF16 (or unsupported): resident fallback
1960    };
1961    let shard = file
1962        .tensor_shard_index(name)
1963        .expect("find_info succeeded, shard index must exist");
1964    // The mmap range of a tensor within a GgufFile IS its byte offset
1965    // range within that shard file (the mmap covers the whole file).
1966    let (_, full_range) = file.tensor_mapped_range(name)?;
1967    let total_len = full_range.end - full_range.start;
1968    let bytes_per_expert = total_len / n_experts;
1969    let per_expert: Vec<(u64, usize)> = (0..n_experts)
1970        .map(|e| {
1971            (
1972                (full_range.start + e * bytes_per_expert) as u64,
1973                bytes_per_expert,
1974            )
1975        })
1976        .collect();
1977    let spec = StoredMatrixSpec {
1978        offset: 0, // caller assigns the position within the combined buffer
1979        len: bytes_per_expert,
1980        rows: out_dim,
1981        cols: in_dim,
1982        kind,
1983    };
1984    Ok(Some(StoredTensorSpecs {
1985        shard,
1986        per_expert,
1987        spec,
1988    }))
1989}
1990
1991impl Decoder {
1992    /// Loads real weights from `path` for the given `config`. `config`
1993    /// supplies the architecture shape (layer count, head counts, MoE
1994    /// topology); tensor names are resolved against it using the
1995    /// llama.cpp naming convention described in the module docs.
1996    ///
1997    /// A `config.moe.n_experts <= 1` model is treated as dense: expert
1998    /// weights are read from the plain `blk.N.ffn_{gate,up,down}.weight`
1999    /// tensor names rather than the packed 3D `_exps` variant.
2000    pub fn from_gguf(
2001        path: impl AsRef<std::path::Path>,
2002        config: ModelConfig,
2003    ) -> Result<Self, LoadError> {
2004        Self::from_gguf_with_expert_cache(path, config, None)
2005    }
2006
2007    /// Like `from_gguf`, but with `expert_cache_bytes: Some(budget)`
2008    /// routed experts are NOT loaded resident: each layer holds only
2009    /// byte-range layouts, and expert bytes are read on demand through
2010    /// one bounded, lease-protected `ExpertStore` shared by every
2011    /// layer (a single global byte budget; see
2012    /// `ferrox_core::expert_store`). Dense layers, shared experts,
2013    /// attention, embeddings, and the output head stay resident/mapped
2014    /// exactly as before -- only routed experts stream. Layers whose
2015    /// expert tensors are F32/BF16 fall back to resident loading (the
2016    /// store exists for the quantized case). Output is bit-identical
2017    /// to the resident path -- same bytes, same kernels -- pinned by
2018    /// the roundtrip suite's equivalence test.
2019    pub fn from_gguf_with_expert_cache(
2020        path: impl AsRef<std::path::Path>,
2021        mut config: ModelConfig,
2022        expert_cache_bytes: Option<u64>,
2023    ) -> Result<Self, LoadError> {
2024        let path = path.as_ref();
2025        let file = ShardedGguf::open(path)?;
2026
2027        // gpt-oss carries five per-layer tensors the generic GQA layer
2028        // structs have no home for, and reuses `post_attention_norm` for
2029        // a *different* norm slot than Gemma does. Both are decided by
2030        // the architecture string, so resolve them once here. See
2031        // `crate::decoder::GptOssWeights` and
2032        // `PRE_FFN_NORM_IS_POST_ATTENTION_NORM`.
2033        //
2034        // These used to be ONE flag, `arch == "gpt-oss"`, standing for
2035        // two unrelated facts. Splitting them is what let `seed_oss` --
2036        // which shares the norm slot and has none of the extra tensors
2037        // -- be admitted without also being handed attention sinks.
2038        let arch = file
2039            .metadata_str("general.architecture")
2040            .unwrap_or_default()
2041            .to_string();
2042        let is_gpt_oss = arch == "gpt-oss";
2043        let post_attn_norm_is_pre_ffn_norm = pre_ffn_norm_is_post_attention_norm(&arch);
2044        let mut gpt_oss_layers: Vec<crate::decoder::GptOssLayer> = Vec::new();
2045
2046        // One store for the whole model (keys are (layer, expert)),
2047        // built up-front with every stored expert's segments; created
2048        // only when the cache is enabled AND some layer can use it.
2049        let mut store_segments: std::collections::HashMap<ExpertKey, [(usize, u64, usize); 3]> =
2050            std::collections::HashMap::new();
2051        let mut stored_layouts: Vec<Option<Vec<StoredExpertLayout>>> = Vec::new();
2052
2053        // Loaded like any other weight matrix: a quantized embedding
2054        // table stays quantized (zero-copy mmap) and token lookup
2055        // dequantizes one row via `WeightMatrix::dequant_row`, instead
2056        // of the whole vocabulary tensor being widened to f32 up front.
2057        let embedding = load_weight_matrix(&file, "token_embd.weight")?;
2058
2059        let mut layers = Vec::with_capacity(config.n_layers);
2060        let mut refined_qk_norm = config.qk_norm_style;
2061        for l in 0..config.n_layers {
2062            let (q_proj, k_proj, v_proj) = load_qkv_projections(&file, l, &config)?;
2063            let q_norm = load_f32_vec_optional(&file, &format!("blk.{l}.attn_q_norm.weight"))?;
2064            let k_norm = load_f32_vec_optional(&file, &format!("blk.{l}.attn_k_norm.weight"))?;
2065            // Refine WholeVector vs PerHead from the first observed norm length.
2066            if let Some(ref w) = q_norm {
2067                if w.len() == config.head_dim {
2068                    refined_qk_norm = crate::capability::QkNormStyle::PerHead;
2069                } else if w.len() == config.n_heads * config.head_dim {
2070                    refined_qk_norm = crate::capability::QkNormStyle::WholeVector;
2071                } else {
2072                    return Err(LoadError::UnsupportedFeature(
2073                        config.name.to_string(),
2074                        format!(
2075                            "blk.{l}.attn_q_norm.weight length {} matches neither head_dim={} \
2076                             nor n_heads*head_dim={}",
2077                            w.len(),
2078                            config.head_dim,
2079                            config.n_heads * config.head_dim
2080                        ),
2081                    ));
2082                }
2083            }
2084            let attn = AttnWeights {
2085                q_proj,
2086                k_proj,
2087                v_proj,
2088                o_proj: load_weight_matrix(&file, &format!("blk.{l}.attn_output.weight"))?,
2089                norm_weight: load_f32_vec(&file, &format!("blk.{l}.attn_norm.weight"))?,
2090                q_norm,
2091                k_norm,
2092                // Qwen2/Qwen2-MoE-family real QKV bias (`attn_{q,k,v}.bias`,
2093                // real config `qkv_bias`, `o_proj` has none) -- see
2094                // `AttnWeights::q_bias`'s doc comment.
2095                q_bias: load_f32_vec_optional(&file, &format!("blk.{l}.attn_q.bias"))?,
2096                k_bias: load_f32_vec_optional(&file, &format!("blk.{l}.attn_k.bias"))?,
2097                v_bias: load_f32_vec_optional(&file, &format!("blk.{l}.attn_v.bias"))?,
2098                // gpt-oss ships `post_attention_norm` but applies it in
2099                // Gemma's *other* slot: llama.cpp's openai-moe graph
2100                // norms `ffn_inp` with it after the attention residual,
2101                // i.e. it is the pre-FFN norm, not a post-attention one.
2102                // It is read below into `MoeWeights::norm_weight`.
2103                post_attn_norm: if post_attn_norm_is_pre_ffn_norm {
2104                    None
2105                } else {
2106                    // Both spellings; see `load_norm_vec_either_spelling`.
2107                    load_norm_vec_either_spelling(&file, &format!("blk.{l}.post_attention_norm"))?
2108                },
2109                post_ffn_norm: load_norm_vec_either_spelling(
2110                    &file,
2111                    &format!("blk.{l}.post_ffw_norm"),
2112                )?,
2113            };
2114
2115            // Leading dense layers (see ModelConfig::layer_is_dense's
2116            // doc comment) load from the plain dense tensor names
2117            // regardless of this model's global MoE topology, matching
2118            // the DeepSeek-2/3-family convention found in
2119            // ik_llama.cpp's source. A model with n_experts<=1
2120            // globally (the dense test fixture) is dense on every
2121            // layer either way.
2122            let is_dense_layer = config.layer_is_dense(l) || config.moe.n_experts <= 1;
2123            let n_experts = if is_dense_layer {
2124                1
2125            } else {
2126                config.moe.n_experts
2127            };
2128            let experts: ExpertBacking = if is_dense_layer {
2129                ExpertBacking::Resident(vec![load_dense_expert(&file, l, &config)?])
2130            } else {
2131                // Try store-backed layouts first when the cache is
2132                // enabled; fall back to resident when any of the three
2133                // tensors isn't a supported quantized dtype.
2134                let stored = if expert_cache_bytes.is_some() {
2135                    let g = stored_expert_specs(
2136                        &file,
2137                        &format!("blk.{l}.ffn_gate_exps.weight"),
2138                        n_experts,
2139                    )?;
2140                    let u = stored_expert_specs(
2141                        &file,
2142                        &format!("blk.{l}.ffn_up_exps.weight"),
2143                        n_experts,
2144                    )?;
2145                    let d = stored_expert_specs(
2146                        &file,
2147                        &format!("blk.{l}.ffn_down_exps.weight"),
2148                        n_experts,
2149                    )?;
2150                    match (g, u, d) {
2151                        (Some(gt), Some(ut), Some(dt)) => {
2152                            let mut layouts = Vec::with_capacity(n_experts);
2153                            for e in 0..n_experts {
2154                                let key = ExpertKey {
2155                                    layer: l as u32,
2156                                    expert: e as u32,
2157                                };
2158                                store_segments.insert(
2159                                    key,
2160                                    [
2161                                        (gt.shard, gt.per_expert[e].0, gt.per_expert[e].1),
2162                                        (ut.shard, ut.per_expert[e].0, ut.per_expert[e].1),
2163                                        (dt.shard, dt.per_expert[e].0, dt.per_expert[e].1),
2164                                    ],
2165                                );
2166                                let mut gate = gt.spec;
2167                                let mut up = ut.spec;
2168                                let mut down = dt.spec;
2169                                gate.offset = 0;
2170                                up.offset = gate.len;
2171                                down.offset = gate.len + up.len;
2172                                layouts.push(StoredExpertLayout { gate, up, down });
2173                            }
2174                            Some(layouts)
2175                        }
2176                        _ => None,
2177                    }
2178                } else {
2179                    None
2180                };
2181                match stored {
2182                    Some(layouts) => {
2183                        // Placeholder; the shared store is attached in a
2184                        // second pass below once every layer's segments
2185                        // are collected.
2186                        stored_layouts.push(Some(layouts));
2187                        ExpertBacking::Resident(Vec::new())
2188                    }
2189                    None => {
2190                        let gates = split_expert_tensor(
2191                            &file,
2192                            &format!("blk.{l}.ffn_gate_exps.weight"),
2193                            n_experts,
2194                        )?;
2195                        let ups = split_expert_tensor(
2196                            &file,
2197                            &format!("blk.{l}.ffn_up_exps.weight"),
2198                            n_experts,
2199                        )?;
2200                        let downs = split_expert_tensor(
2201                            &file,
2202                            &format!("blk.{l}.ffn_down_exps.weight"),
2203                            n_experts,
2204                        )?;
2205                        ExpertBacking::Resident(
2206                            gates
2207                                .into_iter()
2208                                .zip(ups)
2209                                .zip(downs)
2210                                .map(|((gate, up), down)| ExpertWeights { gate, up, down })
2211                                .collect(),
2212                        )
2213                    }
2214                }
2215            };
2216            if stored_layouts.len() < layers.len() + 1 {
2217                stored_layouts.push(None);
2218            }
2219
2220            let shared_experts: Vec<ExpertWeights> =
2221                if config.moe.n_shared_experts > 0 && !is_dense_layer {
2222                    vec![ExpertWeights {
2223                        gate: load_weight_matrix(&file, &format!("blk.{l}.ffn_gate_shexp.weight"))?,
2224                        up: load_weight_matrix(&file, &format!("blk.{l}.ffn_up_shexp.weight"))?,
2225                        down: load_weight_matrix(&file, &format!("blk.{l}.ffn_down_shexp.weight"))?,
2226                    }]
2227                } else {
2228                    Vec::new()
2229                };
2230
2231            let router = if !is_dense_layer {
2232                load_weight_matrix(&file, &format!("blk.{l}.ffn_gate_inp.weight"))?
2233            } else {
2234                // dense layer: no real router; a zero [1, hidden] matrix
2235                // always selects the single expert deterministically.
2236                WeightMatrix::F32(Tensor::zeros(vec![1, config.hidden_dim]))
2237            };
2238
2239            let n_for_counts = match &experts {
2240                ExpertBacking::Resident(v) if v.is_empty() => n_experts,
2241                other => other.n_experts(),
2242            };
2243            let activation_counts = (0..n_for_counts)
2244                .map(|_| std::sync::atomic::AtomicU64::new(0))
2245                .collect();
2246            // Qwen2-MoE-specific real tensor (`blk.N.ffn_gate_inp_shexp.weight`,
2247            // real on-disk shape `[hidden_dim]`, confirmed against
2248            // llama.cpp's real `qwen2moe.cpp`) -- see
2249            // `MoeWeights::shared_expert_gate`'s doc comment. Presence
2250            // of the tensor itself is the real signal (not an
2251            // architecture-name list): every other supported
2252            // architecture's checkpoints simply don't carry this
2253            // tensor, so this naturally stays `None` there.
2254            let shared_expert_gate = if is_dense_layer {
2255                None
2256            } else {
2257                load_f32_vec_optional(&file, &format!("blk.{l}.ffn_gate_inp_shexp.weight"))?
2258            };
2259            #[cfg(feature = "metal")]
2260            let packed_q4 = match &experts {
2261                ExpertBacking::Resident(v) if !v.is_empty() => try_build_moe_packed_q4_planes(v),
2262                _ => None,
2263            };
2264            // DeepSeek-V3's aux-loss-free selection bias. The on-disk
2265            // name carries no `ffn_` prefix -- llama.cpp's
2266            // `LLM_TENSOR_FFN_EXP_PROBS_B` maps to `blk.%d.exp_probs_b`
2267            // (`llama-arch.cpp:416`, `gguf-py/gguf/constants.py:1240`).
2268            // Optional: only the DeepSeek-V3-lineage MoE recipes carry
2269            // it, and this same generic loader serves OLMoE / Qwen2-MoE /
2270            // Mixtral, which do not.
2271            let exp_probs_bias = if is_dense_layer {
2272                None
2273            } else {
2274                load_f32_vec_optional(&file, &format!("blk.{l}.exp_probs_b.bias"))?
2275            };
2276            if let Some(bias) = &exp_probs_bias {
2277                if bias.len() != config.moe.n_experts {
2278                    return Err(LoadError::UnsupportedFeature(
2279                        arch.clone(),
2280                        format!(
2281                            "blk.{l}.exp_probs_b.bias has {} entries but the model has {} experts",
2282                            bias.len(),
2283                            config.moe.n_experts
2284                        ),
2285                    ));
2286                }
2287                // Grouped selection masks the *biased* scores before the
2288                // global top-k (`build_moe_ffn`, the `n_expert_groups > 1`
2289                // block). ferrox's `route_top_k_grouped` takes a fixed
2290                // count from every group instead, which is a different
2291                // algorithm, so combining the two here would be a guess.
2292                // Refuse rather than route wrongly.
2293                if config.moe.expert_group_count.is_some() {
2294                    return Err(LoadError::UnsupportedFeature(
2295                        arch.clone(),
2296                        format!(
2297                            "blk.{l}.exp_probs_b.bias together with expert groups \
2298                             ({:?}): llama.cpp masks the biased scores per group \
2299                             before a global top-k, which is not the per-group \
2300                             top-k ferrox implements",
2301                            config.moe.expert_group_count
2302                        ),
2303                    ));
2304                }
2305            }
2306            let moe = MoeWeights {
2307                router,
2308                experts,
2309                shared_experts,
2310                shared_expert_gate,
2311                exp_probs_bias,
2312                norm_weight: if post_attn_norm_is_pre_ffn_norm {
2313                    // Same two spellings as above, and the same helper,
2314                    // so the pre-FFN-norm slot cannot drift away from
2315                    // the post-attention one about what a file may be
2316                    // called. gpt-oss and seed_oss both write `.weight`
2317                    // today; sharing the rule is what stops that being
2318                    // a thing to rediscover.
2319                    load_norm_vec_either_spelling(&file, &format!("blk.{l}.post_attention_norm"))?
2320                        .ok_or_else(|| {
2321                        LoadError::Gguf(GgufError::TensorNotFound(format!(
2322                            "blk.{l}.post_attention_norm[.weight]"
2323                        )))
2324                    })?
2325                } else {
2326                    load_f32_vec(&file, &format!("blk.{l}.ffn_norm.weight"))?
2327                },
2328                activation_counts,
2329                #[cfg(feature = "metal")]
2330                packed_q4,
2331            };
2332
2333            if is_gpt_oss {
2334                gpt_oss_layers.push(load_gpt_oss_layer(&file, l, &config)?);
2335            }
2336
2337            layers.push(LayerWeights { attn, moe });
2338        }
2339
2340        let final_norm = load_f32_vec(&file, "output_norm.weight")?;
2341        // Many small Llama/Gemma-family GGUFs tie the lm-head to
2342        // `token_embd.weight` and omit `output.weight` (llama.cpp
2343        // `llama_model_loader` falls back the same way). Prefer the
2344        // explicit head when present.
2345        let output_head = match load_weight_matrix(&file, "output.weight") {
2346            Ok(w) => w,
2347            Err(_) => load_weight_matrix(&file, "token_embd.weight")?,
2348        };
2349
2350        // Second pass: attach the one shared store to every
2351        // store-backed layer. Opening the shard files fresh (plain
2352        // `File` handles for positional reads, not mmaps) keeps the
2353        // stored experts' bytes out of the process's mapped footprint
2354        // entirely.
2355        if !store_segments.is_empty() {
2356            let budget = expert_cache_bytes
2357                .expect("store_segments only populated when a cache budget is set")
2358                as usize;
2359            let files: Result<Vec<std::fs::File>, std::io::Error> =
2360                file.shard_paths().iter().map(std::fs::File::open).collect();
2361            let files = files.map_err(GgufError::from)?;
2362            let store = std::sync::Arc::new(ExpertStore::new(
2363                GgufExpertSource {
2364                    files,
2365                    segments: store_segments,
2366                },
2367                budget,
2368            ));
2369            for (l, layer) in layers.iter_mut().enumerate() {
2370                if let Some(layouts) = stored_layouts.get_mut(l).and_then(Option::take) {
2371                    layer.moe.experts = ExpertBacking::Stored {
2372                        store: std::sync::Arc::clone(&store),
2373                        layouts,
2374                        layer: l as u32,
2375                    };
2376                }
2377            }
2378        }
2379
2380        config.qk_norm_style = refined_qk_norm;
2381
2382        let family = crate::capability::resolve_profile(
2383            file.metadata_str("general.architecture").unwrap_or("llama"),
2384        )
2385        .map(|p| p.family)
2386        .unwrap_or(crate::capability::DecoderFamily::StandardGqa);
2387        let memory_kind = crate::capability::resolve_profile(
2388            file.metadata_str("general.architecture").unwrap_or("llama"),
2389        )
2390        .map(|p| p.memory)
2391        .unwrap_or(crate::capability::MemoryKind::KvGqa);
2392        let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
2393            &config,
2394            family,
2395            memory_kind,
2396            crate::execution_plan::ExecutionPlan::probe_metal_caps(),
2397        );
2398
2399        let decoder = Decoder {
2400            config,
2401            embedding,
2402            layers,
2403            final_norm,
2404            output_head,
2405            gpu_vram_budget_bytes: None,
2406            gpt_oss: if is_gpt_oss {
2407                Some(crate::decoder::GptOssWeights {
2408                    layers: gpt_oss_layers,
2409                })
2410            } else {
2411                None
2412            },
2413            qk_norm_after_rope: QK_NORM_AFTER_ROPE_ARCHITECTURES.contains(&arch.as_str()),
2414            #[cfg(feature = "metal")]
2415            metal_attn_kv: std::sync::Mutex::new(None),
2416            execution_plan,
2417            kv_window: crate::decoder::KvWindowPolicy::from_env(),
2418            plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
2419        };
2420        // Resolve every kernel the model will need while we still have a
2421        // load-time error path to report it on, then seal: from here a
2422        // lookup that misses is an unpredicted slow path and says so.
2423        decoder.probe_kernels();
2424        ferrox_core::kernel_registry::seal_or_error()
2425            .map_err(|e| LoadError::StrictKernels(e.to_string()))?;
2426        // `ModelConfig` is parsed from a *different* handle on the same
2427        // file (the CLI opens its own `GgufFile`, then hands the config
2428        // here), so the model-level tensors it consumed were recorded on
2429        // that handle, not this one. Replay them before the gate, or
2430        // every Llama-3.x checkpoint reads as carrying an unread
2431        // `rope_freqs.weight` it in fact uses on every RoPE call.
2432        for name in crate::config::MODEL_LEVEL_TENSORS_READ_BY_CONFIG {
2433            file.note_consumed(name);
2434        }
2435        assert_every_tensor_consumed(&file)?;
2436        Ok(decoder)
2437    }
2438}
2439
2440/// Tensor-name prefixes a text-generation load legitimately never
2441/// reads. Everything here is consumed by a *different* code path, not by
2442/// nothing: multimodal projector planes belong to `mmproj`, and the
2443/// per-shard split bookkeeping is metadata, not weights.
2444const IGNORED_TENSOR_PREFIXES: &[&str] = &["mm.", "v.", "mmproj.", "resampler.", "audio."];
2445
2446/// Fails the load when the checkpoint carries tensors this build never
2447/// looked at.
2448///
2449/// A tensor nobody reads is not a harmless extra: it is a term of the
2450/// real graph that ours is missing. gpt-oss ships `blk.N.attn_sinks`
2451/// and ferrox has no attention-sink code anywhere, so the file loads,
2452/// runs at full speed, and emits a different distribution than the model
2453/// it claims to be; the newer MoE recipes ship `ffn_exp_probs_b` the
2454/// same way. Both are silent today, and both are exactly what the
2455/// architecture registry cannot catch, because the architecture *string*
2456/// is one ferrox does support -- it is the checkpoint that carries more
2457/// than the registry entry promises.
2458///
2459/// This is deliberately the last check in the load: by here every loader
2460/// arm has had its chance to ask for what it needs, so what is left over
2461/// is what nothing in this build knows about.
2462///
2463/// `FERROX_ALLOW_UNKNOWN_TENSORS=1` downgrades it to a warning, for the
2464/// case where a human has decided the missing term does not matter (a
2465/// bias tensor of zeros, an auxiliary head that never runs). The default
2466/// is refusal: a wrong answer is worse than no answer.
2467pub fn assert_every_tensor_consumed(file: &ShardedGguf) -> Result<(), LoadError> {
2468    let mut left: Vec<String> = file
2469        .unconsumed_tensors()
2470        .into_iter()
2471        .filter(|n| !IGNORED_TENSOR_PREFIXES.iter().any(|p| n.starts_with(p)))
2472        .collect();
2473    if left.is_empty() {
2474        return Ok(());
2475    }
2476    left.sort();
2477    let shown = left.iter().take(8).cloned().collect::<Vec<_>>().join(", ");
2478    let listing = if left.len() > 8 {
2479        format!("{shown}, … (+{} more)", left.len() - 8)
2480    } else {
2481        shown
2482    };
2483    if matches!(
2484        std::env::var("FERROX_ALLOW_UNKNOWN_TENSORS")
2485            .ok()
2486            .as_deref(),
2487        Some("1") | Some("true") | Some("on")
2488    ) {
2489        eprintln!(
2490            "ferrox: WARNING -- {} tensor(s) in this checkpoint are never read \
2491             ({listing}); output may be wrong (FERROX_ALLOW_UNKNOWN_TENSORS=1)",
2492            left.len()
2493        );
2494        return Ok(());
2495    }
2496    Err(LoadError::UnconsumedTensors(left.len(), listing))
2497}
2498
2499#[cfg(test)]
2500mod tests {
2501
2502    /// A quantized 1-D tensor loads through the shared helper.
2503    ///
2504    /// This used to be six copies of `load_f32_vec`, and they had
2505    /// drifted badly: this one decoded twenty dtypes while the five
2506    /// architecture loaders decoded three (F32/F16/BF16). A quantizer
2507    /// that emits a Q8_0 norm or bias -- ordinary for aggressive
2508    /// quants -- loaded on the generic path and was rejected with
2509    /// `UnsupportedDtype` on GLM-5.2, Kimi, DeepSeek-MLA, Gemma-4 and
2510    /// the hybrid stack.
2511    ///
2512    /// This file's own comment predicted exactly that, about the same
2513    /// split one level down: "a dtype ferrox can decode should never be
2514    /// rejected here just because the *other* dispatch table below
2515    /// knows it -- that split is how a supported format turns into a
2516    /// load failure on the one checkpoint that uses it."
2517    #[test]
2518    fn a_quantized_one_dimensional_tensor_widens_through_the_shared_helper() {
2519        let values: Vec<f32> = (0..64).map(|i| (i as f32 - 32.0) * 0.25).collect();
2520        let quantized = ferrox_quant::quantize_q8_0(&values);
2521
2522        struct OneTensor {
2523            info: TensorInfo,
2524            bytes: Vec<u8>,
2525        }
2526        impl TensorSource for OneTensor {
2527            fn metadata(&self, _key: &str) -> Option<&ferrox_gguf::GgufValue> {
2528                None
2529            }
2530            fn find_tensor(&self, name: &str) -> Option<&TensorInfo> {
2531                (name == self.info.name).then_some(&self.info)
2532            }
2533            fn tensor_bytes(&self, _name: &str) -> Result<&[u8], GgufError> {
2534                Ok(&self.bytes)
2535            }
2536            fn tensor_mapped_range(
2537                &self,
2538                name: &str,
2539            ) -> Result<
2540                (
2541                    std::sync::Arc<ferrox_gguf::MmapHandle>,
2542                    std::ops::Range<usize>,
2543                ),
2544                GgufError,
2545            > {
2546                // Never reached: `load_f32_vec` widens from bytes.
2547                Err(GgufError::TensorNotFound(name.to_string()))
2548            }
2549        }
2550
2551        let source = OneTensor {
2552            info: TensorInfo {
2553                name: "blk.0.attn_norm.weight".to_string(),
2554                shape: vec![64],
2555                dtype: GgmlType::Q8_0,
2556                offset: 0,
2557            },
2558            bytes: quantized,
2559        };
2560
2561        let widened = load_f32_vec(&source, "blk.0.attn_norm.weight")
2562            .expect("a Q8_0 norm must load, not report an unsupported dtype");
2563        assert_eq!(widened.len(), values.len());
2564        for (got, want) in widened.iter().zip(values.iter()) {
2565            assert!(
2566                (got - want).abs() < 0.05,
2567                "q8_0 round trip: got {got}, want {want}"
2568            );
2569        }
2570    }
2571    use super::*;
2572    use byteorder::{LittleEndian, WriteBytesExt};
2573    use std::io::Write;
2574
2575    fn write_string(buf: &mut Vec<u8>, s: &str) {
2576        buf.write_u64::<LittleEndian>(s.len() as u64).unwrap();
2577        buf.write_all(s.as_bytes()).unwrap();
2578    }
2579
2580    fn write_kv_str(buf: &mut Vec<u8>, key: &str, val: &str) {
2581        write_string(buf, key);
2582        buf.write_u32::<LittleEndian>(8).unwrap(); // type = string
2583        write_string(buf, val);
2584    }
2585
2586    /// A minimal, tensor-free GGUF byte buffer declaring only
2587    /// `general.architecture` (no `{arch}.block_count` or any other
2588    /// hparam key) -- the shape a stripped-down or malformed file might
2589    /// take, and the exact case `ModelConfig::from_gguf` must reject
2590    /// loudly rather than silently default around.
2591    fn build_arch_only_gguf(arch: &str) -> Vec<u8> {
2592        let mut buf = Vec::new();
2593        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
2594            .unwrap();
2595        buf.write_u32::<LittleEndian>(3).unwrap(); // version
2596        buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
2597        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
2598        write_kv_str(&mut buf, "general.architecture", arch);
2599        buf
2600    }
2601
2602    #[test]
2603    fn model_config_from_gguf_fails_loudly_when_required_hparams_are_missing() {
2604        let tmp =
2605            std::env::temp_dir().join(format!("ferrox_test_arch_only_{}.gguf", std::process::id()));
2606        // Use a registered architecture so the failure is MissingHparam,
2607        // not UnsupportedArchitecture.
2608        std::fs::write(&tmp, build_arch_only_gguf("llama")).unwrap();
2609        let file = ferrox_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
2610        std::fs::remove_file(&tmp).ok();
2611
2612        match ModelConfig::from_gguf(&file) {
2613            Err(LoadError::MissingHparam(key)) => {
2614                assert_eq!(key, "llama.block_count");
2615            }
2616            other => panic!(
2617                "expected LoadError::MissingHparam for a file with no hparam keys, got {other:?}"
2618            ),
2619        }
2620    }
2621
2622    #[test]
2623    fn model_config_from_gguf_fails_closed_on_unknown_architecture() {
2624        let tmp = std::env::temp_dir().join(format!(
2625            "ferrox_test_unknown_arch_{}.gguf",
2626            std::process::id()
2627        ));
2628        std::fs::write(&tmp, build_arch_only_gguf("bogus-arch-with-no-hparams")).unwrap();
2629        let file = ferrox_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
2630        std::fs::remove_file(&tmp).ok();
2631
2632        match ModelConfig::from_gguf(&file) {
2633            Err(LoadError::UnsupportedArchitecture(arch)) => {
2634                assert_eq!(arch, "bogus-arch-with-no-hparams");
2635            }
2636            other => panic!(
2637                "expected LoadError::UnsupportedArchitecture for an unregistered arch, got {other:?}"
2638            ),
2639        }
2640    }
2641
2642    fn write_kv_f32(buf: &mut Vec<u8>, key: &str, val: f32) {
2643        write_string(buf, key);
2644        buf.write_u32::<LittleEndian>(6).unwrap(); // type = float32
2645        buf.write_f32::<LittleEndian>(val).unwrap();
2646    }
2647
2648    /// `arch` plus one f32 hparam, so a metadata-only feature gate can be
2649    /// exercised without building a whole checkpoint.
2650    fn build_arch_plus_f32_gguf(arch: &str, key: &str, val: f32) -> Vec<u8> {
2651        let mut buf = Vec::new();
2652        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
2653            .unwrap();
2654        buf.write_u32::<LittleEndian>(3).unwrap(); // version
2655        buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
2656        buf.write_u64::<LittleEndian>(2).unwrap(); // kv_count
2657        write_kv_str(&mut buf, "general.architecture", arch);
2658        write_kv_f32(&mut buf, key, val);
2659        buf
2660    }
2661
2662    fn config_error_for(arch: &str, key: &str, val: f32, tag: &str) -> LoadError {
2663        let tmp = std::env::temp_dir().join(format!("ferrox_test_scale_{tag}.gguf"));
2664        std::fs::write(&tmp, build_arch_plus_f32_gguf(arch, key, val)).unwrap();
2665        let file = ferrox_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
2666        std::fs::remove_file(&tmp).ok();
2667        ModelConfig::from_gguf(&file).expect_err("must not succeed")
2668    }
2669
2670    /// Granite / MiniCPM / Command-R multipliers are hparams, not
2671    /// tensors, so `assert_every_tensor_consumed` cannot see them: a
2672    /// checkpoint declaring one loads, runs at full speed, and computes
2673    /// a differently-scaled graph than it was trained as. Refuse by name
2674    /// until the math lands.
2675    #[test]
2676    fn a_declared_multiplier_this_decoder_does_not_apply_is_refused_by_name() {
2677        for (key, val) in [
2678            ("granite.logit_scale", 6.0f32),
2679            ("granite.residual_scale", 0.22),
2680            ("granite.embedding_scale", 12.0),
2681            ("granite.attention.scale", 0.015_625),
2682        ] {
2683            let tag = key.replace('.', "_");
2684            match config_error_for("granite", key, val, &tag) {
2685                LoadError::UnsupportedFeature(arch, msg) => {
2686                    assert_eq!(arch, "granite");
2687                    assert!(msg.contains(key), "error must name the key: {msg}");
2688                }
2689                other => panic!("expected UnsupportedFeature for {key}, got {other:?}"),
2690            }
2691        }
2692    }
2693
2694    /// The gate must not fire on a multiplier that is a no-op. A file
2695    /// writing `residual_scale = 1.0` describes the graph ferrox already
2696    /// computes, and refusing it would be a false alarm. llama.cpp's
2697    /// `f_attention_scale` uses `0.0` rather than `1.0` as its "unset"
2698    /// sentinel, so the two are checked against their own no-op values.
2699    #[test]
2700    fn a_multiplier_that_is_a_no_op_is_not_refused() {
2701        for (key, val) in [
2702            ("granite.logit_scale", 1.0f32),
2703            ("granite.residual_scale", 1.0),
2704            ("granite.embedding_scale", 1.0),
2705            ("granite.attention.scale", 0.0),
2706        ] {
2707            let tag = format!("noop_{}", key.replace('.', "_"));
2708            // The file carries no `block_count`, so the load still fails
2709            // -- but on the *missing hparam*, having passed this gate.
2710            match config_error_for("granite", key, val, &tag) {
2711                LoadError::MissingHparam(k) => assert_eq!(k, "granite.block_count"),
2712                other => panic!("no-op {key}={val} must pass the scaling gate, got {other:?}"),
2713            }
2714        }
2715    }
2716
2717    /// One GGUF metadata value, in the three types these header-only
2718    /// fixtures need.
2719    enum Kv<'a> {
2720        Str(&'a str),
2721        U32(u32),
2722        F32(f32),
2723        /// A uint32 ARRAY. Only one gate needs it -- the sliding-window
2724        /// pattern, which llama.cpp reads with `ml.get_key_or_arr` --
2725        /// and without it that gate could only be tested through a
2726        /// value of some other type, which is not the case it exists
2727        /// for.
2728        Arr32(&'a [u32]),
2729    }
2730
2731    /// A tensor-free GGUF carrying exactly `kvs` -- enough for
2732    /// `ModelConfig::from_gguf` to run without a single weight on disk.
2733    fn build_metadata_gguf(kvs: &[(&str, Kv)]) -> Vec<u8> {
2734        let mut buf = Vec::new();
2735        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
2736            .unwrap();
2737        buf.write_u32::<LittleEndian>(3).unwrap(); // version
2738        buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
2739        buf.write_u64::<LittleEndian>(kvs.len() as u64).unwrap();
2740        for (k, v) in kvs {
2741            match v {
2742                Kv::Str(s) => write_kv_str(&mut buf, k, s),
2743                Kv::U32(n) => {
2744                    write_string(&mut buf, k);
2745                    buf.write_u32::<LittleEndian>(4).unwrap(); // type = uint32
2746                    buf.write_u32::<LittleEndian>(*n).unwrap();
2747                }
2748                Kv::F32(f) => write_kv_f32(&mut buf, k, *f),
2749                Kv::Arr32(values) => {
2750                    write_string(&mut buf, k);
2751                    buf.write_u32::<LittleEndian>(9).unwrap(); // type = array
2752                    buf.write_u32::<LittleEndian>(4).unwrap(); // element type = uint32
2753                    buf.write_u64::<LittleEndian>(values.len() as u64).unwrap();
2754                    for v in *values {
2755                        buf.write_u32::<LittleEndian>(*v).unwrap();
2756                    }
2757                }
2758            }
2759        }
2760        buf
2761    }
2762
2763    fn open_metadata_gguf(tag: &str, kvs: &[(&str, Kv)]) -> ferrox_gguf::GgufFile {
2764        let tmp = std::env::temp_dir().join(format!("ferrox_test_meta_{tag}.gguf"));
2765        std::fs::write(&tmp, build_metadata_gguf(kvs)).unwrap();
2766        let file = ferrox_gguf::GgufFile::open(&tmp).expect("header-only file must parse");
2767        std::fs::remove_file(&tmp).ok();
2768        file
2769    }
2770
2771    /// A minimal `llama` hparam set (64-wide single head, base 10000)
2772    /// plus whatever RoPE-scaling keys a test wants to add.
2773    fn llama_config_with(tag: &str, extra: &[(&str, Kv)]) -> ModelConfig {
2774        let mut kvs: Vec<(&str, Kv)> = vec![
2775            ("general.architecture", Kv::Str("llama")),
2776            ("llama.block_count", Kv::U32(1)),
2777            ("llama.embedding_length", Kv::U32(64)),
2778            ("llama.attention.head_count", Kv::U32(1)),
2779            ("llama.attention.head_count_kv", Kv::U32(1)),
2780            ("llama.attention.key_length", Kv::U32(64)),
2781            ("llama.rope.freq_base", Kv::F32(10_000.0)),
2782        ];
2783        for (k, v) in extra {
2784            kvs.push((
2785                k,
2786                match v {
2787                    Kv::Str(s) => Kv::Str(s),
2788                    Kv::U32(n) => Kv::U32(*n),
2789                    Kv::F32(f) => Kv::F32(*f),
2790                    Kv::Arr32(a) => Kv::Arr32(a),
2791                },
2792            ));
2793        }
2794        ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("fixture must load")
2795    }
2796
2797    /// Builds a config for an arbitrary architecture tag, returning the
2798    /// error rather than unwrapping it.
2799    /// llama.cpp chooses the FFN gate activation PER ARCHITECTURE;
2800    /// ferrox chose it per family. Those are different partitions, and
2801    /// `grok` is where they disagree: `src/models/grok.cpp:165` passes
2802    /// `LLM_FFN_GELU` to `build_moe_ffn`, while `grok` is
2803    /// `DecoderFamily::StandardGqa` and so was handed SwiGLU -- a
2804    /// different FFN on every layer.
2805    ///
2806    /// Latent, because `grok` is not audited and refuses today. Pinned
2807    /// anyway: the failure mode is that auditing it later makes it
2808    /// silently wrong, and an audit is exactly when nobody thinks to
2809    /// re-check the activation.
2810    #[test]
2811    fn the_ffn_activation_follows_the_architecture_not_the_family() {
2812        use crate::capability::uses_geglu;
2813        use crate::config::FfnActivation;
2814
2815        assert!(uses_geglu("grok"), "grok's MoE FFN gate is GELU upstream");
2816        // Same family, SiLU upstream (`src/models/dbrx.cpp:122`), so the
2817        // family rule alone cannot be what selects grok.
2818        assert!(!uses_geglu("dbrx"));
2819        assert!(!uses_geglu("llama"));
2820
2821        // The Gemma lineage keeps its GELU through the FAMILY rule, so
2822        // the new per-architecture arm must not have displaced it.
2823        // gemma2/gemma3 only: `gemma` v1 is unaudited and refuses, so
2824        // it cannot be loaded to check its activation.
2825        for gemma in ["gemma2", "gemma3"] {
2826            assert!(
2827                !uses_geglu(gemma),
2828                "{gemma} is GELU via GemmaFamily; listing it here too \
2829                 would hide a later regression in the family rule"
2830            );
2831            assert_eq!(
2832                config_for_arch(gemma).expect("gemma loads").ffn_activation,
2833                FfnActivation::Gelu,
2834                "{gemma}"
2835            );
2836        }
2837
2838        // And a plain SwiGLU architecture stays SwiGLU.
2839        assert_eq!(
2840            config_for_arch("llama")
2841                .expect("llama loads")
2842                .ffn_activation,
2843            FfnActivation::Swiglu
2844        );
2845    }
2846
2847    /// The no-renormalise list is keyed on what llama.cpp's GRAPH does,
2848    /// not on what a GGUF says, because for these architectures the
2849    /// GGUF says nothing.
2850    ///
2851    /// `expert_weights_norm` is only written by converters that set it.
2852    /// `deepseek.cpp:145` passes `norm_w=false`, and
2853    /// `conversion/deepseek.py`'s `DeepseekModel` never writes the key
2854    /// -- only `DeepseekV2Model` does. So a real `deepseek` checkpoint
2855    /// carries no key at all and ferrox fell through to its default,
2856    /// renormalising the selected experts' softmax weights where
2857    /// llama.cpp leaves them alone.
2858    ///
2859    /// The same mistake made OLMoE emit garbage, which is why that list
2860    /// exists. This pins the membership so a later edit cannot quietly
2861    /// drop a name back into the renormalising default.
2862    #[test]
2863    fn the_architectures_llama_cpp_does_not_renormalise_are_pinned() {
2864        for arch in ["deepseek", "olmoe", "qwen2moe"] {
2865            assert!(
2866                NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&arch),
2867                "{arch} passes norm_w=false in llama.cpp and must not be renormalised"
2868            );
2869        }
2870        // `deepseek2` is a DIFFERENT architecture whose converter DOES
2871        // write the key, so it must not be on this list -- it gets its
2872        // answer from the file.
2873        assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"deepseek2"));
2874        assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen3moe"));
2875    }
2876
2877    /// Every architecture llama.cpp defaults to SIGMOID gating must be
2878    /// on the list, because for these the GGUF carries no key to say so.
2879    ///
2880    /// Each of these reads `LLM_KV_EXPERT_GATING_FUNC` as optional and
2881    /// then sets SIGMOID when it is absent, so a converted checkpoint
2882    /// has nothing in it that would correct ferrox's softmax default.
2883    /// Same shape as the `deepseek` top-k renormalisation bug, and as
2884    /// `phi3`'s sliding window: the file is silent and the architecture
2885    /// decides.
2886    #[test]
2887    fn the_architectures_llama_cpp_defaults_to_sigmoid_gating_are_pinned() {
2888        for arch in ["afmoe", "deepseek2", "glm4moe", "laguna", "step35"] {
2889            assert!(
2890                SIGMOID_GATING_ARCHITECTURES.contains(&arch),
2891                "{arch} sets SIGMOID when the gating key is absent"
2892            );
2893        }
2894        // Architectures that HARDCODE softmax must stay off it, or the
2895        // fix becomes the opposite bug: `ernie4-5-moe.cpp:90` and
2896        // `qwen3moe` both gate with softmax unconditionally.
2897        for softmax in ["ernie4_5-moe", "qwen3moe", "olmoe", "llama"] {
2898            assert!(
2899                !SIGMOID_GATING_ARCHITECTURES.contains(&softmax),
2900                "{softmax} does not default to sigmoid"
2901            );
2902        }
2903    }
2904
2905    /// Every name in every architecture-keyed behaviour table is a name
2906    /// the catalog actually resolves, on the generic-GQA path.
2907    ///
2908    /// These five tables are the repo's dominant bug shape in its purest
2909    /// form: five lists of strings that have to agree with a sixth
2910    /// structure (`capability::architecture_catalog`) about what an
2911    /// architecture is called, with nothing checking it. A typo, a
2912    /// hyphen where the GGUF has an underscore, or a name that later
2913    /// moves to a dedicated stack all produce the same thing -- an entry
2914    /// that reads as coverage and can never fire. This repo has shipped
2915    /// exactly that once already, in `unsupported_feature_keys`, keyed
2916    /// on a GGUF spelling no converter writes.
2917    ///
2918    /// The generic-path check is the second half and the sharper one: a
2919    /// behaviour flag on an architecture that is `DedicatedOnly` or
2920    /// `Deferred` never reaches this loader, so it is dead text.
2921    ///
2922    /// Sabotage to confirm: add `"seedoss"` to any list below.
2923    #[test]
2924    fn every_architecture_keyed_behaviour_table_names_a_real_generic_row() {
2925        let tables: &[(&str, &[&str])] = &[
2926            ("SIGMOID_GATING_ARCHITECTURES", SIGMOID_GATING_ARCHITECTURES),
2927            (
2928                "NO_TOPK_RENORMALIZE_ARCHITECTURES",
2929                NO_TOPK_RENORMALIZE_ARCHITECTURES,
2930            ),
2931            (
2932                "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
2933                PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
2934            ),
2935            ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
2936            (
2937                "QK_NORM_AFTER_ROPE_ARCHITECTURES",
2938                QK_NORM_AFTER_ROPE_ARCHITECTURES,
2939            ),
2940        ];
2941        for (table, names) in tables {
2942            for arch in *names {
2943                let profile = crate::capability::resolve_profile(arch).unwrap_or_else(|| {
2944                    panic!("{table} names `{arch}`, which the catalog does not have")
2945                });
2946                if matches!(profile.path, crate::capability::ArchPath::GenericGqa { .. }) {
2947                    continue;
2948                }
2949                // Not a generic row, so the entry cannot fire HERE.
2950                // That is allowed only when something else is named as
2951                // applying the behaviour instead. An unexplained dead
2952                // entry still fails, which is the whole point.
2953                let owner = DEDICATED_OWNS_ITS_BEHAVIOUR
2954                    .iter()
2955                    .find(|(name, _)| name == arch)
2956                    .map(|(_, owner)| *owner);
2957                assert!(
2958                    owner.is_some(),
2959                    "{table} names `{arch}`, which resolves to {:?} and never reaches this \
2960                     loader, so the entry cannot fire. Either drop it, or add it to \
2961                     DEDICATED_OWNS_ITS_BEHAVIOUR naming what applies the behaviour instead",
2962                    profile.path
2963                );
2964            }
2965        }
2966    }
2967
2968    /// The three tables that describe how a layer is BUILT, rather than
2969    /// how it is routed, only carry architectures that are audited.
2970    ///
2971    /// The distinction matters and is not pedantry. A routing default
2972    /// (`SIGMOID_GATING_ARCHITECTURES`, `NO_TOPK_RENORMALIZE_ARCHITECTURES`)
2973    /// is allowed to name an architecture that still refuses: it is
2974    /// written down ahead of time so a later admission inherits the
2975    /// right answer, and the tables say so. But the three below change
2976    /// which TENSOR a layer reads and in what order -- and each was
2977    /// added for exactly one architecture, whose fixture is the only
2978    /// thing proving the change is right. A fourth name appearing here
2979    /// without evidence would be a claim about a graph nobody read,
2980    /// carried by a list whose doc comment cites two.
2981    #[test]
2982    fn the_layer_shape_tables_only_name_audited_architectures() {
2983        for (table, names) in [
2984            (
2985                "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
2986                PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
2987            ),
2988            ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
2989            (
2990                "QK_NORM_AFTER_ROPE_ARCHITECTURES",
2991                QK_NORM_AFTER_ROPE_ARCHITECTURES,
2992            ),
2993        ] {
2994            for arch in names {
2995                assert!(
2996                    crate::capability::is_audited_generic(arch),
2997                    "{table} names `{arch}`, which is not in AUDITED_GENERIC_GQA. Either it \
2998                     has a fixture proving the change is right -- audit it -- or the entry \
2999                     is a guess about a graph"
3000                );
3001            }
3002        }
3003    }
3004
3005    fn config_for_arch(arch: &'static str) -> Result<ModelConfig, LoadError> {
3006        // The per-arch hyperparameter keys are looked up by the arch's
3007        // own prefix, so they have to be built for the arch under test.
3008        let keys: Vec<String> = [
3009            "block_count",
3010            "embedding_length",
3011            "attention.head_count",
3012            "attention.head_count_kv",
3013            "attention.key_length",
3014        ]
3015        .iter()
3016        .map(|k| format!("{arch}.{k}"))
3017        .collect();
3018        let theta = format!("{arch}.rope.freq_base");
3019        let kvs: Vec<(&str, Kv)> = vec![
3020            ("general.architecture", Kv::Str(arch)),
3021            (keys[0].as_str(), Kv::U32(1)),
3022            (keys[1].as_str(), Kv::U32(64)),
3023            (keys[2].as_str(), Kv::U32(1)),
3024            (keys[3].as_str(), Kv::U32(1)),
3025            (keys[4].as_str(), Kv::U32(64)),
3026            (theta.as_str(), Kv::F32(10_000.0)),
3027        ];
3028        ModelConfig::from_gguf(&open_metadata_gguf(arch, &kvs))
3029    }
3030
3031    /// The generic path is OPT-IN, and this is what proves it.
3032    ///
3033    /// An architecture nobody has checked used to FALL ONTO generic GQA
3034    /// and run. Five did exactly that and computed the wrong thing for
3035    /// the life of the project. The refusal exists; nothing tested it,
3036    /// so a reordering or an unevidenced addition to
3037    /// `AUDITED_GENERIC_GQA` would have gone unnoticed.
3038    #[test]
3039    fn an_unaudited_generic_architecture_refuses_rather_than_guessing() {
3040        // `nanbeige` is on the generic path and is not in the audited
3041        // list. It is the third name to hold this slot: `starcoder` was
3042        // first, until an audit found it REQUIRES a fused
3043        // `attn_qkv.bias` and a learned `position_embd` the generic
3044        // decoder has no slot for, so it refuses for a stronger reason;
3045        // then `xverse`, until it was admitted with a libllama-golden
3046        // fixture (`tests/fixture_away_graphs.rs`). `nanbeige` cannot go
3047        // the same way soon: it runs the same physical layers more than
3048        // once (`src/models/nanbeige.cpp:13-31`), which is NEW CODE, and
3049        // its blocker is invisible in metadata, so nothing but this gate
3050        // stops it.
3051        assert!(
3052            !crate::capability::is_audited_generic("nanbeige"),
3053            "this test needs an arch that is generic AND unaudited"
3054        );
3055        match config_for_arch("nanbeige") {
3056            Err(LoadError::UnauditedArchitecture(name, ..)) => assert_eq!(name, "nanbeige"),
3057            other => panic!("expected an unaudited refusal, got {other:?}"),
3058        }
3059    }
3060
3061    /// An architecture with evidence still loads, or the inversion would
3062    /// have turned every model off.
3063    #[test]
3064    fn an_audited_architecture_still_loads() {
3065        assert!(crate::capability::is_audited_generic("llama"));
3066        assert!(config_for_arch("llama").is_ok());
3067    }
3068
3069    /// A NAMED problem must outrank "unaudited".
3070    ///
3071    /// `gpt2` uses learned absolute position embeddings, and that is
3072    /// what its refusal should say. Reporting "unaudited" instead would
3073    /// be true and far less useful, and it is the ordering the loader's
3074    /// own comment claims. Nothing checked that claim.
3075    #[test]
3076    fn a_named_refusal_outranks_the_unaudited_one() {
3077        let err = config_for_arch("gpt2").expect_err("gpt2 must refuse");
3078        assert!(
3079            !matches!(err, LoadError::UnauditedArchitecture(..)),
3080            "gpt2 should report its own reason, not that nobody audited it: {err:?}"
3081        );
3082    }
3083
3084    /// A checkpoint that declares YaRN gets the per-band divisors the
3085    /// reference's `"yarn"` arm implies, folded into `rope_freqs` so the
3086    /// existing RoPE kernels apply them. Expected values are hand-derived
3087    /// from `_find_correction_dim` for this fixture (rotary width 64,
3088    /// base 10000, original context 131072): `low = 22`, `high = 35`.
3089    ///
3090    /// Before this, ferrox read neither `rope.scaling.type` nor
3091    /// `rope.scaling.factor`, so this file roped exactly like an
3092    /// unscaled one -- correct near position 0, progressively wrong
3093    /// further in.
3094    #[test]
3095    fn a_gguf_declaring_yarn_gets_its_rope_frequencies_rewritten() {
3096        let cfg = llama_config_with(
3097            "yarn",
3098            &[
3099                ("llama.rope.scaling.type", Kv::Str("yarn")),
3100                ("llama.rope.scaling.factor", Kv::F32(8.0)),
3101                (
3102                    "llama.rope.scaling.original_context_length",
3103                    Kv::U32(131_072),
3104                ),
3105            ],
3106        );
3107        let factors = cfg
3108            .rope_freqs
3109            .expect("a YaRN checkpoint must carry rewritten per-band frequencies")
3110            .full;
3111        assert_eq!(factors.len(), 32, "one divisor per rotation band");
3112        assert!(
3113            (factors[0] - 1.0).abs() < 1e-6,
3114            "the fastest band is left extrapolated, got {}",
3115            factors[0]
3116        );
3117        let ramp = (31.0 - 22.0) / (35.0 - 22.0);
3118        let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
3119        assert!(
3120            (factors[31] - want).abs() < 1e-4,
3121            "slowest band: got {}, reference {want}",
3122            factors[31]
3123        );
3124    }
3125
3126    /// The rewrite must not fire on a file that did not ask for it. A
3127    /// scaling type ferrox does not implement (`linear`, `longrope`) is
3128    /// left exactly as it was rather than being roped as YaRN, which
3129    /// would be a new kind of wrong rather than the current known one.
3130    /// `rope.scaling.type = "linear"` must actually scale.
3131    ///
3132    /// Rotating position `p/s` is the same as rotating `p` with every
3133    /// band's frequency divided by `s`, and `rope_freqs` is exactly a
3134    /// per-band frequency divisor, so a uniform vector of `s` expresses
3135    /// linear scaling with no new code on the RoPE paths.
3136    ///
3137    /// Before this, the scaling type was compared against "yarn" and
3138    /// anything else returned None, so such a file loaded and roped at
3139    /// unscaled positions: a different model, no error.
3140    #[test]
3141    fn linear_scaling_is_applied_as_a_uniform_frequency_divisor() {
3142        let cfg = llama_config_with(
3143            "linear",
3144            &[
3145                ("llama.rope.scaling.type", Kv::Str("linear")),
3146                ("llama.rope.scaling.factor", Kv::F32(4.0)),
3147            ],
3148        );
3149        let freqs = &cfg
3150            .rope_freqs
3151            .as_ref()
3152            .expect("linear scaling must produce frequency factors")
3153            .full;
3154        assert_eq!(freqs.len(), cfg.head_dim / 2, "one factor per rotated pair");
3155        assert!(
3156            freqs.iter().all(|f| (*f - 4.0).abs() < 1e-6),
3157            "linear scaling is uniform across bands, unlike YaRN: got {freqs:?}"
3158        );
3159    }
3160
3161    /// A factor that corrects nothing is not a correction.
3162    #[test]
3163    fn a_linear_factor_of_one_is_treated_as_absent() {
3164        assert!(llama_config_with(
3165            "linear_one",
3166            &[
3167                ("llama.rope.scaling.type", Kv::Str("linear")),
3168                ("llama.rope.scaling.factor", Kv::F32(1.0)),
3169            ],
3170        )
3171        .rope_freqs
3172        .is_none());
3173    }
3174
3175    #[test]
3176    fn a_gguf_without_yarn_scaling_keeps_its_rope_frequencies_untouched() {
3177        assert!(llama_config_with("noscale", &[]).rope_freqs.is_none());
3178        // Linear scaling is NOT "no scaling". It used to land here,
3179        // asserted as `is_none()`, on the reasoning that leaving
3180        // positions alone beat roping them wrong in a new way. Both are
3181        // wrong output: llama.cpp divides the positions by the factor.
3182        // See `linear_scaling_is_applied_as_a_uniform_frequency_divisor`.
3183        // YaRN with a no-op factor is not a correction either.
3184        assert!(llama_config_with(
3185            "yarn_factor_one",
3186            &[
3187                ("llama.rope.scaling.type", Kv::Str("yarn")),
3188                ("llama.rope.scaling.factor", Kv::F32(1.0)),
3189                (
3190                    "llama.rope.scaling.original_context_length",
3191                    Kv::U32(131_072),
3192                ),
3193            ],
3194        )
3195        .rope_freqs
3196        .is_none());
3197    }
3198
3199    /// The correction range is measured against the context the
3200    /// checkpoint was *trained* at, so a file that declares YaRN without
3201    /// `rope.scaling.original_context_length` leaves the rotation alone
3202    /// rather than inventing a trained length (`context_length` on such
3203    /// a file is the *extended* one, which would put the ramp in the
3204    /// wrong place at every band).
3205    #[test]
3206    fn yarn_without_an_original_context_length_is_not_guessed_at() {
3207        let cfg = llama_config_with(
3208            "yarn_noctx",
3209            &[
3210                ("llama.rope.scaling.type", Kv::Str("yarn")),
3211                ("llama.rope.scaling.factor", Kv::F32(8.0)),
3212            ],
3213        );
3214        assert!(cfg.rope_freqs.is_none());
3215    }
3216
3217    /// `general.sampling.*` is the checkpoint's own recommendation, and
3218    /// only the keys the file carries become one: a file naming just
3219    /// `top_k` must leave temperature and top_p to the server's
3220    /// defaults.
3221    #[test]
3222    fn gguf_sampling_metadata_is_read_as_the_checkpoints_recommendation() {
3223        use crate::sampling::RecommendedSampling;
3224        let full = RecommendedSampling::from_gguf(&open_metadata_gguf(
3225            "sampling_full",
3226            &[
3227                ("general.architecture", Kv::Str("llama")),
3228                ("general.sampling.temp", Kv::F32(1.0)),
3229                ("general.sampling.top_k", Kv::U32(20)),
3230                ("general.sampling.top_p", Kv::F32(0.95)),
3231            ],
3232        ));
3233        assert_eq!(
3234            full,
3235            RecommendedSampling {
3236                temperature: Some(1.0),
3237                top_p: Some(0.95),
3238                top_k: Some(20),
3239            }
3240        );
3241
3242        let partial = RecommendedSampling::from_gguf(&open_metadata_gguf(
3243            "sampling_partial",
3244            &[
3245                ("general.architecture", Kv::Str("llama")),
3246                ("general.sampling.top_k", Kv::U32(40)),
3247            ],
3248        ));
3249        assert_eq!(partial.top_k, Some(40));
3250        assert_eq!(partial.temperature, None);
3251        assert_eq!(partial.top_p, None);
3252    }
3253
3254    /// A converter that wrote `temp = 1` stores a GGUF integer, not a
3255    /// float. Dropping it would serve a checkpoint that asked for
3256    /// temperature 1.0 at the framework's greedy default -- the
3257    /// repetition-loop failure the recommendation exists to prevent.
3258    #[test]
3259    fn an_integer_valued_sampling_temp_is_still_a_recommendation() {
3260        let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
3261            "sampling_int_temp",
3262            &[
3263                ("general.architecture", Kv::Str("llama")),
3264                ("general.sampling.temp", Kv::U32(1)),
3265            ],
3266        ));
3267        assert_eq!(recommended.temperature, Some(1.0));
3268    }
3269
3270    /// The overwhelming majority of checkpoints recommend nothing, and
3271    /// those must keep ferrox's existing defaults exactly.
3272    #[test]
3273    fn a_gguf_without_sampling_metadata_recommends_nothing() {
3274        let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
3275            "sampling_absent",
3276            &[("general.architecture", Kv::Str("llama"))],
3277        ));
3278        assert!(recommended.is_empty());
3279    }
3280
3281    #[test]
3282    fn model_config_from_gguf_rejects_dedicated_architectures() {
3283        let tmp = std::env::temp_dir().join(format!(
3284            "ferrox_test_dedicated_arch_{}.gguf",
3285            std::process::id()
3286        ));
3287        std::fs::write(&tmp, build_arch_only_gguf("deepseek4")).unwrap();
3288        let file = ferrox_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3289        std::fs::remove_file(&tmp).ok();
3290
3291        match ModelConfig::from_gguf(&file) {
3292            Err(LoadError::DedicatedArchitectureRequired(arch, _)) => {
3293                assert_eq!(arch, "deepseek4");
3294            }
3295            other => panic!(
3296                "expected LoadError::DedicatedArchitectureRequired for deepseek4, got {other:?}"
3297            ),
3298        }
3299    }
3300
3301    /// The same Q5_K block bytes cross-validated against an independent
3302    /// Python reference in `ferrox-quant`'s own tests, reused here for
3303    /// the same full-path proof as the Q6_K test below.
3304    #[rustfmt::skip]
3305    const Q5_K_TEST_BLOCK: [u8; 176] = [
3306        0x66, 0x2a, 0x66, 0x2a, 0x01, 0x01, 0x01, 0x01, 0x4f, 0x4b, 0x10, 0x12, 0x41, 0xe2, 0xc1,
3307        0xb1, 0x72, 0x2f, 0x20, 0x07, 0x31, 0x0c, 0x38, 0xb3, 0x9c, 0xb8, 0xad, 0x2f, 0x9a, 0xea,
3308        0x17, 0xd0, 0xee, 0x93, 0x9e, 0x3e, 0x74, 0xbb, 0x28, 0x18, 0x39, 0x25, 0xb6, 0x09, 0x18,
3309        0x29, 0x1c, 0x1d, 0x29, 0x41, 0x40, 0x0a, 0x74, 0x7d, 0xfd, 0x21, 0xdd, 0x6d, 0x45, 0x73,
3310        0x0e, 0x1e, 0xc0, 0x4a, 0xfc, 0xf3, 0x8e, 0x24, 0x6b, 0x34, 0x7d, 0xbe, 0x94, 0xde, 0x59,
3311        0x7a, 0x35, 0x30, 0x36, 0x0a, 0xf9, 0x4a, 0x9b, 0xa2, 0x26, 0x21, 0xa2, 0xfa, 0xdf, 0x4b,
3312        0x29, 0x64, 0x6f, 0xbb, 0xca, 0x0f, 0x3c, 0xda, 0x20, 0xf4, 0x93, 0x86, 0xab, 0x6e, 0xb9,
3313        0xe5, 0xd5, 0xa0, 0x82, 0xd6, 0x41, 0xff, 0x12, 0xbc, 0x34, 0xbb, 0xab, 0xb8, 0x20, 0x2f,
3314        0xbb, 0x5f, 0x0c, 0x10, 0xcf, 0x49, 0xc5, 0x86, 0x5c, 0xdf, 0xff, 0x78, 0x44, 0x26, 0x3b,
3315        0xc2, 0x23, 0x3d, 0x2b, 0xe9, 0x00, 0x12, 0xf8, 0xea, 0xe2, 0x9e, 0x5e, 0x50, 0x20, 0x9f,
3316        0x9d, 0x8d, 0x7d, 0x7f, 0xcc, 0x1d, 0x0e, 0x13, 0xf8, 0xc2, 0xf1, 0x3d, 0x08, 0x2f, 0x23,
3317        0x13, 0xac, 0x0d, 0xa7, 0xe7, 0x20, 0xa3, 0x90, 0xb7, 0xc8, 0x28,
3318    ];
3319
3320    fn build_single_q5_k_tensor_gguf() -> Vec<u8> {
3321        let mut buf = Vec::new();
3322        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3323            .unwrap();
3324        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3325        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3326        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3327
3328        write_kv_str(&mut buf, "general.architecture", "ferrox-q5k-test");
3329
3330        write_string(&mut buf, "test.weight");
3331        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3332                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols,
3333                                                   // rows] -- reversed from the semantic [rows, cols] this tensor
3334                                                   // represents (1 row, 256 cols / 1 Q5_K block).
3335        buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q5_K block)
3336        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3337        buf.write_u32::<LittleEndian>(13).unwrap(); // dtype tag: Q5_K
3338        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3339
3340        while buf.len() % 32 != 0 {
3341            buf.push(0);
3342        }
3343        buf.extend_from_slice(&Q5_K_TEST_BLOCK);
3344        buf
3345    }
3346
3347    /// How far a fused dot may sit from an exact dequantized dot.
3348    ///
3349    /// Two regimes, and one fixed number cannot describe both. With
3350    /// `FERROX_CPU_INT_DOT` off the activation stays f32 and only
3351    /// rounding separates the two. With it on, the activation is
3352    /// quantized to int8 at `d = amax / 127`, which is the flag both
3353    /// binaries turn on by default and the reason the Q5_K and Q6_K
3354    /// cases failed against a flat `1e-2`.
3355    ///
3356    /// The bound grows with the L2 norm of the row, NOT the L1. Each
3357    /// element carries an independent rounding of up to `d/2`, so the
3358    /// dot's error is a sum of independent terms whose standard
3359    /// deviation is `d/sqrt(12) * ||w||_2`. Bounding by the worst case
3360    /// `d/2 * ||w||_1` instead assumes every rounding aligns with its
3361    /// weight's sign, which on this fixture gives 0.347 against a dot
3362    /// of 2.77: 12% of the value, loose enough that injecting a 5%
3363    /// error still passed. Measured here, the real error is 1.8 sigma,
3364    /// so four sigma keeps better than 2x headroom while still failing
3365    /// that 5% injection.
3366    fn fused_dot_tolerance(weights: &[f32], x: &[f32], exact_bound: f32) -> f32 {
3367        if !ferrox_core::weight_matrix::cpu_int_dot_for(
3368            ferrox_core::weight_matrix::IntDotShape::Matvec,
3369        ) {
3370            return exact_bound;
3371        }
3372        let amax = x.iter().fold(0.0f32, |a, v| a.max(v.abs()));
3373        let l2 = weights.iter().map(|w| w * w).sum::<f32>().sqrt();
3374        4.0 * (amax / 127.0) / 12f32.sqrt() * l2 + exact_bound
3375    }
3376
3377    #[test]
3378    fn load_weight_matrix_handles_a_real_on_disk_q5_k_tensor_end_to_end() {
3379        let tmp = std::env::temp_dir().join(format!(
3380            "ferrox_test_q5k_tensor_{}.gguf",
3381            std::process::id()
3382        ));
3383        std::fs::write(&tmp, build_single_q5_k_tensor_gguf()).unwrap();
3384        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real Q5_K GGUF file must parse");
3385        std::fs::remove_file(&tmp).ok();
3386
3387        let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_K tensor must load");
3388        assert_eq!(matrix.rows(), 1);
3389        assert_eq!(matrix.cols(), 256);
3390        match &matrix {
3391            WeightMatrix::Quantized { kind, data, .. } => {
3392                assert_eq!(*kind, QuantKind::Q5K);
3393                assert!(
3394                    data.is_mapped(),
3395                    "Q5_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
3396                );
3397            }
3398            _ => panic!("expected a Quantized matrix for a Q5_K tensor"),
3399        }
3400
3401        let expected = ferrox_quant::dequant_q5_k(&Q5_K_TEST_BLOCK).unwrap();
3402        let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
3403        let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
3404
3405        let got = matrix.apply(&x);
3406        assert_eq!(got.len(), 1);
3407        assert!(
3408            (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
3409            "end-to-end loaded+applied Q5_K matrix diverged from direct dequant: got={} expected={}",
3410            got[0],
3411            expected_dot
3412        );
3413    }
3414
3415    /// The same Q6_K block bytes cross-validated against an independent
3416    /// Python reference in `ferrox-quant`'s own tests; reused here to
3417    /// prove the *full*
3418    /// path -- real on-disk GGUF bytes, parsed by `ferrox-gguf`, read
3419    /// through `GgufFile::tensor_mapped_range`, dispatched by
3420    /// `WeightMatrix::apply` to `ferrox_quant::dot_q6_k_f32` -- produces
3421    /// the same result as directly dequantizing those bytes, not just
3422    /// that the isolated kernel is correct in unit-test isolation.
3423    #[rustfmt::skip]
3424    const Q6_K_TEST_BLOCK: [u8; 210] = [
3425        0xe0, 0xa5, 0x40, 0x5c, 0x8d, 0x3a, 0x0a, 0x26, 0xfb, 0x4b, 0x6e, 0x9a, 0xdf, 0x3e, 0xa3,
3426        0xc4, 0xf8, 0x2b, 0x1d, 0x95, 0x76, 0x7d, 0x3b, 0xcd, 0xfd, 0xef, 0xc2, 0x0b, 0x07, 0x63,
3427        0x29, 0xfb, 0x81, 0x57, 0xbe, 0xbe, 0x06, 0xf7, 0x3a, 0x92, 0xc4, 0x43, 0xff, 0xad, 0xac,
3428        0x7e, 0x0f, 0x00, 0x2a, 0x4f, 0xf0, 0xf8, 0xa9, 0xfa, 0x3c, 0x90, 0x6d, 0x73, 0x2d, 0x5a,
3429        0xe6, 0xc6, 0x46, 0xf2, 0x0d, 0x55, 0x4c, 0x25, 0x38, 0x71, 0x2b, 0x35, 0x38, 0x82, 0x16,
3430        0x37, 0x5f, 0x32, 0x61, 0x02, 0xdd, 0x2f, 0x6f, 0x7b, 0x1f, 0xb4, 0x1a, 0x1b, 0x3e, 0x4f,
3431        0x11, 0xa3, 0x17, 0x40, 0x5a, 0x5f, 0x76, 0xcd, 0x19, 0x27, 0x9b, 0xc7, 0xc8, 0xf7, 0xf7,
3432        0xee, 0xf4, 0x86, 0xd9, 0xfd, 0xa7, 0xfe, 0x9e, 0xac, 0x70, 0x53, 0x5b, 0x76, 0xfb, 0x39,
3433        0xf8, 0x4b, 0x98, 0xfe, 0xd0, 0x06, 0x21, 0x4c, 0x4d, 0xbe, 0x10, 0x2b, 0x06, 0x65, 0xc9,
3434        0x5e, 0xf9, 0x95, 0x72, 0xae, 0x99, 0xd9, 0x7e, 0x15, 0xbd, 0x5e, 0x6d, 0xe8, 0x25, 0x8a,
3435        0xd5, 0x99, 0xc6, 0x6b, 0x69, 0xc7, 0x84, 0xc6, 0xa4, 0xf7, 0xb9, 0x6d, 0x68, 0x45, 0x0e,
3436        0x65, 0x69, 0xeb, 0xe6, 0xeb, 0xe9, 0x28, 0xa6, 0xb9, 0x96, 0xf2, 0xe8, 0xa7, 0x9b, 0x6e,
3437        0x79, 0x8a, 0x68, 0x65, 0x59, 0x98, 0x8b, 0x44, 0x41, 0x98, 0x9a, 0x56, 0x01, 0x01, 0x01,
3438        0x02, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x01, 0x02, 0x1f, 0x25,
3439    ];
3440
3441    fn build_single_q6_k_tensor_gguf() -> Vec<u8> {
3442        let mut buf = Vec::new();
3443        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3444            .unwrap();
3445        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3446        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3447        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3448
3449        write_kv_str(&mut buf, "general.architecture", "ferrox-q6k-test");
3450
3451        write_string(&mut buf, "test.weight");
3452        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3453                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
3454        buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q6_K block)
3455        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3456        buf.write_u32::<LittleEndian>(14).unwrap(); // dtype tag: Q6_K
3457        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3458
3459        while buf.len() % 32 != 0 {
3460            buf.push(0);
3461        }
3462        buf.extend_from_slice(&Q6_K_TEST_BLOCK);
3463        buf
3464    }
3465
3466    #[test]
3467    fn load_weight_matrix_handles_a_real_on_disk_q6_k_tensor_end_to_end() {
3468        let tmp = std::env::temp_dir().join(format!(
3469            "ferrox_test_q6k_tensor_{}.gguf",
3470            std::process::id()
3471        ));
3472        std::fs::write(&tmp, build_single_q6_k_tensor_gguf()).unwrap();
3473        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real Q6_K GGUF file must parse");
3474        std::fs::remove_file(&tmp).ok();
3475
3476        let matrix = load_weight_matrix(&file, "test.weight").expect("Q6_K tensor must load");
3477        assert_eq!(matrix.rows(), 1);
3478        assert_eq!(matrix.cols(), 256);
3479        match &matrix {
3480            WeightMatrix::Quantized { kind, data, .. } => {
3481                assert_eq!(*kind, QuantKind::Q6K);
3482                assert!(
3483                    data.is_mapped(),
3484                    "Q6_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
3485                );
3486            }
3487            _ => panic!("expected a Quantized matrix for a Q6_K tensor"),
3488        }
3489
3490        let expected = ferrox_quant::dequant_q6_k(&Q6_K_TEST_BLOCK).unwrap();
3491        let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
3492        let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
3493
3494        let got = matrix.apply(&x);
3495        assert_eq!(got.len(), 1);
3496        assert!(
3497            (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
3498            "end-to-end loaded+applied Q6_K matrix diverged from direct dequant: got={} expected={}",
3499            got[0],
3500            expected_dot
3501        );
3502    }
3503
3504    fn build_single_bf16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
3505        let mut buf = Vec::new();
3506        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3507            .unwrap();
3508        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3509        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3510        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3511
3512        write_kv_str(&mut buf, "general.architecture", "ferrox-bf16-test");
3513
3514        write_string(&mut buf, "test.weight");
3515        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3516                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
3517        buf.write_u64::<LittleEndian>(cols).unwrap();
3518        buf.write_u64::<LittleEndian>(rows).unwrap();
3519        buf.write_u32::<LittleEndian>(30).unwrap(); // dtype tag: BF16
3520        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3521
3522        while buf.len() % 32 != 0 {
3523            buf.push(0);
3524        }
3525        for &v in values {
3526            // Real bf16 truncation (round-toward-zero, matching a real
3527            // writer closely enough for round-trip test purposes): top
3528            // 16 bits of the f32 bit pattern.
3529            let bf16_bits = (v.to_bits() >> 16) as u16;
3530            buf.extend_from_slice(&bf16_bits.to_le_bytes());
3531        }
3532        buf
3533    }
3534
3535    #[test]
3536    fn load_weight_matrix_handles_a_real_on_disk_bf16_tensor_end_to_end() {
3537        // Values with zero low-mantissa bits, so f32->bf16 truncation
3538        // is lossless and this is an exact-equality check.
3539        let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
3540        let tmp = std::env::temp_dir().join(format!(
3541            "ferrox_test_bf16_tensor_{}.gguf",
3542            std::process::id()
3543        ));
3544        std::fs::write(&tmp, build_single_bf16_tensor_gguf(2, 3, &values)).unwrap();
3545        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real BF16 GGUF file must parse");
3546        std::fs::remove_file(&tmp).ok();
3547
3548        let matrix = load_weight_matrix(&file, "test.weight").expect("BF16 tensor must load");
3549        assert_eq!(matrix.rows(), 2);
3550        assert_eq!(matrix.cols(), 3);
3551        match &matrix {
3552            WeightMatrix::F32(tensor) => {
3553                assert_eq!(tensor.data, values, "BF16 must widen to f32 exactly");
3554            }
3555            _ => panic!("expected an F32 matrix for a BF16 tensor (no fused dot kernel for it)"),
3556        }
3557    }
3558
3559    fn build_single_f16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
3560        let mut buf = Vec::new();
3561        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3562            .unwrap();
3563        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3564        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3565        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3566
3567        write_kv_str(&mut buf, "general.architecture", "ferrox-f16-test");
3568
3569        write_string(&mut buf, "test.weight");
3570        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3571        buf.write_u64::<LittleEndian>(cols).unwrap();
3572        buf.write_u64::<LittleEndian>(rows).unwrap();
3573        buf.write_u32::<LittleEndian>(1).unwrap(); // dtype tag: F16
3574        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3575
3576        while buf.len() % 32 != 0 {
3577            buf.push(0);
3578        }
3579        for &v in values {
3580            buf.extend_from_slice(&half::f16::from_f32(v).to_le_bytes());
3581        }
3582        buf
3583    }
3584
3585    /// `GgmlType::F16` was parsed and sized but had no dequant arm in any
3586    /// of the seven loaders, so every `*-f16.gguf` was a hard
3587    /// `UnsupportedDtype`. Values are exactly representable in f16, so
3588    /// this is an exact-equality check.
3589    #[test]
3590    fn load_weight_matrix_handles_a_real_on_disk_f16_tensor_end_to_end() {
3591        let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
3592        let tmp = std::env::temp_dir().join(format!(
3593            "ferrox_test_f16_tensor_{}.gguf",
3594            std::process::id()
3595        ));
3596        std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
3597        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
3598        std::fs::remove_file(&tmp).ok();
3599
3600        let matrix = load_weight_matrix(&file, "test.weight").expect("F16 tensor must load");
3601        assert_eq!(matrix.rows(), 2);
3602        assert_eq!(matrix.cols(), 3);
3603        match &matrix {
3604            WeightMatrix::F32(tensor) => {
3605                assert_eq!(tensor.data, values, "F16 must widen to f32 exactly");
3606            }
3607            _ => panic!("expected an F32 matrix for an F16 tensor (no fused dot kernel for it)"),
3608        }
3609
3610        // The same tensor read as a plain vector (norm weights, biases and
3611        // the router all take this path, not `load_weight_matrix`).
3612        let tmp =
3613            std::env::temp_dir().join(format!("ferrox_test_f16_vec_{}.gguf", std::process::id()));
3614        std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
3615        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
3616        std::fs::remove_file(&tmp).ok();
3617        assert_eq!(load_f32_vec(&file, "test.weight").unwrap(), values);
3618    }
3619
3620    fn build_single_q5_1_tensor_gguf() -> Vec<u8> {
3621        let mut buf = Vec::new();
3622        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3623            .unwrap();
3624        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3625        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3626        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3627
3628        write_kv_str(&mut buf, "general.architecture", "ferrox-q5-1-test");
3629
3630        write_string(&mut buf, "test.weight");
3631        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3632                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
3633        buf.write_u64::<LittleEndian>(32).unwrap(); // cols (1 Q5_1 block)
3634        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3635        buf.write_u32::<LittleEndian>(7).unwrap(); // dtype tag: Q5_1
3636        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3637
3638        while buf.len() % 32 != 0 {
3639            buf.push(0);
3640        }
3641        // d=0.25 (f16 0x3400), m=1.5 (f16 0x3E00) -- both exact in f16,
3642        // hand-verified bit patterns to avoid pulling in the `half`
3643        // crate just for two test constants. qh varied, qs a real
3644        // (non-degenerate) pattern.
3645        buf.extend_from_slice(&0x3400u16.to_le_bytes());
3646        buf.extend_from_slice(&0x3E00u16.to_le_bytes());
3647        buf.extend_from_slice(&[0x9au8, 0x3c, 0xf0, 0x0f]);
3648        buf.extend_from_slice(&(0..16u8).map(|i| i | ((15 - i) << 4)).collect::<Vec<u8>>());
3649        buf
3650    }
3651
3652    #[test]
3653    fn load_weight_matrix_handles_a_real_on_disk_q5_1_tensor_end_to_end() {
3654        let tmp = std::env::temp_dir().join(format!(
3655            "ferrox_test_q5_1_tensor_{}.gguf",
3656            std::process::id()
3657        ));
3658        std::fs::write(&tmp, build_single_q5_1_tensor_gguf()).unwrap();
3659        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real Q5_1 GGUF file must parse");
3660        std::fs::remove_file(&tmp).ok();
3661
3662        let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_1 tensor must load");
3663        assert_eq!(matrix.rows(), 1);
3664        assert_eq!(matrix.cols(), 32);
3665        let raw = file.tensor_bytes("test.weight").unwrap();
3666        let expected = ferrox_quant::dequant_q5_1(raw).unwrap();
3667        match &matrix {
3668            WeightMatrix::Quantized { kind, data, .. } => {
3669                assert_eq!(*kind, QuantKind::Q5_1);
3670                assert!(data.is_mapped());
3671            }
3672            _ => panic!("expected a Quantized matrix for a Q5_1 tensor"),
3673        }
3674
3675        let x: Vec<f32> = (0..32).map(|i| ((i as f32) * 0.017).cos()).collect();
3676        let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
3677        let got = matrix.apply(&x);
3678        assert_eq!(got.len(), 1);
3679        assert!(
3680            (got[0] - expected_dot).abs() < 1e-2,
3681            "end-to-end loaded+applied Q5_1 matrix diverged from direct dequant: got={} expected={}",
3682            got[0],
3683            expected_dot
3684        );
3685    }
3686
3687    // Same bytes as ferrox-quant's own Q3_K_TEST_BLOCK (Python-cross-
3688    // validated there); duplicated here to build a real on-disk GGUF
3689    // file, matching this file's existing per-format test convention
3690    // (see Q6_K_TEST_BLOCK above).
3691    const Q3_K_TEST_BLOCK: [u8; 110] = [
3692        0x56, 0xf2, 0xb4, 0x2b, 0xd5, 0x6f, 0x51, 0x71, 0x3c, 0x0a, 0xb9, 0x1d, 0xd0, 0xb9, 0x3b,
3693        0xb3, 0x0f, 0xff, 0x8c, 0xb2, 0x83, 0x3a, 0x3d, 0x24, 0xb1, 0x12, 0x56, 0xe3, 0x23, 0x54,
3694        0xf2, 0xfa, 0x7f, 0xdf, 0x31, 0xe1, 0x18, 0x26, 0x6e, 0xcd, 0x5b, 0x38, 0xee, 0xbd, 0x9f,
3695        0x8c, 0x57, 0x47, 0x0b, 0x11, 0xcb, 0xfb, 0xb4, 0x83, 0xa0, 0x4e, 0x0b, 0xd4, 0xa7, 0x85,
3696        0xe0, 0x60, 0xf3, 0xb3, 0xe3, 0x95, 0x43, 0xc6, 0x05, 0x05, 0x77, 0x53, 0xed, 0x23, 0xcc,
3697        0x6a, 0x0e, 0x89, 0xa1, 0x79, 0x85, 0xf6, 0x6e, 0x5a, 0x23, 0x63, 0xbe, 0x53, 0xfa, 0xa2,
3698        0x2b, 0xe9, 0xcd, 0xce, 0xf8, 0x3d, 0x6f, 0xd0, 0x42, 0x6e, 0x3b, 0x7f, 0x23, 0x26, 0xd3,
3699        0xb9, 0x18, 0xbf, 0xa4, 0x34,
3700    ];
3701
3702    fn build_single_q3_k_tensor_gguf() -> Vec<u8> {
3703        let mut buf = Vec::new();
3704        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3705            .unwrap();
3706        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3707        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3708        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3709
3710        write_kv_str(&mut buf, "general.architecture", "ferrox-q3k-test");
3711
3712        write_string(&mut buf, "test.weight");
3713        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3714                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
3715        buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q3_K block)
3716        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3717        buf.write_u32::<LittleEndian>(11).unwrap(); // dtype tag: Q3_K
3718        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3719
3720        while buf.len() % 32 != 0 {
3721            buf.push(0);
3722        }
3723        buf.extend_from_slice(&Q3_K_TEST_BLOCK);
3724        buf
3725    }
3726
3727    #[test]
3728    fn load_weight_matrix_handles_a_real_on_disk_q3_k_tensor_end_to_end() {
3729        let tmp = std::env::temp_dir().join(format!(
3730            "ferrox_test_q3k_tensor_{}.gguf",
3731            std::process::id()
3732        ));
3733        std::fs::write(&tmp, build_single_q3_k_tensor_gguf()).unwrap();
3734        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real Q3_K GGUF file must parse");
3735        std::fs::remove_file(&tmp).ok();
3736
3737        let matrix = load_weight_matrix(&file, "test.weight").expect("Q3_K tensor must load");
3738        assert_eq!(matrix.rows(), 1);
3739        assert_eq!(matrix.cols(), 256);
3740        match &matrix {
3741            WeightMatrix::Quantized { kind, data, .. } => {
3742                assert_eq!(*kind, QuantKind::Q3K);
3743                assert!(data.is_mapped());
3744            }
3745            _ => panic!("expected a Quantized matrix for a Q3_K tensor"),
3746        }
3747
3748        let expected = ferrox_quant::dequant_q3_k(&Q3_K_TEST_BLOCK).unwrap();
3749        let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
3750        let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
3751
3752        let got = matrix.apply(&x);
3753        assert_eq!(got.len(), 1);
3754        assert!(
3755            (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-1),
3756            "end-to-end loaded+applied Q3_K matrix diverged from direct dequant: got={} expected={}",
3757            got[0],
3758            expected_dot
3759        );
3760    }
3761
3762    // Same bytes as ferrox-quant's own IQ4_XS_TEST_BLOCK (Python-cross-
3763    // validated there); duplicated here to build a real on-disk GGUF
3764    // file, matching this file's existing per-format test convention.
3765    const IQ4_XS_TEST_BLOCK: [u8; 136] = [
3766        0x5c, 0x33, 0xb4, 0x39, 0xd1, 0x64, 0x97, 0x82, 0xcb, 0xbd, 0x88, 0x95, 0xf3, 0x60, 0x2a,
3767        0xb5, 0xe7, 0x24, 0xd3, 0xee, 0xfe, 0x71, 0x13, 0xbe, 0x70, 0x84, 0x48, 0x79, 0x7b, 0x3e,
3768        0xf0, 0x55, 0xdc, 0xb2, 0xb2, 0xde, 0x32, 0xa1, 0x5b, 0x02, 0x01, 0xdc, 0x2a, 0xbb, 0xf7,
3769        0x0b, 0x8a, 0x88, 0xdd, 0x0b, 0x02, 0x7e, 0x5e, 0x76, 0x87, 0x30, 0x1e, 0x1c, 0xcf, 0x48,
3770        0xd7, 0x61, 0xf3, 0x51, 0x52, 0x17, 0x98, 0x0a, 0x87, 0xcf, 0x02, 0x91, 0xc8, 0xee, 0xc0,
3771        0x91, 0x69, 0x2a, 0x4f, 0x64, 0x68, 0xa7, 0xb2, 0xe6, 0x98, 0x21, 0x81, 0x75, 0x53, 0x2a,
3772        0x8d, 0x12, 0xae, 0xe0, 0xea, 0x0c, 0x75, 0xff, 0x22, 0x5e, 0x25, 0x19, 0xda, 0x2e, 0x51,
3773        0x4e, 0x81, 0xdc, 0x0e, 0x78, 0x86, 0xd7, 0x58, 0xb5, 0xb7, 0xf6, 0x45, 0xa9, 0x0a, 0x83,
3774        0xfd, 0x2a, 0x12, 0x7d, 0xf0, 0x12, 0x97, 0xe2, 0xfe, 0xf4, 0xd0, 0xa2, 0x11, 0x14, 0x78,
3775        0xdb,
3776    ];
3777
3778    fn build_single_iq4_xs_tensor_gguf() -> Vec<u8> {
3779        let mut buf = Vec::new();
3780        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3781            .unwrap();
3782        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3783        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3784        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3785
3786        write_kv_str(&mut buf, "general.architecture", "ferrox-iq4xs-test");
3787
3788        write_string(&mut buf, "test.weight");
3789        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3790                                                   // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
3791        buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 IQ4_XS block)
3792        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3793        buf.write_u32::<LittleEndian>(23).unwrap(); // dtype tag: IQ4_XS
3794        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3795
3796        while buf.len() % 32 != 0 {
3797            buf.push(0);
3798        }
3799        buf.extend_from_slice(&IQ4_XS_TEST_BLOCK);
3800        buf
3801    }
3802
3803    #[test]
3804    fn load_weight_matrix_handles_a_real_on_disk_iq4_xs_tensor_end_to_end() {
3805        let tmp = std::env::temp_dir().join(format!(
3806            "ferrox_test_iq4xs_tensor_{}.gguf",
3807            std::process::id()
3808        ));
3809        std::fs::write(&tmp, build_single_iq4_xs_tensor_gguf()).unwrap();
3810        let file = ferrox_gguf::GgufFile::open(&tmp).expect("real IQ4_XS GGUF file must parse");
3811        std::fs::remove_file(&tmp).ok();
3812
3813        let matrix = load_weight_matrix(&file, "test.weight").expect("IQ4_XS tensor must load");
3814        assert_eq!(matrix.rows(), 1);
3815        assert_eq!(matrix.cols(), 256);
3816        match &matrix {
3817            WeightMatrix::Quantized { kind, data, .. } => {
3818                assert_eq!(*kind, QuantKind::IQ4XS);
3819                assert!(data.is_mapped());
3820            }
3821            _ => panic!("expected a Quantized matrix for an IQ4_XS tensor"),
3822        }
3823
3824        let expected = ferrox_quant::dequant_iq4_xs(&IQ4_XS_TEST_BLOCK).unwrap();
3825        let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
3826        let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
3827
3828        let got = matrix.apply(&x);
3829        assert_eq!(got.len(), 1);
3830        assert!(
3831            (got[0] - expected_dot).abs() < 1e-1,
3832            "end-to-end loaded+applied IQ4_XS matrix diverged from direct dequant: got={} expected={}",
3833            got[0],
3834            expected_dot
3835        );
3836    }
3837
3838    // Same bytes as ferrox-quant's own IQ low-bit test blocks
3839    // (Python-cross-validated there against the real compiled ggml
3840    // implementation), duplicated as literals for the same reason as
3841    // IQ4_XS_TEST_BLOCK above.
3842    const IQ1_S_TEST_BLOCK: [u8; 50] = [
3843        0x0a, 0x2f, 0xfa, 0x06, 0x1e, 0x37, 0x6f, 0xe3, 0x62, 0xd0, 0xb6, 0xa4, 0x25, 0xae, 0x76,
3844        0x14, 0x72, 0x5b, 0xfa, 0x05, 0xd1, 0xf1, 0x2a, 0x4c, 0xad, 0x29, 0xae, 0xf4, 0xcf, 0x0c,
3845        0x96, 0x51, 0x58, 0x03, 0x6d, 0xd3, 0x10, 0x92, 0x70, 0xff, 0x61, 0x58, 0xc8, 0x30, 0x25,
3846        0x64, 0x49, 0x85, 0xc0, 0x24,
3847    ];
3848    const IQ2_XXS_TEST_BLOCK: [u8; 66] = [
3849        0x29, 0x30, 0xd9, 0x33, 0x95, 0x4c, 0x08, 0x1e, 0xad, 0x79, 0x49, 0xf2, 0x8d, 0x5f, 0x93,
3850        0xea, 0x78, 0x18, 0x98, 0xb9, 0x94, 0x14, 0xad, 0xce, 0xca, 0x1d, 0xab, 0x81, 0x53, 0x4a,
3851        0x68, 0xd0, 0x59, 0x96, 0x36, 0x5d, 0xbe, 0x20, 0xc4, 0xff, 0xe4, 0x2c, 0xcd, 0x2f, 0x4f,
3852        0x4f, 0x67, 0x53, 0xc6, 0xd5, 0xa2, 0xfb, 0xc7, 0xf3, 0xe2, 0x6b, 0xf1, 0x99, 0x23, 0x1e,
3853        0x2d, 0x5e, 0x8c, 0x78, 0xc2, 0x31,
3854    ];
3855    const IQ3_XXS_TEST_BLOCK: [u8; 98] = [
3856        0x71, 0x31, 0x16, 0x0a, 0x79, 0x04, 0x5d, 0x87, 0xae, 0x2a, 0x4a, 0x43, 0xfd, 0x02, 0xba,
3857        0x6c, 0x10, 0x42, 0x80, 0xe5, 0x1d, 0x08, 0x22, 0xcb, 0x21, 0x54, 0xf9, 0xaa, 0x8e, 0xc2,
3858        0xf2, 0x34, 0x66, 0x1e, 0x2a, 0xef, 0x19, 0xae, 0x48, 0x47, 0x29, 0xa0, 0x72, 0xd1, 0x31,
3859        0xc0, 0x65, 0x49, 0xde, 0x79, 0x32, 0xe6, 0x4d, 0xb6, 0x55, 0x3f, 0x4d, 0xf1, 0x18, 0xbb,
3860        0x18, 0x59, 0x4c, 0x31, 0xa3, 0xb2, 0x34, 0xdd, 0xf6, 0x4a, 0x91, 0x51, 0x3f, 0x3e, 0x40,
3861        0x69, 0xad, 0xbf, 0x1a, 0xd0, 0x05, 0xfb, 0xbe, 0x8b, 0x0b, 0xdd, 0xdf, 0x7d, 0x94, 0x74,
3862        0x92, 0x3e, 0xff, 0x04, 0x2a, 0xc4, 0xea, 0xc9,
3863    ];
3864
3865    #[rustfmt::skip]
3866    const MXFP4_GGUF_TEST_BLOCKS: [u8; 68] = [0x79, 0xb4, 0x8d, 0xe2, 0x62, 0x5d, 0xbb, 0x9d, 0x54, 0xe6, 0xdb, 0x94, 0x59, 0x7d, 0x28, 0xf9, 0x79, 0x7a, 0xfc, 0xc1, 0xfa, 0x1e, 0x53, 0x5b, 0x0e, 0xc2, 0x5a, 0x2f, 0x0c, 0x82, 0x4d, 0xcb, 0x11, 0x28, 0x7b, 0x7c, 0xb6, 0x45, 0xe0, 0xb0, 0x52, 0x40, 0x51, 0xec, 0x30, 0x1a, 0xd2, 0x17, 0xf3, 0xbb, 0xfc, 0x7c, 0x8f, 0xf0, 0x67, 0x83, 0x88, 0x9d, 0x79, 0xdb, 0xf4, 0x45, 0x29, 0x78, 0xe6, 0xf4, 0x99, 0xea];
3867
3868    /// A live ggml type this build has no kernel for must be REFUSED BY
3869    /// NAME at execution, having been sized correctly at parse.
3870    ///
3871    /// Before `TQ2_0` was recognized, tag 35 was `Other(35)`, which had
3872    /// no block layout: the tensor's size was unknown, so `tensor_bytes`
3873    /// could not even hand back the row, and the error named a number.
3874    /// Now the file parses, the tensor measures 66 bytes per 256
3875    /// elements, and the stop happens where it belongs -- at the point
3876    /// something wants to multiply by it -- naming `TQ2_0`.
3877    #[test]
3878    fn a_recognized_but_unimplemented_ggml_type_refuses_by_name_after_sizing_correctly() {
3879        // 256 elements of TQ2_0 = one 66-byte block.
3880        let block = pseudo_iq_block(66, 0x0720_5eed);
3881        let tmp =
3882            std::env::temp_dir().join(format!("ferrox_test_tq2_0_{}.gguf", std::process::id()));
3883        std::fs::write(
3884            &tmp,
3885            build_single_iq_lowbit_tensor_gguf("tq2test", 35, 256, &block),
3886        )
3887        .unwrap();
3888        let file = ferrox_gguf::GgufFile::open(&tmp).expect("a TQ2_0 file must still parse");
3889        std::fs::remove_file(&tmp).ok();
3890
3891        // Sized, not zero: the size estimate is right even though the
3892        // kernel is missing.
3893        let info = file.find_tensor("test.weight").expect("tensor present");
3894        assert_eq!(info.dtype, GgmlType::TQ2_0);
3895        assert_eq!(info.byte_len(), Some(66));
3896        assert_eq!(
3897            file.tensor_bytes("test.weight").map(<[u8]>::len).ok(),
3898            Some(66)
3899        );
3900
3901        match load_weight_matrix(&file, "test.weight") {
3902            Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
3903                assert_eq!(name, "test.weight");
3904            }
3905            Err(other) => panic!("TQ2_0 must be refused by name, got {other:?}"),
3906            Ok(_) => panic!("TQ2_0 must be refused, not loaded as some other kind"),
3907        }
3908    }
3909
3910    /// An MXFP4 norm/bias must widen, not be refused.
3911    ///
3912    /// `load_weight_matrix` accepts MXFP4 as a 2-D weight and
3913    /// `load_moe_expert_matrices` accepts it as an expert tensor, and
3914    /// `WeightMatrix::dequant` calls `dequant_mxfp4_gguf` on both. One
3915    /// missing arm in `widen_plain_float` made the *1-D* tensors of the
3916    /// exact same dtype a hard `UnsupportedDtype` -- the split that
3917    /// turns a supported format into a load failure on the one
3918    /// checkpoint that uses it.
3919    #[test]
3920    fn an_mxfp4_one_dimensional_tensor_widens_instead_of_being_refused() {
3921        let expected = ferrox_quant::dequant_mxfp4_gguf(&MXFP4_GGUF_TEST_BLOCKS)
3922            .expect("the fixture blocks must dequantize");
3923        let cols = expected.len();
3924        let tmp = std::env::temp_dir().join(format!(
3925            "ferrox_test_mxfp4_norm_{}.gguf",
3926            std::process::id()
3927        ));
3928        std::fs::write(
3929            &tmp,
3930            build_single_iq_lowbit_tensor_gguf(
3931                "mxfp4norm",
3932                39,
3933                cols as u64,
3934                &MXFP4_GGUF_TEST_BLOCKS,
3935            ),
3936        )
3937        .unwrap();
3938        let file = ferrox_gguf::GgufFile::open(&tmp).expect("file must parse");
3939        std::fs::remove_file(&tmp).ok();
3940
3941        let got = load_f32_vec(&file, "test.weight")
3942            .expect("an MXFP4 norm must load, not report an unsupported dtype");
3943        assert_eq!(got, expected);
3944
3945        // Same arm, reached directly: `widen_plain_float` is the shared
3946        // helper the six architecture loaders call, so its table is the
3947        // one that has to know MXFP4.
3948        let direct = widen_plain_float(GgmlType::MXFP4, &MXFP4_GGUF_TEST_BLOCKS, "test.weight")
3949            .expect("widen_plain_float must widen MXFP4");
3950        assert_eq!(direct, expected);
3951
3952        // And the refusal still works for a dtype that genuinely has no
3953        // widening path, so this test cannot pass by making everything
3954        // succeed.
3955        match widen_plain_float(GgmlType::TQ2_0, &MXFP4_GGUF_TEST_BLOCKS, "test.weight") {
3956            Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
3957                assert_eq!(name, "test.weight");
3958            }
3959            other => panic!("TQ2_0 must be refused by name, got {other:?}"),
3960        }
3961    }
3962
3963    fn build_single_iq_lowbit_tensor_gguf(
3964        arch: &str,
3965        tag: u32,
3966        cols: u64,
3967        block: &[u8],
3968    ) -> Vec<u8> {
3969        let mut buf = Vec::new();
3970        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
3971            .unwrap();
3972        buf.write_u32::<LittleEndian>(3).unwrap(); // version
3973        buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
3974        buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3975        write_kv_str(&mut buf, "general.architecture", arch);
3976        write_string(&mut buf, "test.weight");
3977        buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
3978        buf.write_u64::<LittleEndian>(cols).unwrap();
3979        buf.write_u64::<LittleEndian>(1).unwrap(); // rows
3980        buf.write_u32::<LittleEndian>(tag).unwrap();
3981        buf.write_u64::<LittleEndian>(0).unwrap(); // offset
3982        while buf.len() % 32 != 0 {
3983            buf.push(0);
3984        }
3985        buf.extend_from_slice(block);
3986        buf
3987    }
3988
3989    /// A structurally valid block of `len` bytes for any of the
3990    /// codebook-grid formats: every bit pattern is a legal code in all
3991    /// of them (the grid indices are bounded by their own bit widths),
3992    /// so a deterministic byte fill is a real block, not a fixture that
3993    /// happens to avoid the interesting paths. Only the f16 scale needs
3994    /// pinning, and only so the comparison below can't be NaN-vs-NaN.
3995    fn pseudo_iq_block(len: usize, seed: u32) -> Vec<u8> {
3996        let mut s = seed;
3997        let mut out = Vec::with_capacity(len);
3998        for _ in 0..len {
3999            s ^= s << 13;
4000            s ^= s >> 17;
4001            s ^= s << 5;
4002            out.push((s >> 24) as u8);
4003        }
4004        out
4005    }
4006
4007    /// End-to-end load+apply for the codebook-grid low-bit formats the
4008    /// published Dynamic GGUFs are built from: a real on-disk tensor of
4009    /// each type must load zero-copy as the right `QuantKind` and
4010    /// produce the same matvec result as dequantizing the block
4011    /// directly. That is the property this test exists for -- the
4012    /// *values* are pinned against real ggml in `ferrox-quant`; what
4013    /// can only break here is the tag -> kind -> block-stride chain,
4014    /// and a wrong stride silently reads the neighbouring row.
4015    /// Dtype tags (19/29/16/17/22/18/21/39) verified against ggml.h's
4016    /// enum ggml_type.
4017    #[test]
4018    fn load_weight_matrix_handles_real_on_disk_iq_lowbit_tensors_end_to_end() {
4019        type DequantFn = fn(&[u8]) -> Result<Vec<f32>, ferrox_quant::QuantError>;
4020        // IQ1_M carries no f16 scale field; its scale is reassembled
4021        // from the four scale words' top nibbles, and the top nibble of
4022        // the last one supplies the f16 sign + high exponent bits.
4023        // Pinning it to 0x2 keeps the exponent out of the all-ones
4024        // NaN/Inf pattern whatever the rest of the fill does. The other
4025        // three do carry a leading f16 `d`, pinned for the same reason.
4026        let mut iq1m = pseudo_iq_block(ferrox_quant::IQ1_M_BLOCK_BYTES, 0x2907_31A0);
4027        iq1m[55] = (iq1m[55] & 0x0F) | 0x20;
4028        let mut iq2xs = pseudo_iq_block(ferrox_quant::IQ2_XS_BLOCK_BYTES, 0x2107_31A1);
4029        let mut iq2s = pseudo_iq_block(ferrox_quant::IQ2_S_BLOCK_BYTES, 0x2207_31A2);
4030        let mut iq3s = pseudo_iq_block(ferrox_quant::IQ3_S_BLOCK_BYTES, 0x2307_31A3);
4031        for blk in [&mut iq2xs, &mut iq2s, &mut iq3s] {
4032            blk[0..2].copy_from_slice(&half::f16::from_f32(0.115).to_le_bytes());
4033        }
4034        let cases: [(&str, u32, &[u8], QuantKind, DequantFn); 8] = [
4035            (
4036                "iq1s",
4037                19,
4038                &IQ1_S_TEST_BLOCK,
4039                QuantKind::IQ1S,
4040                ferrox_quant::dequant_iq1_s,
4041            ),
4042            (
4043                "iq1m",
4044                29,
4045                &iq1m,
4046                QuantKind::IQ1M,
4047                ferrox_quant::dequant_iq1_m,
4048            ),
4049            (
4050                "iq2xxs",
4051                16,
4052                &IQ2_XXS_TEST_BLOCK,
4053                QuantKind::IQ2XXS,
4054                ferrox_quant::dequant_iq2_xxs,
4055            ),
4056            (
4057                "iq2xs",
4058                17,
4059                &iq2xs,
4060                QuantKind::IQ2XS,
4061                ferrox_quant::dequant_iq2_xs,
4062            ),
4063            (
4064                "iq2s",
4065                22,
4066                &iq2s,
4067                QuantKind::IQ2S,
4068                ferrox_quant::dequant_iq2_s,
4069            ),
4070            (
4071                "iq3xxs",
4072                18,
4073                &IQ3_XXS_TEST_BLOCK,
4074                QuantKind::IQ3XXS,
4075                ferrox_quant::dequant_iq3_xxs,
4076            ),
4077            (
4078                "iq3s",
4079                21,
4080                &iq3s,
4081                QuantKind::IQ3S,
4082                ferrox_quant::dequant_iq3_s,
4083            ),
4084            (
4085                "mxfp4_gguf",
4086                39,
4087                &MXFP4_GGUF_TEST_BLOCKS,
4088                QuantKind::Mxfp4Gguf,
4089                ferrox_quant::dequant_mxfp4_gguf,
4090            ),
4091        ];
4092        for (name, tag, block, kind, dequant) in cases {
4093            let expected = dequant(block).unwrap();
4094            let cols = expected.len();
4095            let tmp = std::env::temp_dir().join(format!("ferrox_test_{name}_tensor.gguf"));
4096            std::fs::write(
4097                &tmp,
4098                build_single_iq_lowbit_tensor_gguf(name, tag, cols as u64, block),
4099            )
4100            .unwrap();
4101            let file = ferrox_gguf::GgufFile::open(&tmp).expect("file must parse");
4102            std::fs::remove_file(&tmp).ok();
4103
4104            let matrix =
4105                load_weight_matrix(&file, "test.weight").expect("low-bit tensor must load");
4106            assert_eq!((matrix.rows(), matrix.cols()), (1, cols), "{name}");
4107            match &matrix {
4108                WeightMatrix::Quantized { kind: k, data, .. } => {
4109                    assert_eq!(*k, kind, "{name}");
4110                    assert!(data.is_mapped(), "{name} must load zero-copy");
4111                }
4112                _ => panic!("expected a Quantized matrix for {name}"),
4113            }
4114
4115            let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.013).sin()).collect();
4116            let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4117            let got = matrix.apply(&x);
4118            assert!(
4119                (got[0] - expected_dot).abs() < 1e-1,
4120                "{name}: loaded+applied diverged from direct dequant: got={} expected={}",
4121                got[0],
4122                expected_dot
4123            );
4124        }
4125    }
4126
4127    #[test]
4128    fn qwen2moe_disables_topk_renorm() {
4129        assert!(
4130            NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen2moe"),
4131            "qwen2moe must have norm_topk_prob=false (llama.cpp build_moe_ffn norm_w=false)"
4132        );
4133    }
4134
4135    /// An architecture that uses no RoPE must not reach the generic
4136    /// decoder, which rotates unconditionally.
4137    ///
4138    /// All five of these were admitted as `GenericGqa { rope: Neox }`.
4139    /// Nothing downstream could have caught it: `bloom` and `refact`
4140    /// hardcode their ALiBi slope in `load_arch_hparams` with no GGUF
4141    /// key, so the metadata gates above see nothing, and `mpt` carries
4142    /// no tensor the generic loader fails to consume, so
4143    /// `assert_every_tensor_consumed` sees nothing either. It would have
4144    /// loaded, run at full speed, and answered from rotated positions.
4145    #[test]
4146    fn an_architecture_with_no_rope_is_refused_by_name() {
4147        for arch in ["gpt2", "mpt", "refact", "bloom", "jais"] {
4148            let file = open_metadata_gguf(
4149                &format!("norope_{arch}"),
4150                &[("general.architecture", Kv::Str(arch))],
4151            );
4152            match ModelConfig::from_gguf(&file) {
4153                Err(LoadError::DedicatedArchitectureRequired(got, reason)) => {
4154                    assert_eq!(got, arch);
4155                    assert!(
4156                        reason.contains("ALiBi") || reason.contains("position embeddings"),
4157                        "{arch}: the refusal must name what is missing, got {reason:?}"
4158                    );
4159                }
4160                other => panic!("{arch} must be refused, got {other:?}"),
4161            }
4162        }
4163    }
4164
4165    /// A per-layer sliding-window pattern is refused, and a scalar one
4166    /// still loads.
4167    ///
4168    /// Both halves matter. `capability::unsupported_feature_keys` used
4169    /// to refuse the key outright with the reason "not implemented in
4170    /// the generic decoder", which was false -- the alternating pattern
4171    /// is `ModelConfig::layer_sliding_window`, both phases, and
4172    /// `gpt-oss` has run on it since it was audited. What that gate
4173    /// really did was make the loader's own read of the key dead for
4174    /// every non-Gemma architecture. The case ferrox genuinely cannot
4175    /// express is the ARRAY, which `metadata_u64_any` reads as `None`
4176    /// and which therefore used to fall through to
4177    /// `default_swa_layout` -- substituting the architecture's
4178    /// hardcoded layout for the file's, silently.
4179    #[test]
4180    fn an_array_valued_sliding_window_pattern_is_refused_rather_than_substituted() {
4181        // llama.cpp reads this key with `ml.get_key_or_arr`, so a file
4182        // may declare a per-layer array instead of a scalar period.
4183        // ferrox carries ONE period for the whole model and
4184        // `metadata_u64_any` returns `None` for an array, so before this
4185        // gate the loader fell through to `default_swa_layout` and ran
4186        // the architecture's hardcoded layout in place of the file's --
4187        // silently, which is the failure this repo keeps finding.
4188        //
4189        // `capability::unsupported_feature_keys` used to refuse the key
4190        // outright, which stopped this AND stopped every legitimate
4191        // scalar. Now presence is fine and unreadability is not, so
4192        // both halves have to be tested: the scalar case is the plamo3
4193        // fixture in `tests/fixture_away_graphs.rs`, and this is the
4194        // array case.
4195        let pattern: [u32; 4] = [1, 0, 1, 0];
4196        let kvs: Vec<(&str, Kv)> = vec![
4197            ("general.architecture", Kv::Str("plamo3")),
4198            ("plamo3.block_count", Kv::U32(4)),
4199            ("plamo3.embedding_length", Kv::U32(64)),
4200            ("plamo3.attention.head_count", Kv::U32(1)),
4201            ("plamo3.attention.head_count_kv", Kv::U32(1)),
4202            ("plamo3.attention.key_length", Kv::U32(64)),
4203            ("plamo3.rope.freq_base", Kv::F32(10_000.0)),
4204            ("plamo3.attention.sliding_window", Kv::U32(3)),
4205            (
4206                "plamo3.attention.sliding_window_pattern",
4207                Kv::Arr32(&pattern),
4208            ),
4209        ];
4210        let file = open_metadata_gguf("swa_pattern_array", &kvs);
4211        match ModelConfig::from_gguf(&file) {
4212            Err(LoadError::UnsupportedFeature(arch, msg)) => {
4213                assert_eq!(arch, "plamo3");
4214                assert!(
4215                    msg.contains("not a scalar period"),
4216                    "the refusal must name what is wrong with the value: {msg}"
4217                );
4218            }
4219            other => panic!("an array-valued SWA pattern must refuse, got {other:?}"),
4220        }
4221
4222        // And the scalar spelling of the same key still loads, or the
4223        // gate would be refusing the feature rather than the shape.
4224        let mut scalar = kvs;
4225        scalar.pop();
4226        scalar.push(("plamo3.attention.sliding_window_pattern", Kv::U32(2)));
4227        let file = open_metadata_gguf("swa_pattern_scalar", &scalar);
4228        let config = ModelConfig::from_gguf(&file).expect("a scalar period must load");
4229        assert_eq!(config.swa_pattern, Some(2));
4230        assert_eq!(config.layer_sliding_window(0), Some(3));
4231        assert_eq!(config.layer_sliding_window(1), None);
4232    }
4233
4234    /// Baichuan is one `general.architecture` string covering two
4235    /// positional schemes, and llama.cpp picks between them on the layer
4236    /// count alone (`src/models/baichuan.cpp:11-14`, with its own "TODO:
4237    /// become GGUF KV parameter"). So the 13B is the MiniCPM case: no
4238    /// key to gate on and no tensor to miss.
4239    #[test]
4240    fn baichuan_13b_is_refused_because_it_uses_alibi_and_the_7b_is_not() {
4241        let thirteen_b = open_metadata_gguf(
4242            "baichuan13b",
4243            &[
4244                ("general.architecture", Kv::Str("baichuan")),
4245                ("baichuan.block_count", Kv::U32(40)),
4246            ],
4247        );
4248        match ModelConfig::from_gguf(&thirteen_b) {
4249            Err(LoadError::UnsupportedFeature(arch, msg)) => {
4250                assert_eq!(arch, "baichuan");
4251                assert!(msg.contains("ALiBi"), "{msg}");
4252                assert!(
4253                    msg.contains("40"),
4254                    "the refusal must name the layer count: {msg}"
4255                );
4256            }
4257            other => panic!("Baichuan-13B must be refused, got {other:?}"),
4258        }
4259
4260        // The 7B rotates exactly as the generic decoder does, so it must
4261        // pass this gate. It still fails later, on the next missing
4262        // hparam, which is what proves the gate let it through.
4263        let seven_b = open_metadata_gguf(
4264            "baichuan7b",
4265            &[
4266                ("general.architecture", Kv::Str("baichuan")),
4267                ("baichuan.block_count", Kv::U32(32)),
4268            ],
4269        );
4270        match ModelConfig::from_gguf(&seven_b) {
4271            Err(LoadError::MissingHparam(key)) => assert_eq!(key, "baichuan.embedding_length"),
4272            other => panic!("Baichuan-7B must pass the ALiBi gate, got {other:?}"),
4273        }
4274    }
4275
4276    /// The hyper-parameters a real Gemma-3 GGUF header carries for one
4277    /// size. `block_count` is the field llama.cpp's `LLM_TYPE_27B`
4278    /// switch reads (`gemma3.cpp:20-28`), so it is never a free
4279    /// parameter here.
4280    ///
4281    /// `linear_factor` adds the pair `conversion/base.py:1222-1230`
4282    /// writes from `rope_parameters["full_attention"]` -- and only from
4283    /// there: its own comment is "TODO: Handle sliding_attention
4284    /// similarly when models start implementing it", so the sliding
4285    /// layers get no scaling key at all.
4286    fn gemma3_config(
4287        tag: &str,
4288        n_layers: u32,
4289        hidden_dim: u32,
4290        n_heads: u32,
4291        head_dim: u32,
4292        linear_factor: Option<f32>,
4293    ) -> ModelConfig {
4294        let mut kvs: Vec<(&str, Kv)> = vec![
4295            ("general.architecture", Kv::Str("gemma3")),
4296            ("gemma3.block_count", Kv::U32(n_layers)),
4297            ("gemma3.embedding_length", Kv::U32(hidden_dim)),
4298            ("gemma3.attention.head_count", Kv::U32(n_heads)),
4299            ("gemma3.attention.head_count_kv", Kv::U32(n_heads)),
4300            ("gemma3.attention.key_length", Kv::U32(head_dim)),
4301            ("gemma3.attention.value_length", Kv::U32(head_dim)),
4302            // Global layers rotate at 1e6; the sliding ones fall back to
4303            // llama.cpp's `rope_freq_base_train_swa` default of 10000,
4304            // because `gemma3.cpp:11` reads only the BASE key.
4305            ("gemma3.rope.freq_base", Kv::F32(1_000_000.0)),
4306            ("gemma3.attention.sliding_window", Kv::U32(1024)),
4307            ("gemma3.attention.sliding_window_pattern", Kv::U32(6)),
4308        ];
4309        if let Some(factor) = linear_factor {
4310            kvs.push(("gemma3.rope.scaling.type", Kv::Str("linear")));
4311            kvs.push(("gemma3.rope.scaling.factor", Kv::F32(factor)));
4312        }
4313        ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("gemma3 fixture must load")
4314    }
4315
4316    /// The 27B attention scale reaches `ModelConfig`, and no other
4317    /// Gemma-3 size acquires one.
4318    ///
4319    /// `capability::attention_scale_override` is where the arithmetic is
4320    /// checked; this pins that the LOADER calls it with this file's own
4321    /// numbers. That step is the one that shipped broken: the function
4322    /// did not exist and `attention_scale` was the literal `None`, under
4323    /// a comment naming the exception. A helper nobody calls looks
4324    /// exactly like a fix.
4325    #[test]
4326    fn a_gemma3_27b_header_sets_the_attention_scale_and_no_smaller_size_does() {
4327        // Gemma-3-27B: 62 layers, n_embd 5376, 32 heads, head_dim 128.
4328        let big = gemma3_config("g3_27b_scale", 62, 5376, 32, 128, Some(8.0));
4329        let want = 1.0f32 / (5376.0f32 / 32.0).sqrt();
4330        let got = big
4331            .attention_scale
4332            .expect("Gemma-3-27B is llama.cpp's LLM_TYPE_27B");
4333        assert!(
4334            (got - want).abs() < 1e-7,
4335            "want 1/sqrt(168) = {want}, got {got}"
4336        );
4337        // The bug's magnitude: scores were sqrt(168/128) = 1.146x too
4338        // large without this.
4339        let kernel = 1.0f32 / 128.0f32.sqrt();
4340        assert!((kernel / got - (168.0f32 / 128.0).sqrt()).abs() < 1e-5);
4341
4342        // Gemma-3-1B and -4B take llama.cpp's other branch, which is the
4343        // scale the attention kernels already apply. A `Some` here would
4344        // double-scale them.
4345        for (tag, n_layers, hidden, heads) in
4346            [("g3_1b_scale", 26, 1152, 4), ("g3_4b_scale", 34, 2560, 8)]
4347        {
4348            let cfg = gemma3_config(tag, n_layers, hidden, heads, 256, None);
4349            assert_eq!(
4350                cfg.attention_scale, None,
4351                "{tag} must keep the kernels' own 1/sqrt(head_dim)"
4352            );
4353        }
4354    }
4355
4356    /// Gemma-3's declared linear scaling reaches the FULL-ATTENTION
4357    /// layers only, and the sliding ones rope unscaled.
4358    ///
4359    /// `gemma3.cpp:11` reads `LLM_KV_ROPE_FREQ_BASE_SWA` and nothing
4360    /// else, so `rope_freq_scale_train_swa` keeps its `1.0f` default
4361    /// (`src/llama-hparams.h:129`) while `get_rope_freq_scale`
4362    /// (`llama-model.cpp:2033-2035`) hands the trained scale to the full
4363    /// layers. The converter agrees: `conversion/base.py:1222-1230`
4364    /// takes the factor from `rope_parameters["full_attention"]` and
4365    /// writes nothing for the sliding half.
4366    ///
4367    /// ferrox folded the factor into ONE global `rope_freqs` vector, so
4368    /// Gemma-3-4B/12B/27B rotated five layers in six at `p/8`.
4369    #[test]
4370    fn gemma3_linear_scaling_reaches_the_full_layers_and_not_the_sliding_ones() {
4371        // Gemma-3-4B: 34 layers, head_dim 256, `rope_scaling: linear 8`.
4372        let cfg = gemma3_config("g3_4b_rope", 34, 2560, 8, 256, Some(8.0));
4373        let freqs = cfg
4374            .rope_freqs
4375            .as_ref()
4376            .expect("declared linear scaling must produce per-band divisors");
4377        assert!(
4378            freqs.full.iter().all(|f| (*f - 8.0).abs() < 1e-6),
4379            "full-attention layers divide every band by the trained factor: {:?}",
4380            freqs.full
4381        );
4382        let swa = freqs
4383            .swa
4384            .as_ref()
4385            .expect("gemma3 does not assign rope_freq_scale_train_swa, so 1.0 applies");
4386        assert!(
4387            swa.iter().all(|f| (*f - 1.0).abs() < 1e-6),
4388            "sliding layers rope at the raw position: {swa:?}"
4389        );
4390        assert_eq!(swa.len(), freqs.full.len(), "one divisor per rotated pair");
4391
4392        // Period 6, last-dense (`capability::default_swa_layout`), so
4393        // layer 5 is the full-attention one and 0..=4 slide. The phase
4394        // matters: getting it wrong swaps which five-sixths are wrong.
4395        assert!(cfg.layer_sliding_window(0).is_some());
4396        assert!(cfg.layer_sliding_window(5).is_none());
4397        assert_eq!(cfg.layer_rope_freqs(0), Some(&[1.0f32; 128][..]));
4398        assert_eq!(cfg.layer_rope_freqs(5), Some(&[8.0f32; 128][..]));
4399        assert_eq!(cfg.layer_rope_theta(0), 10_000.0);
4400        assert_eq!(cfg.layer_rope_theta(5), 1_000_000.0);
4401        assert!(
4402            cfg.rope_freqs_vary_by_layer(),
4403            "the fused Metal stacks take one divisor slice for a whole run \
4404             and so must refuse this model"
4405        );
4406
4407        // Gemma-3-1B declares no scaling at all -- the audited fixture,
4408        // and the reason this was invisible. Nothing to split, so no
4409        // per-layer set and no Metal refusal.
4410        let plain = gemma3_config("g3_1b_rope", 26, 1152, 4, 256, None);
4411        assert!(plain.rope_freqs.is_none());
4412        assert!(!plain.rope_freqs_vary_by_layer());
4413    }
4414
4415    /// Gemma-2 is the counter-case, and it is why the SWA scale needs
4416    /// its own table rather than reusing `swa_rope_base_follows_model`.
4417    ///
4418    /// `gemma2.cpp:10-11` assigns BOTH `rope_freq_base_train_swa` and
4419    /// `rope_freq_scale_train_swa` from the model's trained values, so
4420    /// its sliding layers keep the declared scaling. Splitting them here
4421    /// would be the same bug pointed the other way.
4422    #[test]
4423    fn gemma2_sliding_layers_inherit_the_trained_rope_scale() {
4424        let cfg = ModelConfig::from_gguf(&open_metadata_gguf(
4425            "g2_rope",
4426            &[
4427                ("general.architecture", Kv::Str("gemma2")),
4428                ("gemma2.block_count", Kv::U32(26)),
4429                ("gemma2.embedding_length", Kv::U32(2304)),
4430                ("gemma2.attention.head_count", Kv::U32(8)),
4431                ("gemma2.attention.head_count_kv", Kv::U32(4)),
4432                ("gemma2.attention.key_length", Kv::U32(256)),
4433                ("gemma2.attention.value_length", Kv::U32(256)),
4434                ("gemma2.rope.freq_base", Kv::F32(10_000.0)),
4435                ("gemma2.attention.sliding_window", Kv::U32(4096)),
4436                ("gemma2.rope.scaling.type", Kv::Str("linear")),
4437                ("gemma2.rope.scaling.factor", Kv::F32(8.0)),
4438            ],
4439        ))
4440        .expect("gemma2 fixture must load");
4441
4442        let freqs = cfg.rope_freqs.as_ref().expect("linear scaling declared");
4443        assert_eq!(
4444            freqs.swa, None,
4445            "gemma2.cpp:11 assigns rope_freq_scale_train_swa from the trained scale"
4446        );
4447        assert!(!cfg.rope_freqs_vary_by_layer());
4448        // Period 2, last-dense: layer 0 slides, layer 1 does not, and
4449        // both get the same divisors.
4450        assert!(cfg.layer_sliding_window(0).is_some());
4451        assert!(cfg.layer_sliding_window(1).is_none());
4452        assert_eq!(cfg.layer_rope_freqs(0), cfg.layer_rope_freqs(1));
4453
4454        // The two tables really are different: this is the pair that
4455        // must not be collapsed into one.
4456        assert!(crate::capability::swa_rope_scale_follows_model("gemma2"));
4457        assert!(!crate::capability::swa_rope_scale_follows_model("gemma3"));
4458        for arch in ["olmo2", "laguna"] {
4459            assert!(
4460                crate::capability::swa_rope_base_follows_model(arch),
4461                "{arch} seeds the SWA base from the model"
4462            );
4463            assert!(
4464                !crate::capability::swa_rope_scale_follows_model(arch),
4465                "{arch} pins the SWA scale to 1.0 (olmo2.cpp:14, laguna.cpp:48)"
4466            );
4467        }
4468    }
4469}