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}