frink_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, frink 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 frink-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/frink-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 frink_core::expert_store::{ExpertKey, ExpertSource, ExpertStore};
26use frink_core::tensor::Tensor;
27use frink_core::weight_matrix::quant_kind_for;
28use frink_core::weight_matrix::{QuantKind, WeightBytes, WeightMatrix};
29use frink_gguf::{GgmlType, GgufError, GgufValue, ShardedGguf, TensorInfo, TensorSource};
30use frink_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};
38use crate::norm::NormOp;
39
40#[derive(Debug, Error)]
41pub enum LoadError {
42 #[error(transparent)]
43 Gguf(#[from] GgufError),
44 #[error(transparent)]
45 Shard(#[from] frink_gguf::ShardError),
46 #[error("tensor '{0}' has unsupported dtype {1:?}")]
47 UnsupportedDtype(String, GgmlType),
48 #[error(
49 "MoE tensor '{0}' is not 3D or its expert count {1} does not match config n_experts {2}"
50 )]
51 ExpertCountMismatch(String, usize, usize),
52 #[error("GGUF file is missing required hparam metadata key '{0}'")]
53 MissingHparam(String),
54 /// `general.architecture` is not in the capability registry -- refuse
55 /// to guess RoPE/gating rather than emit fluent-but-wrong logits.
56 #[error(
57 "unsupported GGUF architecture '{0}': not in frink's capability registry \
58 (unknown required features fail closed; see frink_models::capability)"
59 )]
60 UnsupportedArchitecture(String),
61 /// Architecture exists but must not use the generic GQA decoder.
62 #[error("architecture '{0}' cannot use the generic Decoder: {1}")]
63 DedicatedArchitectureRequired(String, &'static str),
64 /// Metadata advertises a feature the generic decoder does not implement.
65 #[error("architecture '{0}' requires unimplemented feature: {1}")]
66 UnsupportedFeature(String, String),
67 #[error(
68 "architecture '{0}' has never been verified against llama.cpp. It would run on \
69 frink's shared generic-GQA path, which ASSUMES plain GQA with {1:?} RoPE and no \
70 ALiBi, no learned position embeddings and no per-layer rope skipping. That \
71 assumption has already been wrong for gpt2, mpt, refact, bloom and jais, each of \
72 which loaded clean and answered as a different model. {2} Set \
73 FRINK_ALLOW_UNAUDITED_ARCH=1 to run it anyway and compare the output against \
74 llama.cpp yourself"
75 )]
76 UnauditedArchitecture(String, crate::config::RopeLayout, String),
77 /// The checkpoint carries per-block tensors this build never reads,
78 /// i.e. weights that contribute to the real graph and would simply
79 /// be missing from ours. See [`assert_every_tensor_consumed`].
80 #[error(
81 "checkpoint carries {0} tensor(s) this build never reads, so its graph is not the one \
82 frink would run: {1}. This is a missing feature, not a corrupt file. Override with \
83 FRINK_ALLOW_UNKNOWN_TENSORS=1 to load anyway and accept wrong output."
84 )]
85 UnconsumedTensors(usize, String),
86 /// `FRINK_STRICT_KERNELS=1` and the model has weights with no
87 /// kernel on the selected accelerator, i.e. it would run, correctly,
88 /// on a silently slower path. Refusing is the point: a benchmark or
89 /// CI run must not be able to publish a number taken off the
90 /// backend it claims. See [`frink_core::kernel_registry`].
91 #[error("{0}")]
92 StrictKernels(String),
93}
94
95/// Architecture-family name strings (GGUF's `general.architecture` value)
96/// known, from reading ik_llama.cpp's `llama-hparams.cpp`
97/// (`LLM_ARCH_DEEPSEEK2`, `LLM_ARCH_GLM4_MOE` cases), to default to
98/// sigmoid MoE gating with post-selection renormalization rather than
99/// softmax. Every member's citation is inline here; `docs/MODELS.md`
100/// carries none and the pointer that used to send readers there was
101/// dangling.
102/// `afmoe`, `laguna` and `step35` added 2026-09-01 by the
103/// unaudited-refusal triage's gating sweep. Each reads
104/// `LLM_KV_EXPERT_GATING_FUNC` as OPTIONAL and then, when the key is
105/// absent, sets `LLAMA_EXPERT_GATING_FUNC_TYPE_SIGMOID`
106/// (`afmoe.cpp:29-30`, `laguna.cpp:55-56`, `step35.cpp:19-20`). Frink
107/// fell back to softmax for all three.
108///
109/// This is the `deepseek` shape a third, fourth and fifth time: a
110/// default that is right for most architectures and silently wrong for
111/// one, where the GGUF carries no key to correct it. Nothing is live
112/// today -- all three are `NewCode` for other reasons and refuse before
113/// reaching here -- but the list is what a later admission would trust.
114const SIGMOID_GATING_ARCHITECTURES: &[&str] = &[
115 "afmoe",
116 // cohere2moe.cpp:27-29: the key read optional, SIGMOID when absent.
117 "cohere2moe",
118 "deepseek2",
119 "glm4moe",
120 "laguna",
121 "step35",
122];
123
124/// Architectures whose graph passes a gating LITERAL into
125/// `build_moe_ffn`, so the file's `expert_gating_func` is never read:
126/// the literal wins even over a key that says otherwise.
127///
128/// Measured 2026-09-12 by parsing every `build_moe_ffn(` call's
129/// arguments in all 155 `src/models/*.cpp`: three graphs pass
130/// `LLAMA_EXPERT_GATING_FUNC_TYPE_SIGMOID` (`llama4.cpp`, `mimo2.cpp:227`,
131/// `nemotron-h.cpp`), twenty-six pass `_SOFTMAX`, nineteen pass
132/// `hparams.expert_gating_func`. Only `mimo2` of the three is on this
133/// loader. The twenty-six softmax literals are not tabled: every
134/// converter for them writes no key or writes SOFTMAX, so the key and
135/// the literal agree on every real file, and a table of twenty-six
136/// hand-copied rows would be a bigger risk than the hand-written file
137/// it guards against. `conversion/mimo.py` writes SIGMOID from
138/// `scoring_func`, so on a real MiMo file the two agree too; the row
139/// exists because the literal is what llama.cpp runs.
140const GATING_LITERAL_ARCHITECTURES: &[(&str, GatingFunction)] = &[
141 ("mimo2", GatingFunction::Sigmoid),
142 // `nemotron-h.cpp:218`: the SIGMOID literal; the converter writes no
143 // `expert_gating_func` (`conversion/nemotron.py:238-250`).
144 ("nemotron_h_moe", GatingFunction::Sigmoid),
145 // `llama4.cpp:230`: the SIGMOID literal, with `norm_w = false` at
146 // `:228` (`NO_TOPK_RENORMALIZE_ARCHITECTURES`); the converter
147 // writes no key (`conversion/llama.py:374-394`).
148 ("llama4", GatingFunction::Sigmoid),
149];
150/// The names alone, for the cross-table test.
151#[cfg(test)]
152const GATING_LITERAL_NAMES: &[&str] = &["mimo2", "nemotron_h_moe", "llama4"];
153
154/// Architectures whose `load_arch_hparams` reads
155/// `{arch}.expert_weights_scale` (`LLM_KV_EXPERT_WEIGHTS_SCALE`) -- and
156/// so the only ones whose `build_moe_ffn` call sees a nonzero
157/// `hparams.expert_weights_scale`. On every other architecture the key
158/// is dead metadata upstream: the field stays 0 and the multiply is
159/// skipped, whatever the file says.
160///
161/// Measured 2026-09-12: `grep -l LLM_KV_EXPERT_WEIGHTS_SCALE
162/// src/models/*.cpp` is twenty graphs; these are the eight on this
163/// loader (`deepseek2` / `deepseek32` / `deepseek2ocr` / `deepseek4` /
164/// `glm-dsa` / `glm4-moe` / `kimi-linear` / `minimax-m3` / `dflash` /
165/// `nemotron-h` / `hy-v3` are on other engines, refused, or unknown
166/// here; `cohere2moe.cpp:20` joined on 2026-09-14). Found by `mimo2`'s fixture: `mimo2.cpp` reads the
167/// key nowhere, libllama ran the fixture unscaled, and frink -- which
168/// honoured the key for any architecture -- scaled it by 2.5.
169const EXPERT_WEIGHTS_SCALE_READERS: &[&str] = &[
170 "afmoe",
171 "bailingmoe",
172 "bailingmoe2",
173 // `cohere2moe.cpp:19-20` read both the norm and the scale.
174 "cohere2moe",
175 "deepseek",
176 "dots1",
177 "exaone-moe",
178 // `glm4-moe.cpp:13-14` read both the scale and the norm.
179 "glm4moe",
180 "laguna",
181 // `nemotron-h.cpp:19` (`routed_scaling_factor`, 2.5 on Nemotron-3 Nano).
182 "nemotron_h_moe",
183 "step35",
184];
185
186/// The same for `{arch}.expert_weights_norm` (`LLM_KV_EXPERT_WEIGHTS_NORM`):
187/// eighteen graphs read it upstream, seven on this loader; every other
188/// graph passes `norm_w` as a LITERAL into `build_moe_ffn`, and the
189/// literal is what `NO_TOPK_RENORMALIZE_ARCHITECTURES` and its default
190/// transcribe. `deepseek` reads the scale but not the norm
191/// (`deepseek.cpp` passes `false`).
192const EXPERT_WEIGHTS_NORM_READERS: &[&str] = &[
193 "afmoe",
194 "bailingmoe",
195 "bailingmoe2",
196 "cohere2moe",
197 "dots1",
198 "exaone-moe",
199 "glm4moe",
200 "laguna",
201 // `nemotron-h.cpp:18` (`norm_topk_prob`).
202 "nemotron_h_moe",
203 "step35",
204];
205
206/// Names that appear in a behaviour table above but are `DedicatedOnly`
207/// or `Deferred`, together with the module that actually applies the
208/// behaviour for them.
209///
210/// Two true things were in conflict here, and deleting either would
211/// have lost one. `SIGMOID_GATING_ARCHITECTURES` records a fact about
212/// llama.cpp (these architectures default to sigmoid when the GGUF
213/// carries no `expert_gating_func`), and a test pins it as such. The
214/// cross-table test records a different fact: an entry for an
215/// architecture that never reaches THIS loader cannot fire, and a gate
216/// that cannot fire is worse than no gate because it reads as coverage.
217///
218/// Both hold. `deepseek2` is genuinely sigmoid-gated and genuinely
219/// never arrives here. So the resolution is not to drop a name
220/// from either place, it is to say out loud who owns it instead, and to
221/// make an unexplained dead entry still fail.
222///
223/// Adding a name here is a claim that the named module applies the
224/// behaviour. It is checked no further than that, so it is the one line
225/// in this file to be suspicious of.
226/// Test-only: it asserts a relationship rather than driving one, and a
227/// production reader would have to be told that.
228#[cfg(test)]
229const DEDICATED_OWNS_ITS_BEHAVIOUR: &[(&str, &str)] = &[
230 // `mla_gguf_loader` reads `expert_gating_func` and falls back to
231 // Sigmoid itself, so deepseek2's gating is decided there.
232 ("deepseek2", "mla_gguf_loader"),
233 // `glm4moe` was here while it was refused; it is a generic-path
234 // row now (2026-09-12) and the sigmoid default is live in THIS
235 // loader.
236];
237
238/// Architecture-family names whose real reference implementation skips
239/// renormalizing top-k softmax routing weights after selection (GGUF
240/// carries no metadata key for this -- it's hardcoded per-architecture in
241/// both the real HF `transformers` model code and llama.cpp's
242/// `build_moe_ffn` call sites, not read from the file). Confirmed for
243/// `olmoe` against `OlmoeTopKRouter.forward` in
244/// `transformers/models/olmoe/modeling_olmoe.py` (`config.norm_topk_prob`
245/// is `false` in the real published config.json) and llama.cpp's
246/// `src/models/olmoe.cpp` (`build_moe_ffn(..., false, ...,
247/// LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX, ...)`). See
248/// `MoeLayerConfig::norm_topk_prob`'s doc comment for why this matters:
249/// getting it wrong silently produces wrong generation output even
250/// though the file loads and shape-validates fine.
251// Architectures whose reference graphs pass `norm_w=false` to
252// `build_moe_ffn` (llama.cpp) / `norm_topk_prob=false` in HF config.
253// Qwen2-MoE: `.scratch/llama.cpp/src/models/qwen2moe.cpp` -- Softmax +
254// `false` for the norm_topk slot. Renormalizing top-k weights made
255// Qwen1.5-MoE greedy decode emit garbage despite shared-expert load.
256// `deepseek` (V1) added 2026-09-01 by the unaudited-refusal triage.
257// `src/models/deepseek.cpp:145-155` passes `norm_w=false`, and
258// `conversion/deepseek.py`'s `DeepseekModel` never writes
259// `{arch}.expert_weights_norm` -- only `DeepseekV2Model` does -- so no
260// real `deepseek` GGUF carries the key to override the default with.
261// Frink therefore renormalised where llama.cpp does not. Same class of
262// bug as the OLMoE one above, and latent only because `deepseek` is
263// unaudited and refuses first.
264// `jamba.cpp:164` passes `norm_w = false` and its converter writes no
265// `expert_weights_norm` (`conversion/jamba.py:24-54`).
266// `llama4.cpp:228` passes `false` beside its SIGMOID literal
267// (`GATING_LITERAL_ARCHITECTURES`): the top-k sigmoid scores weight
268// the experts unrenormalised.
269const NO_TOPK_RENORMALIZE_ARCHITECTURES: &[&str] =
270 &["deepseek", "jamba", "llama4", "olmoe", "qwen2moe"];
271
272/// Architectures whose `{arch}.feed_forward_length` counts the gate and
273/// the up projection TOGETHER, so each FFN matrix is half as wide as the
274/// key says.
275///
276/// Qwen-1 (`QWenLMHeadModel`, GGUF string `qwen` -- not `qwen2` and not
277/// `qwen3`) is the only one. HF's `QWenMLP` sets
278/// `ff_dim_in = config.intermediate_size // 2` and builds `w1` and `w2`
279/// at that width; `conversion/qwen.py`'s `QwenModel` inherits the base
280/// `set_gguf_parameters`, which writes `intermediate_size` through
281/// unchanged (`conversion/base.py:1206`); and `src/models/qwen.cpp:33-35`
282/// therefore creates `ffn_gate`, `ffn_up` and `ffn_down` at `n_ff / 2`.
283///
284/// **This costs no logits and is still worth fixing.** frink loads the
285/// dense FFN by tensor NAME and uses each matrix's own shape, so the
286/// forward pass was always right; what was wrong was `expert_ffn_dim`,
287/// which is what every memory estimate and `frink inspect-plan` row
288/// prices the FFN from. That is this repo's dominant bug shape --
289/// `ModelConfig` and the weights disagreeing about one number with
290/// nothing comparing them -- so
291/// `the_declared_ffn_width_matches_the_matrices_that_load`
292/// (tests/one_match_arm_graphs.rs) now compares them.
293const FFN_LENGTH_COUNTS_GATE_AND_UP: &[&str] = &["qwen"];
294
295// The norm-slot lists (`PRE_FFN_NORM_IS_POST_ATTENTION_NORM` and its
296// siblings) live in `crate::norm_sites`, beside the table that reads
297// them; the tests below still walk them.
298
299/// Architectures whose checkpoints carry `{arch}.leading_dense_block_count`
300/// while their reference graph never branches on it: **every** layer is
301/// MoE regardless of what the key says.
302///
303/// `bailingmoe` is the case this list exists for.
304/// `src/models/bailingmoe.cpp:5` reads
305/// `LLM_KV_LEADING_DENSE_BLOCK_COUNT` into `n_layer_dense_lead` and then
306/// `load_arch_tensors` creates `ffn_gate_inp`, the expert tensors and
307/// the shared-expert tensors unconditionally for every layer (:39-54 --
308/// there is no `if (i < n_layer_dense_lead)` anywhere in the file) and
309/// the graph has no dense branch either (:119-152). Meanwhile
310/// `conversion/bailingmoe.py:27` writes `first_k_dense_replace` into the
311/// key verbatim, so real Ling checkpoints DO carry a nonzero value.
312///
313/// Frink's `ModelConfig::layer_is_dense` does branch on it, so without
314/// this list frink looks for `blk.0.ffn_gate.weight` on a layer that
315/// only ships experts and dies on a missing tensor. That is a load
316/// failure rather than wrong logits, which is why it stayed latent.
317///
318/// Do not read this as "the key is meaningless": for `deepseek`,
319/// `dots1`, `glm4moe` and every other leading-dense architecture the key
320/// is load-bearing and must be honoured. Membership here is a statement
321/// about ONE architecture's graph, checked in that graph.
322const LEADING_DENSE_KEY_IS_INERT: &[&str] = &["bailingmoe"];
323
324/// Architectures whose reference graph applies `attn_q_norm` /
325/// `attn_k_norm` AFTER `ggml_rope_ext`, not before it.
326///
327/// There is no GGUF key for this. llama.cpp writes the order into each
328/// hand-written graph, so the only place it can come from is the
329/// architecture string, and getting it wrong changes every layer's
330/// attention scores without changing a single tensor shape.
331///
332/// - `maincoder`: `src/models/maincoder.cpp:78-90` ropes Q and K, then
333/// norms them at `:92` and `:95`.
334/// - `hunyuan-moe`: `src/models/hunyuan-moe.cpp:93,104` rope, `:110,115`
335/// norm.
336///
337/// - `hunyuan-dense`: it has no graph of its own --
338/// `src/models/models.h:1830-1834` derives `llama_model_hunyuan_dense`
339/// from `llama_model_hunyuan_vl` and reuses its graph -- so the lines
340/// are `hunyuan-vl.cpp:56-66` (rope) then `:73-81` (norm). Its second
341/// blocker, the NTK-alpha RoPE base rescale, is implemented too; see
342/// [`crate::rope_ntk_alpha`].
343///
344/// The audited majority is the other way round -- `qwen3moe.cpp:99,108`
345/// and `bailingmoe2.cpp:123-135` both norm first -- which is why the
346/// decoder's default is "before" and this list is the exception.
347const QK_NORM_AFTER_ROPE_ARCHITECTURES: &[&str] =
348 &["hunyuan-dense", "hunyuan-moe", "maincoder", "talkie"];
349
350fn metadata_u64_any(file: &impl TensorSource, keys: &[String]) -> Option<u64> {
351 keys.iter().find_map(|k| file.metadata_u64(k))
352}
353
354fn metadata_f32_any(file: &impl TensorSource, keys: &[String]) -> Option<f32> {
355 keys.iter()
356 .find_map(|k| file.metadata(k).and_then(GgufValue::as_f32))
357}
358
359impl ModelConfig {
360 /// Derives a `ModelConfig` from a real GGUF file's own hyperparameter
361 /// metadata, following llama.cpp's `general.architecture`-prefixed key
362 /// convention (`{arch}.block_count`, `{arch}.embedding_length`,
363 /// `{arch}.attention.head_count`, `{arch}.expert_count`, ...) rather
364 /// than requiring a hand-written preset to already match the file's
365 /// shape exactly. This is what lets `frink-server` (and `frink
366 /// run-real`) load an arbitrary checkpoint, not just the three
367 /// hand-tuned presets in `config.rs`.
368 ///
369 /// Fields with no corresponding metadata key fall back to widely-used
370 /// llama.cpp defaults (documented inline) and are listed in the
371 /// returned config's `best_effort_fields`, following the same
372 /// confirmed-vs-estimated discipline as the hand-written presets.
373 pub fn from_gguf(file: &impl TensorSource) -> Result<Self, LoadError> {
374 let arch = file
375 .metadata_str("general.architecture")
376 .ok_or_else(|| LoadError::MissingHparam("general.architecture".to_string()))?
377 .to_string();
378 let arch_profile = crate::capability::resolve_profile(&arch)
379 .ok_or_else(|| LoadError::UnsupportedArchitecture(arch.clone()))?;
380 let rope_layout = match arch_profile.path {
381 crate::capability::ArchPath::GenericGqa { rope }
382 | crate::capability::ArchPath::TestFixture { rope } => rope,
383 crate::capability::ArchPath::DedicatedOnly { reason } => {
384 return Err(LoadError::DedicatedArchitectureRequired(
385 arch.clone(),
386 reason,
387 ));
388 }
389 crate::capability::ArchPath::Deferred { reason } => {
390 return Err(LoadError::UnsupportedFeature(
391 arch.clone(),
392 format!("architecture deferred from Frink text-generation scope: {reason}"),
393 ));
394 }
395 };
396 let qk_norm_style = arch_profile.qk_norm;
397 // A vision export's text tower declaring M-RoPE sections on an
398 // architecture whose text rotation is NORM (`crate::mrope`).
399 if let Some(reason) = crate::mrope::mrope_refusal(file, &arch) {
400 return Err(LoadError::UnsupportedFeature(arch.clone(), reason));
401 }
402 for (meta_key, feature) in crate::capability::unsupported_feature_keys(&arch) {
403 if let Some(v) = metadata_f32_any(file, std::slice::from_ref(&meta_key)) {
404 if v > 0.0 {
405 return Err(LoadError::UnsupportedFeature(
406 arch.clone(),
407 format!("{feature} (metadata {meta_key}={v})"),
408 ));
409 }
410 }
411 if let Some(v) = metadata_u64_any(file, std::slice::from_ref(&meta_key)) {
412 if v > 0 {
413 return Err(LoadError::UnsupportedFeature(
414 arch.clone(),
415 feature.to_string(),
416 ));
417 }
418 }
419 }
420 // Metadata-declared multipliers the generic decoder does not
421 // apply. Unlike the tensor-consumption gate, nothing about these
422 // is visible in the weights, so a Granite checkpoint would load
423 // and answer at the wrong scale. See
424 // `capability::unsupported_scaling_keys`.
425 for (meta_key, feature, no_op) in crate::capability::unsupported_scaling_keys(&arch) {
426 if let Some(v) = metadata_f32_any(file, std::slice::from_ref(&meta_key)) {
427 if (v - no_op).abs() > 1e-6 {
428 return Err(LoadError::UnsupportedFeature(
429 arch.clone(),
430 format!("{feature} (metadata {meta_key}={v})"),
431 ));
432 }
433 }
434 }
435 let key = |suffix: &str| format!("{arch}.{suffix}");
436
437 let name: &'static str = Box::leak(
438 file.metadata_str("general.name")
439 .unwrap_or(&arch)
440 .to_string()
441 .into_boxed_str(),
442 );
443
444 let block_count =
445 file.metadata_u64(&key("block_count"))
446 .ok_or_else(|| LoadError::MissingHparam(key("block_count")))? as usize;
447 // llama.cpp's `n_layer()` is `block_count` MINUS the NextN/MTP
448 // blocks the converter appended inside it, for the graphs that
449 // read `nextn_predict_layers` (`crate::mtp_blocks`). `n_layers`
450 // is the trunk from here on; `block_count` is handed ONLY to the
451 // two things llama.cpp decides before it has read the key --
452 // `exaone4.cpp:4`'s layer-count gate and the per-layer array
453 // lengths -- and nowhere else.
454 let trunk = crate::mtp_blocks::trunk_layers(file, &arch, block_count)?;
455 // Nanbeige's `num_loops`: the trunk is the PHYSICAL count and
456 // `n_layers` the logical one from here on (`crate::layer_loops`);
457 // the per-layer arrays below are read at physical length and
458 // replicated per pass, as `nanbeige.cpp:24-26` replicate them.
459 let layer_loops = crate::layer_loops::read_layer_loops(file, &arch, trunk.n_layers)?;
460 let n_layers = layer_loops
461 .map(|l| l.logical_layers())
462 .unwrap_or(trunk.n_layers);
463 // Baichuan-13B (block_count 40) used to be refused HERE: one
464 // architecture string, two positional schemes, decided by layer
465 // count with no key (`baichuan.cpp:11-14`). It is served now
466 // through `crate::alibi` (the bias) and `crate::rope_layers`
467 // (no rotation), both keyed on the same layer count.
468 // EXAONE-4 32B used to be refused HERE, on the same shape:
469 // `exaone4.cpp:4-14` switches the whole SWA machinery on inside
470 // `if (hparams.n_layer() == 64)` and :116 then ropes only the
471 // sliding layers, so its full-attention layers get no rotation
472 // at all and no GGUF key says so. That is now IMPLEMENTED rather
473 // than refused -- `capability::swa_disabled_by_arch` carries the
474 // layer-count gate and `crate::rope_layers` the per-layer
475 // rotation rule it feeds -- so the two EXAONE-4 sizes are one
476 // code path with two answers instead of one running and one
477 // stopping. `tests/no_rope_layer_graphs.rs` has a 64-layer
478 // fixture against libllama's own logits.
479 let hidden_dim = file
480 .metadata_u64(&key("embedding_length"))
481 .ok_or_else(|| LoadError::MissingHparam(key("embedding_length")))?
482 as usize;
483 // Scalar OR per-layer array, as llama.cpp reads all three
484 // (`get_key_or_arr`, llama-model.cpp:1149-1158). The scalars
485 // below are the WIDEST layer's; `ModelConfig::layer_shape` is
486 // what a layer body reads. See `crate::layer_shapes`.
487 let heads_per_layer =
488 crate::layer_shapes::read_u64_trunk_layers(file, &key("attention.head_count"), &trunk)?
489 .ok_or_else(|| LoadError::MissingHparam(key("attention.head_count")))?;
490 let n_heads = heads_per_layer.iter().copied().max().unwrap_or(0) as usize;
491
492 let mut best_effort_fields: Vec<&'static str> = Vec::new();
493
494 let kv_heads_per_layer = match crate::layer_shapes::read_u64_trunk_layers(
495 file,
496 &key("attention.head_count_kv"),
497 &trunk,
498 )? {
499 Some(v) => v,
500 None => {
501 best_effort_fields.push("n_kv_heads (no attention.head_count_kv key; assumed equal to n_heads, i.e. plain MHA)");
502 heads_per_layer.clone()
503 }
504 };
505 let n_kv_heads = kv_heads_per_layer.iter().copied().max().unwrap_or(0) as usize;
506 let head_dim = match file.metadata_u64(&key("attention.key_length")) {
507 Some(v) => v as usize,
508 None => {
509 // llama.cpp derives it from LAYER 0's head count
510 // (`n_embd / n_head()`, llama-model.cpp:1195), which on
511 // a file whose layer 0 has none is a division by zero
512 // there and a refusal here.
513 let h0 = heads_per_layer.first().copied().unwrap_or(0) as usize;
514 // A pure recurrent model has no heads and no head width
515 // (`layer_shapes::PURE_RECURRENT`); nothing reads one.
516 match hidden_dim.checked_div(h0) {
517 _ if h0 == 0 && crate::layer_shapes::pure_recurrent_block(&arch).is_some() => 0,
518 None => {
519 return Err(LoadError::MissingHparam(format!(
520 "{} (layer 0 declares head_count 0, so it cannot be derived as \
521 hidden_dim / n_heads)",
522 key("attention.key_length")
523 )));
524 }
525 Some(derived) => {
526 best_effort_fields.push(
527 "head_dim (no attention.key_length key; derived as hidden_dim / n_heads)",
528 );
529 derived
530 }
531 }
532 }
533 };
534 let v_head_dim = crate::kv_head_dims::resolve_v_head_dim(
535 &arch,
536 head_dim,
537 file.metadata_u64(&key("attention.value_length"))
538 .map(|v| v as usize),
539 )?;
540 // `Some` only when it differs: see `ModelConfig::v_head_dim`.
541 let v_head_dim = (v_head_dim != head_dim).then_some(v_head_dim);
542 let vocab_size = file
543 .metadata("tokenizer.ggml.tokens")
544 .and_then(|v| match v {
545 GgufValue::Array(items) => Some(items.len()),
546 _ => None,
547 })
548 .or_else(|| file.metadata_u64(&key("vocab_size")).map(|v| v as usize))
549 .unwrap_or_else(|| {
550 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)");
551 // `output.weight`'s real raw shape is `[hidden_dim,
552 // vocab_size]` (ggml's fastest-first `ne[]` order --
553 // see `load_weight_matrix`'s doc comment), so vocab_size
554 // is the *last* element, not the first.
555 file.find_tensor("output.weight")
556 .and_then(|t| t.shape.last().copied())
557 .unwrap_or(0) as usize
558 });
559 let rope_theta = metadata_f32_any(file, &[key("rope.freq_base")]).unwrap_or_else(|| {
560 best_effort_fields.push("rope_theta (no rope.freq_base key; defaulted to 10000.0)");
561 10000.0
562 });
563 // NTK-alpha: `{arch}.rope.scaling.alpha` is read for every
564 // architecture (llama-model.cpp:1186) and APPLIED by two
565 // (`hunyuan-vl.cpp:8-12`, inherited by `hunyuan-dense`). The
566 // list and the arithmetic live together in one module so the
567 // key's readers and its appliers cannot drift apart -- see
568 // `crate::rope_ntk_alpha`, which also records why a converted
569 // `hunyuan-dense` file carries the already-scaled base instead.
570 let rope_theta = crate::rope_ntk_alpha::ntk_alpha_scaled_rope_base(
571 &arch,
572 rope_theta,
573 head_dim,
574 metadata_f32_any(file, &[key("rope.scaling.alpha")]),
575 );
576 // The norm FUNCTION is the architecture's, except where the file
577 // decides it (`crate::norm::NORM_BY_RMS_EPS_KEY`): a present and
578 // nonzero RMS epsilon means RMSNorm there, and a zero one is
579 // llama.cpp's "absent", so it is dropped before the epsilon
580 // itself is read below.
581 let declared_rms_eps = metadata_f32_any(file, &[key("attention.layer_norm_rms_epsilon")])
582 .filter(|eps| {
583 *eps != 0.0
584 || !crate::norm::NORM_BY_RMS_EPS_KEY
585 .iter()
586 .any(|(a, _)| *a == arch)
587 });
588 let norm_function = crate::norm::norm_function_for_file(&arch, declared_rms_eps);
589 let rms_norm_eps = declared_rms_eps
590 .or_else(|| metadata_f32_any(file, &[key("attention.layer_norm_epsilon")]))
591 .unwrap_or_else(|| {
592 best_effort_fields
593 .push("rms_norm_eps (no layer_norm_rms_epsilon key; defaulted to 1e-5)");
594 1e-5
595 });
596
597 let n_experts = metadata_u64_any(file, &[key("expert_count")]).unwrap_or(0) as usize;
598 let is_moe = n_experts > 1;
599
600 // `expert_used_count` is scalar OR an array at `block_count`
601 // length: `llama-model.cpp:1266` reads it with `get_key_or_arr`
602 // in the COMMON loader, for every architecture, and
603 // `gguf_writer.py:869-873` writes whichever it is handed.
604 // `conversion/nemotron.py:574` hands it a LIST (Nemotron-H
605 // Puzzle, one entry per block), and `nemotron_h` is an
606 // architecture frink serves -- so before 2026-09-19 such a
607 // file read no scalar here, fell into the default, and routed
608 // top-2 on every layer whatever the file said. A uniform array
609 // is that one value; a varying one needs a per-layer top-k the
610 // MoE layer does not have and stops by name rather than
611 // picking a number.
612 let n_experts_active = if is_moe {
613 match crate::layer_shapes::read_u64_per_layer(
614 file,
615 &key("expert_used_count"),
616 // `n_layer_all`, i.e. `block_count` including any MTP
617 // blocks, which is the length llama.cpp asks for at
618 // `llama-model.cpp:1266` -- BEFORE `n_layer()` drops
619 // them.
620 block_count,
621 )? {
622 Some(per_layer) => {
623 let first = per_layer[0];
624 if per_layer.iter().any(|v| *v != first) {
625 return Err(LoadError::UnsupportedFeature(
626 key("expert_used_count"),
627 format!(
628 "a PER-LAYER expert count ({per_layer:?}). llama.cpp reads this \
629 key with `get_key_or_arr` for every architecture \
630 (llama-model.cpp:1266) and routes layer `il` to \
631 `n_expert_used_arr[il]` experts; frink carries one top-k for \
632 the model, so it would route every layer to {first} and answer \
633 something else. conversion/nemotron.py:574 writes the array for \
634 Nemotron-H Puzzle"
635 ),
636 ));
637 }
638 first as usize
639 }
640 None => {
641 best_effort_fields
642 .push("moe.n_experts_active (no expert_used_count key; defaulted to 2)");
643 2
644 }
645 }
646 } else {
647 1
648 };
649 // Read here, ahead of the shared-expert inference below, because
650 // the tensor that inference probes lives on the first MoE
651 // layer, not on layer 0.
652 let n_dense_leading_layers = if LEADING_DENSE_KEY_IS_INERT.contains(&arch.as_str()) {
653 0
654 } else {
655 metadata_u64_any(file, &[key("leading_dense_block_count")]).unwrap_or(0) as usize
656 };
657 // Prefer the GGUF hparam when present. Qwen2MoE (and some other
658 // HF→GGUF exports) omit `expert_shared_count` but still ship
659 // `blk.N.ffn_{gate,up,down}_shexp.weight` -- without a tensor-
660 // presence fallback those weights are silently dropped and the
661 // model runs with a large chunk of active FFN missing.
662 //
663 // The probe is the FIRST MoE LAYER, not `blk.0`: a leading-dense
664 // model has no shared expert on layer 0, and probing there
665 // answered 0 for every such file. `laguna` is the case that
666 // found it -- `laguna.cpp:20` assigns `n_expert_shared = 1`
667 // before reading the key, `conversion/laguna.py` never writes
668 // the key, and its layer 0 is dense (:105), so a real Laguna
669 // export loaded with its three REQUIRED `_shexp` tensors
670 // (:138-140) unread on every MoE layer.
671 // The interleave step the loader honours (`crate::moe_interleave`,
672 // `llama4.cpp:64`), read here because the shared-expert probe
673 // below needs the first layer it makes MoE.
674 let moe_interleave_step = crate::moe_interleave::interleave_step(
675 &arch,
676 metadata_u64_any(file, &[key("interleave_moe_layer_step")]),
677 n_experts,
678 )
679 .map_err(|reason| LoadError::UnsupportedFeature(arch.clone(), reason))?;
680 let first_moe_layer = (n_dense_leading_layers..n_layers)
681 .find(|&il| !moe_interleave_step.is_some_and(|step| !(il + 1).is_multiple_of(step)))
682 .unwrap_or(n_layers.saturating_sub(1));
683 let shexp_probe = format!("blk.{first_moe_layer}.ffn_gate_shexp.weight");
684 let n_shared_experts = match metadata_u64_any(file, &[key("expert_shared_count")]) {
685 Some(n) => n as usize,
686 None if is_moe && file.find_tensor(&shexp_probe).is_some() => {
687 best_effort_fields.push(
688 "moe.n_shared_experts (no expert_shared_count; inferred 1 from the first \
689 MoE layer's ffn_gate_shexp.weight)",
690 );
691 1
692 }
693 None => 0,
694 };
695 // MoE GGUFs often only set `feed_forward_length` (OLMoE=1024,
696 // Qwen2-MoE=5632 for the shared expert). `expert_feed_forward_length`
697 // is optional. llama.cpp `qwen2moe.cpp` uses
698 // `n_ff_exp = n_ff_exp ? n_ff_exp : n_ff / n_expert_used` (1408 for
699 // Qwen1.5-MoE); the shared expert keeps the full `n_ff` (5632).
700 let ffn_per_layer =
701 crate::layer_shapes::read_u64_trunk_layers(file, &key("feed_forward_length"), &trunk)?
702 // Qwen-1 declares gate and up as one number; see
703 // `FFN_LENGTH_COUNTS_GATE_AND_UP`.
704 .map(|v| {
705 if FFN_LENGTH_COUNTS_GATE_AND_UP.contains(&arch.as_str()) {
706 v.into_iter().map(|ff| ff / 2).collect()
707 } else {
708 v
709 }
710 });
711 let feed_forward_length = ffn_per_layer.as_ref().and_then(|v| v.iter().copied().max());
712 // Scalar OR an array, exactly as `expert_used_count` above:
713 // llama.cpp reads it with `get_key_or_arr` (`maple.cpp:6`,
714 // `dots3note.cpp:11`, `nemotron-h.cpp`), and
715 // `conversion/nemotron.py:573` writes a LIST for Nemotron-H
716 // Puzzle -- an architecture frink serves. Read as a scalar
717 // alone, an array answered `None` here and the fallback below
718 // silently sized every expert at `feed_forward_length /
719 // n_experts_used`, which is a different FFN and loads without
720 // complaint when the tensors happen to be that wide.
721 let expert_ffn_per_layer = crate::layer_shapes::read_u64_per_layer(
722 file,
723 &key("expert_feed_forward_length"),
724 block_count,
725 )?;
726 if let Some(per_layer) = expert_ffn_per_layer.as_ref() {
727 let first = per_layer[0];
728 if per_layer.iter().any(|v| *v != first) {
729 return Err(LoadError::UnsupportedFeature(
730 key("expert_feed_forward_length"),
731 format!(
732 "a PER-LAYER expert FFN width ({per_layer:?}). llama.cpp sizes the \
733 expert tensors from layer 0's entry and `LayerShapes` carries one \
734 expert width for the model, so frink would build every layer at \
735 {first} and read the others' weights at the wrong stride"
736 ),
737 ));
738 }
739 }
740 let expert_ffn_dim = expert_ffn_per_layer
741 .map(|v| v[0])
742 .or_else(|| {
743 feed_forward_length.map(|ff| {
744 if is_moe && n_experts_active > 0 {
745 ff / n_experts_active as u64
746 } else {
747 ff
748 }
749 })
750 })
751 .unwrap_or_else(|| {
752 best_effort_fields.push(
753 "moe.expert_ffn_dim (no expert_feed_forward_length/feed_forward_length; defaulted to 4x hidden_dim)",
754 );
755 (hidden_dim * 4) as u64
756 }) as usize;
757 // `{arch}.attention.rope_pattern`: one entry per layer, nonzero
758 // meaning "this layer rotates" (`llama-hparams.cpp:333-343`).
759 // Read only where llama.cpp reads it, because
760 // `llama-model.cpp:1314` seeds the array with 1 for every
761 // architecture and only `granite-swa.cpp:43` reads it back --
762 // so honouring it elsewhere would answer differently from
763 // upstream on a file that carries it as dead metadata.
764 let rope_pattern: Option<std::sync::Arc<[bool]>> =
765 if crate::rope_layers::reads_rope_pattern(&arch) {
766 crate::layer_shapes::read_u64_per_layer(
767 file,
768 &key("attention.rope_pattern"),
769 block_count,
770 )?
771 .map(|v| v.into_iter().map(|x| x != 0).collect())
772 } else {
773 None
774 };
775
776 // Qwen3.5's recurrent layers come from two keys, not from the
777 // head counts (`crate::gdn::recurrent_layers`).
778 let recurrent_layers =
779 crate::gdn::recurrent_layers(file, &arch, trunk.block_count, n_layers)?;
780 let layer_shapes = crate::layer_shapes::LayerShapes::resolve(
781 &arch,
782 &heads_per_layer,
783 &kv_heads_per_layer,
784 ffn_per_layer.as_deref(),
785 expert_ffn_dim,
786 recurrent_layers.as_ref(),
787 )?
788 // `nanbeige.cpp:24-26` copies each physical layer's shape
789 // arrays to every logical slot; HRM-Text's two stacks are
790 // uniform and its arrays are scalars, so the replication is
791 // spelled for the one schedule that needs it rather than
792 // divided out of the other's counts.
793 .replicated(match layer_loops {
794 Some(crate::layer_loops::LayerLoops::Repeat { n_loops, .. }) => n_loops,
795 _ => 1,
796 });
797 // The OTHER half of llama.cpp's dense-vs-MoE rule.
798 // `ModelConfig::layer_is_dense` implements the leading-dense
799 // prefix and not the `(il + 1) % n_moe_layer_step == 0` at
800 // `src/models/ernie4-5-moe.cpp:64`, so a file whose step would
801 // change the answer stops here rather than looking for expert
802 // tensors on a layer that stores dense ones. `moe_interleave`
803 // records what building the fixture found: llama.cpp cannot load
804 // such a file either, because its own tensor loader has no step
805 // in it.
806 if let Some(reason) = crate::moe_interleave::interleave_step_refusal(
807 &arch,
808 metadata_u64_any(file, &[key("interleave_moe_layer_step")]),
809 ) {
810 return Err(LoadError::UnsupportedFeature(arch.clone(), reason));
811 }
812
813 // ik_llama.cpp's real gating-function hparam
814 // (LLM_KV_EXPERT_GATING_FUNC: 1=softmax, 2=sigmoid) if the file
815 // carries it; otherwise fall back to the same architecture-name
816 // convention the hand-written presets in config.rs use (see
817 // docs/MODELS.md for the citations behind that list).
818 let gating_literal = GATING_LITERAL_ARCHITECTURES
819 .iter()
820 .find(|(name, _)| *name == arch)
821 .map(|(_, g)| *g);
822 let gating = match (
823 gating_literal,
824 metadata_u64_any(file, &[key("expert_gating_func")]),
825 ) {
826 (Some(literal), _) => literal,
827 (None, Some(2)) => GatingFunction::Sigmoid,
828 (None, Some(1)) => GatingFunction::Softmax,
829 (None, _) => {
830 if SIGMOID_GATING_ARCHITECTURES.contains(&arch.as_str()) {
831 GatingFunction::Sigmoid
832 } else {
833 if is_moe {
834 best_effort_fields.push(
835 "moe.gating (no expert_gating_func key and architecture not in the known-sigmoid list; defaulted to softmax)",
836 );
837 }
838 GatingFunction::Softmax
839 }
840 }
841 };
842
843 // `{arch}.expert_weights_norm` (llama.cpp
844 // `LLM_KV_EXPERT_WEIGHTS_NORM`) is the real metadata key for
845 // whether the selected experts' weights are renormalised. Most
846 // checkpoints do not carry it, which is why the fallback below
847 // exists at all -- but when one does, the file's own answer wins
848 // over an architecture-name guess.
849 // The key only where llama.cpp reads it (`EXPERT_WEIGHTS_NORM_READERS`);
850 // everywhere else the graph's literal, which the table below
851 // transcribes, whatever the file says.
852 let norm_key = if EXPERT_WEIGHTS_NORM_READERS.contains(&arch.as_str()) {
853 file.metadata_bool(&key("expert_weights_norm"))
854 } else {
855 None
856 };
857 let norm_topk_prob = match norm_key {
858 Some(v) => v,
859 None => {
860 // See `NO_TOPK_RENORMALIZE_ARCHITECTURES`'s doc comment:
861 // an architecture-name lookup, the same convention
862 // `gating`'s fallback above uses.
863 if is_moe && matches!(gating, GatingFunction::Softmax) {
864 best_effort_fields.push(
865 "moe.norm_topk_prob (no expert_weights_norm key; defaulted by architecture-name lookup against NO_TOPK_RENORMALIZE_ARCHITECTURES)",
866 );
867 }
868 !NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&arch.as_str())
869 }
870 };
871
872 // `{arch}.expert_weights_scale` (`LLM_KV_EXPERT_WEIGHTS_SCALE`).
873 // llama.cpp's `build_moe_ffn` skips the multiply for both 0.0 and
874 // 1.0, so both mean "no scaling" and both land on 1.0 here.
875 // And only where llama.cpp reads it (`EXPERT_WEIGHTS_SCALE_READERS`).
876 let expert_weights_scale = if EXPERT_WEIGHTS_SCALE_READERS.contains(&arch.as_str()) {
877 metadata_f32_any(file, &[key("expert_weights_scale")])
878 .filter(|s| *s != 0.0)
879 .unwrap_or(1.0)
880 } else {
881 1.0
882 };
883
884 // Real GGUF key (`{arch}.attention.sliding_window`, confirmed
885 // against `gguf-py/gguf/constants.py`'s real
886 // `LLM_KV_ATTENTION_SLIDING_WINDOW`). Some checkpoints
887 // (confirmed for real published Qwen1.5-MoE/Qwen2-MoE GGUFs)
888 // carry a nonzero window value even when the model's own
889 // config disables sliding-window attention entirely
890 // (`use_sliding_window: false`) -- llama.cpp's own convention
891 // is that a window of 0 means "unused," so only a real nonzero
892 // value here is treated as active.
893 let declared_window = metadata_u64_any(file, &[key("attention.sliding_window")]);
894 let sliding_window = declared_window
895 .map(|v| v as usize)
896 .filter(|&w| w > 0)
897 // `phi3` declares a window that llama.cpp deliberately does
898 // NOT honour -- see `capability::swa_disabled_by_arch`. This
899 // has to drop the window rather than pick a period, because
900 // upstream is declining to use the file's value, not
901 // choosing a different one.
902 //
903 // `block_count`, NOT `n_layers`: `exaone4.cpp:4` tests
904 // `n_layer() == 64` at a point where `n_layer_nextn` has not
905 // been read yet (`:18`), so a 64-trunk EXAONE-4 with an MTP
906 // block appended sees 65 there and gets no window.
907 //
908 // `smallthinker` declares a window that llama.cpp REPLACES:
909 // `smallthinker.cpp:8` assigns `n_swa = 4096` on the branch
910 // the file's nonzero value selected. One table decides all
911 // three answers (`capability::swa_window_override`), so a
912 // row cannot be dropped by one reader and pinned by another.
913 .and_then(
914 |w| match crate::capability::swa_window_override(&arch, trunk.block_count) {
915 crate::capability::SwaWindowOverride::Honour => Some(w),
916 crate::capability::SwaWindowOverride::Drop => None,
917 crate::capability::SwaWindowOverride::Pin(pinned) => Some(pinned),
918 },
919 );
920
921 // A CHUNKED window (`crate::chunked_swa`): the literal chunk on
922 // the branch the file takes, whatever nonzero value it declares
923 // and whether it declares one at all; a declared ZERO is the
924 // branch libllama aborts on, refused there by name.
925 let swa_chunked = crate::chunked_swa::chunked_window(&arch, declared_window)?;
926 let sliding_window = swa_chunked.or(sliding_window);
927 let swa_chunked = swa_chunked.is_some();
928
929 // A window llama.cpp REQUIRES (`crate::swa_geometry::
930 // window_required`): without it the file does not load upstream,
931 // and here it would run with no layer rotated.
932 if let (None, Some(line)) = (sliding_window, crate::swa_geometry::window_required(&arch)) {
933 return Err(LoadError::UnsupportedFeature(
934 arch.clone(),
935 format!(
936 "`{arch}.attention.sliding_window` is absent or zero, and llama.cpp reads it \
937 as a REQUIRED key for this architecture (src/models/{line}); every real \
938 export writes it, and a file without it does not load upstream"
939 ),
940 ));
941 }
942
943 // A window on a short-conv architecture: `lfm2.cpp:24-29`
944 // honours it on the ATTENTION layers alone (`is_swa_impl[il] =
945 // !is_recr_impl[il]`), a per-layer answer `crate::swa_layers`
946 // has no variant for, and one that would also arm eviction
947 // against the history the conv indexes by row
948 // (`crate::shortconv`). No published export writes the key.
949 if let (Some(w), true) = (
950 sliding_window,
951 crate::shortconv::is_shortconv_architecture(&arch),
952 ) {
953 return Err(LoadError::UnsupportedFeature(
954 arch.clone(),
955 format!(
956 "`{arch}.attention.sliding_window` {w}: lfm2.cpp:24-29 windows the attention \
957 layers and not the conv layers, which `swa_layers` cannot yet spell, and a \
958 window on a conv layer would evict the history its convolution reads \
959 (`crate::shortconv`); no published LFM2 export writes the key"
960 ),
961 ));
962 }
963
964 // Three graphs rope their SLIDING layers with the scaling
965 // switched off -- freq_scale = 1, ext_factor = 0, attn_factor =
966 // 1 -- while the full-attention layers use the model's:
967 // `olmo2` (Olmo-3), `mellum` and `laguna` (Laguna-XS.2), each
968 // at the lines `crate::swa_geometry` cites. frink's
969 // `RopeFreqs` already keeps their sliding layers' divisors
970 // unscaled, but `rope_attn_factor` is one value for the whole
971 // model, so honouring the file would mean rotating half the
972 // layers at a magnitude the checkpoint never trained at.
973 //
974 // A window with NO scaling is not this case and is not refused:
975 // both branches then reduce to the same plain RoPE, and the
976 // difference is masking alone, which frink implements.
977 if let (Some(lines), true) = (
978 crate::swa_geometry::swa_layers_unscaled_rope(&arch),
979 sliding_window.is_some(),
980 ) {
981 let scaling_type = file
982 .metadata_str(&key("rope.scaling.type"))
983 .unwrap_or("none")
984 .to_string();
985 if !scaling_type.eq_ignore_ascii_case("none") {
986 return Err(LoadError::UnsupportedFeature(
987 arch.clone(),
988 format!(
989 "this {arch} checkpoint declares BOTH a sliding window and \
990 rope.scaling.type = \"{scaling_type}\". llama.cpp ropes the \
991 sliding layers with the scaling switched off (freq_scale = 1, \
992 ext_factor = 0, attn_factor = 1; {lines}) and the \
993 full-attention layers with it on, and frink carries one RoPE \
994 scaling for the whole model. A {arch} file with a window and no \
995 scaling, or with scaling and no window, is unaffected"
996 ),
997 ));
998 }
999 }
1000
1001 // WHICH LAYERS SLIDE (`attention.sliding_window_pattern`).
1002 //
1003 // llama.cpp seeds a period per architecture (gemma2 2, gemma3
1004 // 6, exaone4 4, ...) and only then reads the key, so a missing
1005 // key must NOT mean "all SWA": the seed is
1006 // `capability::default_swa_layout`, with the gemma3+ period for
1007 // any Gemma variant the table does not name. The phase is a
1008 // property of the architecture -- `dense_first` is an argument
1009 // to `set_swa_pattern`, not a GGUF key -- so it comes from the
1010 // seed either way.
1011 //
1012 // The key itself is a scalar period OR a per-layer bool array,
1013 // and which of the two an architecture's graph honours -- and
1014 // what it does with the other -- is `crate::swa_layers`'s
1015 // table, transcribed from the `get_key_or_arr` overload each
1016 // `load_arch_hparams` calls. The array used to be REFUSED here
1017 // for every architecture, which stopped every real EXAONE-4
1018 // 32B, EXAONE-MoE and Olmo-3 export at the door over a value
1019 // llama.cpp never reads for them.
1020 let swa_seed = crate::capability::default_swa_layout(&arch).or(match arch_profile.family {
1021 crate::capability::DecoderFamily::GemmaFamily => Some(crate::capability::SwaPattern {
1022 period: 6,
1023 dense_first: false,
1024 }),
1025 _ => None,
1026 });
1027 let swa_layers = match sliding_window {
1028 // No window: no graph consults `is_swa`, and reading the
1029 // key would only refuse a file over a value nothing uses.
1030 None => crate::swa_layers::SwaLayers::All,
1031 Some(_) => crate::swa_layers::read_swa_layers(
1032 file,
1033 &arch,
1034 &key("attention.sliding_window_pattern"),
1035 &trunk,
1036 swa_seed,
1037 )?,
1038 };
1039
1040 // The metadata-declared scalar multipliers, resolved once for
1041 // whichever subset this architecture's reference graph applies.
1042 // See `crate::scalar_multipliers`; the keys the graph does NOT
1043 // apply were already refused above, by a list derived from the
1044 // same table. Read first because its `defaults` also seed the
1045 // softcap below: `grok.cpp:5-12` assigns all of them in one
1046 // place, and so does this.
1047 let multiplier_support = crate::scalar_multipliers::multiplier_support(&arch);
1048
1049 // The file's softcap, then the architecture's default for a
1050 // file that declares none (`grok.cpp:9`), then llama.cpp's own
1051 // "off". `> 0.0` is what every graph tests before applying one.
1052 let attn_logit_softcap = metadata_f32_any(
1053 file,
1054 &[
1055 key("attention.logit_softcapping"),
1056 key("attn_logit_softcapping"),
1057 ],
1058 )
1059 .or(multiplier_support.defaults.attn_logit_softcap())
1060 .filter(|&v| v > 0.0);
1061 let final_logit_softcap =
1062 metadata_f32_any(file, &[key("final_logit_softcapping")]).filter(|&v| v > 0.0);
1063
1064 let declared = crate::scalar_multipliers::DeclaredMultipliers {
1065 logit: metadata_f32_any(file, &[key("logit_scale")]),
1066 residual: metadata_f32_any(file, &[key("residual_scale")]),
1067 embedding: metadata_f32_any(file, &[key("embedding_scale")]),
1068 // Exactly the spelling this architecture's graph reads --
1069 // `attention.scale` for Granite, `attention.output_scale`
1070 // for Grok -- and nothing for the rest. The other spelling
1071 // was refused above, by the same table.
1072 attention: multiplier_support
1073 .attention
1074 .suffix()
1075 .and_then(|suffix| metadata_f32_any(file, &[key(suffix)])),
1076 };
1077 let multipliers = crate::scalar_multipliers::resolve(
1078 multiplier_support,
1079 declared,
1080 // `n_layer` and `n_embd` are here for MiniCPM's defaults
1081 // (`minicpm.cpp:6-7`), which are computed from the model's
1082 // own shape rather than declared: an older MiniCPM export
1083 // carries none of the three keys and is still scaled by all
1084 // three.
1085 crate::scalar_multipliers::MultiplierDims {
1086 head_dim,
1087 n_layer: n_layers,
1088 n_embd: hidden_dim,
1089 },
1090 )
1091 .map_err(|e| LoadError::UnsupportedFeature(arch.clone(), e.message(&arch)))?;
1092
1093 // Gemma and afmoe: embeddings are scaled by sqrt(hidden_dim) at
1094 // input. That is ARITHMETIC, not a key -- those graphs read no
1095 // `embedding_scale` at all -- so it comes from the table and a
1096 // file declaring the key on one of them is refused above rather
1097 // than honoured. Granite's comes out of `{arch}.embedding_scale`.
1098 let embedding_scale =
1099 if crate::capability::embeddings_scaled_by_sqrt_n_embd(&arch, arch_profile.family) {
1100 Some((hidden_dim as f32).sqrt())
1101 } else {
1102 multipliers.embedding_scale
1103 };
1104
1105 // llama.cpp's `f_attention_scale`, and ONLY where it differs from
1106 // the `1/sqrt(head_dim)` frink's attention kernels already
1107 // apply -- `Some` here means "pre-scale Q", so restating the
1108 // kernels' own scale would double-scale every score.
1109 //
1110 // For Gemma-2 and Gemma-3 that difference is real at 27B and
1111 // nowhere else (`capability::attention_scale_override` carries
1112 // the llama.cpp lines). This used to be a hardcoded `None` under
1113 // a comment that NAMED the 27B exception without implementing
1114 // it, so Gemma-2-27B scored 1.061x and Gemma-3-27B 1.146x too
1115 // large on every layer: a sharper softmax than the trained one,
1116 // fluent and wrong, with no error.
1117 //
1118 // Granite reaches the same slot from the file's own
1119 // `{arch}.attention.scale` (`granite.cpp:225`, whose `0.0f`
1120 // sentinel means "use the kernels' scale"). The two sources
1121 // cannot both be live on one architecture: `attention_scale_override`
1122 // covers the architectures that COMPUTE the scale and
1123 // `scalar_multipliers` the ones that READ it, and no llama.cpp
1124 // architecture does both. `.or` rather than a match because the
1125 // computed one is the one that cannot be turned off by a file.
1126 let attention_scale = crate::capability::attention_scale_override(
1127 &arch, n_layers, hidden_dim, n_heads, head_dim,
1128 )
1129 .or(multipliers.attention_scale);
1130
1131 // Granite reads `{arch}.rope.scaling.finetuned` as a switch for
1132 // RoPE itself, not as a note about the scaling: a file declaring
1133 // it false runs UNROTATED in llama.cpp (every Granite-4.0 hybrid
1134 // export), which is `RopeLayers::Never` below
1135 // (`crate::rope_finetuned`).
1136 let rope_switched_off = crate::rope_finetuned::unrotated(
1137 &arch,
1138 file.metadata(&key("rope.scaling.finetuned"))
1139 .and_then(GgufValue::as_bool),
1140 );
1141
1142 // OLMo-1 and DBRX clamp Q, K and V by `{arch}.attention.clamp_kqv`
1143 // inside the shared `build_qkv`. Resolved here for the
1144 // architectures whose loader reads the key, REQUIRED where
1145 // llama.cpp's is (`dbrx.cpp:5`), and applied by the one helper
1146 // every host body shares (`decoder/qkv_bias.rs`). See
1147 // `crate::clamp_kqv`, which also records that both converters
1148 // really write this key.
1149 let clamp_kqv = crate::clamp_kqv::resolve_clamp(
1150 &arch,
1151 metadata_f32_any(file, &[key("attention.clamp_kqv")]),
1152 )
1153 .map_err(|e| LoadError::UnsupportedFeature(arch.clone(), e.message(&arch)))?;
1154
1155 // SWA-layer RoPE base. `llama_hparams` defaults it to 10000 and
1156 // the Gemma-3 lineage relies on that default; the architectures
1157 // in `swa_rope_base_follows_model` instead seed it from the
1158 // model's own base before the key can override.
1159 let rope_theta_swa = if sliding_window.is_some() {
1160 let fallback = if crate::capability::swa_rope_base_follows_model(&arch) {
1161 rope_theta
1162 } else {
1163 10_000.0
1164 };
1165 Some(
1166 metadata_f32_any(
1167 file,
1168 &[key("rope.freq_base_swa"), key("rope_freq_base_swa")],
1169 )
1170 .unwrap_or(fallback),
1171 )
1172 } else {
1173 None
1174 };
1175
1176 let ffn_activation = match arch_profile.family {
1177 // Per-ARCHITECTURE first, because llama.cpp's choice is per
1178 // architecture and the family partition does not match it:
1179 // `grok` is StandardGqa and passes `LLM_FFN_GELU`.
1180 _ if crate::capability::uses_geglu(&arch) => crate::config::FfnActivation::Gelu,
1181 _ if crate::capability::uses_relu_sqr(&arch) => crate::config::FfnActivation::ReluSqr,
1182 _ if crate::capability::uses_gelu_ungated(&arch) => {
1183 crate::config::FfnActivation::GeluUngated
1184 }
1185 // The GATED ReLU (`ggml_reglu_split`), a real gate tensor:
1186 // NOT the row above, which aliases gate to up.
1187 _ if crate::capability::uses_reglu(&arch) => crate::config::FfnActivation::Reglu,
1188 // The four per-layer arrays travel IN the variant, read as
1189 // `apertus.cpp:6-9` reads them (`crate::act_layers`).
1190 _ if crate::act_layers::uses_xielu(&arch) => crate::config::FfnActivation::Xielu(
1191 crate::act_layers::read_xielu_layers(file, trunk.n_layers)?,
1192 ),
1193 // The two clamp arrays, read as `step35.cpp:28-29` read them
1194 // (optional; a file with neither is plain SwiGLU).
1195 _ if crate::act_layers::reads_swiglu_clamps(&arch) => {
1196 match crate::act_layers::read_swiglu_clamps(file, &arch, &trunk)? {
1197 Some(clamps) => crate::config::FfnActivation::SwigluClamped(clamps),
1198 None => crate::config::FfnActivation::Swiglu,
1199 }
1200 }
1201 crate::capability::DecoderFamily::GemmaFamily => crate::config::FfnActivation::Gelu,
1202 crate::capability::DecoderFamily::PhiFamily => {
1203 crate::config::FfnActivation::SwigluFused
1204 }
1205 _ => crate::config::FfnActivation::Swiglu,
1206 };
1207
1208 // Llama 3/3.1/3.2's real per-band RoPE frequency correction: one
1209 // model-level tensor (`TENSOR_NOT_REQUIRED`, `TENSOR_DUPLICATED`
1210 // for every layer but the first in the real llama.cpp source --
1211 // i.e. every layer shares this same array), not per-layer. See
1212 // `frink_core::attention::apply_rope_with_freq_factors`'s doc
1213 // comment for why this matters.
1214 let rope_freqs = load_f32_vec_optional(file, "rope_freqs.weight")?;
1215
1216 // Phi-3/Phi-4 LongRoPE: two per-band factor tensors instead of
1217 // Llama's single `rope_freqs.weight`, selected by context size
1218 // (llama.cpp `llama_model::get_rope_factors`: `rope_freqs` wins if
1219 // present, else `rope_long` when the run's context exceeds
1220 // `rope.scaling.original_context_length`, else `rope_short`).
1221 //
1222 // Provisional pick from the checkpoint's advertised context length
1223 // (llama.cpp's default `n_ctx`). The definitive pick happens in
1224 // `ModelConfig::apply_runtime_context`, called from `frink run`
1225 // (`--ctx-size`) and from `verify_engine::load_and_tokenize`
1226 // (`n_tokens + 8`, matching `tools/llama_logits.c`).
1227 let rope_orig_ctx = metadata_u64_any(file, &[key("rope.scaling.original_context_length")])
1228 .map(|v| v as usize);
1229
1230 // The per-position attention temperature (`crate::attn_temperature`).
1231 // `mistral3.cpp:15` floors it on `hparams.n_ctx_orig_yarn`, which
1232 // `llama-model.cpp:1164-1165` seeds from `context_length` BEFORE
1233 // the YaRN key overrides it -- so a Ministral file with no YaRN
1234 // key floors on its context length, and the resolver is handed
1235 // that value rather than the key. Before this existed the key
1236 // loaded and was silently dropped on the one generic-path
1237 // architecture whose graph applies it.
1238 let attn_temperature = crate::attn_temperature::resolve_attn_temperature(
1239 &arch,
1240 crate::attn_temperature::DeclaredTemperature {
1241 scale: metadata_f32_any(file, &[key("attention.temperature_scale")]),
1242 length: metadata_u64_any(file, &[key("attention.temperature_length")]),
1243 n_ctx_orig_yarn: rope_orig_ctx
1244 .map(|v| v as u64)
1245 .or_else(|| metadata_u64_any(file, &[key("context_length")])),
1246 },
1247 )
1248 .map_err(|e| LoadError::UnsupportedFeature(arch.clone(), e.message(&arch)))?;
1249 // `rope_freqs.weight` outranks the LongRoPE pair (llama.cpp
1250 // `get_rope_factors` checks it first), so a checkpoint carrying
1251 // it never populates these and the runtime re-pick below cannot
1252 // overwrite a Llama-3 correction with a Phi one.
1253 let (rope_freqs_long, rope_freqs_short) = if rope_freqs.is_some() {
1254 (None, None)
1255 } else {
1256 (
1257 load_f32_vec_optional(file, "rope_factors_long.weight")?,
1258 load_f32_vec_optional(file, "rope_factors_short.weight")?,
1259 )
1260 };
1261 // Provisional pick from the checkpoint's own advertised context;
1262 // `ModelConfig::apply_runtime_context` re-picks once the run's
1263 // `--ctx-size` is known, which is the number llama.cpp decides on.
1264 let rope_freqs = match (rope_freqs, rope_orig_ctx) {
1265 (Some(f), _) => Some(f),
1266 (None, Some(orig)) => {
1267 let model_ctx = metadata_u64_any(file, &[key("context_length")])
1268 .unwrap_or(orig as u64) as usize;
1269 if model_ctx > orig {
1270 rope_freqs_long.clone().or_else(|| rope_freqs_short.clone())
1271 } else {
1272 rope_freqs_short.clone().or_else(|| rope_freqs_long.clone())
1273 }
1274 }
1275 (None, None) => None,
1276 };
1277
1278 // Partial rotary: the file's `rope.dimension_count`, or the head
1279 // width when absent -- llama.cpp's seeded `n_rot_full`
1280 // (`llama-model.cpp:1200-1202`).
1281 let rope_dim_seeded = metadata_u64_any(file, &[key("rope.dimension_count")])
1282 .map(|d| d as usize)
1283 .filter(|d| *d > 0)
1284 .unwrap_or(head_dim);
1285
1286 // The sliding layers' OWN rotary and head widths
1287 // (`llama-model.cpp:1215-1223`). The rotary one is honoured --
1288 // `crate::swa_geometry` resolves the key and step35's halving
1289 // into the two widths `ModelConfig::layer_rope` hands out -- and
1290 // the head ones are refused, since frink carries one head width
1291 // in every cache. Only a model with a sliding layer reads the
1292 // keys; on one that has none they are dead metadata, as they
1293 // are upstream (`n_rot(il)` never takes the `_swa` branch). The
1294 // halving is not a key and applies regardless.
1295 let geometry = crate::swa_geometry::SwaGeometry {
1296 rope_dim_swa: metadata_u64_any(file, &[key("rope.dimension_count_swa")]),
1297 key_length_swa: metadata_u64_any(file, &[key("attention.key_length_swa")]),
1298 value_length_swa: metadata_u64_any(file, &[key("attention.value_length_swa")]),
1299 rope_dim_full: rope_dim_seeded as u64,
1300 head_dim: head_dim as u64,
1301 };
1302 let widths = if sliding_window.is_some()
1303 || crate::swa_geometry::full_layers_rotate_half(&arch).is_some()
1304 {
1305 if let Some(reason) = crate::swa_geometry::swa_geometry_refusal(&arch, geometry) {
1306 return Err(LoadError::UnsupportedFeature(arch.clone(), reason));
1307 }
1308 crate::swa_geometry::rotary_widths(&arch, geometry)
1309 } else {
1310 crate::swa_geometry::RotaryWidths {
1311 full: Some(rope_dim_seeded).filter(|d| *d < head_dim),
1312 swa: None,
1313 }
1314 };
1315 // Equal values mean "whole head", which is the same thing as
1316 // `None` and stays `None` so nothing downstream has to
1317 // special-case it.
1318 let rope_dim = widths.full;
1319 let rope_dim_swa = widths.swa;
1320
1321 // See `ModelConfig::rope_attn_factor`. `mut` because YaRN's
1322 // magnitude term is folded into it below.
1323 let mut rope_attn_factor = metadata_f32_any(file, &[key("rope.scaling.attn_factor")])
1324 .filter(|f| f.is_finite() && *f > 0.0)
1325 .unwrap_or(1.0);
1326
1327 // YaRN long-context scaling. `rope.scaling.attn_factor` above is
1328 // only YaRN's *magnitude* term (ggml `rope_yarn`'s `mscale`); the
1329 // frequency half -- which bands get interpolated toward the
1330 // trained context and which stay extrapolated -- lives in
1331 // `rope.scaling.type` + `rope.scaling.factor`, and frink read
1332 // neither before this. A YaRN checkpoint was therefore roped as
1333 // if it declared no scaling at all: right near position 0 and
1334 // progressively wrong further in, i.e. the failure that reads as
1335 // long-prompt quality decay rather than as a bug.
1336 //
1337 // The rewrite is folded into `rope_freqs`, the same per-band
1338 // divisor array Llama-3's `rope_freqs.weight` supplies (ggml
1339 // divides each band's theta by it), so it rides the existing CPU
1340 // and Metal RoPE paths unchanged. When a file carries both, the
1341 // two corrections compose by multiplication, as they do in
1342 // llama.cpp (`ggml_rope_cache_init` divides by `freq_factors`
1343 // *and then* runs `rope_yarn`).
1344 // Linear scaling, which was silently DROPPED before this.
1345 //
1346 // `rope.scaling.type = "linear"` with factor s means rotating
1347 // position `p/s` instead of `p`. Since the angle is `p * freq`,
1348 // that is exactly `p * (freq / s)`, and `rope_freqs` already
1349 // divides each band's frequency. So a uniform vector of `s`
1350 // expresses it exactly and rides the existing CPU and Metal RoPE
1351 // paths unchanged, the same way YaRN does below.
1352 //
1353 // Before this, the type was compared against "yarn" and anything
1354 // else returned None, so a checkpoint declaring linear scaling
1355 // with factor 4 loaded and roped at UNSCALED positions where
1356 // llama.cpp divides them by 4. It answered as a different model
1357 // with no error. Affects the long-context community rescales
1358 // (`*-16k`, `*-32k` Llama-2 derivatives).
1359
1360 // The file's own per-band factors, BEFORE any position-scaling
1361 // fold. That is what a sliding layer uses on an architecture
1362 // whose SWA layers do not inherit the trained scale -- llama.cpp
1363 // keeps the two apart as `freq_factors` (a tensor, the same for
1364 // every layer) and `freq_scale` (per layer,
1365 // `llama-model.cpp:2033`), while frink folds them into one
1366 // vector. See `config::RopeFreqs`.
1367 let rope_freqs_unscaled = rope_freqs.clone();
1368
1369 let rope_freqs = match linear_scaling_from_gguf(file, &arch) {
1370 None => rope_freqs,
1371 Some(factor) => {
1372 let rotary_dim = rope_dim.unwrap_or(head_dim);
1373 if rotary_dim == 0 || !rotary_dim.is_multiple_of(2) {
1374 best_effort_fields.push(
1375 "rope_freqs (linear scaling declared but the rotary width is odd; \
1376 scaling not applied)",
1377 );
1378 rope_freqs
1379 } else {
1380 let linear = vec![factor; rotary_dim / 2];
1381 match rope_freqs {
1382 None => Some(linear),
1383 // Compose by multiplication, as a file carrying
1384 // its own `rope_freqs.weight` tensor and a
1385 // declared linear factor means both.
1386 Some(own) if own.len() == linear.len() => {
1387 Some(own.iter().zip(linear.iter()).map(|(a, b)| a * b).collect())
1388 }
1389 Some(own) => {
1390 best_effort_fields.push(
1391 "rope_freqs (linear scaling declared but the file's own \
1392 rope_freqs tensor has a different width; scaling not applied)",
1393 );
1394 Some(own)
1395 }
1396 }
1397 }
1398 }
1399 };
1400 let rope_freqs = match yarn_scaling_from_gguf(file, &arch, rope_orig_ctx) {
1401 None => rope_freqs,
1402 Some(scaling) => {
1403 // YaRN's MAGNITUDE half (`crate::yarn_magnitude`):
1404 // llama.cpp multiplies the rotated channels of q and k
1405 // by `get_mscale(factor, 1) / get_mscale(factor,
1406 // log_mul)` on top of `rope.scaling.attn_factor`
1407 // (`llama-context.cpp:196-231` with ggml's `rope_yarn`
1408 // term cancelled), and frink applied only the key.
1409 // Folded into the same field so it reaches the CPU
1410 // helper and the Metal `mscale` uniform through one
1411 // value. Gated on the same `Some(scaling)` as the
1412 // frequency half, so a file frink does not rewrite
1413 // (no `original_context_length`) takes neither half.
1414 rope_attn_factor *= crate::yarn_magnitude::yarn_attn_magnitude(
1415 scaling.factor,
1416 crate::yarn_magnitude::yarn_log_mul_for(
1417 &arch,
1418 metadata_f32_any(file, &[key("rope.scaling.yarn_log_multiplier")]),
1419 ),
1420 );
1421 let rotary_dim = rope_dim.unwrap_or(head_dim);
1422 if rotary_dim == 0 || !rotary_dim.is_multiple_of(2) {
1423 best_effort_fields.push(
1424 "rope_freqs (YaRN declared but the rotary width is odd; scaling not applied)",
1425 );
1426 rope_freqs
1427 } else {
1428 let yarn =
1429 frink_core::attention::yarn_freq_factors(scaling, rotary_dim, rope_theta);
1430 match rope_freqs {
1431 None => Some(yarn),
1432 Some(own) if own.len() == yarn.len() => {
1433 Some(own.iter().zip(yarn.iter()).map(|(a, b)| a * b).collect())
1434 }
1435 Some(own) => {
1436 best_effort_fields.push(
1437 "rope_freqs (YaRN declared alongside a per-band factor tensor of a \
1438 different width; the file's own tensor is used unscaled)",
1439 );
1440 Some(own)
1441 }
1442 }
1443 }
1444 }
1445 };
1446
1447 // The SWA half of the split. llama.cpp defaults
1448 // `rope_freq_scale_train_swa` to `1.0f`
1449 // (`src/llama-hparams.h:129`) and only the architectures in
1450 // `swa_rope_scale_follows_model` assign it from
1451 // `rope_freq_scale_train`; `get_rope_freq_scale`
1452 // (`llama-model.cpp:2033-2035`) then picks between them per
1453 // layer. `gemma3.cpp` is not on that list and its converter
1454 // writes the FULL-ATTENTION factor
1455 // (`conversion/base.py:1222-1230`), so a Gemma-3 4B/12B/27B was
1456 // rotating five layers in six at `p/8` where llama.cpp rotates
1457 // at `p`.
1458 //
1459 // "No scaling" is spelled as an all-ones divisor vector, which
1460 // is what dividing by nothing is, so the sliding layers need no
1461 // second code path anywhere downstream.
1462 // With TWO rotary widths, one divisor vector cannot serve both
1463 // kinds of layer. `step35.cpp:247` passes NO factors to its
1464 // sliding layers (`crate::swa_geometry::swa_layers_drop_rope_
1465 // factors`), so for it the full layers take the first
1466 // `rope_dim/2` bands of the tensor -- ggml reads only that many
1467 // -- and the sliding layers divide by nothing at their own
1468 // width; every other architecture is refused by name, because
1469 // nothing upstream says which layers would take which.
1470 if rope_freqs.is_some() {
1471 if let Some(reason) =
1472 crate::swa_geometry::two_widths_with_factors_refusal(&arch, widths)
1473 {
1474 return Err(LoadError::UnsupportedFeature(arch.clone(), reason));
1475 }
1476 }
1477 let rope_freqs = rope_freqs
1478 .map(|full| -> Result<crate::config::RopeFreqs, LoadError> {
1479 if let Some(swa_width) = rope_dim_swa {
1480 let full_width = rope_dim.unwrap_or(head_dim);
1481 if full.len() < full_width / 2 {
1482 return Err(LoadError::UnsupportedFeature(
1483 arch.clone(),
1484 format!(
1485 "rope_freqs.weight has {} bands; the full-attention layers rotate \
1486 {full_width} dims and need {}",
1487 full.len(),
1488 full_width / 2
1489 ),
1490 ));
1491 }
1492 let full: Vec<f32> = full[..full_width / 2].to_vec();
1493 return Ok(crate::config::RopeFreqs {
1494 full,
1495 swa: Some(vec![1.0; swa_width / 2]),
1496 });
1497 }
1498 let swa = (sliding_window.is_some()
1499 && !crate::capability::swa_rope_scale_follows_model(&arch))
1500 .then(|| rope_freqs_unscaled.unwrap_or_else(|| vec![1.0; full.len()]))
1501 .filter(|swa| *swa != full);
1502 Ok(crate::config::RopeFreqs { full, swa })
1503 })
1504 .transpose()?;
1505
1506 // RoPE layout comes from the capability registry above (fail-
1507 // closed). Getting this wrong for `llama` (needs Norm) was the
1508 // real root cause of the Llama-3.1-8B early-stop/wrong-logits bug.
1509
1510 if best_effort_fields.is_empty() {
1511 best_effort_fields.push(
1512 "none -- every field above was read directly from this file's own GGUF metadata",
1513 );
1514 }
1515
1516 // LAST, deliberately. The generic path is a GUESS, so it has to
1517 // be opted into rather than fallen onto: it assumes plain GQA
1518 // with no ALiBi, no learned position embeddings and no
1519 // per-layer rope skipping, and that assumption was already
1520 // wrong for gpt2, mpt, refact, bloom and jais.
1521 //
1522 // But it runs AFTER every architecture-specific refusal, so a
1523 // checkpoint with a NAMED problem still reports that problem.
1524 // Checking first would have replaced "this uses ALiBi" with
1525 // "this is unaudited", which is true and much less useful.
1526 if matches!(
1527 arch_profile.path,
1528 crate::capability::ArchPath::GenericGqa { .. }
1529 ) && !crate::capability::is_audited_generic(&arch)
1530 && !matches!(
1531 std::env::var("FRINK_ALLOW_UNAUDITED_ARCH").ok().as_deref(),
1532 Some("1") | Some("true") | Some("on")
1533 )
1534 {
1535 return Err(LoadError::UnauditedArchitecture(
1536 arch.clone(),
1537 rope_layout,
1538 crate::capability::unaudited_refusal_detail(&arch),
1539 ));
1540 }
1541
1542 Ok(ModelConfig {
1543 name,
1544 n_layers,
1545 n_mtp_blocks: trunk.n_mtp_blocks,
1546 layer_loops,
1547 skip_stream: crate::skip_stream::has_skip_stream(&arch),
1548 parallel_ssm: crate::mamba2::parallel_with_attention(&arch),
1549 swa_chunked,
1550 weightless_qk_norm: crate::weightless_qk_norm::weightless_qk_norm(&arch, n_experts),
1551 hidden_dim,
1552 n_heads,
1553 n_kv_heads,
1554 head_dim,
1555 v_head_dim,
1556 vocab_size,
1557 rope_theta,
1558 rms_norm_eps,
1559 // `crate::norm::POST_NORM_EPS_LITERAL`: the architecture's, which
1560 // is the model's for all but one graph of 155.
1561 post_norm_eps: crate::norm::post_norm_eps(&arch, rms_norm_eps),
1562 // No GGUF file encodes a hybrid KDA/Gated-MLA attention
1563 // topology today; every real checkpoint loaded this way
1564 // runs the standard Gqa path.
1565 attention: crate::config::AttentionKind::Gqa,
1566 sliding_window,
1567 swa_layers,
1568 // llama.cpp's per-layer `use_rope`. Fed the POST-gate window
1569 // answer (`sliding_window`, not the raw key), because
1570 // `exaone4` decides both off the same layer count and the
1571 // two must not be able to disagree.
1572 rope_layers: if rope_switched_off {
1573 crate::rope_layers::RopeLayers::Never
1574 } else if let Some(mask) = rope_pattern.clone() {
1575 // The FILE's answer, for the one architecture that
1576 // reads the key (`rope_layers::ROPE_PATTERN_READERS`).
1577 crate::rope_layers::RopeLayers::FileMask(mask)
1578 } else {
1579 crate::rope_layers::rope_layers(
1580 &arch,
1581 n_layers,
1582 sliding_window.is_some(),
1583 n_dense_leading_layers,
1584 )
1585 },
1586 router_input: crate::router_input::router_input(&arch),
1587 block_sub_norms: crate::sub_norms::block_sub_norms(&arch),
1588 parallel_residual: crate::parallel_residual::model_has_parallel_layer(
1589 file, &arch, n_layers,
1590 ),
1591 learned_positions: crate::position_embd::learned_positions(&arch),
1592 attn_value_scale: crate::attn_value_scale::resolve_attn_value_scale(
1593 &arch,
1594 file.metadata_f32(&key("attention.value_scale")),
1595 ),
1596 alibi_max_bias: crate::alibi::max_alibi_bias(
1597 &arch,
1598 n_layers,
1599 file.metadata_f32(&key("attention.max_alibi_bias")),
1600 ),
1601 layer_shapes,
1602 moe: MoeLayerConfig {
1603 n_experts: n_experts.max(1),
1604 n_experts_active,
1605 n_shared_experts,
1606 hidden_dim,
1607 expert_ffn_dim,
1608 gating,
1609 norm_topk_prob,
1610 expert_group_count: metadata_u64_any(file, &[key("expert_group_count")])
1611 .map(|v| v as usize)
1612 .filter(|&c| c > 1),
1613 expert_group_used_count: metadata_u64_any(file, &[key("expert_group_used_count")])
1614 .map(|v| v as usize)
1615 .filter(|&c| c > 0),
1616 expert_weights_scale,
1617 routed_weight_before_ffn: crate::routed_weight_site::weight_before_ffn(&arch),
1618 },
1619 n_dense_leading_layers,
1620 moe_interleave_step,
1621 norm_function,
1622 rope_freqs,
1623 rope_layout,
1624 qk_norm_style,
1625 attn_logit_softcap,
1626 final_logit_softcap,
1627 embedding_scale,
1628 residual_scale: multipliers.residual_scale,
1629 normed_residual_scale: multipliers.normed_residual_scale,
1630 clamp_kqv,
1631 attn_temperature,
1632 logit_multiplier: multipliers.logit_multiplier,
1633 attention_scale,
1634 rope_attn_factor,
1635 rope_dim,
1636 rope_dim_swa,
1637 rope_freqs_long,
1638 rope_freqs_short,
1639 rope_orig_ctx,
1640 rope_theta_swa,
1641 ffn_activation,
1642 best_effort_fields: Box::leak(best_effort_fields.into_boxed_slice()),
1643 })
1644 }
1645}
1646
1647impl crate::sampling::RecommendedSampling {
1648 /// The sampling a GGUF recommends for itself, from the
1649 /// `general.sampling.*` metadata keys llama.cpp's converter writes
1650 /// when the source checkpoint carried a `generation_config.json`.
1651 ///
1652 /// This is the GGUF half of FreeToken's `load_generation_sampling`
1653 /// (`python/freetoken/utils/hf.py:92`), which checks the GGUF
1654 /// metadata *first* and only falls back to a `generation_config.json`
1655 /// sidecar for non-GGUF checkpoints -- a GGUF is a single file and
1656 /// has no sidecar to read.
1657 ///
1658 /// Key names are llama.cpp's own (`general.sampling.temp`, not
1659 /// `temperature`). Each key is independent: a file that names only
1660 /// `top_k` recommends only `top_k`, and the two fields it did not
1661 /// mention stay `None` so the server's own defaults keep speaking
1662 /// for them.
1663 ///
1664 /// `temp` / `top_p` are read as float *or* integer, because a
1665 /// converter that wrote `temp = 1` stores a GGUF integer and
1666 /// dropping that value would silently serve the checkpoint greedy --
1667 /// the exact repetition-loop failure the recommendation exists to
1668 /// prevent.
1669 pub fn from_gguf(file: &impl TensorSource) -> Self {
1670 let number = |k: &str| -> Option<f32> {
1671 file.metadata(k)
1672 .and_then(|v| v.as_f32().or_else(|| v.as_u64().map(|u| u as f32)))
1673 };
1674 crate::sampling::RecommendedSampling {
1675 temperature: number("general.sampling.temp"),
1676 top_p: number("general.sampling.top_p"),
1677 top_k: file
1678 .metadata("general.sampling.top_k")
1679 .and_then(|v| v.as_u64())
1680 .map(|v| v as usize),
1681 }
1682 }
1683}
1684
1685/// The `linear` RoPE scaling factor, if this file declares one.
1686///
1687/// Deliberately separate from [`yarn_scaling_from_gguf`]: YaRN needs an
1688/// original context length and per-band betas, and linear needs neither.
1689/// Any factor at or below one is not a correction, and is treated as
1690/// absent rather than applied as a no-op.
1691fn linear_scaling_from_gguf(file: &impl TensorSource, arch: &str) -> Option<f32> {
1692 let key = |suffix: &str| format!("{arch}.{suffix}");
1693 let scaling_type = file.metadata_str(&key("rope.scaling.type"))?;
1694 if !scaling_type.eq_ignore_ascii_case("linear") {
1695 return None;
1696 }
1697 metadata_f32_any(file, &[key("rope.scaling.factor")]).filter(|f| f.is_finite() && *f > 1.0)
1698}
1699
1700/// The YaRN RoPE scaling a GGUF declares, or `None` when this file
1701/// declares none that changes the rotation.
1702///
1703/// llama.cpp's key names (`llama-arch.cpp`
1704/// `LLM_KV_ROPE_SCALING_TYPE` / `_FACTOR`): `<arch>.rope.scaling.type`
1705/// is a string (`"none"`, `"linear"`, `"yarn"`, `"longrope"`) and
1706/// `<arch>.rope.scaling.factor` the ratio of served to trained context.
1707/// `beta_fast` / `beta_slow` are read from both the plain and the
1708/// `yarn_`-prefixed spelling and otherwise fall back to the reference's
1709/// own defaults (32.0 / 1.0), which is what a real checkpoint relies on
1710/// -- almost none of them write those two keys.
1711///
1712/// `None` is returned for every case where applying YaRN would be a
1713/// guess or a no-op rather than a correction, so that no checkpoint's
1714/// rotation moves without the file having asked for it:
1715///
1716/// * a scaling type other than `yarn` (`linear` divides positions,
1717/// `longrope` rides the `rope_factors_long`/`_short` tensors this
1718/// loader already reads -- neither is this rewrite, and treating them
1719/// as YaRN would rope them wrong in a *new* way instead of leaving
1720/// them as they are),
1721/// * a missing, non-finite or `<= 1.0` factor (the reference's own
1722/// `get_mscale` treats `scale <= 1` as unscaled, and a factor of 1.0
1723/// makes every band's divisor exactly 1.0 anyway),
1724/// * a missing `rope.scaling.original_context_length` -- the trained
1725/// context is what the correction range is measured against, and
1726/// inventing one (say, from `context_length`, which on a YaRN file is
1727/// the *extended* length) would put the ramp in the wrong place and
1728/// quietly rope the checkpoint at frequencies nobody trained.
1729pub(crate) fn yarn_scaling_from_gguf(
1730 file: &impl TensorSource,
1731 arch: &str,
1732 orig_ctx: Option<usize>,
1733) -> Option<frink_core::attention::YarnScaling> {
1734 let key = |suffix: &str| format!("{arch}.{suffix}");
1735 let scaling_type = file.metadata_str(&key("rope.scaling.type"))?;
1736 if !scaling_type.eq_ignore_ascii_case("yarn") {
1737 return None;
1738 }
1739 let factor = metadata_f32_any(file, &[key("rope.scaling.factor")])
1740 .filter(|f| f.is_finite() && *f > 1.0)?;
1741 let orig_max_pos = orig_ctx?;
1742 let beta = |suffix: &str, default: f32| -> f32 {
1743 metadata_f32_any(
1744 file,
1745 &[
1746 key(&format!("rope.scaling.{suffix}")),
1747 key(&format!("rope.scaling.yarn_{suffix}")),
1748 ],
1749 )
1750 .filter(|v| v.is_finite() && *v > 0.0)
1751 .unwrap_or(default)
1752 };
1753 // `llama-hparams.h:137` seeds `yarn_beta_fast = 32.0f` for every
1754 // architecture and `grok.cpp:5` reseeds it to 8.0 before the key is
1755 // read; the table that holds Grok's other defaults holds that one
1756 // too, so it is not a second literal here.
1757 let beta_fast_default = crate::scalar_multipliers::multiplier_support(arch)
1758 .defaults
1759 .yarn_beta_fast()
1760 .unwrap_or(32.0);
1761 Some(frink_core::attention::YarnScaling {
1762 factor,
1763 beta_fast: beta("beta_fast", beta_fast_default),
1764 beta_slow: beta("beta_slow", 1.0),
1765 orig_max_pos,
1766 // No GGUF key carries the reference's `truncate` flag, and its
1767 // default is `true`; a file that wanted the fractional range
1768 // would have no way to say so here.
1769 truncate: true,
1770 })
1771}
1772
1773pub(crate) fn find_info<'a>(
1774 file: &'a impl TensorSource,
1775 name: &str,
1776) -> Result<&'a TensorInfo, LoadError> {
1777 file.find_tensor(name)
1778 .ok_or_else(|| LoadError::Gguf(GgufError::TensorNotFound(name.to_string())))
1779}
1780
1781/// Like `load_f32_vec`, but for tensors that only exist on some
1782/// checkpoints (e.g. `attn_q_norm`/`attn_k_norm` -- OLMoE-style
1783/// per-projection QK-RMSNorm applied to the full q_proj/k_proj output
1784/// before RoPE, confirmed against `OlmoeAttention.forward` in
1785/// `transformers/models/olmoe/modeling_olmoe.py`: `q_norm(q_proj(x))`,
1786/// `k_norm(k_proj(x))`, both plain RMSNorm over the whole projected
1787/// width, not per-head). Absent for every other preset/fixture this
1788/// loader already handles -- `None` there is correct, not a missing
1789/// feature.
1790/// Loads the four gpt-oss-only side-table tensors for one layer, and
1791/// checks that the fifth, the attention sinks, was loaded onto the
1792/// layer's [`AttnWeights`] by the generic tensor-presence read.
1793///
1794/// Every one of them is **required**: a gpt-oss checkpoint that is
1795/// missing any of these is not a gpt-oss checkpoint frink can run, and
1796/// quietly substituting zeros would reintroduce exactly the
1797/// silently-wrong-graph failure this path exists to remove. The lengths
1798/// are asserted against the config for the same reason -- a bias of the
1799/// wrong width would otherwise be applied to a `zip`-truncated prefix
1800/// and produce a plausible, wrong answer.
1801///
1802/// Shapes follow `src/models/openai-moe.cpp::load_arch_tensors`:
1803/// `attn_sinks {n_head}` (`:44`, flags `0`, so REQUIRED there too),
1804/// `attn_output.bias {n_embd}`, `ffn_gate_inp.bias {n_expert}`,
1805/// `ffn_{gate,up}_exps.bias {n_ff_exp, n_expert}`,
1806/// `ffn_down_exps.bias {n_embd, n_expert}`. GGUF stores the fastest
1807/// dimension first, so the 2-D bias tensors arrive expert-major and
1808/// split by simple chunking.
1809fn load_gpt_oss_layer(
1810 file: &impl TensorSource,
1811 l: usize,
1812 config: &ModelConfig,
1813 sinks_loaded: bool,
1814) -> Result<crate::decoder::GptOssLayer, LoadError> {
1815 let n_experts = config.moe.n_experts;
1816 let ff = config.moe.expert_ffn_dim;
1817
1818 let want = |name: &str, got: usize, expect: usize| -> Result<(), LoadError> {
1819 if got == expect {
1820 Ok(())
1821 } else {
1822 Err(LoadError::UnsupportedFeature(
1823 config.name.to_string(),
1824 format!("{name} has {got} elements, expected {expect}"),
1825 ))
1826 }
1827 };
1828
1829 if !sinks_loaded {
1830 return Err(LoadError::UnsupportedFeature(
1831 config.name.to_string(),
1832 format!(
1833 "blk.{l}.attn_sinks.weight is missing; gpt-oss requires it \
1834 (src/models/openai-moe.cpp:44) and llama.cpp refuses the file without it"
1835 ),
1836 ));
1837 }
1838 // `attn_output.bias` is `AttnWeights::o_bias` now, read by
1839 // `crate::proj_bias` (gpt-oss is a REQUIRED row of its table).
1840 let router_bias = load_f32_vec(file, &format!("blk.{l}.ffn_gate_inp.bias"))?;
1841 want(
1842 &format!("blk.{l}.ffn_gate_inp.bias"),
1843 router_bias.len(),
1844 n_experts,
1845 )?;
1846
1847 let gate_b = load_f32_vec(file, &format!("blk.{l}.ffn_gate_exps.bias"))?;
1848 want(
1849 &format!("blk.{l}.ffn_gate_exps.bias"),
1850 gate_b.len(),
1851 n_experts * ff,
1852 )?;
1853 let up_b = load_f32_vec(file, &format!("blk.{l}.ffn_up_exps.bias"))?;
1854 want(
1855 &format!("blk.{l}.ffn_up_exps.bias"),
1856 up_b.len(),
1857 n_experts * ff,
1858 )?;
1859 let down_b = load_f32_vec(file, &format!("blk.{l}.ffn_down_exps.bias"))?;
1860 want(
1861 &format!("blk.{l}.ffn_down_exps.bias"),
1862 down_b.len(),
1863 n_experts * config.hidden_dim,
1864 )?;
1865
1866 let expert_bias = (0..n_experts)
1867 .map(|e| frink_moe::ExpertBias {
1868 gate: gate_b[e * ff..(e + 1) * ff].to_vec(),
1869 up: up_b[e * ff..(e + 1) * ff].to_vec(),
1870 down: down_b[e * config.hidden_dim..(e + 1) * config.hidden_dim].to_vec(),
1871 })
1872 .collect();
1873
1874 Ok(crate::decoder::GptOssLayer {
1875 router_bias,
1876 expert_bias,
1877 })
1878}
1879
1880/// `blk.N.attn_sinks.weight` when the file carries it, checked to be
1881/// one logit per query head of THIS layer (`{n_head}` in every graph
1882/// that creates it: `openai-moe.cpp:44`, `mimo2.cpp:58`).
1883///
1884/// Optional here because that is what the tensor's consumers make it:
1885/// `build_attn_mha` takes a nullable `sinks` and `mimo2.cpp:58` creates
1886/// it `TENSOR_NOT_REQUIRED`. gpt-oss, which requires it, checks the
1887/// result where its side table loads.
1888fn load_attn_sinks(
1889 file: &impl TensorSource,
1890 l: usize,
1891 n_heads: usize,
1892) -> Result<Option<Vec<f32>>, LoadError> {
1893 let name = format!("blk.{l}.attn_sinks.weight");
1894 let Some(sinks) = load_f32_vec_optional(file, &name)? else {
1895 return Ok(None);
1896 };
1897 if sinks.len() != n_heads {
1898 return Err(LoadError::UnsupportedFeature(
1899 name,
1900 format!(
1901 "attention sinks are one logit per query head; this layer has {n_heads} heads \
1902 and the tensor {} entries",
1903 sinks.len()
1904 ),
1905 ));
1906 }
1907 Ok(Some(sinks))
1908}
1909
1910pub(crate) fn load_f32_vec_optional(
1911 file: &impl TensorSource,
1912 name: &str,
1913) -> Result<Option<Vec<f32>>, LoadError> {
1914 if file.find_tensor(name).is_none() {
1915 return Ok(None);
1916 }
1917 Ok(Some(load_f32_vec(file, name)?))
1918}
1919
1920/// Slice `n` rows starting at `start` out of a quantized matrix without
1921/// dequantizing: every `Quantized` kind stores one interleaved block
1922/// buffer per row (fixed `row_bytes`), so a row range is a contiguous
1923/// byte range. Mapped sources stay zero-copy (sub-range of the same
1924/// mmap); other backings get an owned copy. Returns `None` for non-
1925/// quantized matrices (F32 / MXFP4) -- callers fall back to dequant.
1926pub(crate) fn slice_quantized_rows(
1927 m: &WeightMatrix,
1928 start: usize,
1929 n: usize,
1930) -> Option<WeightMatrix> {
1931 // A folded fused projection splits into folded parts sharing ONE
1932 // fold: the input transform is the same for q, k and v, and
1933 // `apply_gpu_multi` recognises the shared `Arc`.
1934 if let WeightMatrix::Folded { base, fold } = m {
1935 let mut part = slice_quantized_rows(base, start, n)?;
1936 part.fold_hadamard(fold.clone());
1937 return Some(part);
1938 }
1939 let WeightMatrix::Quantized {
1940 data,
1941 rows,
1942 cols,
1943 kind,
1944 } = m
1945 else {
1946 return None;
1947 };
1948 let total = data.len();
1949 if *rows == 0 || total % *rows != 0 || start + n > *rows {
1950 return None;
1951 }
1952 let row_bytes = total / *rows;
1953 let (b0, b1) = (start * row_bytes, (start + n) * row_bytes);
1954 let bytes = match data {
1955 WeightBytes::Mapped { mmap, range } => WeightBytes::Mapped {
1956 mmap: mmap.clone(),
1957 range: range.start + b0..range.start + b1,
1958 },
1959 other => WeightBytes::Owned(other.as_slice()[b0..b1].to_vec()),
1960 };
1961 Some(WeightMatrix::Quantized {
1962 data: bytes,
1963 rows: n,
1964 cols: *cols,
1965 kind: *kind,
1966 })
1967}
1968
1969/// Dense-layer FFN tensors: standard gate/up/down, Phi-3 fused
1970/// `ffn_up` with `2 * ffn_dim` rows and no separate gate, the UNGATED
1971/// two-matrix FFN (`FfnActivation::ReluSqr`), or nothing at all for an
1972/// FFN-free layer (`ffn_dim == 0`).
1973///
1974/// `ffn_dim` is THIS layer's width (`ModelConfig::layer_shape`), which
1975/// is the model's for every architecture but the per-layer ones.
1976fn load_dense_expert(
1977 file: &impl TensorSource,
1978 layer: usize,
1979 config: &ModelConfig,
1980 ffn_dim: usize,
1981) -> Result<ExpertWeights, LoadError> {
1982 if ffn_dim == 0 {
1983 return Ok(crate::layer_shapes::absent_ffn(config.hidden_dim));
1984 }
1985 let gate_name = format!("blk.{layer}.ffn_gate.weight");
1986 let up_name = format!("blk.{layer}.ffn_up.weight");
1987 let down_name = format!("blk.{layer}.ffn_down.weight");
1988 if config.ffn_is_ungated() {
1989 // `arcee.cpp:39-40` and `apertus.cpp:45-46` create `ffn_up` and
1990 // `ffn_down` and no gate; a file carrying one describes a graph
1991 // this architecture does not compute, and would otherwise be
1992 // left as an unread tensor with a less specific message.
1993 if file.find_tensor(&gate_name).is_some() {
1994 return Err(LoadError::UnsupportedFeature(
1995 config.name.to_string(),
1996 format!(
1997 "{gate_name} is present but this architecture's FFN is ungated \
1998 ({:?}: LLM_FFN_RELU_SQR under LLM_FFN_SEQ with a null gate, \
1999 arcee.cpp:123-128, or ggml_xielu over ffn_up alone, apertus.cpp:129-142)",
2000 config.ffn_activation
2001 ),
2002 ));
2003 }
2004 let up = load_weight_matrix(file, &up_name)?;
2005 if up.rows() != ffn_dim {
2006 return Err(LoadError::UnsupportedFeature(
2007 config.name.to_string(),
2008 format!(
2009 "{up_name} has {} rows; the ungated FFN expects feed_forward_length = \
2010 {ffn_dim}",
2011 up.rows()
2012 ),
2013 ));
2014 }
2015 // The alias: the same tensor read again. A zero-copy view of the
2016 // same bytes for a quantized mmapped file; an owned widening for
2017 // an F32/F16 one. See `FfnActivation::ReluSqr` for why the pair
2018 // is aliased rather than the struct given an `Option`.
2019 return Ok(ExpertWeights {
2020 gate: load_weight_matrix(file, &up_name)?,
2021 up,
2022 down: load_weight_matrix(file, &down_name)?,
2023 });
2024 }
2025 if file.find_tensor(&gate_name).is_some() {
2026 return Ok(ExpertWeights {
2027 gate: load_weight_matrix(file, &gate_name)?,
2028 up: load_weight_matrix(file, &up_name)?,
2029 down: load_weight_matrix(file, &down_name)?,
2030 });
2031 }
2032 // Phi-3 fused SwiGLU: up is [hidden, 2*ff], first half gate, second up.
2033 let fused = load_weight_matrix(file, &up_name)?;
2034 let ff = ffn_dim;
2035 if fused.rows() != 2 * ff {
2036 return Err(LoadError::UnsupportedFeature(
2037 config.name.to_string(),
2038 format!(
2039 "{up_name} has {} rows without a companion ffn_gate; \
2040 expected fused SwiGLU with 2*ffn_dim = {} rows",
2041 fused.rows(),
2042 2 * ff
2043 ),
2044 ));
2045 }
2046 let cols = fused.cols();
2047 // Quantized fused gate+up: split by rows, no dequant (Metal-capable).
2048 if let (Some(gate), Some(up)) = (
2049 slice_quantized_rows(&fused, 0, ff),
2050 slice_quantized_rows(&fused, ff, ff),
2051 ) {
2052 return Ok(ExpertWeights {
2053 gate,
2054 up,
2055 down: load_weight_matrix(file, &down_name)?,
2056 });
2057 }
2058 let mut full = Vec::with_capacity(fused.rows() * cols);
2059 for r in 0..fused.rows() {
2060 full.extend_from_slice(&fused.dequant_row(r));
2061 }
2062 let gate = WeightMatrix::F32(Tensor::new(full[..ff * cols].to_vec(), vec![ff, cols]));
2063 let up = WeightMatrix::F32(Tensor::new(full[ff * cols..].to_vec(), vec![ff, cols]));
2064 Ok(ExpertWeights {
2065 gate,
2066 up,
2067 down: load_weight_matrix(file, &down_name)?,
2068 })
2069}
2070
2071/// Widen a raw plain-float tensor (`F32` / `F16` / `BF16`) to `f32`.
2072///
2073/// The three unquantized element types are handled identically at every
2074/// call site (eager widening to an owned buffer -- none of them has a
2075/// block structure a fused dot kernel could exploit), and each of the
2076/// seven GGUF loaders used to inline the same two-way match. F16 had no
2077/// arm in any of them, which made every `*-f16.gguf` a hard
2078/// `UnsupportedDtype` even though the type was parsed and sized.
2079pub(crate) fn widen_plain_float(
2080 dtype: GgmlType,
2081 raw: &[u8],
2082 name: &str,
2083) -> Result<Vec<f32>, LoadError> {
2084 match dtype {
2085 GgmlType::F32 => {
2086 let mut out = Vec::with_capacity(raw.len() / 4);
2087 for chunk in raw.as_chunks::<4>().0 {
2088 out.push(f32::from_le_bytes(*chunk));
2089 }
2090 Ok(out)
2091 }
2092 GgmlType::F16 => frink_quant::dequant_f16(raw)
2093 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::F16)),
2094 GgmlType::BF16 => frink_quant::dequant_bf16(raw)
2095 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::BF16)),
2096 // MXFP4 is accepted as a weight matrix and as an MoE expert
2097 // tensor, and `WeightMatrix::dequant` already calls this
2098 // dequantizer, so refusing it here made a 1-D MXFP4 norm or
2099 // bias a hard load error on a checkpoint whose 2-D tensors of
2100 // the same type load fine. That contradicted this function's
2101 // own contract, which is to widen whatever the loaders accept.
2102 GgmlType::MXFP4 => frink_quant::dequant_mxfp4_gguf(raw)
2103 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::MXFP4)),
2104 other => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
2105 }
2106}
2107
2108pub(crate) fn load_f32_vec(file: &impl TensorSource, name: &str) -> Result<Vec<f32>, LoadError> {
2109 let info = find_info(file, name)?;
2110 let raw = file.tensor_bytes(name)?;
2111 match info.dtype {
2112 // MXFP4 rides with the plain floats because `widen_plain_float`
2113 // is where its arm already lives -- routing it here rather than
2114 // giving this table its own `dequant_mxfp4_gguf` call keeps ONE
2115 // MXFP4 arm in this file instead of two that can drift.
2116 //
2117 // It has to be in *both* tables' reach, and it was in neither's:
2118 // `load_weight_matrix` accepts MXFP4 as a 2-D weight and
2119 // `load_moe_expert_matrices` accepts it as an expert tensor, so
2120 // a checkpoint whose norms happen to be MXFP4 failed here with
2121 // `UnsupportedDtype` while its far larger tensors of the exact
2122 // same dtype loaded fine.
2123 GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 | GgmlType::MXFP4 => {
2124 widen_plain_float(info.dtype, raw, name)
2125 }
2126 GgmlType::Q8_0 => frink_quant::dequant_q8_0(raw)
2127 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q8_0)),
2128 GgmlType::Q4_0 => frink_quant::dequant_q4_0(raw)
2129 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4_0)),
2130 GgmlType::Q4K => frink_quant::dequant_q4_k(raw)
2131 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4K)),
2132 GgmlType::Q5K => frink_quant::dequant_q5_k(raw)
2133 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5K)),
2134 GgmlType::Q6K => frink_quant::dequant_q6_k(raw)
2135 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q6K)),
2136 GgmlType::Q2K => frink_quant::dequant_q2_k(raw)
2137 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q2K)),
2138 GgmlType::Q3K => frink_quant::dequant_q3_k(raw)
2139 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q3K)),
2140 GgmlType::Q4_1 => frink_quant::dequant_q4_1(raw)
2141 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q4_1)),
2142 GgmlType::Q5_0 => frink_quant::dequant_q5_0(raw)
2143 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5_0)),
2144 GgmlType::Q5_1 => frink_quant::dequant_q5_1(raw)
2145 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q5_1)),
2146 GgmlType::Q8_1 => frink_quant::dequant_q8_1(raw)
2147 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::Q8_1)),
2148 GgmlType::IQ4NL => frink_quant::dequant_iq4_nl(raw)
2149 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ4NL)),
2150 GgmlType::IQ4XS => frink_quant::dequant_iq4_xs(raw)
2151 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ4XS)),
2152 // The codebook-grid tiers. Rare on the 1-D tensors this
2153 // function widens (norms and biases are almost always F32),
2154 // but a dtype frink can decode should never be rejected here
2155 // just because the *other* dispatch table below knows it --
2156 // that split is how a supported format turns into a load
2157 // failure on the one checkpoint that uses it.
2158 GgmlType::IQ1S => frink_quant::dequant_iq1_s(raw)
2159 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ1S)),
2160 GgmlType::IQ1M => frink_quant::dequant_iq1_m(raw)
2161 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ1M)),
2162 GgmlType::IQ2XXS => frink_quant::dequant_iq2_xxs(raw)
2163 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2XXS)),
2164 GgmlType::IQ2XS => frink_quant::dequant_iq2_xs(raw)
2165 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2XS)),
2166 GgmlType::IQ2S => frink_quant::dequant_iq2_s(raw)
2167 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ2S)),
2168 GgmlType::IQ3XXS => frink_quant::dequant_iq3_xxs(raw)
2169 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ3XXS)),
2170 GgmlType::IQ3S => frink_quant::dequant_iq3_s(raw)
2171 .map_err(|_| LoadError::UnsupportedDtype(name.to_string(), GgmlType::IQ3S)),
2172 other => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
2173 }
2174}
2175
2176/// Loads a 2D weight matrix, keeping Q8_0/Q4_0 tensors quantized (raw
2177/// bytes copied out, never dequantized) and only expanding truly F32
2178/// tensors. This is the memory- and bandwidth-saving path: for a
2179/// multi-billion-parameter checkpoint the difference between this and
2180/// "dequant everything on load" is the difference between fitting in
2181/// RAM and not.
2182pub(crate) fn load_weight_matrix(
2183 file: &impl TensorSource,
2184 name: &str,
2185) -> Result<WeightMatrix, LoadError> {
2186 let mut m = load_weight_matrix_unfolded(file, name)?;
2187 // A PrismML checkpoint folds a Hadamard rotation into the listed
2188 // weights (`crate::hadamard_fold`); the matrix carries the
2189 // activation-side transform so every `apply` undoes it.
2190 if let Some(fold) = crate::hadamard_fold::fold_for(file, name, m.cols())? {
2191 m.fold_hadamard(fold);
2192 }
2193 Ok(m)
2194}
2195
2196fn load_weight_matrix_unfolded(
2197 file: &impl TensorSource,
2198 name: &str,
2199) -> Result<WeightMatrix, LoadError> {
2200 let info = find_info(file, name)?;
2201 // GGUF's on-disk `ne[]` shape array is fastest-varying-dimension-first
2202 // (ggml convention), i.e. `[in_features, out_features]` for a 2D
2203 // weight matrix -- the *reverse* of the row-major `[rows, cols]` =
2204 // `[out_features, in_features]` order `WeightMatrix`/`matmul_f32`
2205 // need. Reversed here once so every consumer below gets the correct
2206 // orientation. Before this reversal existed, every 2D tensor in an
2207 // externally-produced GGUF file was silently loaded transposed -- a
2208 // real bug found by running a real downloaded checkpoint
2209 // (TinyLlama-1.1B-Chat, e.g. `attn_k.weight`'s real raw shape is
2210 // `[2048, 256]` = `[hidden_dim, kv_dim]` = `[in, out]`) -- found
2211 // as a real transposition bug affecting every externally-produced
2212 // GGUF file, caught by serving a real downloaded checkpoint.
2213 let shape: Vec<usize> = info.shape.iter().rev().map(|&d| d as usize).collect();
2214 // A ggml tensor's `ne[]` is always four long and trailing 1s are
2215 // implicit, so a GGUF writer is free to store a `[in, 1]` matrix
2216 // with `n_dims = 1`. llama.cpp reads it back as a matrix anyway --
2217 // `check_tensor_dims` compares each requested dimension against
2218 // `cur->ne[i]` and requires 1 for the dimensions the file does not
2219 // carry -- so a single-output projection is a 2-D weight there and
2220 // must be one here. Refusing it instead made a real checkpoint
2221 // unloadable: `cross-encoder/ms-marco-MiniLM-L6-v2` writes
2222 // `cls.output.weight` as `[384]`, i.e. the 1x384 relevance head
2223 // that `/v1/rerank` exists to run, and the whole route died at load
2224 // with "expected 2D".
2225 let (rows, cols) = match shape.as_slice() {
2226 [r, c] => (*r, *c),
2227 [c] => (1, *c),
2228 other => {
2229 return Err(LoadError::UnsupportedDtype(
2230 format!("{name} (expected 2D, got shape {other:?})"),
2231 info.dtype,
2232 ))
2233 }
2234 };
2235 // `shape` is what the file said; `[rows, cols]` is what the matrix
2236 // is. They differ exactly in the 1-D case above, and the `Tensor`
2237 // must carry the matrix shape or `apply` reads it as a vector.
2238 let shape = vec![rows, cols];
2239
2240 match info.dtype {
2241 // BF16 has no block/scale structure to keep quantized-in-place
2242 // the way Q4_0/Q8_0/K-quants do -- there's no fused dot kernel
2243 // that would make sense for a plain narrowed float, so it's
2244 // eagerly widened to an owned f32 Tensor exactly like F32
2245 // tensors already are.
2246 GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
2247 let data = load_f32_vec(file, name)?;
2248 Ok(WeightMatrix::F32(Tensor::new(data, shape)))
2249 }
2250 other => match quant_kind_for(other) {
2251 Some(kind) => {
2252 let (mmap, range) = file.tensor_mapped_range(name)?;
2253 #[cfg(feature = "metal")]
2254 frink_metal::gpu::register_weight_mmap(Arc::clone(&mmap));
2255 Ok(WeightMatrix::Quantized {
2256 data: WeightBytes::Mapped { mmap, range },
2257 rows,
2258 cols,
2259 kind,
2260 })
2261 }
2262 None => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
2263 },
2264 }
2265}
2266
2267/// Splits a packed 3D MoE expert tensor `blk.N.ffn_{gate,up,down}_exps.weight`
2268/// (shape `[n_experts, out_dim, in_dim]`) into per-expert `WeightMatrix`es,
2269/// slicing raw bytes directly (quantized tensors stay quantized; block
2270/// boundaries never cross expert boundaries since `in_dim` is a whole
2271/// number of quantization blocks). Matches llama.cpp/ik_llama.cpp layout
2272/// confirmed on real OLMoE and Qwen2-MoE GGUF checkpoints.
2273pub(crate) fn split_expert_tensor(
2274 file: &impl TensorSource,
2275 name: &str,
2276 n_experts: usize,
2277) -> Result<Vec<WeightMatrix>, LoadError> {
2278 let info = find_info(file, name)?;
2279 // Real raw shape is `[in_dim, out_dim, n_experts]` (ggml's
2280 // fastest-first `ne[]` order -- see `load_weight_matrix`'s doc
2281 // comment for the confirmed 2D case this generalizes from). `n_experts`
2282 // is the slowest-varying (last, i.e. outermost/most-major) dimension,
2283 // so each expert's `out_dim*in_dim` block is contiguous with experts
2284 // back-to-back in the mmap.
2285 if info.shape.len() != 3 || info.shape[2] as usize != n_experts {
2286 let file_experts = info.shape.last().map(|&d| d as usize).unwrap_or(0);
2287 return Err(LoadError::ExpertCountMismatch(
2288 name.to_string(),
2289 file_experts,
2290 n_experts,
2291 ));
2292 }
2293 let out_dim = info.shape[1] as usize;
2294 let in_dim = info.shape[0] as usize;
2295 let raw = file.tensor_bytes(name)?;
2296
2297 match info.dtype {
2298 GgmlType::F32 | GgmlType::F16 | GgmlType::BF16 => {
2299 let all = crate::loader::widen_plain_float(info.dtype, raw, name)?;
2300 let per_expert = out_dim * in_dim;
2301 Ok((0..n_experts)
2302 .map(|e| {
2303 WeightMatrix::F32(Tensor::new(
2304 all[e * per_expert..(e + 1) * per_expert].to_vec(),
2305 vec![out_dim, in_dim],
2306 ))
2307 })
2308 .collect())
2309 }
2310 other => match quant_kind_for(other) {
2311 Some(kind) => {
2312 let (mmap, full_range) = file.tensor_mapped_range(name)?;
2313 #[cfg(feature = "metal")]
2314 frink_metal::gpu::register_weight_mmap(Arc::clone(&mmap));
2315 let bytes_per_expert = raw.len() / n_experts;
2316 Ok((0..n_experts)
2317 .map(|e| WeightMatrix::Quantized {
2318 data: WeightBytes::Mapped {
2319 mmap: Arc::clone(&mmap),
2320 range: (full_range.start + e * bytes_per_expert)
2321 ..(full_range.start + (e + 1) * bytes_per_expert),
2322 },
2323 rows: out_dim,
2324 cols: in_dim,
2325 kind,
2326 })
2327 .collect())
2328 }
2329 None => Err(LoadError::UnsupportedDtype(name.to_string(), other)),
2330 },
2331 }
2332}
2333
2334/// When every routed expert is mmap-backed with a Metal simdgroup-GEMM
2335/// kind and back-to-back slices, record the combined gate/up/down planes
2336/// for Metal packed MoE. Gate/up/down may differ in kind (e.g. Q4_K /
2337/// Q4_K / Q8_0) but must be uniform across experts per role.
2338#[cfg(feature = "metal")]
2339fn try_build_moe_packed_q4_planes(experts: &[ExpertWeights]) -> Option<MoePackedQ4Planes> {
2340 use frink_core::weight_matrix::{QuantKind, WeightBytes};
2341 use std::sync::Arc;
2342
2343 if experts.is_empty() {
2344 return None;
2345 }
2346
2347 fn mapped_sg(m: &WeightMatrix) -> Option<(WeightBytes, usize, &'static str)> {
2348 match m {
2349 WeightMatrix::Quantized {
2350 data: WeightBytes::Mapped { mmap, range },
2351 rows,
2352 kind,
2353 ..
2354 } => {
2355 let kind_str = match kind {
2356 QuantKind::Q4_0 => "Q4_0",
2357 QuantKind::Q5_0 => "Q5_0",
2358 QuantKind::Q4K => "Q4_K",
2359 QuantKind::Q5K => "Q5_K",
2360 QuantKind::Q6K => "Q6_K",
2361 QuantKind::Q8_0 => "Q8_0",
2362 QuantKind::IQ4XS => "IQ4_XS",
2363 _ => return None,
2364 };
2365 let _ = frink_metal::gpu::mul_mm_sg_meta(kind_str)?;
2366 Some((
2367 WeightBytes::Mapped {
2368 mmap: Arc::clone(mmap),
2369 range: range.clone(),
2370 },
2371 *rows,
2372 kind_str,
2373 ))
2374 }
2375 _ => None,
2376 }
2377 }
2378
2379 let (gate0, ffn_rows, gate_kind) = mapped_sg(&experts[0].gate)?;
2380 let (up0, up_rows, up_kind) = mapped_sg(&experts[0].up)?;
2381 let (down0, hidden_rows, down_kind) = mapped_sg(&experts[0].down)?;
2382 if up_rows != ffn_rows {
2383 return None;
2384 }
2385 let WeightBytes::Mapped {
2386 mmap: gate_mmap,
2387 range: gate0_range,
2388 } = &gate0
2389 else {
2390 return None;
2391 };
2392 let WeightBytes::Mapped {
2393 mmap: up_mmap,
2394 range: up0_range,
2395 } = &up0
2396 else {
2397 return None;
2398 };
2399 let WeightBytes::Mapped {
2400 mmap: down_mmap,
2401 range: down0_range,
2402 } = &down0
2403 else {
2404 return None;
2405 };
2406
2407 let gate_stride = gate0_range.len();
2408 let up_stride = up0_range.len();
2409 let down_stride = down0_range.len();
2410 if gate_stride == 0 || up_stride == 0 || down_stride == 0 {
2411 return None;
2412 }
2413
2414 let n = experts.len();
2415 for (i, ex) in experts.iter().enumerate().skip(1) {
2416 let (g, fr, gk) = mapped_sg(&ex.gate)?;
2417 let (u, ur, uk) = mapped_sg(&ex.up)?;
2418 let (d, hr, dk) = mapped_sg(&ex.down)?;
2419 if gk != gate_kind || uk != up_kind || dk != down_kind {
2420 return None;
2421 }
2422 let WeightBytes::Mapped { mmap, range } = &g else {
2423 return None;
2424 };
2425 if fr != ffn_rows {
2426 return None;
2427 }
2428 if !Arc::ptr_eq(mmap, gate_mmap)
2429 || range.len() != gate_stride
2430 || range.start != gate0_range.start + i * gate_stride
2431 {
2432 return None;
2433 }
2434 let WeightBytes::Mapped { mmap, range } = &u else {
2435 return None;
2436 };
2437 if ur != ffn_rows
2438 || !Arc::ptr_eq(mmap, up_mmap)
2439 || range.len() != up_stride
2440 || range.start != up0_range.start + i * up_stride
2441 {
2442 return None;
2443 }
2444 let WeightBytes::Mapped { mmap, range } = &d else {
2445 return None;
2446 };
2447 if hr != hidden_rows
2448 || !Arc::ptr_eq(mmap, down_mmap)
2449 || range.len() != down_stride
2450 || range.start != down0_range.start + i * down_stride
2451 {
2452 return None;
2453 }
2454 }
2455
2456 Some(MoePackedQ4Planes::new(
2457 WeightBytes::Mapped {
2458 mmap: Arc::clone(gate_mmap),
2459 range: gate0_range.start..gate0_range.start + n * gate_stride,
2460 },
2461 WeightBytes::Mapped {
2462 mmap: Arc::clone(up_mmap),
2463 range: up0_range.start..up0_range.start + n * up_stride,
2464 },
2465 WeightBytes::Mapped {
2466 mmap: Arc::clone(down_mmap),
2467 range: down0_range.start..down0_range.start + n * down_stride,
2468 },
2469 gate_stride,
2470 up_stride,
2471 down_stride,
2472 n,
2473 ffn_rows,
2474 hidden_rows,
2475 gate_kind,
2476 up_kind,
2477 down_kind,
2478 ))
2479}
2480
2481/// One matrix's place inside a store-backed expert's combined byte
2482/// buffer (gate bytes, then up, then down, concatenated by
2483/// `GgufExpertSource::read_expert`).
2484#[derive(Debug, Clone, Copy)]
2485pub struct StoredMatrixSpec {
2486 pub offset: usize,
2487 pub len: usize,
2488 pub rows: usize,
2489 pub cols: usize,
2490 pub kind: QuantKind,
2491}
2492
2493/// Byte-range layout of one store-backed routed expert.
2494#[derive(Debug, Clone, Copy)]
2495pub struct StoredExpertLayout {
2496 pub gate: StoredMatrixSpec,
2497 pub up: StoredMatrixSpec,
2498 pub down: StoredMatrixSpec,
2499}
2500
2501impl StoredExpertLayout {
2502 pub fn total_bytes(&self) -> usize {
2503 self.gate.len + self.up.len + self.down.len
2504 }
2505
2506 /// Builds temporary zero-copy `WeightMatrix` views over a leased
2507 /// buffer. Each view's `WeightBytes::Shared` clone of the lease's
2508 /// `Arc` keeps the cache entry pinned for the view's lifetime.
2509 pub fn materialize(&self, lease: &frink_core::expert_store::ExpertLease) -> ExpertWeights {
2510 let mk = |spec: &StoredMatrixSpec| WeightMatrix::Quantized {
2511 data: WeightBytes::Shared {
2512 buf: lease.shared_buf(),
2513 range: spec.offset..spec.offset + spec.len,
2514 },
2515 rows: spec.rows,
2516 cols: spec.cols,
2517 kind: spec.kind,
2518 };
2519 ExpertWeights {
2520 gate: mk(&self.gate),
2521 up: mk(&self.up),
2522 down: mk(&self.down),
2523 }
2524 }
2525}
2526
2527/// [`ExpertSource`] over a (possibly sharded) GGUF checkpoint: each
2528/// expert's gate/up/down byte ranges are read positionally from the
2529/// owning shard file and concatenated, so a store miss touches exactly
2530/// that expert's bytes -- no mmap of the expert region, no shared seek
2531/// cursor.
2532pub struct GgufExpertSource {
2533 files: Vec<std::fs::File>,
2534 /// (layer, expert) -> the three (file index, offset, len) segments
2535 /// in gate/up/down order.
2536 segments: std::collections::HashMap<ExpertKey, [(usize, u64, usize); 3]>,
2537}
2538
2539impl ExpertSource for GgufExpertSource {
2540 fn expert_len(&self, key: ExpertKey) -> Option<usize> {
2541 self.segments
2542 .get(&key)
2543 .map(|segs| segs.iter().map(|&(_, _, len)| len).sum())
2544 }
2545
2546 fn read_expert(&self, key: ExpertKey) -> std::io::Result<Vec<u8>> {
2547 let segs = self
2548 .segments
2549 .get(&key)
2550 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{key:?}")))?;
2551 let total: usize = segs.iter().map(|&(_, _, len)| len).sum();
2552 let mut buf = vec![0u8; total];
2553 let mut written = 0;
2554 for &(fi, offset, len) in segs {
2555 let dst = &mut buf[written..written + len];
2556 #[cfg(unix)]
2557 {
2558 use std::os::unix::fs::FileExt;
2559 self.files[fi].read_exact_at(dst, offset)?;
2560 }
2561 #[cfg(not(unix))]
2562 {
2563 use std::io::{Read, Seek, SeekFrom};
2564 let mut f = &self.files[fi];
2565 f.seek(SeekFrom::Start(offset))?;
2566 f.read_exact(dst)?;
2567 }
2568 written += len;
2569 }
2570 Ok(buf)
2571 }
2572}
2573
2574/// Collects the per-expert `(file, offset, len)` segments and layout
2575/// for one packed 3D expert tensor -- the store-backed counterpart of
2576/// `split_expert_tensor`, sharing its shape/offset math. Only
2577/// quantized dtypes are supported (an F32/BF16 expert tensor keeps the
2578/// resident path; the store exists for the quantized multi-hundred-GB
2579/// case).
2580/// One packed 3D expert tensor's store-backed description: the owning
2581/// shard index, each expert's `(offset, len)` within that shard file,
2582/// and the matrix spec shared by every expert's slice.
2583struct StoredTensorSpecs {
2584 shard: usize,
2585 per_expert: Vec<(u64, usize)>,
2586 spec: StoredMatrixSpec,
2587}
2588
2589fn stored_expert_specs(
2590 file: &ShardedGguf,
2591 name: &str,
2592 n_experts: usize,
2593) -> Result<Option<StoredTensorSpecs>, LoadError> {
2594 let info = find_info(file, name)?;
2595 if info.shape.len() != 3 || info.shape[2] as usize != n_experts {
2596 let file_experts = info.shape.last().map(|&d| d as usize).unwrap_or(0);
2597 return Err(LoadError::ExpertCountMismatch(
2598 name.to_string(),
2599 file_experts,
2600 n_experts,
2601 ));
2602 }
2603 let out_dim = info.shape[1] as usize;
2604 let in_dim = info.shape[0] as usize;
2605 let Some(kind) = quant_kind_for(info.dtype) else {
2606 return Ok(None); // F32/BF16 (or unsupported): resident fallback
2607 };
2608 let shard = file
2609 .tensor_shard_index(name)
2610 .expect("find_info succeeded, shard index must exist");
2611 // The mmap range of a tensor within a GgufFile IS its byte offset
2612 // range within that shard file (the mmap covers the whole file).
2613 let (_, full_range) = file.tensor_mapped_range(name)?;
2614 let total_len = full_range.end - full_range.start;
2615 let bytes_per_expert = total_len / n_experts;
2616 let per_expert: Vec<(u64, usize)> = (0..n_experts)
2617 .map(|e| {
2618 (
2619 (full_range.start + e * bytes_per_expert) as u64,
2620 bytes_per_expert,
2621 )
2622 })
2623 .collect();
2624 let spec = StoredMatrixSpec {
2625 offset: 0, // caller assigns the position within the combined buffer
2626 len: bytes_per_expert,
2627 rows: out_dim,
2628 cols: in_dim,
2629 kind,
2630 };
2631 Ok(Some(StoredTensorSpecs {
2632 shard,
2633 per_expert,
2634 spec,
2635 }))
2636}
2637
2638impl Decoder {
2639 /// Loads real weights from `path` for the given `config`. `config`
2640 /// supplies the architecture shape (layer count, head counts, MoE
2641 /// topology); tensor names are resolved against it using the
2642 /// llama.cpp naming convention described in the module docs.
2643 ///
2644 /// A `config.moe.n_experts <= 1` model is treated as dense: expert
2645 /// weights are read from the plain `blk.N.ffn_{gate,up,down}.weight`
2646 /// tensor names rather than the packed 3D `_exps` variant.
2647 pub fn from_gguf(
2648 path: impl AsRef<std::path::Path>,
2649 config: ModelConfig,
2650 ) -> Result<Self, LoadError> {
2651 Self::from_gguf_with_expert_cache(path, config, None)
2652 }
2653
2654 /// Like `from_gguf`, but with `expert_cache_bytes: Some(budget)`
2655 /// routed experts are NOT loaded resident: each layer holds only
2656 /// byte-range layouts, and expert bytes are read on demand through
2657 /// one bounded, lease-protected `ExpertStore` shared by every
2658 /// layer (a single global byte budget; see
2659 /// `frink_core::expert_store`). Dense layers, shared experts,
2660 /// attention, embeddings, and the output head stay resident/mapped
2661 /// exactly as before -- only routed experts stream. Layers whose
2662 /// expert tensors are F32/BF16 fall back to resident loading (the
2663 /// store exists for the quantized case). Output is bit-identical
2664 /// to the resident path -- same bytes, same kernels -- pinned by
2665 /// the roundtrip suite's equivalence test.
2666 pub fn from_gguf_with_expert_cache(
2667 path: impl AsRef<std::path::Path>,
2668 mut config: ModelConfig,
2669 expert_cache_bytes: Option<u64>,
2670 ) -> Result<Self, LoadError> {
2671 let path = path.as_ref();
2672 let file = ShardedGguf::open(path)?;
2673
2674 // gpt-oss carries four per-layer tensors the generic GQA layer
2675 // structs have no home for (its fifth, the attention sinks, is
2676 // `AttnWeights::sinks` and loads by tensor presence). That is
2677 // decided by the architecture string, so resolve it once here.
2678 // See `crate::decoder::GptOssWeights`.
2679 //
2680 // This used to be ONE flag with the norm-slot fact below,
2681 // `arch == "gpt-oss"`, standing for two unrelated facts.
2682 // Splitting them is what let `seed_oss` -- which shares the norm
2683 // slot and has none of the extra tensors -- be admitted without
2684 // also being handed attention sinks.
2685 // Canonicalised: an architecture that llama.cpp computes
2686 // another one's graph for reads that one's tables, so an alias
2687 // needs one entry rather than a row in each of a dozen
2688 // per-architecture lists (`capability::canonical_architecture`).
2689 let arch = crate::capability::canonical_architecture(
2690 file.metadata_str("general.architecture")
2691 .unwrap_or_default(),
2692 )
2693 .to_string();
2694 let is_gpt_oss = arch == "gpt-oss";
2695 // A `<projection>.scale` companion is a multiply llama.cpp
2696 // applies and frink does not; refused by name here, before
2697 // the unread-tensor gate can be talked past
2698 // (`crate::weight_scales`).
2699 crate::weight_scales::refuse_weight_scale_tensors(
2700 &arch,
2701 file.tensors().map(|(_, t)| t.name.as_str()),
2702 )?;
2703 // Which tensor each of the five norm sites is stored under and
2704 // which FUNCTION norms it, resolved ONCE. Four shapes reach this
2705 // loader -- the plain pre-norm layer, the post-norm-only
2706 // topology (`olmo2`, `exaone4`), the non-parametric LayerNorm
2707 // (`olmo`) and the weighted LayerNorm (`dbrx`) -- and three
2708 // architectures keep a norm under a name another architecture
2709 // uses for a different site (`gpt-oss` / `seed_oss`, `dbrx`,
2710 // `grok`). `crate::norm_sites` is the one table for all of it;
2711 // this loader used to restate the decision at every site.
2712 let norm_sites = crate::norm_sites::NormSites::with_function(&arch, config.norm_function);
2713 let mut gpt_oss_layers: Vec<crate::decoder::GptOssLayer> = Vec::new();
2714
2715 // One store for the whole model (keys are (layer, expert)),
2716 // built up-front with every stored expert's segments; created
2717 // only when the cache is enabled AND some layer can use it.
2718 let mut store_segments: std::collections::HashMap<ExpertKey, [(usize, u64, usize); 3]> =
2719 std::collections::HashMap::new();
2720 let mut stored_layouts: Vec<Option<Vec<StoredExpertLayout>>> = Vec::new();
2721
2722 // Loaded like any other weight matrix: a quantized embedding
2723 // table stays quantized (zero-copy mmap) and token lookup
2724 // dequantizes one row via `WeightMatrix::dequant_row`, instead
2725 // of the whole vocabulary tensor being widened to f32 up front.
2726 let embedding = load_weight_matrix(&file, "token_embd.weight")?;
2727 // The learned position table (`crate::position_embd`), one row
2728 // per trained position, for the graphs that add one.
2729 let position_embd = crate::position_embd::load_position_embd(
2730 &file,
2731 &arch,
2732 config.hidden_dim,
2733 metadata_u64_any(&file, &[format!("{arch}.context_length")]).map(|v| v as usize),
2734 )?;
2735
2736 // PHYSICAL layers: the blocks the file holds tensors for. A
2737 // looped model (`crate::layer_loops`) has more logical layers
2738 // than this, and they run these same weights.
2739 let n_physical = config
2740 .layer_loops
2741 .map_or(config.n_layers, |loops| loops.physical_layers());
2742 let mut layers = Vec::with_capacity(n_physical);
2743 let mut refined_qk_norm = config.qk_norm_style;
2744 for l in 0..n_physical {
2745 // THIS layer's head counts and FFN width. Uniform for every
2746 // architecture but the per-layer ones (`crate::layer_shapes`),
2747 // and the loader reads the shape rather than the scalars so
2748 // that a deci / openelm layer is sized by its own header.
2749 let shape = config.layer_shape(l);
2750 // Whether THIS layer's FFN reads the layer input rather
2751 // than the post-attention residual, and under which norm
2752 // (`crate::parallel_residual`).
2753 let parallel = crate::parallel_residual::layer_parallel_norm(&file, &arch, l);
2754 // THIS layer's norm slots: the architecture's row, with
2755 // Falcon-40B's `attn_norm_2` crossing the two pre-norm names
2756 // on the layers that carry it (`crate::norm_sites`).
2757 let layer_sites = norm_sites.for_layer(&arch, &file, l);
2758 // BitNet's two inner norms, REQUIRED when the architecture
2759 // has them and untouched otherwise (`crate::sub_norms`).
2760 let sub_norms = crate::sub_norms::load_sub_norms(
2761 &file,
2762 &arch,
2763 config.block_sub_norms,
2764 l,
2765 config.hidden_dim,
2766 shape.ffn_dim,
2767 )?;
2768 let attn = match shape.attention {
2769 crate::layer_shapes::AttnShape::Gqa { n_heads, .. } => {
2770 // Q/K/V and their biases come out of ONE decision about
2771 // which spelling this layer uses -- see `qkv_fused`. They
2772 // used to be resolved independently, and a checkpoint that
2773 // fused both (ChatGLM, Qwen-1) had its bias dropped.
2774 let crate::qkv_fused::QkvProjections {
2775 q: q_proj,
2776 k: k_proj,
2777 v: v_proj,
2778 q_bias,
2779 k_bias,
2780 v_bias,
2781 } = crate::qkv_fused::load_fused_or_split_qkv(&file, l, &config)?;
2782 let q_norm =
2783 load_f32_vec_optional(&file, &format!("blk.{l}.attn_q_norm.weight"))?;
2784 let k_norm =
2785 load_f32_vec_optional(&file, &format!("blk.{l}.attn_k_norm.weight"))?;
2786 // The per-head LAYERNORM (`crate::qk_layer_norm`), whose
2787 // weight is `n_heads * head_dim` long and would pass
2788 // the length rule below as `WholeVector`.
2789 if let Some(reason) = crate::qk_layer_norm::per_head_layer_norm_refusal(
2790 &arch,
2791 l,
2792 q_norm.is_some() || k_norm.is_some(),
2793 ) {
2794 return Err(LoadError::UnsupportedFeature(
2795 config.name.to_string(),
2796 reason,
2797 ));
2798 }
2799 // Refine WholeVector vs PerHead from the first observed norm length.
2800 // The per-head SCALAR gain is decided by architecture first
2801 // (`capability::PER_HEAD_SCALAR_QK_GAIN`): its length is
2802 // `n_heads`, which a length test alone could confuse with
2803 // `head_dim`.
2804 if let Some(ref w) = q_norm {
2805 if crate::capability::uses_per_head_scalar_qk_gain(&arch) {
2806 if w.len() != n_heads {
2807 return Err(LoadError::UnsupportedFeature(
2808 config.name.to_string(),
2809 format!(
2810 "blk.{l}.attn_q_norm.weight length {} is not one gain per \
2811 head (n_heads={n_heads}; talkie.cpp:26 creates it {{1, \
2812 n_head}})",
2813 w.len()
2814 ),
2815 ));
2816 }
2817 refined_qk_norm = crate::capability::QkNormStyle::PerHeadScalar;
2818 } else if crate::capability::uses_per_head_distinct_qk_norm(&arch) {
2819 // `plamo2.cpp:92-93`: `{head_dim, n_head}`, one
2820 // row per head, RMS per head. The same length as
2821 // a whole-vector weight, so the architecture
2822 // decides (`capability::PER_HEAD_DISTINCT_QK_NORM`).
2823 if w.len() != n_heads * config.head_dim {
2824 return Err(LoadError::UnsupportedFeature(
2825 config.name.to_string(),
2826 format!(
2827 "blk.{l}.attn_q_norm.weight length {} is not one row per \
2828 head (n_heads={n_heads} x head_dim={}; plamo2.cpp:92 \
2829 creates it {{head_dim, n_head}})",
2830 w.len(),
2831 config.head_dim
2832 ),
2833 ));
2834 }
2835 refined_qk_norm = crate::capability::QkNormStyle::PerHeadDistinct;
2836 } else if w.len() == config.head_dim {
2837 refined_qk_norm = crate::capability::QkNormStyle::PerHead;
2838 } else if w.len() == n_heads * config.head_dim {
2839 refined_qk_norm = crate::capability::QkNormStyle::WholeVector;
2840 } else {
2841 return Err(LoadError::UnsupportedFeature(
2842 config.name.to_string(),
2843 format!(
2844 "blk.{l}.attn_q_norm.weight length {} matches neither \
2845 head_dim={} nor n_heads*head_dim={}",
2846 w.len(),
2847 config.head_dim,
2848 n_heads * config.head_dim
2849 ),
2850 ));
2851 }
2852 }
2853 let attn = AttnWeights {
2854 q_proj,
2855 k_proj,
2856 v_proj,
2857 o_proj: load_weight_matrix(&file, &format!("blk.{l}.attn_output.weight"))?,
2858 // Which tensor, which function, and whether there is a
2859 // norm here at all: all three answered by the table.
2860 norm_weight: layer_sites.load_pre_norm(layer_sites.attn, &file, Some(l))?,
2861 q_norm,
2862 k_norm,
2863 // Qwen2/Qwen2-MoE-family real QKV bias (`attn_{q,k,v}.bias`,
2864 // real config `qkv_bias`, `o_proj` has none) -- see
2865 // `AttnWeights::q_bias`'s doc comment. Resolved above,
2866 // alongside the projections they belong to, because a
2867 // file that fuses the weight fuses the bias too.
2868 q_bias,
2869 k_bias,
2870 v_bias,
2871 post_attn_norm: crate::norm_sites::NormSites::load_post_norm(
2872 norm_sites.post_attn,
2873 &file,
2874 l,
2875 )?,
2876 post_ffn_norm: crate::norm_sites::NormSites::load_post_norm(
2877 norm_sites.post_ffn,
2878 &file,
2879 l,
2880 )?,
2881 // The architecture table decides whether there is
2882 // a gate and how it is applied; the tensor decides
2883 // its width. See `crate::attn_gate`.
2884 output_gate: crate::attn_gate::AttnGate::load(
2885 &file,
2886 &arch,
2887 l,
2888 n_heads,
2889 config.head_dim,
2890 config.hidden_dim,
2891 )?,
2892 // The TENSOR decides. Four llama.cpp graphs pass it
2893 // into the one `build_attn_mha`; on the generic
2894 // path a file that has it gets the sink term and
2895 // a file that does not gets none, whatever the
2896 // architecture string. gpt-oss's requirement is
2897 // checked where its side table loads.
2898 sinks: load_attn_sinks(&file, l, n_heads)?,
2899 attn_sub_norm: sub_norms.as_ref().map(|n| n.attn.clone()),
2900 o_scale: crate::weight_scales::load_projection_gain(
2901 &file,
2902 &arch,
2903 l,
2904 "attn_output",
2905 )?,
2906 o_bias: crate::proj_bias::load_attn_out_bias(
2907 &file,
2908 &arch,
2909 l,
2910 config.hidden_dim,
2911 )?,
2912 shortconv: None,
2913 // falcon-h1.cpp:55-71: the Mamba-2 block beside
2914 // attention on every layer (`crate::mamba2::
2915 // PARALLEL_WITH_ATTENTION`).
2916 ssm: if config.parallel_ssm {
2917 Some(crate::ssm_block::SsmBlock::Mamba2(
2918 crate::mamba2::Mamba2::load(&file, &arch, l, config.hidden_dim)?,
2919 ))
2920 } else {
2921 None
2922 },
2923 q_gate_interleaved: crate::attn_gate::q_gate_interleaved(&arch),
2924 };
2925 crate::layer_shapes::check_gqa_projection_widths(
2926 l,
2927 shape.attention,
2928 config.head_dim,
2929 config.v_head_dim(),
2930 config.hidden_dim,
2931 &attn,
2932 )?;
2933 attn
2934 }
2935 other => crate::layer_shapes::load_non_gqa_attention(
2936 other,
2937 &file,
2938 &arch,
2939 l,
2940 &norm_sites,
2941 &config,
2942 )?,
2943 };
2944
2945 // Leading dense layers (see ModelConfig::layer_is_dense's
2946 // doc comment) load from the plain dense tensor names
2947 // regardless of this model's global MoE topology, matching
2948 // the DeepSeek-2/3-family convention found in
2949 // ik_llama.cpp's source. A model with n_experts<=1
2950 // globally (the dense test fixture) is dense on every
2951 // layer either way.
2952 // A layer with NO FFN at all (`ffn_dim 0`: deci's, Nemotron-H's
2953 // block-only layers) takes the dense arm, whose loader answers
2954 // `absent_ffn` for that width, whatever the model's MoE says.
2955 let is_dense_layer = config.layer_is_dense(l)
2956 || config.moe.n_experts <= 1
2957 || shape.ffn_dim == 0
2958 || crate::moe_interleave::dense_by_router_absence(&arch, &file, l);
2959 // The ungated experts (`nemotron-h.cpp:82-86,209-215`: a null
2960 // gate into `build_moe_ffn`, `LLM_FFN_RELU_SQR`) are spelled
2961 // the way the dense ungated FFN is (`load_dense_expert`): the
2962 // gate ALIASED to `up`, so `relu(up)^2` runs through the gated
2963 // body with no branch. A file that carries a gate anyway is
2964 // refused, as the dense loader refuses one.
2965 let routed_gate_name = if config.ffn_is_ungated() && !is_dense_layer {
2966 if file
2967 .find_tensor(&format!("blk.{l}.ffn_gate_exps.weight"))
2968 .is_some()
2969 {
2970 return Err(LoadError::UnsupportedFeature(
2971 arch.clone(),
2972 format!(
2973 "blk.{l}.ffn_gate_exps.weight is present but this architecture's \
2974 experts are ungated ({:?}: a null gate into build_moe_ffn, \
2975 nemotron-h.cpp:212)",
2976 config.ffn_activation
2977 ),
2978 ));
2979 }
2980 format!("blk.{l}.ffn_up_exps.weight")
2981 } else {
2982 format!("blk.{l}.ffn_gate_exps.weight")
2983 };
2984 // The inner FFN norm has a site in the dense body only
2985 // (`build_ffn` with a NULL down, `bitnet.cpp:127-141`);
2986 // `build_moe_ffn` has none, so a routed layer that carried
2987 // one would have nowhere to apply it.
2988 if sub_norms.is_some() && !is_dense_layer {
2989 return Err(LoadError::UnsupportedFeature(
2990 arch.clone(),
2991 format!(
2992 "blk.{l}.ffn_sub_norm on a MoE layer: llama.cpp applies the inner FFN \
2993 norm in the dense `build_ffn` body only (bitnet.cpp:127-141), and no \
2994 routed-expert graph has that site"
2995 ),
2996 ));
2997 }
2998 let n_experts = if is_dense_layer {
2999 1
3000 } else {
3001 config.moe.n_experts
3002 };
3003 let experts: ExpertBacking = if is_dense_layer {
3004 ExpertBacking::Resident(vec![load_dense_expert(&file, l, &config, shape.ffn_dim)?])
3005 } else {
3006 // Try store-backed layouts first when the cache is
3007 // enabled; fall back to resident when any of the three
3008 // tensors isn't a supported quantized dtype.
3009 let stored = if expert_cache_bytes.is_some() {
3010 let g = stored_expert_specs(&file, &routed_gate_name, n_experts)?;
3011 let u = stored_expert_specs(
3012 &file,
3013 &format!("blk.{l}.ffn_up_exps.weight"),
3014 n_experts,
3015 )?;
3016 let d = stored_expert_specs(
3017 &file,
3018 &format!("blk.{l}.ffn_down_exps.weight"),
3019 n_experts,
3020 )?;
3021 match (g, u, d) {
3022 (Some(gt), Some(ut), Some(dt)) => {
3023 let mut layouts = Vec::with_capacity(n_experts);
3024 for e in 0..n_experts {
3025 let key = ExpertKey {
3026 layer: l as u32,
3027 expert: e as u32,
3028 };
3029 store_segments.insert(
3030 key,
3031 [
3032 (gt.shard, gt.per_expert[e].0, gt.per_expert[e].1),
3033 (ut.shard, ut.per_expert[e].0, ut.per_expert[e].1),
3034 (dt.shard, dt.per_expert[e].0, dt.per_expert[e].1),
3035 ],
3036 );
3037 let mut gate = gt.spec;
3038 let mut up = ut.spec;
3039 let mut down = dt.spec;
3040 gate.offset = 0;
3041 up.offset = gate.len;
3042 down.offset = gate.len + up.len;
3043 layouts.push(StoredExpertLayout { gate, up, down });
3044 }
3045 Some(layouts)
3046 }
3047 _ => None,
3048 }
3049 } else {
3050 None
3051 };
3052 match stored {
3053 Some(layouts) => {
3054 // Placeholder; the shared store is attached in a
3055 // second pass below once every layer's segments
3056 // are collected.
3057 stored_layouts.push(Some(layouts));
3058 ExpertBacking::Resident(Vec::new())
3059 }
3060 None => {
3061 let gates = split_expert_tensor(&file, &routed_gate_name, n_experts)?;
3062 let ups = split_expert_tensor(
3063 &file,
3064 &format!("blk.{l}.ffn_up_exps.weight"),
3065 n_experts,
3066 )?;
3067 let downs = split_expert_tensor(
3068 &file,
3069 &format!("blk.{l}.ffn_down_exps.weight"),
3070 n_experts,
3071 )?;
3072 ExpertBacking::Resident(
3073 gates
3074 .into_iter()
3075 .zip(ups)
3076 .zip(downs)
3077 .map(|((gate, up), down)| ExpertWeights { gate, up, down })
3078 .collect(),
3079 )
3080 }
3081 }
3082 };
3083 if stored_layouts.len() < layers.len() + 1 {
3084 stored_layouts.push(None);
3085 }
3086
3087 let mut shared_experts: Vec<ExpertWeights> =
3088 if config.moe.n_shared_experts > 0 && !is_dense_layer {
3089 // The shared expert takes the architecture's dense
3090 // activation, so an ungated one aliases its gate as
3091 // `load_dense_expert` does (`nemotron-h.cpp:222-227`).
3092 let shexp_gate = if config.ffn_is_ungated() {
3093 if file
3094 .find_tensor(&format!("blk.{l}.ffn_gate_shexp.weight"))
3095 .is_some()
3096 {
3097 return Err(LoadError::UnsupportedFeature(
3098 arch.clone(),
3099 format!(
3100 "blk.{l}.ffn_gate_shexp.weight is present but this \
3101 architecture's shared expert is ungated"
3102 ),
3103 ));
3104 }
3105 format!("blk.{l}.ffn_up_shexp.weight")
3106 } else {
3107 format!("blk.{l}.ffn_gate_shexp.weight")
3108 };
3109 vec![ExpertWeights {
3110 gate: load_weight_matrix(&file, &shexp_gate)?,
3111 up: load_weight_matrix(&file, &format!("blk.{l}.ffn_up_shexp.weight"))?,
3112 down: load_weight_matrix(&file, &format!("blk.{l}.ffn_down_shexp.weight"))?,
3113 }]
3114 } else {
3115 Vec::new()
3116 };
3117 // A dense FFN SUMMED with the experts (Grok-2, Arctic) is the
3118 // shared-expert slot under the dense names, plus the row's
3119 // scale on the sum (`crate::parallel_dense_ffn`). Decided per
3120 // layer: Grok-1's layers have no triple and take neither.
3121 // ...and the same scale under the `_shexp` names
3122 // (`SHARED_EXPERT_SUM_SCALE`, cohere2moe's `* 0.5`), on a
3123 // layer that loaded a shared expert above.
3124 let parallel_sum_scale = if is_dense_layer {
3125 None
3126 } else {
3127 match crate::parallel_dense_ffn::parallel_dense_for_layer(&arch, &file, l)? {
3128 Some(row) => {
3129 shared_experts.push(ExpertWeights {
3130 gate: load_weight_matrix(&file, &format!("blk.{l}.ffn_gate.weight"))?,
3131 up: load_weight_matrix(&file, &format!("blk.{l}.ffn_up.weight"))?,
3132 down: load_weight_matrix(&file, &format!("blk.{l}.ffn_down.weight"))?,
3133 });
3134 row.sum_scale
3135 }
3136 None => crate::parallel_dense_ffn::shared_expert_sum_scale(
3137 &arch,
3138 !shared_experts.is_empty(),
3139 ),
3140 }
3141 };
3142 // Arctic's second per-layer norm, the routed branch's operand
3143 // (`crate::router_input::RouterInput::NormedLayerInput`):
3144 // REQUIRED on its routed layers, unread everywhere else.
3145 let exps_norm = if config.router_input.needs_exps_norm() && !is_dense_layer {
3146 Some(load_f32_vec(
3147 &file,
3148 &format!("blk.{l}.ffn_norm_exps.weight"),
3149 )?)
3150 } else {
3151 None
3152 };
3153
3154 let router = if !is_dense_layer {
3155 load_weight_matrix(&file, &format!("blk.{l}.ffn_gate_inp.weight"))?
3156 } else {
3157 // dense layer: no real router; a zero [1, hidden] matrix
3158 // always selects the single expert deterministically.
3159 WeightMatrix::F32(Tensor::zeros(vec![1, config.hidden_dim]))
3160 };
3161
3162 let n_for_counts = match &experts {
3163 ExpertBacking::Resident(v) if v.is_empty() => n_experts,
3164 other => other.n_experts(),
3165 };
3166 let activation_counts = (0..n_for_counts)
3167 .map(|_| std::sync::atomic::AtomicU64::new(0))
3168 .collect();
3169 // Qwen2-MoE-specific real tensor (`blk.N.ffn_gate_inp_shexp.weight`,
3170 // real on-disk shape `[hidden_dim]`, confirmed against
3171 // llama.cpp's real `qwen2moe.cpp`) -- see
3172 // `MoeWeights::shared_expert_gate`'s doc comment. Presence
3173 // of the tensor itself is the real signal (not an
3174 // architecture-name list): every other supported
3175 // architecture's checkpoints simply don't carry this
3176 // tensor, so this naturally stays `None` there.
3177 let shared_expert_gate = if is_dense_layer {
3178 None
3179 } else {
3180 load_f32_vec_optional(&file, &format!("blk.{l}.ffn_gate_inp_shexp.weight"))?
3181 };
3182 #[cfg(feature = "metal")]
3183 let packed_q4 = match &experts {
3184 ExpertBacking::Resident(v) if !v.is_empty() => try_build_moe_packed_q4_planes(v),
3185 _ => None,
3186 };
3187 // DeepSeek-V3's aux-loss-free selection bias. The on-disk
3188 // name carries no `ffn_` prefix -- llama.cpp's
3189 // `LLM_TENSOR_FFN_EXP_PROBS_B` maps to `blk.%d.exp_probs_b`
3190 // (`llama-arch.cpp:416`, `gguf-py/gguf/constants.py:1240`).
3191 // Optional: only the DeepSeek-V3-lineage MoE recipes carry
3192 // it, and this same generic loader serves OLMoE / Qwen2-MoE /
3193 // Mixtral, which do not.
3194 let exp_probs_bias = if is_dense_layer {
3195 None
3196 } else {
3197 load_f32_vec_optional(&file, &format!("blk.{l}.exp_probs_b.bias"))?
3198 };
3199 if let Some(bias) = &exp_probs_bias {
3200 if bias.len() != config.moe.n_experts {
3201 return Err(LoadError::UnsupportedFeature(
3202 arch.clone(),
3203 format!(
3204 "blk.{l}.exp_probs_b.bias has {} entries but the model has {} experts",
3205 bias.len(),
3206 config.moe.n_experts
3207 ),
3208 ));
3209 }
3210 // Grouped selection masks the *biased* scores before the
3211 // global top-k (`build_moe_ffn`, the `n_expert_groups > 1`
3212 // block). frink's `route_top_k_grouped` takes a fixed
3213 // count from every group instead, which is a different
3214 // algorithm, so combining the two here would be a guess.
3215 // Refuse rather than route wrongly.
3216 if config.moe.expert_group_count.is_some() {
3217 return Err(LoadError::UnsupportedFeature(
3218 arch.clone(),
3219 format!(
3220 "blk.{l}.exp_probs_b.bias together with expert groups \
3221 ({:?}): llama.cpp masks the biased scores per group \
3222 before a global top-k, which is not the per-group \
3223 top-k frink implements",
3224 config.moe.expert_group_count
3225 ),
3226 ));
3227 }
3228 }
3229 let moe = MoeWeights {
3230 router,
3231 experts,
3232 shared_experts,
3233 shared_expert_gate,
3234 exp_probs_bias,
3235 exps_norm,
3236 parallel_sum_scale,
3237 // The dense FFN's biases (`crate::proj_bias`), on a dense
3238 // layer; a routed layer's experts carry none on the
3239 // generic path (gpt-oss's are its side table's).
3240 dense_bias: if is_dense_layer && shape.ffn_dim > 0 {
3241 let bias = crate::proj_bias::load_dense_ffn_bias(
3242 &file,
3243 &arch,
3244 l,
3245 config.hidden_dim,
3246 shape.ffn_dim,
3247 config.ffn_is_ungated(),
3248 )?;
3249 if bias.is_some() && sub_norms.is_some() {
3250 return Err(LoadError::UnsupportedFeature(
3251 arch.clone(),
3252 format!(
3253 "layer {l} has both an inner FFN norm and FFN biases; no llama.cpp \
3254 graph has both and the dense body has one arm for each"
3255 ),
3256 ));
3257 }
3258 bias
3259 } else {
3260 None
3261 },
3262 ffn_sub_norm: sub_norms.map(|n| n.ffn),
3263 down_scale: {
3264 let gain =
3265 crate::weight_scales::load_projection_gain(&file, &arch, l, "ffn_down")?;
3266 if gain.is_some() && !is_dense_layer {
3267 return Err(LoadError::UnsupportedFeature(
3268 arch.clone(),
3269 format!(
3270 "blk.{l}.ffn_down.scale on a MoE layer: the routed experts' \
3271 scales are `ffn_down_exps.scale`, one per expert, which is \
3272 not applied here"
3273 ),
3274 ));
3275 }
3276 gain
3277 },
3278 // The same table as the attention slot, so the two
3279 // pre-norms cannot disagree about the function, and the
3280 // pre-FFN tensor's NAME comes from the same row that
3281 // decided the post-attention slot must not read it. An
3282 // FFN-free layer (`deci.cpp:52-54`) has no such tensor.
3283 // A parallel layer with ONE shared norm has no pre-FFN
3284 // tensor and no pre-FFN norm: the FFN reads the vector
3285 // attention read (`crate::parallel_residual`).
3286 norm_weight: if shape.ffn_dim == 0
3287 || parallel == Some(crate::parallel_residual::ParallelNorm::SharedNorm)
3288 {
3289 NormOp::None
3290 } else {
3291 layer_sites.load_pre_norm(layer_sites.ffn, &file, Some(l))?
3292 },
3293 parallel,
3294 activation_counts,
3295 #[cfg(feature = "metal")]
3296 packed_q4,
3297 };
3298
3299 if is_gpt_oss {
3300 gpt_oss_layers.push(load_gpt_oss_layer(&file, l, &config, attn.sinks.is_some())?);
3301 }
3302
3303 // Talkie's per-layer skip scalar (`crate::skip_stream`);
3304 // REQUIRED there, untouched everywhere else.
3305 let out_scale =
3306 crate::skip_stream::load_out_scale(&file, &arch, config.skip_stream, l)?;
3307 layers.push(LayerWeights {
3308 attn,
3309 moe,
3310 out_scale,
3311 });
3312 }
3313
3314 // `olmo.cpp:15-36` creates no `output_norm` at all and
3315 // `:128-130` norms the final hidden state with a null weight, so
3316 // asking for the tensor would refuse every real OLMo-1 file;
3317 // the table's function decides whether the read happens.
3318 let final_norm = norm_sites.load_pre_norm(norm_sites.output, &file, None)?;
3319 // `hrm-text.cpp:46` creates `hrm_z_l_init` REQUIRED, and only
3320 // that graph does (`crate::hrm`): the learned LOW stream, one
3321 // `[n_embd]` row broadcast over the tokens at `:182`.
3322 let hrm_z_l_init = match config.layer_loops {
3323 Some(crate::layer_loops::LayerLoops::Hrm { .. }) => {
3324 Some(load_f32_vec(&file, "hrm.z_l_init")?)
3325 }
3326 _ => None,
3327 };
3328 // The embedding norm (`norm_sites::EMBEDDING_NORM_ARCHITECTURES`),
3329 // `NormOp::None` where the site is absent.
3330 // Two answers, one field: a STORED embedding norm (`bloom`) or
3331 // a weightless one (`muse-glimmer.cpp:69`), and the tables that
3332 // decide them are disjoint by construction
3333 // (`norm_sites::WEIGHTLESS_EMBEDDING_NORM`).
3334 let embedding_norm = if crate::norm_sites::weightless_embedding_norm(&arch) {
3335 crate::norm::NormOp::RmsNoParams
3336 } else {
3337 norm_sites.load_pre_norm(norm_sites.embedding, &file, None)?
3338 };
3339 // Many small Llama/Gemma-family GGUFs tie the lm-head to
3340 // `token_embd.weight` and omit `output.weight` (llama.cpp
3341 // `llama_model_loader` falls back the same way). Prefer the
3342 // explicit head when present.
3343 let output_head = match load_weight_matrix(&file, "output.weight") {
3344 Ok(w) => w,
3345 Err(_) => load_weight_matrix(&file, "token_embd.weight")?,
3346 };
3347 // `output.bias` for the graphs that create it (`crate::proj_bias`).
3348 let output_bias = crate::proj_bias::load_output_bias(&file, &arch, output_head.rows())?;
3349
3350 // Second pass: attach the one shared store to every
3351 // store-backed layer. Opening the shard files fresh (plain
3352 // `File` handles for positional reads, not mmaps) keeps the
3353 // stored experts' bytes out of the process's mapped footprint
3354 // entirely.
3355 if !store_segments.is_empty() {
3356 let budget = expert_cache_bytes
3357 .expect("store_segments only populated when a cache budget is set")
3358 as usize;
3359 let files: Result<Vec<std::fs::File>, std::io::Error> =
3360 file.shard_paths().iter().map(std::fs::File::open).collect();
3361 let files = files.map_err(GgufError::from)?;
3362 let store = std::sync::Arc::new(ExpertStore::new(
3363 GgufExpertSource {
3364 files,
3365 segments: store_segments,
3366 },
3367 budget,
3368 ));
3369 for (l, layer) in layers.iter_mut().enumerate() {
3370 if let Some(layouts) = stored_layouts.get_mut(l).and_then(Option::take) {
3371 layer.moe.experts = ExpertBacking::Stored {
3372 store: std::sync::Arc::clone(&store),
3373 layouts,
3374 layer: l as u32,
3375 };
3376 }
3377 }
3378 }
3379
3380 config.qk_norm_style = refined_qk_norm;
3381
3382 let family = crate::capability::resolve_profile(
3383 file.metadata_str("general.architecture").unwrap_or("llama"),
3384 )
3385 .map(|p| p.family)
3386 .unwrap_or(crate::capability::DecoderFamily::StandardGqa);
3387 let memory_kind = crate::capability::resolve_profile(
3388 file.metadata_str("general.architecture").unwrap_or("llama"),
3389 )
3390 .map(|p| p.memory)
3391 .unwrap_or(crate::capability::MemoryKind::KvGqa);
3392 let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
3393 &config,
3394 family,
3395 memory_kind,
3396 crate::execution_plan::ExecutionPlan::probe_metal_caps(),
3397 );
3398
3399 let alibi_slopes = crate::decoder::config_alibi_slopes(&config);
3400 let decoder = Decoder {
3401 config,
3402 embedding,
3403 position_embd,
3404 embedding_norm,
3405 // `hrm-text.cpp:46` creates it REQUIRED, and only that
3406 // graph does (`crate::hrm`); the loader reads it for the
3407 // architecture whose schedule needs it and for no other.
3408 hrm_z_l_init,
3409 alibi_slopes,
3410 layers,
3411 final_norm,
3412 output_head,
3413 output_bias,
3414 gpu_vram_budget_bytes: None,
3415 gpt_oss: if is_gpt_oss {
3416 Some(crate::decoder::GptOssWeights {
3417 layers: gpt_oss_layers,
3418 })
3419 } else {
3420 None
3421 },
3422 qk_norm_after_rope: QK_NORM_AFTER_ROPE_ARCHITECTURES.contains(&arch.as_str()),
3423 #[cfg(feature = "metal")]
3424 metal_attn_kv: std::sync::Mutex::new(None),
3425 execution_plan,
3426 kv_window: crate::decoder::KvWindowPolicy::from_env(),
3427 plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
3428 lora_adapters: Vec::new(),
3429 };
3430 // Resolve every kernel the model will need while we still have a
3431 // load-time error path to report it on, then seal: from here a
3432 // lookup that misses is an unpredicted slow path and says so.
3433 decoder.probe_kernels();
3434 frink_core::kernel_registry::seal_or_error()
3435 .map_err(|e| LoadError::StrictKernels(e.to_string()))?;
3436 // `ModelConfig` is parsed from a *different* handle on the same
3437 // file (the CLI opens its own `GgufFile`, then hands the config
3438 // here), so the model-level tensors it consumed were recorded on
3439 // that handle, not this one. Replay them before the gate, or
3440 // every Llama-3.x checkpoint reads as carrying an unread
3441 // `rope_freqs.weight` it in fact uses on every RoPE call.
3442 for name in crate::config::MODEL_LEVEL_TENSORS_READ_BY_CONFIG {
3443 file.note_consumed(name);
3444 }
3445 // The NextN/MTP blocks llama.cpp creates `TENSOR_SKIP` and never
3446 // runs (`crate::mtp_blocks`): deliberately unread, and said so,
3447 // rather than left for the gate below to report as a term the
3448 // graph is missing. The range is the config's, so the layer
3449 // loop above and this mark cannot disagree about where the
3450 // trunk ends.
3451 let skipped = crate::mtp_blocks::note_mtp_blocks_skipped(
3452 &file,
3453 &crate::mtp_blocks::TrunkLayers {
3454 block_count: n_physical + decoder.config.n_mtp_blocks,
3455 n_layers: n_physical,
3456 n_mtp_blocks: decoder.config.n_mtp_blocks,
3457 },
3458 );
3459 if skipped > 0 {
3460 eprintln!(
3461 "frink: skipping {} NextN/MTP block(s) after layer {} ({skipped} tensors), as \
3462 llama.cpp does",
3463 decoder.config.n_mtp_blocks,
3464 n_physical - 1
3465 );
3466 }
3467 // Slots llama.cpp creates and never reads (`crate::unread_tensors`):
3468 // ignored as upstream ignores them, and said so.
3469 let ignored = crate::unread_tensors::note_unread_layer_tensors(&file, &arch, n_physical);
3470 if !ignored.is_empty() {
3471 eprintln!(
3472 "frink: ignoring {} tensor(s) llama.cpp creates and never reads for `{}` \
3473 (first: {}), as llama.cpp does",
3474 ignored.len(),
3475 arch,
3476 ignored[0]
3477 );
3478 }
3479 assert_every_tensor_consumed(&file)?;
3480 Ok(decoder)
3481 }
3482}
3483
3484/// Tensor-name prefixes a text-generation load legitimately never
3485/// reads. Everything here is consumed by a *different* code path, not by
3486/// nothing: multimodal projector planes belong to `mmproj`, and the
3487/// per-shard split bookkeeping is metadata, not weights.
3488const IGNORED_TENSOR_PREFIXES: &[&str] = &["mm.", "v.", "mmproj.", "resampler.", "audio."];
3489
3490/// Fails the load when the checkpoint carries tensors this build never
3491/// looked at.
3492///
3493/// A tensor nobody reads is not a harmless extra: it is a term of the
3494/// real graph that ours is missing. gpt-oss ships `blk.N.attn_sinks`
3495/// and frink has no attention-sink code anywhere, so the file loads,
3496/// runs at full speed, and emits a different distribution than the model
3497/// it claims to be; the newer MoE recipes ship `ffn_exp_probs_b` the
3498/// same way. Both are silent today, and both are exactly what the
3499/// architecture registry cannot catch, because the architecture *string*
3500/// is one frink does support -- it is the checkpoint that carries more
3501/// than the registry entry promises.
3502///
3503/// This is deliberately the last check in the load: by here every loader
3504/// arm has had its chance to ask for what it needs, so what is left over
3505/// is what nothing in this build knows about.
3506///
3507/// `FRINK_ALLOW_UNKNOWN_TENSORS=1` downgrades it to a warning, for the
3508/// case where a human has decided the missing term does not matter (a
3509/// bias tensor of zeros, an auxiliary head that never runs). The default
3510/// is refusal: a wrong answer is worse than no answer.
3511pub fn assert_every_tensor_consumed(file: &ShardedGguf) -> Result<(), LoadError> {
3512 let mut left: Vec<String> = file
3513 .unconsumed_tensors()
3514 .into_iter()
3515 .filter(|n| !IGNORED_TENSOR_PREFIXES.iter().any(|p| n.starts_with(p)))
3516 .collect();
3517 if left.is_empty() {
3518 return Ok(());
3519 }
3520 left.sort();
3521 let shown = left.iter().take(8).cloned().collect::<Vec<_>>().join(", ");
3522 let listing = if left.len() > 8 {
3523 format!("{shown}, … (+{} more)", left.len() - 8)
3524 } else {
3525 shown
3526 };
3527 if matches!(
3528 std::env::var("FRINK_ALLOW_UNKNOWN_TENSORS").ok().as_deref(),
3529 Some("1") | Some("true") | Some("on")
3530 ) {
3531 eprintln!(
3532 "frink: WARNING -- {} tensor(s) in this checkpoint are never read \
3533 ({listing}); output may be wrong (FRINK_ALLOW_UNKNOWN_TENSORS=1)",
3534 left.len()
3535 );
3536 return Ok(());
3537 }
3538 Err(LoadError::UnconsumedTensors(left.len(), listing))
3539}
3540
3541#[cfg(test)]
3542mod tests {
3543
3544 /// A quantized 1-D tensor loads through the shared helper.
3545 ///
3546 /// This used to be six copies of `load_f32_vec`, and they had
3547 /// drifted badly: this one decoded twenty dtypes while the five
3548 /// architecture loaders decoded three (F32/F16/BF16). A quantizer
3549 /// that emits a Q8_0 norm or bias -- ordinary for aggressive
3550 /// quants -- loaded on the generic path and was rejected with
3551 /// `UnsupportedDtype` on GLM-5.2, Kimi, DeepSeek-MLA, Gemma-4 and
3552 /// the hybrid stack.
3553 ///
3554 /// This file's own comment predicted exactly that, about the same
3555 /// split one level down: "a dtype frink can decode should never be
3556 /// rejected here just because the *other* dispatch table below
3557 /// knows it -- that split is how a supported format turns into a
3558 /// load failure on the one checkpoint that uses it."
3559 #[test]
3560 fn a_quantized_one_dimensional_tensor_widens_through_the_shared_helper() {
3561 let values: Vec<f32> = (0..64).map(|i| (i as f32 - 32.0) * 0.25).collect();
3562 let quantized = frink_quant::quantize_q8_0(&values);
3563
3564 struct OneTensor {
3565 info: TensorInfo,
3566 bytes: Vec<u8>,
3567 }
3568 impl TensorSource for OneTensor {
3569 fn metadata(&self, _key: &str) -> Option<&frink_gguf::GgufValue> {
3570 None
3571 }
3572 fn find_tensor(&self, name: &str) -> Option<&TensorInfo> {
3573 (name == self.info.name).then_some(&self.info)
3574 }
3575 fn tensor_bytes(&self, _name: &str) -> Result<&[u8], GgufError> {
3576 Ok(&self.bytes)
3577 }
3578 fn tensor_mapped_range(
3579 &self,
3580 name: &str,
3581 ) -> Result<
3582 (
3583 std::sync::Arc<frink_gguf::MmapHandle>,
3584 std::ops::Range<usize>,
3585 ),
3586 GgufError,
3587 > {
3588 // Never reached: `load_f32_vec` widens from bytes.
3589 Err(GgufError::TensorNotFound(name.to_string()))
3590 }
3591 }
3592
3593 let source = OneTensor {
3594 info: TensorInfo {
3595 name: "blk.0.attn_norm.weight".to_string(),
3596 shape: vec![64],
3597 dtype: GgmlType::Q8_0,
3598 offset: 0,
3599 },
3600 bytes: quantized,
3601 };
3602
3603 let widened = load_f32_vec(&source, "blk.0.attn_norm.weight")
3604 .expect("a Q8_0 norm must load, not report an unsupported dtype");
3605 assert_eq!(widened.len(), values.len());
3606 for (got, want) in widened.iter().zip(values.iter()) {
3607 assert!(
3608 (got - want).abs() < 0.05,
3609 "q8_0 round trip: got {got}, want {want}"
3610 );
3611 }
3612 }
3613 use super::*;
3614 use byteorder::{LittleEndian, WriteBytesExt};
3615 use std::io::Write;
3616
3617 fn write_string(buf: &mut Vec<u8>, s: &str) {
3618 buf.write_u64::<LittleEndian>(s.len() as u64).unwrap();
3619 buf.write_all(s.as_bytes()).unwrap();
3620 }
3621
3622 fn write_kv_str(buf: &mut Vec<u8>, key: &str, val: &str) {
3623 write_string(buf, key);
3624 buf.write_u32::<LittleEndian>(8).unwrap(); // type = string
3625 write_string(buf, val);
3626 }
3627
3628 /// A minimal, tensor-free GGUF byte buffer declaring only
3629 /// `general.architecture` (no `{arch}.block_count` or any other
3630 /// hparam key) -- the shape a stripped-down or malformed file might
3631 /// take, and the exact case `ModelConfig::from_gguf` must reject
3632 /// loudly rather than silently default around.
3633 fn build_arch_only_gguf(arch: &str) -> Vec<u8> {
3634 let mut buf = Vec::new();
3635 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3636 .unwrap();
3637 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3638 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3639 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3640 write_kv_str(&mut buf, "general.architecture", arch);
3641 buf
3642 }
3643
3644 #[test]
3645 fn model_config_from_gguf_fails_loudly_when_required_hparams_are_missing() {
3646 let tmp =
3647 std::env::temp_dir().join(format!("frink_test_arch_only_{}.gguf", std::process::id()));
3648 // Use a registered architecture so the failure is MissingHparam,
3649 // not UnsupportedArchitecture.
3650 std::fs::write(&tmp, build_arch_only_gguf("llama")).unwrap();
3651 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3652 std::fs::remove_file(&tmp).ok();
3653
3654 match ModelConfig::from_gguf(&file) {
3655 Err(LoadError::MissingHparam(key)) => {
3656 assert_eq!(key, "llama.block_count");
3657 }
3658 other => panic!(
3659 "expected LoadError::MissingHparam for a file with no hparam keys, got {other:?}"
3660 ),
3661 }
3662 }
3663
3664 #[test]
3665 fn model_config_from_gguf_fails_closed_on_unknown_architecture() {
3666 let tmp = std::env::temp_dir().join(format!(
3667 "frink_test_unknown_arch_{}.gguf",
3668 std::process::id()
3669 ));
3670 std::fs::write(&tmp, build_arch_only_gguf("bogus-arch-with-no-hparams")).unwrap();
3671 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3672 std::fs::remove_file(&tmp).ok();
3673
3674 match ModelConfig::from_gguf(&file) {
3675 Err(LoadError::UnsupportedArchitecture(arch)) => {
3676 assert_eq!(arch, "bogus-arch-with-no-hparams");
3677 }
3678 other => panic!(
3679 "expected LoadError::UnsupportedArchitecture for an unregistered arch, got {other:?}"
3680 ),
3681 }
3682 }
3683
3684 fn write_kv_f32(buf: &mut Vec<u8>, key: &str, val: f32) {
3685 write_string(buf, key);
3686 buf.write_u32::<LittleEndian>(6).unwrap(); // type = float32
3687 buf.write_f32::<LittleEndian>(val).unwrap();
3688 }
3689
3690 /// `arch` plus one f32 hparam, so a metadata-only feature gate can be
3691 /// exercised without building a whole checkpoint.
3692 fn build_arch_plus_f32_gguf(arch: &str, key: &str, val: f32) -> Vec<u8> {
3693 let mut buf = Vec::new();
3694 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3695 .unwrap();
3696 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3697 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3698 buf.write_u64::<LittleEndian>(2).unwrap(); // kv_count
3699 write_kv_str(&mut buf, "general.architecture", arch);
3700 write_kv_f32(&mut buf, key, val);
3701 buf
3702 }
3703
3704 fn config_error_for(arch: &str, key: &str, val: f32, tag: &str) -> LoadError {
3705 let tmp = std::env::temp_dir().join(format!("frink_test_scale_{tag}.gguf"));
3706 std::fs::write(&tmp, build_arch_plus_f32_gguf(arch, key, val)).unwrap();
3707 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3708 std::fs::remove_file(&tmp).ok();
3709 ModelConfig::from_gguf(&file).expect_err("must not succeed")
3710 }
3711
3712 /// Granite / MiniCPM / Command-R multipliers are hparams, not
3713 /// tensors, so `assert_every_tensor_consumed` cannot see them: a
3714 /// checkpoint declaring one loads, runs at full speed, and computes
3715 /// a differently-scaled graph than it was trained as. An
3716 /// architecture whose reference graph does not apply one must refuse
3717 /// it by name.
3718 ///
3719 /// Driven on `llama` rather than on `granite`, and that swap is the
3720 /// point: `granite` APPLIES all four now
3721 /// (`crate::scalar_multipliers`), so leaving the case here would
3722 /// have turned this test into a test of nothing the day the feature
3723 /// landed. llama.cpp's llama graph reads none of the four keys, so a
3724 /// `llama` checkpoint declaring one is exactly the silent divergence
3725 /// the gate exists for.
3726 #[test]
3727 fn a_declared_multiplier_this_decoder_does_not_apply_is_refused_by_name() {
3728 for (key, val) in [
3729 ("llama.logit_scale", 6.0f32),
3730 ("llama.residual_scale", 0.22),
3731 ("llama.embedding_scale", 12.0),
3732 ("llama.attention.scale", 0.015_625),
3733 ] {
3734 let tag = key.replace('.', "_");
3735 match config_error_for("llama", key, val, &tag) {
3736 LoadError::UnsupportedFeature(arch, msg) => {
3737 assert_eq!(arch, "llama");
3738 assert!(msg.contains(key), "error must name the key: {msg}");
3739 }
3740 other => panic!("expected UnsupportedFeature for {key}, got {other:?}"),
3741 }
3742 }
3743 }
3744
3745 /// The complement, and the half that would otherwise have gone
3746 /// missing: `granite` must NOT be refused for the keys its graph
3747 /// applies.
3748 ///
3749 /// The refusal list and the implementation are two views of ONE
3750 /// table (`scalar_multipliers::multiplier_support`), so this test
3751 /// and the one above cannot both pass while they disagree -- which
3752 /// is the whole value of deriving the list rather than restating it.
3753 #[test]
3754 fn granite_is_not_refused_for_the_multipliers_it_applies() {
3755 for (key, val) in [
3756 ("granite.logit_scale", 8.0f32),
3757 ("granite.residual_scale", 0.22),
3758 ("granite.embedding_scale", 12.0),
3759 ("granite.attention.scale", 0.015_625),
3760 ] {
3761 let tag = format!("granite_ok_{}", key.replace('.', "_"));
3762 // The file carries no `block_count`, so the load still fails
3763 // -- but on the *missing hparam*, having passed this gate.
3764 match config_error_for("granite", key, val, &tag) {
3765 LoadError::MissingHparam(k) => assert_eq!(k, "granite.block_count"),
3766 other => panic!("{key}={val} must pass the scaling gate, got {other:?}"),
3767 }
3768 }
3769 }
3770
3771 /// The gate must not fire on a multiplier that is a no-op. A file
3772 /// writing `residual_scale = 1.0` describes the graph frink already
3773 /// computes, and refusing it would be a false alarm. llama.cpp's
3774 /// `f_attention_scale` uses `0.0` rather than `1.0` as its "unset"
3775 /// sentinel, so the two are checked against their own no-op values.
3776 #[test]
3777 fn a_multiplier_that_is_a_no_op_is_not_refused() {
3778 for (key, val) in [
3779 ("llama.logit_scale", 1.0f32),
3780 ("llama.residual_scale", 1.0),
3781 ("llama.embedding_scale", 1.0),
3782 ("llama.attention.scale", 0.0),
3783 ] {
3784 let tag = format!("noop_{}", key.replace('.', "_"));
3785 // The file carries no `block_count`, so the load still fails
3786 // -- but on the *missing hparam*, having passed this gate.
3787 match config_error_for("llama", key, val, &tag) {
3788 LoadError::MissingHparam(k) => assert_eq!(k, "llama.block_count"),
3789 other => panic!("no-op {key}={val} must pass the scaling gate, got {other:?}"),
3790 }
3791 }
3792 }
3793
3794 /// One GGUF metadata value, in the three types these header-only
3795 /// fixtures need.
3796 enum Kv<'a> {
3797 Str(&'a str),
3798 U32(u32),
3799 F32(f32),
3800 /// A uint32 ARRAY. Only one gate needs it -- the sliding-window
3801 /// pattern, which llama.cpp reads with `ml.get_key_or_arr` --
3802 /// and without it that gate could only be tested through a
3803 /// value of some other type, which is not the case it exists
3804 /// for.
3805 Arr32(&'a [u32]),
3806 }
3807
3808 /// A tensor-free GGUF carrying exactly `kvs` -- enough for
3809 /// `ModelConfig::from_gguf` to run without a single weight on disk.
3810 fn build_metadata_gguf(kvs: &[(&str, Kv)]) -> Vec<u8> {
3811 let mut buf = Vec::new();
3812 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3813 .unwrap();
3814 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3815 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3816 buf.write_u64::<LittleEndian>(kvs.len() as u64).unwrap();
3817 for (k, v) in kvs {
3818 match v {
3819 Kv::Str(s) => write_kv_str(&mut buf, k, s),
3820 Kv::U32(n) => {
3821 write_string(&mut buf, k);
3822 buf.write_u32::<LittleEndian>(4).unwrap(); // type = uint32
3823 buf.write_u32::<LittleEndian>(*n).unwrap();
3824 }
3825 Kv::F32(f) => write_kv_f32(&mut buf, k, *f),
3826 Kv::Arr32(values) => {
3827 write_string(&mut buf, k);
3828 buf.write_u32::<LittleEndian>(9).unwrap(); // type = array
3829 buf.write_u32::<LittleEndian>(4).unwrap(); // element type = uint32
3830 buf.write_u64::<LittleEndian>(values.len() as u64).unwrap();
3831 for v in *values {
3832 buf.write_u32::<LittleEndian>(*v).unwrap();
3833 }
3834 }
3835 }
3836 }
3837 buf
3838 }
3839
3840 fn open_metadata_gguf(tag: &str, kvs: &[(&str, Kv)]) -> frink_gguf::GgufFile {
3841 let tmp = std::env::temp_dir().join(format!("frink_test_meta_{tag}.gguf"));
3842 std::fs::write(&tmp, build_metadata_gguf(kvs)).unwrap();
3843 let file = frink_gguf::GgufFile::open(&tmp).expect("header-only file must parse");
3844 std::fs::remove_file(&tmp).ok();
3845 file
3846 }
3847
3848 /// A minimal `llama` hparam set (64-wide single head, base 10000)
3849 /// plus whatever RoPE-scaling keys a test wants to add.
3850 fn llama_config_with(tag: &str, extra: &[(&str, Kv)]) -> ModelConfig {
3851 let mut kvs: Vec<(&str, Kv)> = vec![
3852 ("general.architecture", Kv::Str("llama")),
3853 ("llama.block_count", Kv::U32(1)),
3854 ("llama.embedding_length", Kv::U32(64)),
3855 ("llama.attention.head_count", Kv::U32(1)),
3856 ("llama.attention.head_count_kv", Kv::U32(1)),
3857 ("llama.attention.key_length", Kv::U32(64)),
3858 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3859 ];
3860 for (k, v) in extra {
3861 kvs.push((
3862 k,
3863 match v {
3864 Kv::Str(s) => Kv::Str(s),
3865 Kv::U32(n) => Kv::U32(*n),
3866 Kv::F32(f) => Kv::F32(*f),
3867 Kv::Arr32(a) => Kv::Arr32(a),
3868 },
3869 ));
3870 }
3871 ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("fixture must load")
3872 }
3873
3874 /// Builds a config for an arbitrary architecture tag, returning the
3875 /// error rather than unwrapping it.
3876 /// llama.cpp chooses the FFN gate activation PER ARCHITECTURE;
3877 /// frink chose it per family. Those are different partitions, and
3878 /// `grok` is where they disagree: `src/models/grok.cpp:165` passes
3879 /// `LLM_FFN_GELU` to `build_moe_ffn`, while `grok` is
3880 /// `DecoderFamily::StandardGqa` and so was handed SwiGLU -- a
3881 /// different FFN on every layer.
3882 ///
3883 /// It was pinned here while `grok` still refused, because the
3884 /// failure mode is that auditing it later makes it silently wrong,
3885 /// and an audit is exactly when nobody thinks to re-check the
3886 /// activation. `grok` is audited now (tests/grok_graphs.rs), and the
3887 /// fixture's GELU experts are what that suite compares.
3888 #[test]
3889 fn the_ffn_activation_follows_the_architecture_not_the_family() {
3890 use crate::capability::uses_geglu;
3891 use crate::config::FfnActivation;
3892
3893 assert!(uses_geglu("grok"), "grok's MoE FFN gate is GELU upstream");
3894 // Same family, SiLU upstream (`src/models/dbrx.cpp:122`), so the
3895 // family rule alone cannot be what selects grok.
3896 assert!(!uses_geglu("dbrx"));
3897 assert!(!uses_geglu("llama"));
3898
3899 // The Gemma lineage keeps its GELU through the FAMILY rule, so
3900 // the new per-architecture arm must not have displaced it.
3901 // gemma2/gemma3 only: `gemma` v1 is unaudited and refuses, so
3902 // it cannot be loaded to check its activation.
3903 for gemma in ["gemma2", "gemma3"] {
3904 assert!(
3905 !uses_geglu(gemma),
3906 "{gemma} is GELU via GemmaFamily; listing it here too \
3907 would hide a later regression in the family rule"
3908 );
3909 assert_eq!(
3910 config_for_arch(gemma).expect("gemma loads").ffn_activation,
3911 FfnActivation::Gelu,
3912 "{gemma}"
3913 );
3914 }
3915
3916 // And a plain SwiGLU architecture stays SwiGLU.
3917 assert_eq!(
3918 config_for_arch("llama")
3919 .expect("llama loads")
3920 .ffn_activation,
3921 FfnActivation::Swiglu
3922 );
3923
3924 // The ungated ReLU-squared row, and the four that share its FFN
3925 // and refuse for something else (`capability::uses_relu_sqr`).
3926 assert_eq!(
3927 config_for_arch("arcee")
3928 .expect("arcee loads")
3929 .ffn_activation,
3930 FfnActivation::ReluSqr
3931 );
3932 for shared in ["plm", "nemotron", "jais2", "nemotron_h"] {
3933 assert!(crate::capability::uses_relu_sqr(shared), "{shared}");
3934 }
3935 assert!(!crate::capability::uses_relu_sqr("llama"));
3936 }
3937
3938 /// A per-layer array whose entries differ, on an architecture whose
3939 /// llama.cpp graph reads layer 0, is refused naming the table; the
3940 /// same arrays with equal entries are the uniform model, for any
3941 /// architecture, because a converter may spell a scalar as a list.
3942 ///
3943 /// Reachability, not only `LayerShapes::resolve`'s own unit test:
3944 /// this goes through `from_gguf` on a header-only file, which is
3945 /// where `openelm` used to die on `MissingHparam` for a key its
3946 /// file carried.
3947 #[test]
3948 fn a_varying_per_layer_array_is_refused_on_a_layer_zero_architecture_and_equal_ones_are_uniform(
3949 ) {
3950 let kvs = [
3951 ("general.architecture", Kv::Str("llama")),
3952 ("llama.block_count", Kv::U32(2)),
3953 ("llama.embedding_length", Kv::U32(64)),
3954 ("llama.attention.head_count", Kv::Arr32(&[2, 2])),
3955 ("llama.attention.head_count_kv", Kv::Arr32(&[2, 1])),
3956 ("llama.attention.key_length", Kv::U32(32)),
3957 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3958 ];
3959 let err = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_vary", &kvs))
3960 .expect_err("llama takes layer 0 upstream");
3961 let msg = err.to_string();
3962 assert!(msg.contains("PER_LAYER_SHAPE_ARCHS"), "{msg}");
3963 assert!(msg.contains("LLAMA_LOAD_LOCALS"), "{msg}");
3964
3965 let kvs = [
3966 ("general.architecture", Kv::Str("llama")),
3967 ("llama.block_count", Kv::U32(2)),
3968 ("llama.embedding_length", Kv::U32(64)),
3969 ("llama.attention.head_count", Kv::Arr32(&[2, 2])),
3970 ("llama.attention.head_count_kv", Kv::Arr32(&[1, 1])),
3971 ("llama.attention.key_length", Kv::U32(32)),
3972 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3973 ];
3974 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_equal", &kvs))
3975 .expect("equal arrays are the uniform model");
3976 assert!(cfg.layer_shapes.is_uniform());
3977 assert_eq!((cfg.n_heads, cfg.n_kv_heads), (2, 1));
3978
3979 // An array of the wrong length is refused as llama.cpp refuses
3980 // it (`key has wrong array length`).
3981 let kvs = [
3982 ("general.architecture", Kv::Str("llama")),
3983 ("llama.block_count", Kv::U32(2)),
3984 ("llama.embedding_length", Kv::U32(64)),
3985 ("llama.attention.head_count", Kv::Arr32(&[2, 2, 2])),
3986 ("llama.attention.key_length", Kv::U32(32)),
3987 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3988 ];
3989 let err = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_len", &kvs))
3990 .expect_err("three entries for two layers");
3991 assert!(err.to_string().contains("wrong array length"), "{err}");
3992 }
3993
3994 /// The no-renormalise list is keyed on what llama.cpp's GRAPH does,
3995 /// not on what a GGUF says, because for these architectures the
3996 /// GGUF says nothing.
3997 ///
3998 /// `expert_weights_norm` is only written by converters that set it.
3999 /// `deepseek.cpp:145` passes `norm_w=false`, and
4000 /// `conversion/deepseek.py`'s `DeepseekModel` never writes the key
4001 /// -- only `DeepseekV2Model` does. So a real `deepseek` checkpoint
4002 /// carries no key at all and frink fell through to its default,
4003 /// renormalising the selected experts' softmax weights where
4004 /// llama.cpp leaves them alone.
4005 ///
4006 /// The same mistake made OLMoE emit garbage, which is why that list
4007 /// exists. This pins the membership so a later edit cannot quietly
4008 /// drop a name back into the renormalising default.
4009 #[test]
4010 fn the_architectures_llama_cpp_does_not_renormalise_are_pinned() {
4011 for arch in ["deepseek", "olmoe", "qwen2moe"] {
4012 assert!(
4013 NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&arch),
4014 "{arch} passes norm_w=false in llama.cpp and must not be renormalised"
4015 );
4016 }
4017 // `deepseek2` is a DIFFERENT architecture whose converter DOES
4018 // write the key, so it must not be on this list -- it gets its
4019 // answer from the file.
4020 assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"deepseek2"));
4021 assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen3moe"));
4022 }
4023
4024 /// Every architecture llama.cpp defaults to SIGMOID gating must be
4025 /// on the list, because for these the GGUF carries no key to say so.
4026 ///
4027 /// Each of these reads `LLM_KV_EXPERT_GATING_FUNC` as optional and
4028 /// then sets SIGMOID when it is absent, so a converted checkpoint
4029 /// has nothing in it that would correct frink's softmax default.
4030 /// Same shape as the `deepseek` top-k renormalisation bug, and as
4031 /// `phi3`'s sliding window: the file is silent and the architecture
4032 /// decides.
4033 /// The literal table and its name list are two spellings of one
4034 /// fact; this is what keeps them one.
4035 #[test]
4036 fn the_gating_literal_names_are_the_gating_literal_table() {
4037 let from_table: Vec<&str> = GATING_LITERAL_ARCHITECTURES
4038 .iter()
4039 .map(|(n, _)| *n)
4040 .collect();
4041 assert_eq!(from_table, GATING_LITERAL_NAMES);
4042 // `mimo2.cpp:227` passes the SIGMOID literal, so the key is
4043 // never read there; a hand-written SOFTMAX key must not turn it.
4044 assert!(matches!(
4045 GATING_LITERAL_ARCHITECTURES
4046 .iter()
4047 .find(|(n, _)| *n == "mimo2")
4048 .map(|(_, g)| *g),
4049 Some(GatingFunction::Sigmoid)
4050 ));
4051 }
4052
4053 #[test]
4054 fn the_architectures_llama_cpp_defaults_to_sigmoid_gating_are_pinned() {
4055 for arch in ["afmoe", "deepseek2", "glm4moe", "laguna", "step35"] {
4056 assert!(
4057 SIGMOID_GATING_ARCHITECTURES.contains(&arch),
4058 "{arch} sets SIGMOID when the gating key is absent"
4059 );
4060 }
4061 // Architectures that HARDCODE softmax must stay off it, or the
4062 // fix becomes the opposite bug: `ernie4-5-moe.cpp:90` and
4063 // `qwen3moe` both gate with softmax unconditionally.
4064 for softmax in ["ernie4_5-moe", "qwen3moe", "olmoe", "llama"] {
4065 assert!(
4066 !SIGMOID_GATING_ARCHITECTURES.contains(&softmax),
4067 "{softmax} does not default to sigmoid"
4068 );
4069 }
4070 }
4071
4072 /// Every name in every architecture-keyed behaviour table is a name
4073 /// the catalog actually resolves, on the generic-GQA path.
4074 ///
4075 /// These five tables are the repo's dominant bug shape in its purest
4076 /// form: five lists of strings that have to agree with a sixth
4077 /// structure (`capability::architecture_catalog`) about what an
4078 /// architecture is called, with nothing checking it. A typo, a
4079 /// hyphen where the GGUF has an underscore, or a name that later
4080 /// moves to a dedicated stack all produce the same thing -- an entry
4081 /// that reads as coverage and can never fire. This repo has shipped
4082 /// exactly that once already, in `unsupported_feature_keys`, keyed
4083 /// on a GGUF spelling no converter writes.
4084 ///
4085 /// The generic-path check is the second half and the sharper one: a
4086 /// behaviour flag on an architecture that is `DedicatedOnly` or
4087 /// `Deferred` never reaches this loader, so it is dead text.
4088 ///
4089 /// Sabotage to confirm: add `"seedoss"` to any list below.
4090 #[test]
4091 fn every_architecture_keyed_behaviour_table_names_a_real_generic_row() {
4092 let tables: &[(&str, &[&str])] = &[
4093 ("SIGMOID_GATING_ARCHITECTURES", SIGMOID_GATING_ARCHITECTURES),
4094 ("GATING_LITERAL_ARCHITECTURES", GATING_LITERAL_NAMES),
4095 ("EXPERT_WEIGHTS_SCALE_READERS", EXPERT_WEIGHTS_SCALE_READERS),
4096 ("EXPERT_WEIGHTS_NORM_READERS", EXPERT_WEIGHTS_NORM_READERS),
4097 (
4098 "NO_TOPK_RENORMALIZE_ARCHITECTURES",
4099 NO_TOPK_RENORMALIZE_ARCHITECTURES,
4100 ),
4101 (
4102 "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
4103 crate::norm_sites::PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
4104 ),
4105 (
4106 "PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM",
4107 crate::norm_sites::PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM,
4108 ),
4109 (
4110 "POST_NORMS_UNDER_GROK_NAMES",
4111 crate::norm_sites::POST_NORMS_UNDER_GROK_NAMES,
4112 ),
4113 (
4114 "ATTN_NORM_2_FEEDS_ATTENTION",
4115 crate::norm_sites::ATTN_NORM_2_FEEDS_ATTENTION,
4116 ),
4117 ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
4118 (
4119 "QK_NORM_AFTER_ROPE_ARCHITECTURES",
4120 QK_NORM_AFTER_ROPE_ARCHITECTURES,
4121 ),
4122 ];
4123 for (table, names) in tables {
4124 for arch in *names {
4125 let profile = crate::capability::resolve_profile(arch).unwrap_or_else(|| {
4126 panic!("{table} names `{arch}`, which the catalog does not have")
4127 });
4128 if matches!(profile.path, crate::capability::ArchPath::GenericGqa { .. }) {
4129 continue;
4130 }
4131 // Not a generic row, so the entry cannot fire HERE.
4132 // That is allowed only when something else is named as
4133 // applying the behaviour instead. An unexplained dead
4134 // entry still fails, which is the whole point.
4135 let owner = DEDICATED_OWNS_ITS_BEHAVIOUR
4136 .iter()
4137 .find(|(name, _)| name == arch)
4138 .map(|(_, owner)| *owner);
4139 assert!(
4140 owner.is_some(),
4141 "{table} names `{arch}`, which resolves to {:?} and never reaches this \
4142 loader, so the entry cannot fire. Either drop it, or add it to \
4143 DEDICATED_OWNS_ITS_BEHAVIOUR naming what applies the behaviour instead",
4144 profile.path
4145 );
4146 }
4147 }
4148 }
4149
4150 /// The three tables that describe how a layer is BUILT, rather than
4151 /// how it is routed, only carry architectures that are audited.
4152 ///
4153 /// The distinction matters and is not pedantry. A routing default
4154 /// (`SIGMOID_GATING_ARCHITECTURES`, `NO_TOPK_RENORMALIZE_ARCHITECTURES`)
4155 /// is allowed to name an architecture that still refuses: it is
4156 /// written down ahead of time so a later admission inherits the
4157 /// right answer, and the tables say so. But the three below change
4158 /// which TENSOR a layer reads and in what order -- and each was
4159 /// added for exactly one architecture, whose fixture is the only
4160 /// thing proving the change is right. A fourth name appearing here
4161 /// without evidence would be a claim about a graph nobody read,
4162 /// carried by a list whose doc comment cites two.
4163 #[test]
4164 fn the_layer_shape_tables_only_name_audited_architectures() {
4165 for (table, names) in [
4166 (
4167 "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
4168 crate::norm_sites::PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
4169 ),
4170 (
4171 "PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM",
4172 crate::norm_sites::PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM,
4173 ),
4174 (
4175 "POST_NORMS_UNDER_GROK_NAMES",
4176 crate::norm_sites::POST_NORMS_UNDER_GROK_NAMES,
4177 ),
4178 (
4179 "ATTN_NORM_2_FEEDS_ATTENTION",
4180 crate::norm_sites::ATTN_NORM_2_FEEDS_ATTENTION,
4181 ),
4182 ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
4183 (
4184 "QK_NORM_AFTER_ROPE_ARCHITECTURES",
4185 QK_NORM_AFTER_ROPE_ARCHITECTURES,
4186 ),
4187 ] {
4188 for arch in names {
4189 assert!(
4190 crate::capability::is_audited_generic(arch),
4191 "{table} names `{arch}`, which is not in AUDITED_GENERIC_GQA. Either it \
4192 has a fixture proving the change is right -- audit it -- or the entry \
4193 is a guess about a graph"
4194 );
4195 }
4196 }
4197 }
4198
4199 fn config_for_arch(arch: &'static str) -> Result<ModelConfig, LoadError> {
4200 // The per-arch hyperparameter keys are looked up by the arch's
4201 // own prefix, so they have to be built for the arch under test.
4202 let keys: Vec<String> = [
4203 "block_count",
4204 "embedding_length",
4205 "attention.head_count",
4206 "attention.head_count_kv",
4207 "attention.key_length",
4208 ]
4209 .iter()
4210 .map(|k| format!("{arch}.{k}"))
4211 .collect();
4212 let theta = format!("{arch}.rope.freq_base");
4213 let kvs: Vec<(&str, Kv)> = vec![
4214 ("general.architecture", Kv::Str(arch)),
4215 (keys[0].as_str(), Kv::U32(1)),
4216 (keys[1].as_str(), Kv::U32(64)),
4217 (keys[2].as_str(), Kv::U32(1)),
4218 (keys[3].as_str(), Kv::U32(1)),
4219 (keys[4].as_str(), Kv::U32(64)),
4220 (theta.as_str(), Kv::F32(10_000.0)),
4221 ];
4222 ModelConfig::from_gguf(&open_metadata_gguf(arch, &kvs))
4223 }
4224
4225 /// The generic path is OPT-IN, and this is what proves it.
4226 ///
4227 /// An architecture nobody has checked used to FALL ONTO generic GQA
4228 /// and run. Five did exactly that and computed the wrong thing for
4229 /// the life of the project. The refusal exists; nothing tested it,
4230 /// so a reordering or an unevidenced addition to
4231 /// `AUDITED_GENERIC_GQA` would have gone unnoticed.
4232 #[test]
4233 fn an_unaudited_generic_architecture_refuses_rather_than_guessing() {
4234 // `grovemoe` is on the generic path and is not in the audited
4235 // list. It is the sixth name to hold this slot: `starcoder` was
4236 // first, until an audit found it REQUIRES a fused
4237 // `attn_qkv.bias` and a learned `position_embd` the generic
4238 // decoder has no slot for, so it refuses for a stronger reason;
4239 // then `xverse`, until it was admitted with a libllama-golden
4240 // fixture (`tests/fixture_away_graphs.rs`); then `nanbeige`,
4241 // until the layer loop became `crate::layer_loops`; then
4242 // `talkie`, until `crate::skip_stream`; then `arctic`, until
4243 // `crate::parallel_dense_ffn`. `grovemoe` runs a SECOND expert
4244 // bank (`src/models/grovemoe.cpp:57-59,137-164`) whose upstream
4245 // graph diverges from the reference, and its blocker is
4246 // invisible in metadata, so nothing but this gate stops it.
4247 assert!(
4248 !crate::capability::is_audited_generic("grovemoe"),
4249 "this test needs an arch that is generic AND unaudited"
4250 );
4251 match config_for_arch("grovemoe") {
4252 Err(LoadError::UnauditedArchitecture(name, ..)) => assert_eq!(name, "grovemoe"),
4253 other => panic!("expected an unaudited refusal, got {other:?}"),
4254 }
4255 }
4256
4257 /// An architecture with evidence still loads, or the inversion would
4258 /// have turned every model off.
4259 #[test]
4260 fn an_audited_architecture_still_loads() {
4261 assert!(crate::capability::is_audited_generic("llama"));
4262 assert!(config_for_arch("llama").is_ok());
4263 }
4264
4265 /// A NAMED problem must outrank "unaudited".
4266 ///
4267 /// `grovemoe` is unaudited AND names its second expert bank; a
4268 /// `llama4` file declaring a window of zero names the branch
4269 /// libllama aborts on (`crate::chunked_swa`), and that is what its
4270 /// refusal should say. Reporting "unaudited" instead would be true
4271 /// and far less useful, and it is the ordering the loader's own
4272 /// comment claims. Nothing checked that claim. (`gpt2`, `bloom` and
4273 /// then a plain `llama4` were the example until each was served.)
4274 #[test]
4275 fn a_named_refusal_outranks_the_unaudited_one() {
4276 let kvs: Vec<(&str, Kv)> = vec![
4277 ("general.architecture", Kv::Str("llama4")),
4278 ("llama4.block_count", Kv::U32(1)),
4279 ("llama4.embedding_length", Kv::U32(64)),
4280 ("llama4.attention.head_count", Kv::U32(1)),
4281 ("llama4.attention.head_count_kv", Kv::U32(1)),
4282 ("llama4.attention.key_length", Kv::U32(64)),
4283 ("llama4.rope.freq_base", Kv::F32(10_000.0)),
4284 ("llama4.expert_count", Kv::U32(16)),
4285 ("llama4.interleave_moe_layer_step", Kv::U32(1)),
4286 ("llama4.attention.sliding_window", Kv::U32(0)),
4287 ];
4288 let err = ModelConfig::from_gguf(&open_metadata_gguf("llama4", &kvs))
4289 .expect_err("a zero window must refuse");
4290 assert!(
4291 !matches!(err, LoadError::UnauditedArchitecture(..)),
4292 "llama4 should report its own reason, not that nobody audited it: {err:?}"
4293 );
4294 assert!(err.to_string().contains("llama-graph.cpp:159"), "{err}");
4295 }
4296
4297 /// A checkpoint that declares YaRN gets the per-band divisors the
4298 /// reference's `"yarn"` arm implies, folded into `rope_freqs` so the
4299 /// existing RoPE kernels apply them. Expected values are hand-derived
4300 /// from `_find_correction_dim` for this fixture (rotary width 64,
4301 /// base 10000, original context 131072): `low = 22`, `high = 35`.
4302 ///
4303 /// Before this, frink read neither `rope.scaling.type` nor
4304 /// `rope.scaling.factor`, so this file roped exactly like an
4305 /// unscaled one -- correct near position 0, progressively wrong
4306 /// further in.
4307 #[test]
4308 fn a_gguf_declaring_yarn_gets_its_rope_frequencies_rewritten() {
4309 let cfg = llama_config_with(
4310 "yarn",
4311 &[
4312 ("llama.rope.scaling.type", Kv::Str("yarn")),
4313 ("llama.rope.scaling.factor", Kv::F32(8.0)),
4314 (
4315 "llama.rope.scaling.original_context_length",
4316 Kv::U32(131_072),
4317 ),
4318 ],
4319 );
4320 let factors = cfg
4321 .rope_freqs
4322 .expect("a YaRN checkpoint must carry rewritten per-band frequencies")
4323 .full;
4324 assert_eq!(factors.len(), 32, "one divisor per rotation band");
4325 assert!(
4326 (factors[0] - 1.0).abs() < 1e-6,
4327 "the fastest band is left extrapolated, got {}",
4328 factors[0]
4329 );
4330 let ramp = (31.0 - 22.0) / (35.0 - 22.0);
4331 let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
4332 assert!(
4333 (factors[31] - want).abs() < 1e-4,
4334 "slowest band: got {}, reference {want}",
4335 factors[31]
4336 );
4337 }
4338
4339 /// The rewrite must not fire on a file that did not ask for it. A
4340 /// scaling type frink does not implement (`linear`, `longrope`) is
4341 /// left exactly as it was rather than being roped as YaRN, which
4342 /// would be a new kind of wrong rather than the current known one.
4343 /// `rope.scaling.type = "linear"` must actually scale.
4344 ///
4345 /// Rotating position `p/s` is the same as rotating `p` with every
4346 /// band's frequency divided by `s`, and `rope_freqs` is exactly a
4347 /// per-band frequency divisor, so a uniform vector of `s` expresses
4348 /// linear scaling with no new code on the RoPE paths.
4349 ///
4350 /// Before this, the scaling type was compared against "yarn" and
4351 /// anything else returned None, so such a file loaded and roped at
4352 /// unscaled positions: a different model, no error.
4353 #[test]
4354 fn linear_scaling_is_applied_as_a_uniform_frequency_divisor() {
4355 let cfg = llama_config_with(
4356 "linear",
4357 &[
4358 ("llama.rope.scaling.type", Kv::Str("linear")),
4359 ("llama.rope.scaling.factor", Kv::F32(4.0)),
4360 ],
4361 );
4362 let freqs = &cfg
4363 .rope_freqs
4364 .as_ref()
4365 .expect("linear scaling must produce frequency factors")
4366 .full;
4367 assert_eq!(freqs.len(), cfg.head_dim / 2, "one factor per rotated pair");
4368 assert!(
4369 freqs.iter().all(|f| (*f - 4.0).abs() < 1e-6),
4370 "linear scaling is uniform across bands, unlike YaRN: got {freqs:?}"
4371 );
4372 }
4373
4374 /// A factor that corrects nothing is not a correction.
4375 #[test]
4376 fn a_linear_factor_of_one_is_treated_as_absent() {
4377 assert!(llama_config_with(
4378 "linear_one",
4379 &[
4380 ("llama.rope.scaling.type", Kv::Str("linear")),
4381 ("llama.rope.scaling.factor", Kv::F32(1.0)),
4382 ],
4383 )
4384 .rope_freqs
4385 .is_none());
4386 }
4387
4388 #[test]
4389 fn a_gguf_without_yarn_scaling_keeps_its_rope_frequencies_untouched() {
4390 assert!(llama_config_with("noscale", &[]).rope_freqs.is_none());
4391 // Linear scaling is NOT "no scaling". It used to land here,
4392 // asserted as `is_none()`, on the reasoning that leaving
4393 // positions alone beat roping them wrong in a new way. Both are
4394 // wrong output: llama.cpp divides the positions by the factor.
4395 // See `linear_scaling_is_applied_as_a_uniform_frequency_divisor`.
4396 // YaRN with a no-op factor is not a correction either.
4397 assert!(llama_config_with(
4398 "yarn_factor_one",
4399 &[
4400 ("llama.rope.scaling.type", Kv::Str("yarn")),
4401 ("llama.rope.scaling.factor", Kv::F32(1.0)),
4402 (
4403 "llama.rope.scaling.original_context_length",
4404 Kv::U32(131_072),
4405 ),
4406 ],
4407 )
4408 .rope_freqs
4409 .is_none());
4410 }
4411
4412 /// The correction range is measured against the context the
4413 /// checkpoint was *trained* at, so a file that declares YaRN without
4414 /// `rope.scaling.original_context_length` leaves the rotation alone
4415 /// rather than inventing a trained length (`context_length` on such
4416 /// a file is the *extended* one, which would put the ramp in the
4417 /// wrong place at every band).
4418 #[test]
4419 fn yarn_without_an_original_context_length_is_not_guessed_at() {
4420 let cfg = llama_config_with(
4421 "yarn_noctx",
4422 &[
4423 ("llama.rope.scaling.type", Kv::Str("yarn")),
4424 ("llama.rope.scaling.factor", Kv::F32(8.0)),
4425 ],
4426 );
4427 assert!(cfg.rope_freqs.is_none());
4428 }
4429
4430 /// `general.sampling.*` is the checkpoint's own recommendation, and
4431 /// only the keys the file carries become one: a file naming just
4432 /// `top_k` must leave temperature and top_p to the server's
4433 /// defaults.
4434 #[test]
4435 fn gguf_sampling_metadata_is_read_as_the_checkpoints_recommendation() {
4436 use crate::sampling::RecommendedSampling;
4437 let full = RecommendedSampling::from_gguf(&open_metadata_gguf(
4438 "sampling_full",
4439 &[
4440 ("general.architecture", Kv::Str("llama")),
4441 ("general.sampling.temp", Kv::F32(1.0)),
4442 ("general.sampling.top_k", Kv::U32(20)),
4443 ("general.sampling.top_p", Kv::F32(0.95)),
4444 ],
4445 ));
4446 assert_eq!(
4447 full,
4448 RecommendedSampling {
4449 temperature: Some(1.0),
4450 top_p: Some(0.95),
4451 top_k: Some(20),
4452 }
4453 );
4454
4455 let partial = RecommendedSampling::from_gguf(&open_metadata_gguf(
4456 "sampling_partial",
4457 &[
4458 ("general.architecture", Kv::Str("llama")),
4459 ("general.sampling.top_k", Kv::U32(40)),
4460 ],
4461 ));
4462 assert_eq!(partial.top_k, Some(40));
4463 assert_eq!(partial.temperature, None);
4464 assert_eq!(partial.top_p, None);
4465 }
4466
4467 /// A converter that wrote `temp = 1` stores a GGUF integer, not a
4468 /// float. Dropping it would serve a checkpoint that asked for
4469 /// temperature 1.0 at the framework's greedy default -- the
4470 /// repetition-loop failure the recommendation exists to prevent.
4471 #[test]
4472 fn an_integer_valued_sampling_temp_is_still_a_recommendation() {
4473 let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
4474 "sampling_int_temp",
4475 &[
4476 ("general.architecture", Kv::Str("llama")),
4477 ("general.sampling.temp", Kv::U32(1)),
4478 ],
4479 ));
4480 assert_eq!(recommended.temperature, Some(1.0));
4481 }
4482
4483 /// The overwhelming majority of checkpoints recommend nothing, and
4484 /// those must keep frink's existing defaults exactly.
4485 #[test]
4486 fn a_gguf_without_sampling_metadata_recommends_nothing() {
4487 let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
4488 "sampling_absent",
4489 &[("general.architecture", Kv::Str("llama"))],
4490 ));
4491 assert!(recommended.is_empty());
4492 }
4493
4494 #[test]
4495 fn model_config_from_gguf_rejects_dedicated_architectures() {
4496 let tmp = std::env::temp_dir().join(format!(
4497 "frink_test_dedicated_arch_{}.gguf",
4498 std::process::id()
4499 ));
4500 std::fs::write(&tmp, build_arch_only_gguf("deepseek4")).unwrap();
4501 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
4502 std::fs::remove_file(&tmp).ok();
4503
4504 match ModelConfig::from_gguf(&file) {
4505 Err(LoadError::DedicatedArchitectureRequired(arch, _)) => {
4506 assert_eq!(arch, "deepseek4");
4507 }
4508 other => panic!(
4509 "expected LoadError::DedicatedArchitectureRequired for deepseek4, got {other:?}"
4510 ),
4511 }
4512 }
4513
4514 /// The same Q5_K block bytes cross-validated against an independent
4515 /// Python reference in `frink-quant`'s own tests, reused here for
4516 /// the same full-path proof as the Q6_K test below.
4517 #[rustfmt::skip]
4518 const Q5_K_TEST_BLOCK: [u8; 176] = [
4519 0x66, 0x2a, 0x66, 0x2a, 0x01, 0x01, 0x01, 0x01, 0x4f, 0x4b, 0x10, 0x12, 0x41, 0xe2, 0xc1,
4520 0xb1, 0x72, 0x2f, 0x20, 0x07, 0x31, 0x0c, 0x38, 0xb3, 0x9c, 0xb8, 0xad, 0x2f, 0x9a, 0xea,
4521 0x17, 0xd0, 0xee, 0x93, 0x9e, 0x3e, 0x74, 0xbb, 0x28, 0x18, 0x39, 0x25, 0xb6, 0x09, 0x18,
4522 0x29, 0x1c, 0x1d, 0x29, 0x41, 0x40, 0x0a, 0x74, 0x7d, 0xfd, 0x21, 0xdd, 0x6d, 0x45, 0x73,
4523 0x0e, 0x1e, 0xc0, 0x4a, 0xfc, 0xf3, 0x8e, 0x24, 0x6b, 0x34, 0x7d, 0xbe, 0x94, 0xde, 0x59,
4524 0x7a, 0x35, 0x30, 0x36, 0x0a, 0xf9, 0x4a, 0x9b, 0xa2, 0x26, 0x21, 0xa2, 0xfa, 0xdf, 0x4b,
4525 0x29, 0x64, 0x6f, 0xbb, 0xca, 0x0f, 0x3c, 0xda, 0x20, 0xf4, 0x93, 0x86, 0xab, 0x6e, 0xb9,
4526 0xe5, 0xd5, 0xa0, 0x82, 0xd6, 0x41, 0xff, 0x12, 0xbc, 0x34, 0xbb, 0xab, 0xb8, 0x20, 0x2f,
4527 0xbb, 0x5f, 0x0c, 0x10, 0xcf, 0x49, 0xc5, 0x86, 0x5c, 0xdf, 0xff, 0x78, 0x44, 0x26, 0x3b,
4528 0xc2, 0x23, 0x3d, 0x2b, 0xe9, 0x00, 0x12, 0xf8, 0xea, 0xe2, 0x9e, 0x5e, 0x50, 0x20, 0x9f,
4529 0x9d, 0x8d, 0x7d, 0x7f, 0xcc, 0x1d, 0x0e, 0x13, 0xf8, 0xc2, 0xf1, 0x3d, 0x08, 0x2f, 0x23,
4530 0x13, 0xac, 0x0d, 0xa7, 0xe7, 0x20, 0xa3, 0x90, 0xb7, 0xc8, 0x28,
4531 ];
4532
4533 fn build_single_q5_k_tensor_gguf() -> Vec<u8> {
4534 let mut buf = Vec::new();
4535 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4536 .unwrap();
4537 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4538 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4539 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4540
4541 write_kv_str(&mut buf, "general.architecture", "frink-q5k-test");
4542
4543 write_string(&mut buf, "test.weight");
4544 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4545 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols,
4546 // rows] -- reversed from the semantic [rows, cols] this tensor
4547 // represents (1 row, 256 cols / 1 Q5_K block).
4548 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q5_K block)
4549 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4550 buf.write_u32::<LittleEndian>(13).unwrap(); // dtype tag: Q5_K
4551 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4552
4553 while buf.len() % 32 != 0 {
4554 buf.push(0);
4555 }
4556 buf.extend_from_slice(&Q5_K_TEST_BLOCK);
4557 buf
4558 }
4559
4560 /// How far a fused dot may sit from an exact dequantized dot.
4561 ///
4562 /// Two regimes, and one fixed number cannot describe both. With
4563 /// `FRINK_CPU_INT_DOT` off the activation stays f32 and only
4564 /// rounding separates the two. With it on, the activation is
4565 /// quantized to int8 at `d = amax / 127`, which is the flag both
4566 /// binaries turn on by default and the reason the Q5_K and Q6_K
4567 /// cases failed against a flat `1e-2`.
4568 ///
4569 /// The bound grows with the L2 norm of the row, NOT the L1. Each
4570 /// element carries an independent rounding of up to `d/2`, so the
4571 /// dot's error is a sum of independent terms whose standard
4572 /// deviation is `d/sqrt(12) * ||w||_2`. Bounding by the worst case
4573 /// `d/2 * ||w||_1` instead assumes every rounding aligns with its
4574 /// weight's sign, which on this fixture gives 0.347 against a dot
4575 /// of 2.77: 12% of the value, loose enough that injecting a 5%
4576 /// error still passed. Measured here, the real error is 1.8 sigma,
4577 /// so four sigma keeps better than 2x headroom while still failing
4578 /// that 5% injection.
4579 fn fused_dot_tolerance(weights: &[f32], x: &[f32], exact_bound: f32) -> f32 {
4580 if !frink_core::weight_matrix::cpu_int_dot_for(
4581 frink_core::weight_matrix::IntDotShape::Matvec,
4582 ) {
4583 return exact_bound;
4584 }
4585 let amax = x.iter().fold(0.0f32, |a, v| a.max(v.abs()));
4586 let l2 = weights.iter().map(|w| w * w).sum::<f32>().sqrt();
4587 4.0 * (amax / 127.0) / 12f32.sqrt() * l2 + exact_bound
4588 }
4589
4590 #[test]
4591 fn load_weight_matrix_handles_a_real_on_disk_q5_k_tensor_end_to_end() {
4592 let tmp =
4593 std::env::temp_dir().join(format!("frink_test_q5k_tensor_{}.gguf", std::process::id()));
4594 std::fs::write(&tmp, build_single_q5_k_tensor_gguf()).unwrap();
4595 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q5_K GGUF file must parse");
4596 std::fs::remove_file(&tmp).ok();
4597
4598 let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_K tensor must load");
4599 assert_eq!(matrix.rows(), 1);
4600 assert_eq!(matrix.cols(), 256);
4601 match &matrix {
4602 WeightMatrix::Quantized { kind, data, .. } => {
4603 assert_eq!(*kind, QuantKind::Q5K);
4604 assert!(
4605 data.is_mapped(),
4606 "Q5_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
4607 );
4608 }
4609 _ => panic!("expected a Quantized matrix for a Q5_K tensor"),
4610 }
4611
4612 let expected = frink_quant::dequant_q5_k(&Q5_K_TEST_BLOCK).unwrap();
4613 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4614 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4615
4616 let got = matrix.apply(&x);
4617 assert_eq!(got.len(), 1);
4618 assert!(
4619 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
4620 "end-to-end loaded+applied Q5_K matrix diverged from direct dequant: got={} expected={}",
4621 got[0],
4622 expected_dot
4623 );
4624 }
4625
4626 /// The same Q6_K block bytes cross-validated against an independent
4627 /// Python reference in `frink-quant`'s own tests; reused here to
4628 /// prove the *full*
4629 /// path -- real on-disk GGUF bytes, parsed by `frink-gguf`, read
4630 /// through `GgufFile::tensor_mapped_range`, dispatched by
4631 /// `WeightMatrix::apply` to `frink_quant::dot_q6_k_f32` -- produces
4632 /// the same result as directly dequantizing those bytes, not just
4633 /// that the isolated kernel is correct in unit-test isolation.
4634 #[rustfmt::skip]
4635 const Q6_K_TEST_BLOCK: [u8; 210] = [
4636 0xe0, 0xa5, 0x40, 0x5c, 0x8d, 0x3a, 0x0a, 0x26, 0xfb, 0x4b, 0x6e, 0x9a, 0xdf, 0x3e, 0xa3,
4637 0xc4, 0xf8, 0x2b, 0x1d, 0x95, 0x76, 0x7d, 0x3b, 0xcd, 0xfd, 0xef, 0xc2, 0x0b, 0x07, 0x63,
4638 0x29, 0xfb, 0x81, 0x57, 0xbe, 0xbe, 0x06, 0xf7, 0x3a, 0x92, 0xc4, 0x43, 0xff, 0xad, 0xac,
4639 0x7e, 0x0f, 0x00, 0x2a, 0x4f, 0xf0, 0xf8, 0xa9, 0xfa, 0x3c, 0x90, 0x6d, 0x73, 0x2d, 0x5a,
4640 0xe6, 0xc6, 0x46, 0xf2, 0x0d, 0x55, 0x4c, 0x25, 0x38, 0x71, 0x2b, 0x35, 0x38, 0x82, 0x16,
4641 0x37, 0x5f, 0x32, 0x61, 0x02, 0xdd, 0x2f, 0x6f, 0x7b, 0x1f, 0xb4, 0x1a, 0x1b, 0x3e, 0x4f,
4642 0x11, 0xa3, 0x17, 0x40, 0x5a, 0x5f, 0x76, 0xcd, 0x19, 0x27, 0x9b, 0xc7, 0xc8, 0xf7, 0xf7,
4643 0xee, 0xf4, 0x86, 0xd9, 0xfd, 0xa7, 0xfe, 0x9e, 0xac, 0x70, 0x53, 0x5b, 0x76, 0xfb, 0x39,
4644 0xf8, 0x4b, 0x98, 0xfe, 0xd0, 0x06, 0x21, 0x4c, 0x4d, 0xbe, 0x10, 0x2b, 0x06, 0x65, 0xc9,
4645 0x5e, 0xf9, 0x95, 0x72, 0xae, 0x99, 0xd9, 0x7e, 0x15, 0xbd, 0x5e, 0x6d, 0xe8, 0x25, 0x8a,
4646 0xd5, 0x99, 0xc6, 0x6b, 0x69, 0xc7, 0x84, 0xc6, 0xa4, 0xf7, 0xb9, 0x6d, 0x68, 0x45, 0x0e,
4647 0x65, 0x69, 0xeb, 0xe6, 0xeb, 0xe9, 0x28, 0xa6, 0xb9, 0x96, 0xf2, 0xe8, 0xa7, 0x9b, 0x6e,
4648 0x79, 0x8a, 0x68, 0x65, 0x59, 0x98, 0x8b, 0x44, 0x41, 0x98, 0x9a, 0x56, 0x01, 0x01, 0x01,
4649 0x02, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x01, 0x02, 0x1f, 0x25,
4650 ];
4651
4652 fn build_single_q6_k_tensor_gguf() -> Vec<u8> {
4653 let mut buf = Vec::new();
4654 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4655 .unwrap();
4656 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4657 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4658 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4659
4660 write_kv_str(&mut buf, "general.architecture", "frink-q6k-test");
4661
4662 write_string(&mut buf, "test.weight");
4663 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4664 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4665 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q6_K block)
4666 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4667 buf.write_u32::<LittleEndian>(14).unwrap(); // dtype tag: Q6_K
4668 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4669
4670 while buf.len() % 32 != 0 {
4671 buf.push(0);
4672 }
4673 buf.extend_from_slice(&Q6_K_TEST_BLOCK);
4674 buf
4675 }
4676
4677 #[test]
4678 fn load_weight_matrix_handles_a_real_on_disk_q6_k_tensor_end_to_end() {
4679 let tmp =
4680 std::env::temp_dir().join(format!("frink_test_q6k_tensor_{}.gguf", std::process::id()));
4681 std::fs::write(&tmp, build_single_q6_k_tensor_gguf()).unwrap();
4682 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q6_K GGUF file must parse");
4683 std::fs::remove_file(&tmp).ok();
4684
4685 let matrix = load_weight_matrix(&file, "test.weight").expect("Q6_K tensor must load");
4686 assert_eq!(matrix.rows(), 1);
4687 assert_eq!(matrix.cols(), 256);
4688 match &matrix {
4689 WeightMatrix::Quantized { kind, data, .. } => {
4690 assert_eq!(*kind, QuantKind::Q6K);
4691 assert!(
4692 data.is_mapped(),
4693 "Q6_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
4694 );
4695 }
4696 _ => panic!("expected a Quantized matrix for a Q6_K tensor"),
4697 }
4698
4699 let expected = frink_quant::dequant_q6_k(&Q6_K_TEST_BLOCK).unwrap();
4700 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4701 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4702
4703 let got = matrix.apply(&x);
4704 assert_eq!(got.len(), 1);
4705 assert!(
4706 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
4707 "end-to-end loaded+applied Q6_K matrix diverged from direct dequant: got={} expected={}",
4708 got[0],
4709 expected_dot
4710 );
4711 }
4712
4713 fn build_single_bf16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
4714 let mut buf = Vec::new();
4715 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4716 .unwrap();
4717 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4718 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4719 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4720
4721 write_kv_str(&mut buf, "general.architecture", "frink-bf16-test");
4722
4723 write_string(&mut buf, "test.weight");
4724 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4725 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4726 buf.write_u64::<LittleEndian>(cols).unwrap();
4727 buf.write_u64::<LittleEndian>(rows).unwrap();
4728 buf.write_u32::<LittleEndian>(30).unwrap(); // dtype tag: BF16
4729 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4730
4731 while buf.len() % 32 != 0 {
4732 buf.push(0);
4733 }
4734 for &v in values {
4735 // Real bf16 truncation (round-toward-zero, matching a real
4736 // writer closely enough for round-trip test purposes): top
4737 // 16 bits of the f32 bit pattern.
4738 let bf16_bits = (v.to_bits() >> 16) as u16;
4739 buf.extend_from_slice(&bf16_bits.to_le_bytes());
4740 }
4741 buf
4742 }
4743
4744 #[test]
4745 fn load_weight_matrix_handles_a_real_on_disk_bf16_tensor_end_to_end() {
4746 // Values with zero low-mantissa bits, so f32->bf16 truncation
4747 // is lossless and this is an exact-equality check.
4748 let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
4749 let tmp = std::env::temp_dir().join(format!(
4750 "frink_test_bf16_tensor_{}.gguf",
4751 std::process::id()
4752 ));
4753 std::fs::write(&tmp, build_single_bf16_tensor_gguf(2, 3, &values)).unwrap();
4754 let file = frink_gguf::GgufFile::open(&tmp).expect("real BF16 GGUF file must parse");
4755 std::fs::remove_file(&tmp).ok();
4756
4757 let matrix = load_weight_matrix(&file, "test.weight").expect("BF16 tensor must load");
4758 assert_eq!(matrix.rows(), 2);
4759 assert_eq!(matrix.cols(), 3);
4760 match &matrix {
4761 WeightMatrix::F32(tensor) => {
4762 assert_eq!(tensor.data, values, "BF16 must widen to f32 exactly");
4763 }
4764 _ => panic!("expected an F32 matrix for a BF16 tensor (no fused dot kernel for it)"),
4765 }
4766 }
4767
4768 fn build_single_f16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
4769 let mut buf = Vec::new();
4770 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4771 .unwrap();
4772 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4773 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4774 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4775
4776 write_kv_str(&mut buf, "general.architecture", "frink-f16-test");
4777
4778 write_string(&mut buf, "test.weight");
4779 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4780 buf.write_u64::<LittleEndian>(cols).unwrap();
4781 buf.write_u64::<LittleEndian>(rows).unwrap();
4782 buf.write_u32::<LittleEndian>(1).unwrap(); // dtype tag: F16
4783 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4784
4785 while buf.len() % 32 != 0 {
4786 buf.push(0);
4787 }
4788 for &v in values {
4789 buf.extend_from_slice(&half::f16::from_f32(v).to_le_bytes());
4790 }
4791 buf
4792 }
4793
4794 /// `GgmlType::F16` was parsed and sized but had no dequant arm in any
4795 /// of the seven loaders, so every `*-f16.gguf` was a hard
4796 /// `UnsupportedDtype`. Values are exactly representable in f16, so
4797 /// this is an exact-equality check.
4798 #[test]
4799 fn load_weight_matrix_handles_a_real_on_disk_f16_tensor_end_to_end() {
4800 let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
4801 let tmp =
4802 std::env::temp_dir().join(format!("frink_test_f16_tensor_{}.gguf", std::process::id()));
4803 std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
4804 let file = frink_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
4805 std::fs::remove_file(&tmp).ok();
4806
4807 let matrix = load_weight_matrix(&file, "test.weight").expect("F16 tensor must load");
4808 assert_eq!(matrix.rows(), 2);
4809 assert_eq!(matrix.cols(), 3);
4810 match &matrix {
4811 WeightMatrix::F32(tensor) => {
4812 assert_eq!(tensor.data, values, "F16 must widen to f32 exactly");
4813 }
4814 _ => panic!("expected an F32 matrix for an F16 tensor (no fused dot kernel for it)"),
4815 }
4816
4817 // The same tensor read as a plain vector (norm weights, biases and
4818 // the router all take this path, not `load_weight_matrix`).
4819 let tmp =
4820 std::env::temp_dir().join(format!("frink_test_f16_vec_{}.gguf", std::process::id()));
4821 std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
4822 let file = frink_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
4823 std::fs::remove_file(&tmp).ok();
4824 assert_eq!(load_f32_vec(&file, "test.weight").unwrap(), values);
4825 }
4826
4827 fn build_single_q5_1_tensor_gguf() -> Vec<u8> {
4828 let mut buf = Vec::new();
4829 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4830 .unwrap();
4831 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4832 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4833 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4834
4835 write_kv_str(&mut buf, "general.architecture", "frink-q5-1-test");
4836
4837 write_string(&mut buf, "test.weight");
4838 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4839 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4840 buf.write_u64::<LittleEndian>(32).unwrap(); // cols (1 Q5_1 block)
4841 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4842 buf.write_u32::<LittleEndian>(7).unwrap(); // dtype tag: Q5_1
4843 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4844
4845 while buf.len() % 32 != 0 {
4846 buf.push(0);
4847 }
4848 // d=0.25 (f16 0x3400), m=1.5 (f16 0x3E00) -- both exact in f16,
4849 // hand-verified bit patterns to avoid pulling in the `half`
4850 // crate just for two test constants. qh varied, qs a real
4851 // (non-degenerate) pattern.
4852 buf.extend_from_slice(&0x3400u16.to_le_bytes());
4853 buf.extend_from_slice(&0x3E00u16.to_le_bytes());
4854 buf.extend_from_slice(&[0x9au8, 0x3c, 0xf0, 0x0f]);
4855 buf.extend_from_slice(&(0..16u8).map(|i| i | ((15 - i) << 4)).collect::<Vec<u8>>());
4856 buf
4857 }
4858
4859 #[test]
4860 fn load_weight_matrix_handles_a_real_on_disk_q5_1_tensor_end_to_end() {
4861 let tmp = std::env::temp_dir().join(format!(
4862 "frink_test_q5_1_tensor_{}.gguf",
4863 std::process::id()
4864 ));
4865 std::fs::write(&tmp, build_single_q5_1_tensor_gguf()).unwrap();
4866 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q5_1 GGUF file must parse");
4867 std::fs::remove_file(&tmp).ok();
4868
4869 let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_1 tensor must load");
4870 assert_eq!(matrix.rows(), 1);
4871 assert_eq!(matrix.cols(), 32);
4872 let raw = file.tensor_bytes("test.weight").unwrap();
4873 let expected = frink_quant::dequant_q5_1(raw).unwrap();
4874 match &matrix {
4875 WeightMatrix::Quantized { kind, data, .. } => {
4876 assert_eq!(*kind, QuantKind::Q5_1);
4877 assert!(data.is_mapped());
4878 }
4879 _ => panic!("expected a Quantized matrix for a Q5_1 tensor"),
4880 }
4881
4882 let x: Vec<f32> = (0..32).map(|i| ((i as f32) * 0.017).cos()).collect();
4883 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4884 let got = matrix.apply(&x);
4885 assert_eq!(got.len(), 1);
4886 assert!(
4887 (got[0] - expected_dot).abs() < 1e-2,
4888 "end-to-end loaded+applied Q5_1 matrix diverged from direct dequant: got={} expected={}",
4889 got[0],
4890 expected_dot
4891 );
4892 }
4893
4894 // Same bytes as frink-quant's own Q3_K_TEST_BLOCK (Python-cross-
4895 // validated there); duplicated here to build a real on-disk GGUF
4896 // file, matching this file's existing per-format test convention
4897 // (see Q6_K_TEST_BLOCK above).
4898 const Q3_K_TEST_BLOCK: [u8; 110] = [
4899 0x56, 0xf2, 0xb4, 0x2b, 0xd5, 0x6f, 0x51, 0x71, 0x3c, 0x0a, 0xb9, 0x1d, 0xd0, 0xb9, 0x3b,
4900 0xb3, 0x0f, 0xff, 0x8c, 0xb2, 0x83, 0x3a, 0x3d, 0x24, 0xb1, 0x12, 0x56, 0xe3, 0x23, 0x54,
4901 0xf2, 0xfa, 0x7f, 0xdf, 0x31, 0xe1, 0x18, 0x26, 0x6e, 0xcd, 0x5b, 0x38, 0xee, 0xbd, 0x9f,
4902 0x8c, 0x57, 0x47, 0x0b, 0x11, 0xcb, 0xfb, 0xb4, 0x83, 0xa0, 0x4e, 0x0b, 0xd4, 0xa7, 0x85,
4903 0xe0, 0x60, 0xf3, 0xb3, 0xe3, 0x95, 0x43, 0xc6, 0x05, 0x05, 0x77, 0x53, 0xed, 0x23, 0xcc,
4904 0x6a, 0x0e, 0x89, 0xa1, 0x79, 0x85, 0xf6, 0x6e, 0x5a, 0x23, 0x63, 0xbe, 0x53, 0xfa, 0xa2,
4905 0x2b, 0xe9, 0xcd, 0xce, 0xf8, 0x3d, 0x6f, 0xd0, 0x42, 0x6e, 0x3b, 0x7f, 0x23, 0x26, 0xd3,
4906 0xb9, 0x18, 0xbf, 0xa4, 0x34,
4907 ];
4908
4909 fn build_single_q3_k_tensor_gguf() -> Vec<u8> {
4910 let mut buf = Vec::new();
4911 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4912 .unwrap();
4913 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4914 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4915 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4916
4917 write_kv_str(&mut buf, "general.architecture", "frink-q3k-test");
4918
4919 write_string(&mut buf, "test.weight");
4920 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4921 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4922 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q3_K block)
4923 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4924 buf.write_u32::<LittleEndian>(11).unwrap(); // dtype tag: Q3_K
4925 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4926
4927 while buf.len() % 32 != 0 {
4928 buf.push(0);
4929 }
4930 buf.extend_from_slice(&Q3_K_TEST_BLOCK);
4931 buf
4932 }
4933
4934 #[test]
4935 fn load_weight_matrix_handles_a_real_on_disk_q3_k_tensor_end_to_end() {
4936 let tmp =
4937 std::env::temp_dir().join(format!("frink_test_q3k_tensor_{}.gguf", std::process::id()));
4938 std::fs::write(&tmp, build_single_q3_k_tensor_gguf()).unwrap();
4939 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q3_K GGUF file must parse");
4940 std::fs::remove_file(&tmp).ok();
4941
4942 let matrix = load_weight_matrix(&file, "test.weight").expect("Q3_K tensor must load");
4943 assert_eq!(matrix.rows(), 1);
4944 assert_eq!(matrix.cols(), 256);
4945 match &matrix {
4946 WeightMatrix::Quantized { kind, data, .. } => {
4947 assert_eq!(*kind, QuantKind::Q3K);
4948 assert!(data.is_mapped());
4949 }
4950 _ => panic!("expected a Quantized matrix for a Q3_K tensor"),
4951 }
4952
4953 let expected = frink_quant::dequant_q3_k(&Q3_K_TEST_BLOCK).unwrap();
4954 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4955 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4956
4957 let got = matrix.apply(&x);
4958 assert_eq!(got.len(), 1);
4959 assert!(
4960 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-1),
4961 "end-to-end loaded+applied Q3_K matrix diverged from direct dequant: got={} expected={}",
4962 got[0],
4963 expected_dot
4964 );
4965 }
4966
4967 // Same bytes as frink-quant's own IQ4_XS_TEST_BLOCK (Python-cross-
4968 // validated there); duplicated here to build a real on-disk GGUF
4969 // file, matching this file's existing per-format test convention.
4970 const IQ4_XS_TEST_BLOCK: [u8; 136] = [
4971 0x5c, 0x33, 0xb4, 0x39, 0xd1, 0x64, 0x97, 0x82, 0xcb, 0xbd, 0x88, 0x95, 0xf3, 0x60, 0x2a,
4972 0xb5, 0xe7, 0x24, 0xd3, 0xee, 0xfe, 0x71, 0x13, 0xbe, 0x70, 0x84, 0x48, 0x79, 0x7b, 0x3e,
4973 0xf0, 0x55, 0xdc, 0xb2, 0xb2, 0xde, 0x32, 0xa1, 0x5b, 0x02, 0x01, 0xdc, 0x2a, 0xbb, 0xf7,
4974 0x0b, 0x8a, 0x88, 0xdd, 0x0b, 0x02, 0x7e, 0x5e, 0x76, 0x87, 0x30, 0x1e, 0x1c, 0xcf, 0x48,
4975 0xd7, 0x61, 0xf3, 0x51, 0x52, 0x17, 0x98, 0x0a, 0x87, 0xcf, 0x02, 0x91, 0xc8, 0xee, 0xc0,
4976 0x91, 0x69, 0x2a, 0x4f, 0x64, 0x68, 0xa7, 0xb2, 0xe6, 0x98, 0x21, 0x81, 0x75, 0x53, 0x2a,
4977 0x8d, 0x12, 0xae, 0xe0, 0xea, 0x0c, 0x75, 0xff, 0x22, 0x5e, 0x25, 0x19, 0xda, 0x2e, 0x51,
4978 0x4e, 0x81, 0xdc, 0x0e, 0x78, 0x86, 0xd7, 0x58, 0xb5, 0xb7, 0xf6, 0x45, 0xa9, 0x0a, 0x83,
4979 0xfd, 0x2a, 0x12, 0x7d, 0xf0, 0x12, 0x97, 0xe2, 0xfe, 0xf4, 0xd0, 0xa2, 0x11, 0x14, 0x78,
4980 0xdb,
4981 ];
4982
4983 fn build_single_iq4_xs_tensor_gguf() -> Vec<u8> {
4984 let mut buf = Vec::new();
4985 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4986 .unwrap();
4987 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4988 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4989 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4990
4991 write_kv_str(&mut buf, "general.architecture", "frink-iq4xs-test");
4992
4993 write_string(&mut buf, "test.weight");
4994 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4995 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4996 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 IQ4_XS block)
4997 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4998 buf.write_u32::<LittleEndian>(23).unwrap(); // dtype tag: IQ4_XS
4999 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
5000
5001 while buf.len() % 32 != 0 {
5002 buf.push(0);
5003 }
5004 buf.extend_from_slice(&IQ4_XS_TEST_BLOCK);
5005 buf
5006 }
5007
5008 #[test]
5009 fn load_weight_matrix_handles_a_real_on_disk_iq4_xs_tensor_end_to_end() {
5010 let tmp = std::env::temp_dir().join(format!(
5011 "frink_test_iq4xs_tensor_{}.gguf",
5012 std::process::id()
5013 ));
5014 std::fs::write(&tmp, build_single_iq4_xs_tensor_gguf()).unwrap();
5015 let file = frink_gguf::GgufFile::open(&tmp).expect("real IQ4_XS GGUF file must parse");
5016 std::fs::remove_file(&tmp).ok();
5017
5018 let matrix = load_weight_matrix(&file, "test.weight").expect("IQ4_XS tensor must load");
5019 assert_eq!(matrix.rows(), 1);
5020 assert_eq!(matrix.cols(), 256);
5021 match &matrix {
5022 WeightMatrix::Quantized { kind, data, .. } => {
5023 assert_eq!(*kind, QuantKind::IQ4XS);
5024 assert!(data.is_mapped());
5025 }
5026 _ => panic!("expected a Quantized matrix for an IQ4_XS tensor"),
5027 }
5028
5029 let expected = frink_quant::dequant_iq4_xs(&IQ4_XS_TEST_BLOCK).unwrap();
5030 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
5031 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
5032
5033 let got = matrix.apply(&x);
5034 assert_eq!(got.len(), 1);
5035 assert!(
5036 (got[0] - expected_dot).abs() < 1e-1,
5037 "end-to-end loaded+applied IQ4_XS matrix diverged from direct dequant: got={} expected={}",
5038 got[0],
5039 expected_dot
5040 );
5041 }
5042
5043 // Same bytes as frink-quant's own IQ low-bit test blocks
5044 // (Python-cross-validated there against the real compiled ggml
5045 // implementation), duplicated as literals for the same reason as
5046 // IQ4_XS_TEST_BLOCK above.
5047 const IQ1_S_TEST_BLOCK: [u8; 50] = [
5048 0x0a, 0x2f, 0xfa, 0x06, 0x1e, 0x37, 0x6f, 0xe3, 0x62, 0xd0, 0xb6, 0xa4, 0x25, 0xae, 0x76,
5049 0x14, 0x72, 0x5b, 0xfa, 0x05, 0xd1, 0xf1, 0x2a, 0x4c, 0xad, 0x29, 0xae, 0xf4, 0xcf, 0x0c,
5050 0x96, 0x51, 0x58, 0x03, 0x6d, 0xd3, 0x10, 0x92, 0x70, 0xff, 0x61, 0x58, 0xc8, 0x30, 0x25,
5051 0x64, 0x49, 0x85, 0xc0, 0x24,
5052 ];
5053 const IQ2_XXS_TEST_BLOCK: [u8; 66] = [
5054 0x29, 0x30, 0xd9, 0x33, 0x95, 0x4c, 0x08, 0x1e, 0xad, 0x79, 0x49, 0xf2, 0x8d, 0x5f, 0x93,
5055 0xea, 0x78, 0x18, 0x98, 0xb9, 0x94, 0x14, 0xad, 0xce, 0xca, 0x1d, 0xab, 0x81, 0x53, 0x4a,
5056 0x68, 0xd0, 0x59, 0x96, 0x36, 0x5d, 0xbe, 0x20, 0xc4, 0xff, 0xe4, 0x2c, 0xcd, 0x2f, 0x4f,
5057 0x4f, 0x67, 0x53, 0xc6, 0xd5, 0xa2, 0xfb, 0xc7, 0xf3, 0xe2, 0x6b, 0xf1, 0x99, 0x23, 0x1e,
5058 0x2d, 0x5e, 0x8c, 0x78, 0xc2, 0x31,
5059 ];
5060 const IQ3_XXS_TEST_BLOCK: [u8; 98] = [
5061 0x71, 0x31, 0x16, 0x0a, 0x79, 0x04, 0x5d, 0x87, 0xae, 0x2a, 0x4a, 0x43, 0xfd, 0x02, 0xba,
5062 0x6c, 0x10, 0x42, 0x80, 0xe5, 0x1d, 0x08, 0x22, 0xcb, 0x21, 0x54, 0xf9, 0xaa, 0x8e, 0xc2,
5063 0xf2, 0x34, 0x66, 0x1e, 0x2a, 0xef, 0x19, 0xae, 0x48, 0x47, 0x29, 0xa0, 0x72, 0xd1, 0x31,
5064 0xc0, 0x65, 0x49, 0xde, 0x79, 0x32, 0xe6, 0x4d, 0xb6, 0x55, 0x3f, 0x4d, 0xf1, 0x18, 0xbb,
5065 0x18, 0x59, 0x4c, 0x31, 0xa3, 0xb2, 0x34, 0xdd, 0xf6, 0x4a, 0x91, 0x51, 0x3f, 0x3e, 0x40,
5066 0x69, 0xad, 0xbf, 0x1a, 0xd0, 0x05, 0xfb, 0xbe, 0x8b, 0x0b, 0xdd, 0xdf, 0x7d, 0x94, 0x74,
5067 0x92, 0x3e, 0xff, 0x04, 0x2a, 0xc4, 0xea, 0xc9,
5068 ];
5069
5070 #[rustfmt::skip]
5071 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];
5072
5073 /// A live ggml type this build has no kernel for must be REFUSED BY
5074 /// NAME at execution, having been sized correctly at parse.
5075 ///
5076 /// Before `TQ2_0` was recognized, tag 35 was `Other(35)`, which had
5077 /// no block layout: the tensor's size was unknown, so `tensor_bytes`
5078 /// could not even hand back the row, and the error named a number.
5079 /// Now the file parses, the tensor measures 66 bytes per 256
5080 /// elements, and the stop happens where it belongs -- at the point
5081 /// something wants to multiply by it -- naming `TQ2_0`.
5082 #[test]
5083 fn a_recognized_but_unimplemented_ggml_type_refuses_by_name_after_sizing_correctly() {
5084 // 256 elements of TQ2_0 = one 66-byte block.
5085 let block = pseudo_iq_block(66, 0x0720_5eed);
5086 let tmp =
5087 std::env::temp_dir().join(format!("frink_test_tq2_0_{}.gguf", std::process::id()));
5088 std::fs::write(
5089 &tmp,
5090 build_single_iq_lowbit_tensor_gguf("tq2test", 35, 256, &block),
5091 )
5092 .unwrap();
5093 let file = frink_gguf::GgufFile::open(&tmp).expect("a TQ2_0 file must still parse");
5094 std::fs::remove_file(&tmp).ok();
5095
5096 // Sized, not zero: the size estimate is right even though the
5097 // kernel is missing.
5098 let info = file.find_tensor("test.weight").expect("tensor present");
5099 assert_eq!(info.dtype, GgmlType::TQ2_0);
5100 assert_eq!(info.byte_len(), Some(66));
5101 assert_eq!(
5102 file.tensor_bytes("test.weight").map(<[u8]>::len).ok(),
5103 Some(66)
5104 );
5105
5106 match load_weight_matrix(&file, "test.weight") {
5107 Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
5108 assert_eq!(name, "test.weight");
5109 }
5110 Err(other) => panic!("TQ2_0 must be refused by name, got {other:?}"),
5111 Ok(_) => panic!("TQ2_0 must be refused, not loaded as some other kind"),
5112 }
5113 }
5114
5115 /// An MXFP4 norm/bias must widen, not be refused.
5116 ///
5117 /// `load_weight_matrix` accepts MXFP4 as a 2-D weight and
5118 /// `load_moe_expert_matrices` accepts it as an expert tensor, and
5119 /// `WeightMatrix::dequant` calls `dequant_mxfp4_gguf` on both. One
5120 /// missing arm in `widen_plain_float` made the *1-D* tensors of the
5121 /// exact same dtype a hard `UnsupportedDtype` -- the split that
5122 /// turns a supported format into a load failure on the one
5123 /// checkpoint that uses it.
5124 #[test]
5125 fn an_mxfp4_one_dimensional_tensor_widens_instead_of_being_refused() {
5126 let expected = frink_quant::dequant_mxfp4_gguf(&MXFP4_GGUF_TEST_BLOCKS)
5127 .expect("the fixture blocks must dequantize");
5128 let cols = expected.len();
5129 let tmp =
5130 std::env::temp_dir().join(format!("frink_test_mxfp4_norm_{}.gguf", std::process::id()));
5131 std::fs::write(
5132 &tmp,
5133 build_single_iq_lowbit_tensor_gguf(
5134 "mxfp4norm",
5135 39,
5136 cols as u64,
5137 &MXFP4_GGUF_TEST_BLOCKS,
5138 ),
5139 )
5140 .unwrap();
5141 let file = frink_gguf::GgufFile::open(&tmp).expect("file must parse");
5142 std::fs::remove_file(&tmp).ok();
5143
5144 let got = load_f32_vec(&file, "test.weight")
5145 .expect("an MXFP4 norm must load, not report an unsupported dtype");
5146 assert_eq!(got, expected);
5147
5148 // Same arm, reached directly: `widen_plain_float` is the shared
5149 // helper the six architecture loaders call, so its table is the
5150 // one that has to know MXFP4.
5151 let direct = widen_plain_float(GgmlType::MXFP4, &MXFP4_GGUF_TEST_BLOCKS, "test.weight")
5152 .expect("widen_plain_float must widen MXFP4");
5153 assert_eq!(direct, expected);
5154
5155 // And the refusal still works for a dtype that genuinely has no
5156 // widening path, so this test cannot pass by making everything
5157 // succeed.
5158 match widen_plain_float(GgmlType::TQ2_0, &MXFP4_GGUF_TEST_BLOCKS, "test.weight") {
5159 Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
5160 assert_eq!(name, "test.weight");
5161 }
5162 other => panic!("TQ2_0 must be refused by name, got {other:?}"),
5163 }
5164 }
5165
5166 fn build_single_iq_lowbit_tensor_gguf(
5167 arch: &str,
5168 tag: u32,
5169 cols: u64,
5170 block: &[u8],
5171 ) -> Vec<u8> {
5172 let mut buf = Vec::new();
5173 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
5174 .unwrap();
5175 buf.write_u32::<LittleEndian>(3).unwrap(); // version
5176 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
5177 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
5178 write_kv_str(&mut buf, "general.architecture", arch);
5179 write_string(&mut buf, "test.weight");
5180 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
5181 buf.write_u64::<LittleEndian>(cols).unwrap();
5182 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
5183 buf.write_u32::<LittleEndian>(tag).unwrap();
5184 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
5185 while buf.len() % 32 != 0 {
5186 buf.push(0);
5187 }
5188 buf.extend_from_slice(block);
5189 buf
5190 }
5191
5192 /// A structurally valid block of `len` bytes for any of the
5193 /// codebook-grid formats: every bit pattern is a legal code in all
5194 /// of them (the grid indices are bounded by their own bit widths),
5195 /// so a deterministic byte fill is a real block, not a fixture that
5196 /// happens to avoid the interesting paths. Only the f16 scale needs
5197 /// pinning, and only so the comparison below can't be NaN-vs-NaN.
5198 fn pseudo_iq_block(len: usize, seed: u32) -> Vec<u8> {
5199 let mut s = seed;
5200 let mut out = Vec::with_capacity(len);
5201 for _ in 0..len {
5202 s ^= s << 13;
5203 s ^= s >> 17;
5204 s ^= s << 5;
5205 out.push((s >> 24) as u8);
5206 }
5207 out
5208 }
5209
5210 /// End-to-end load+apply for the codebook-grid low-bit formats the
5211 /// published Dynamic GGUFs are built from: a real on-disk tensor of
5212 /// each type must load zero-copy as the right `QuantKind` and
5213 /// produce the same matvec result as dequantizing the block
5214 /// directly. That is the property this test exists for -- the
5215 /// *values* are pinned against real ggml in `frink-quant`; what
5216 /// can only break here is the tag -> kind -> block-stride chain,
5217 /// and a wrong stride silently reads the neighbouring row.
5218 /// Dtype tags (19/29/16/17/22/18/21/39) verified against ggml.h's
5219 /// enum ggml_type.
5220 #[test]
5221 fn load_weight_matrix_handles_real_on_disk_iq_lowbit_tensors_end_to_end() {
5222 type DequantFn = fn(&[u8]) -> Result<Vec<f32>, frink_quant::QuantError>;
5223 // IQ1_M carries no f16 scale field; its scale is reassembled
5224 // from the four scale words' top nibbles, and the top nibble of
5225 // the last one supplies the f16 sign + high exponent bits.
5226 // Pinning it to 0x2 keeps the exponent out of the all-ones
5227 // NaN/Inf pattern whatever the rest of the fill does. The other
5228 // three do carry a leading f16 `d`, pinned for the same reason.
5229 let mut iq1m = pseudo_iq_block(frink_quant::IQ1_M_BLOCK_BYTES, 0x2907_31A0);
5230 iq1m[55] = (iq1m[55] & 0x0F) | 0x20;
5231 let mut iq2xs = pseudo_iq_block(frink_quant::IQ2_XS_BLOCK_BYTES, 0x2107_31A1);
5232 let mut iq2s = pseudo_iq_block(frink_quant::IQ2_S_BLOCK_BYTES, 0x2207_31A2);
5233 let mut iq3s = pseudo_iq_block(frink_quant::IQ3_S_BLOCK_BYTES, 0x2307_31A3);
5234 for blk in [&mut iq2xs, &mut iq2s, &mut iq3s] {
5235 blk[0..2].copy_from_slice(&half::f16::from_f32(0.115).to_le_bytes());
5236 }
5237 let cases: [(&str, u32, &[u8], QuantKind, DequantFn); 8] = [
5238 (
5239 "iq1s",
5240 19,
5241 &IQ1_S_TEST_BLOCK,
5242 QuantKind::IQ1S,
5243 frink_quant::dequant_iq1_s,
5244 ),
5245 (
5246 "iq1m",
5247 29,
5248 &iq1m,
5249 QuantKind::IQ1M,
5250 frink_quant::dequant_iq1_m,
5251 ),
5252 (
5253 "iq2xxs",
5254 16,
5255 &IQ2_XXS_TEST_BLOCK,
5256 QuantKind::IQ2XXS,
5257 frink_quant::dequant_iq2_xxs,
5258 ),
5259 (
5260 "iq2xs",
5261 17,
5262 &iq2xs,
5263 QuantKind::IQ2XS,
5264 frink_quant::dequant_iq2_xs,
5265 ),
5266 (
5267 "iq2s",
5268 22,
5269 &iq2s,
5270 QuantKind::IQ2S,
5271 frink_quant::dequant_iq2_s,
5272 ),
5273 (
5274 "iq3xxs",
5275 18,
5276 &IQ3_XXS_TEST_BLOCK,
5277 QuantKind::IQ3XXS,
5278 frink_quant::dequant_iq3_xxs,
5279 ),
5280 (
5281 "iq3s",
5282 21,
5283 &iq3s,
5284 QuantKind::IQ3S,
5285 frink_quant::dequant_iq3_s,
5286 ),
5287 (
5288 "mxfp4_gguf",
5289 39,
5290 &MXFP4_GGUF_TEST_BLOCKS,
5291 QuantKind::Mxfp4Gguf,
5292 frink_quant::dequant_mxfp4_gguf,
5293 ),
5294 ];
5295 for (name, tag, block, kind, dequant) in cases {
5296 let expected = dequant(block).unwrap();
5297 let cols = expected.len();
5298 let tmp = std::env::temp_dir().join(format!("frink_test_{name}_tensor.gguf"));
5299 std::fs::write(
5300 &tmp,
5301 build_single_iq_lowbit_tensor_gguf(name, tag, cols as u64, block),
5302 )
5303 .unwrap();
5304 let file = frink_gguf::GgufFile::open(&tmp).expect("file must parse");
5305 std::fs::remove_file(&tmp).ok();
5306
5307 let matrix =
5308 load_weight_matrix(&file, "test.weight").expect("low-bit tensor must load");
5309 assert_eq!((matrix.rows(), matrix.cols()), (1, cols), "{name}");
5310 match &matrix {
5311 WeightMatrix::Quantized { kind: k, data, .. } => {
5312 assert_eq!(*k, kind, "{name}");
5313 assert!(data.is_mapped(), "{name} must load zero-copy");
5314 }
5315 _ => panic!("expected a Quantized matrix for {name}"),
5316 }
5317
5318 let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.013).sin()).collect();
5319 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
5320 let got = matrix.apply(&x);
5321 assert!(
5322 (got[0] - expected_dot).abs() < 1e-1,
5323 "{name}: loaded+applied diverged from direct dequant: got={} expected={}",
5324 got[0],
5325 expected_dot
5326 );
5327 }
5328 }
5329
5330 #[test]
5331 fn qwen2moe_disables_topk_renorm() {
5332 assert!(
5333 NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen2moe"),
5334 "qwen2moe must have norm_topk_prob=false (llama.cpp build_moe_ffn norm_w=false)"
5335 );
5336 }
5337
5338 /// The `LLAMA_ROPE_TYPE_NONE` group used to be refused by name here;
5339 /// every row is served now, positioned the way its graph positions
5340 /// (`crate::position_embd`, `crate::alibi`), and what this pins is
5341 /// that not one of them reaches a rotation: the rule is
5342 /// `RopeLayers::Never` for each, at any depth it takes.
5343 #[test]
5344 fn an_architecture_with_no_rope_rotates_nothing() {
5345 for (arch, n_layers) in [
5346 ("gpt2", 12),
5347 ("mpt", 32),
5348 ("refact", 32),
5349 ("bloom", 30),
5350 ("jais", 40),
5351 ("baichuan", 40),
5352 ] {
5353 assert_eq!(
5354 crate::rope_layers::rope_layers(arch, n_layers, false, 0),
5355 crate::rope_layers::RopeLayers::Never,
5356 "{arch} positions without RoPE and must rotate nothing"
5357 );
5358 assert!(crate::capability::is_audited_generic(arch), "{arch}");
5359 }
5360 }
5361
5362 /// A per-layer sliding-window ARRAY on an architecture whose graph
5363 /// reads the key as a scalar is IGNORED and the seeded period
5364 /// stands, exactly as llama.cpp does; a scalar still overrides the
5365 /// period.
5366 ///
5367 /// Three generations of this gate. `capability::
5368 /// unsupported_feature_keys` refused the key outright with the
5369 /// reason "not implemented in the generic decoder", which was
5370 /// false. Then the loader refused the ARRAY form for every
5371 /// architecture, on the reasoning that honouring it as a period was
5372 /// impossible and ignoring it would substitute the seed for the
5373 /// file's layout -- which is TRUE and is ALSO what llama.cpp does:
5374 /// `get_key_or_arr(kid, swa_period, false)` returns false on an
5375 /// array (`llama-model-loader.cpp:502-507`) and `plamo3.cpp:9-11`
5376 /// keeps its 8. Every real EXAONE-4 32B, EXAONE-MoE and Olmo-3
5377 /// export carries the array (`conversion/exaone.py:84`,
5378 /// `olmo.py:59-66`) and was refused over a value upstream never
5379 /// reads. `crate::swa_layers` carries which graphs read which form;
5380 /// the array-HONOURED mode has its own fixture in
5381 /// `tests/window_array_graphs.rs`.
5382 #[test]
5383 fn an_array_valued_sliding_window_pattern_is_ignored_where_llama_cpp_ignores_it() {
5384 // Disagrees with plamo3's seeded last-dense 8 on layers 0..3,
5385 // so honouring it would be visible.
5386 let pattern: [u32; 4] = [0, 0, 0, 0];
5387 let kvs: Vec<(&str, Kv)> = vec![
5388 ("general.architecture", Kv::Str("plamo3")),
5389 ("plamo3.block_count", Kv::U32(4)),
5390 ("plamo3.embedding_length", Kv::U32(64)),
5391 ("plamo3.attention.head_count", Kv::U32(1)),
5392 ("plamo3.attention.head_count_kv", Kv::U32(1)),
5393 ("plamo3.attention.key_length", Kv::U32(64)),
5394 ("plamo3.rope.freq_base", Kv::F32(10_000.0)),
5395 ("plamo3.attention.sliding_window", Kv::U32(3)),
5396 (
5397 "plamo3.attention.sliding_window_pattern",
5398 Kv::Arr32(&pattern),
5399 ),
5400 ];
5401 let file = open_metadata_gguf("swa_pattern_array", &kvs);
5402 let config = ModelConfig::from_gguf(&file).expect("the array is not a refusal");
5403 assert_eq!(
5404 config.swa_layers,
5405 crate::swa_layers::SwaLayers::period(8, false),
5406 "plamo3.cpp:9-11 seeds 8 and the scalar overload ignores an array"
5407 );
5408 assert_eq!(config.layer_sliding_window(0), Some(3));
5409 assert_eq!(config.layer_sliding_window(3), Some(3));
5410
5411 // And the scalar spelling of the same key overrides the seed.
5412 let mut scalar = kvs;
5413 scalar.pop();
5414 scalar.push(("plamo3.attention.sliding_window_pattern", Kv::U32(2)));
5415 let file = open_metadata_gguf("swa_pattern_scalar", &scalar);
5416 let config = ModelConfig::from_gguf(&file).expect("a scalar period must load");
5417 assert_eq!(
5418 config.swa_layers,
5419 crate::swa_layers::SwaLayers::period(2, false)
5420 );
5421 assert_eq!(config.layer_sliding_window(0), Some(3));
5422 assert_eq!(config.layer_sliding_window(1), None);
5423 }
5424
5425 /// Baichuan is one `general.architecture` string covering two
5426 /// positional schemes, and llama.cpp picks between them on the layer
5427 /// count alone (`src/models/baichuan.cpp:11-14`, with its own "TODO:
5428 /// become GGUF KV parameter"). The 13B used to be refused HERE; it is
5429 /// served now, and what this pins is that the two schemes are still
5430 /// told apart by the count, on both tables that must agree about it
5431 /// (`crate::alibi`, `crate::rope_layers`).
5432 #[test]
5433 fn baichuan_13b_positions_by_alibi_and_the_7b_rotates() {
5434 assert_eq!(
5435 crate::alibi::max_alibi_bias("baichuan", 40, None),
5436 Some(8.0)
5437 );
5438 assert_eq!(
5439 crate::rope_layers::rope_layers("baichuan", 40, false, 0),
5440 crate::rope_layers::RopeLayers::Never
5441 );
5442 assert_eq!(crate::alibi::max_alibi_bias("baichuan", 32, None), None);
5443 assert_eq!(
5444 crate::rope_layers::rope_layers("baichuan", 32, false, 0),
5445 crate::rope_layers::RopeLayers::All
5446 );
5447 // Both sizes pass the header stage and fail on the next missing
5448 // hparam, which is what proves neither is gated here any more.
5449 for (name, n) in [("baichuan13b", 40u32), ("baichuan7b", 32)] {
5450 let file = open_metadata_gguf(
5451 name,
5452 &[
5453 ("general.architecture", Kv::Str("baichuan")),
5454 ("baichuan.block_count", Kv::U32(n)),
5455 ],
5456 );
5457 match ModelConfig::from_gguf(&file) {
5458 Err(LoadError::MissingHparam(key)) => assert_eq!(key, "baichuan.embedding_length"),
5459 other => panic!("{name} must pass the header stage, got {other:?}"),
5460 }
5461 }
5462 }
5463
5464 /// EXAONE-4 is ONE architecture string over TWO graphs, and
5465 /// llama.cpp picks between them off the LAYER COUNT with no GGUF key
5466 /// involved. It used to be refused for it; both sizes run now, and
5467 /// this is the test that says they run DIFFERENTLY.
5468 ///
5469 /// `exaone4.cpp:4-9` wraps the entire SWA setup in
5470 /// `if (hparams.n_layer() == 64)`, and :116 then gates rotation on
5471 /// it -- `use_rope = is_swa(il) || swa_type == NONE`. So:
5472 ///
5473 /// * 64 layers: a window, `set_swa_pattern(4)` last-dense, and the
5474 /// FULL-ATTENTION layer of every period gets no rotation at all.
5475 /// * 30 layers: no window whatever the file declares, and every
5476 /// layer rotates.
5477 ///
5478 /// Both halves are here because the gate is a layer-count EQUALITY.
5479 /// A one-sided version would pass while windowing the 1.2B off a key
5480 /// llama.cpp never reaches, which is the divergence
5481 /// `capability::swa_disabled_by_arch` was extended to stop -- and it
5482 /// would then rope three layers in four of the 1.2B not at all.
5483 #[test]
5484 fn the_two_exaone4_sizes_get_different_windows_and_different_rotation() {
5485 // `Kv` is not `Clone`, so the shared header is a builder rather
5486 // than a value; both sizes must read from one list or the test
5487 // compares two transcriptions.
5488 let base = |n_layers: u32| -> Vec<(&str, Kv)> {
5489 vec![
5490 ("general.architecture", Kv::Str("exaone4")),
5491 ("exaone4.block_count", Kv::U32(n_layers)),
5492 ("exaone4.embedding_length", Kv::U32(32)),
5493 ("exaone4.attention.head_count", Kv::U32(4)),
5494 ("exaone4.attention.head_count_kv", Kv::U32(2)),
5495 ("exaone4.attention.key_length", Kv::U32(8)),
5496 ("exaone4.attention.value_length", Kv::U32(8)),
5497 ("exaone4.rope.freq_base", Kv::F32(10_000.0)),
5498 // The SAME declared window for both sizes: that is the
5499 // whole point. Only the layer count may change the
5500 // answer.
5501 ("exaone4.attention.sliding_window", Kv::U32(4096)),
5502 ]
5503 };
5504
5505 let file = open_metadata_gguf("exaone4_32b", &base(64));
5506 let cfg = ModelConfig::from_gguf(&file).expect("EXAONE-4 32B loads");
5507 assert_eq!(cfg.sliding_window, Some(4096));
5508 assert_eq!(
5509 cfg.swa_layers,
5510 crate::swa_layers::SwaLayers::period(4, false),
5511 "exaone4.cpp:7-9, and set_swa_pattern's default phase"
5512 );
5513 for il in 0..64 {
5514 assert_eq!(
5515 cfg.layer_rotates(il),
5516 il % 4 != 3,
5517 "layer {il} of EXAONE-4 32B: only the sliding layers rotate"
5518 );
5519 }
5520
5521 let file = open_metadata_gguf("exaone4_1_2b", &base(30));
5522 let cfg = ModelConfig::from_gguf(&file).expect("EXAONE-4 1.2B loads");
5523 assert_eq!(
5524 cfg.sliding_window, None,
5525 "exaone4.cpp:4 never reaches set_swa_pattern below 64 layers, \
5526 so the declared window is dead metadata"
5527 );
5528 for il in 0..30 {
5529 assert!(cfg.layer_rotates(il), "layer {il} of EXAONE-4 1.2B");
5530 }
5531 }
5532
5533 /// NextN/MTP blocks are inside `block_count` and llama.cpp skips
5534 /// them (`n_layer = n_layer_all - n_layer_nextn`, llama-hparams.cpp
5535 /// :280-282). For a graph that reads the key (`exaone-moe.cpp:23`)
5536 /// the trunk is what loads; the key is written as `0` by
5537 /// `conversion/exaone.py:146` for every EXAONE-MoE export without an
5538 /// MTP head, so zero must be the whole file. A nonzero value on a
5539 /// graph that does NOT read the key stays refused
5540 /// (`mtp_blocks::tests`).
5541 ///
5542 /// The second half pins the ORDER of two reads in `exaone4.cpp`:
5543 /// `:4` tests `n_layer() == 64` before `:18` reads the key, so it
5544 /// sees `block_count`. A 64-trunk file with one MTP block appended
5545 /// is 65 there and gets NO window in llama.cpp; frink feeds
5546 /// `block_count` to the same gate and gets the same answer.
5547 #[test]
5548 fn nextn_predict_layers_subtracts_the_trunk_for_a_reader_and_zero_is_the_whole_file() {
5549 let base = |nextn: u32| -> Vec<(&str, Kv)> {
5550 vec![
5551 ("general.architecture", Kv::Str("exaone-moe")),
5552 ("exaone-moe.block_count", Kv::U32(5)),
5553 ("exaone-moe.nextn_predict_layers", Kv::U32(nextn)),
5554 ("exaone-moe.embedding_length", Kv::U32(32)),
5555 ("exaone-moe.attention.head_count", Kv::U32(4)),
5556 ("exaone-moe.attention.head_count_kv", Kv::U32(2)),
5557 ("exaone-moe.attention.key_length", Kv::U32(8)),
5558 ("exaone-moe.attention.value_length", Kv::U32(8)),
5559 ("exaone-moe.rope.freq_base", Kv::F32(10_000.0)),
5560 ("exaone-moe.attention.sliding_window", Kv::U32(128)),
5561 ("exaone-moe.expert_count", Kv::U32(4)),
5562 ("exaone-moe.expert_used_count", Kv::U32(2)),
5563 ("exaone-moe.expert_gating_func", Kv::U32(2)),
5564 ]
5565 };
5566 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("exaone_moe_mtp", &base(1)))
5567 .expect("a reader with an MTP block loads its trunk");
5568 assert_eq!((cfg.n_layers, cfg.n_mtp_blocks), (4, 1));
5569 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("exaone_moe_no_mtp", &base(0)))
5570 .expect("zero is the whole file");
5571 assert_eq!((cfg.n_layers, cfg.n_mtp_blocks), (5, 0));
5572
5573 // `exaone4.cpp:4` before `:18`: 64 trunk layers plus one MTP
5574 // block is NOT the 32B to llama.cpp.
5575 let exaone4 = |block_count: u32, nextn: u32| -> Vec<(&str, Kv)> {
5576 vec![
5577 ("general.architecture", Kv::Str("exaone4")),
5578 ("exaone4.block_count", Kv::U32(block_count)),
5579 ("exaone4.nextn_predict_layers", Kv::U32(nextn)),
5580 ("exaone4.embedding_length", Kv::U32(32)),
5581 ("exaone4.attention.head_count", Kv::U32(4)),
5582 ("exaone4.attention.head_count_kv", Kv::U32(2)),
5583 ("exaone4.attention.key_length", Kv::U32(8)),
5584 ("exaone4.attention.value_length", Kv::U32(8)),
5585 ("exaone4.rope.freq_base", Kv::F32(10_000.0)),
5586 ("exaone4.attention.sliding_window", Kv::U32(4096)),
5587 ]
5588 };
5589 let with_mtp =
5590 ModelConfig::from_gguf(&open_metadata_gguf("exaone4_65", &exaone4(65, 1))).unwrap();
5591 assert_eq!((with_mtp.n_layers, with_mtp.n_mtp_blocks), (64, 1));
5592 assert_eq!(
5593 with_mtp.sliding_window, None,
5594 "exaone4.cpp:4 sees n_layer_all = 65 and never reaches set_swa_pattern"
5595 );
5596 let without =
5597 ModelConfig::from_gguf(&open_metadata_gguf("exaone4_64", &exaone4(64, 0))).unwrap();
5598 assert_eq!(
5599 without.sliding_window,
5600 Some(4096),
5601 "the same trunk without the block is the 32B"
5602 );
5603 }
5604
5605 /// `expert_used_count` is scalar-or-array upstream, and the array
5606 /// spelling used to fall through to a DEFAULT of 2 here.
5607 ///
5608 /// `llama-model.cpp:1266` reads the key with `get_key_or_arr` in
5609 /// the common loader -- every architecture, `n_layer_all` entries
5610 /// -- and `conversion/nemotron.py:574` writes a list for
5611 /// Nemotron-H Puzzle, whose architecture (`nemotron_h`) frink
5612 /// serves. Before this test, `metadata_u64` answered `None` for an
5613 /// array value, the `unwrap_or_else` below it pushed a best-effort
5614 /// note, and the model routed top-2 on every layer whatever the
5615 /// file declared: the silent-wrong class, not a refusal.
5616 ///
5617 /// Both arms are pinned, because a reader that honoured the
5618 /// uniform case and silently averaged the varying one would pass
5619 /// half of this.
5620 #[test]
5621 fn a_per_layer_expert_used_count_is_honoured_when_uniform_and_refused_when_not() {
5622 fn file(used: Kv<'_>) -> Vec<(&'static str, Kv<'_>)> {
5623 vec![
5624 ("general.architecture", Kv::Str("llama")),
5625 ("llama.block_count", Kv::U32(2)),
5626 ("llama.embedding_length", Kv::U32(32)),
5627 ("llama.attention.head_count", Kv::U32(4)),
5628 ("llama.attention.head_count_kv", Kv::U32(2)),
5629 ("llama.attention.key_length", Kv::U32(8)),
5630 ("llama.attention.value_length", Kv::U32(8)),
5631 ("llama.rope.freq_base", Kv::F32(10_000.0)),
5632 ("llama.expert_count", Kv::U32(8)),
5633 ("llama.expert_used_count", used),
5634 ]
5635 }
5636 let scalar = ModelConfig::from_gguf(&open_metadata_gguf(
5637 "experts_used_scalar",
5638 &file(Kv::U32(3)),
5639 ))
5640 .expect("the scalar spelling loads");
5641 assert_eq!(scalar.moe.n_experts_active, 3);
5642
5643 let uniform = ModelConfig::from_gguf(&open_metadata_gguf(
5644 "experts_used_uniform",
5645 &file(Kv::Arr32(&[3, 3])),
5646 ))
5647 .expect("a uniform array is that one value");
5648 assert_eq!(
5649 uniform.moe.n_experts_active, 3,
5650 "an array of one repeated value is the scalar, not the default of 2"
5651 );
5652
5653 let err = ModelConfig::from_gguf(&open_metadata_gguf(
5654 "experts_used_varying",
5655 &file(Kv::Arr32(&[3, 5])),
5656 ))
5657 .expect_err("a varying array has no single top-k and must stop");
5658 let msg = format!("{err}");
5659 assert!(
5660 msg.contains("expert_used_count") && msg.contains("PER-LAYER"),
5661 "the refusal must name the key and what is wrong with it: {msg}"
5662 );
5663 }
5664
5665 /// An `olmo2` file carrying BOTH a sliding window and a RoPE
5666 /// scaling ropes its two kinds of layer differently, and frink
5667 /// carries one scaling for the whole model.
5668 ///
5669 /// `olmo2.cpp:120-134` runs the sliding layers with the scaling
5670 /// switched off -- `freq_scale = 1`, `ext_factor = 0`,
5671 /// `attn_factor = 1`, and the comment above it says so in as many
5672 /// words -- while :136-146 gives the full-attention layers the
5673 /// model's own. Rotating half the layers at a magnitude the
5674 /// checkpoint never trained at is the ALiBi class of divergence and
5675 /// runs fluently.
5676 ///
5677 /// Both negative halves are here because the gate is a CONJUNCTION
5678 /// and a gate that fires on either half alone would refuse every
5679 /// OLMo-2 checkpoint ever published.
5680 #[test]
5681 fn olmo2_is_refused_only_when_it_has_a_window_and_a_rope_scaling_together() {
5682 // `Kv` is not `Clone`, so the shared header is a builder
5683 // rather than a value -- which also keeps the three cases
5684 // reading from one list instead of three transcriptions.
5685 let base = || -> Vec<(&str, Kv)> {
5686 vec![
5687 ("general.architecture", Kv::Str("olmo2")),
5688 ("olmo2.block_count", Kv::U32(2)),
5689 ("olmo2.embedding_length", Kv::U32(24)),
5690 ("olmo2.attention.head_count", Kv::U32(4)),
5691 ("olmo2.attention.head_count_kv", Kv::U32(2)),
5692 ("olmo2.attention.key_length", Kv::U32(6)),
5693 ("olmo2.attention.value_length", Kv::U32(6)),
5694 ("olmo2.rope.freq_base", Kv::F32(10_000.0)),
5695 ]
5696 };
5697
5698 let mut both = base();
5699 both.push(("olmo2.attention.sliding_window", Kv::U32(3)));
5700 both.push(("olmo2.rope.scaling.type", Kv::Str("yarn")));
5701 both.push(("olmo2.rope.scaling.factor", Kv::F32(4.0)));
5702 let file = open_metadata_gguf("olmo2_swa_yarn", &both);
5703 match ModelConfig::from_gguf(&file) {
5704 Err(LoadError::UnsupportedFeature(arch, msg)) => {
5705 assert_eq!(arch, "olmo2");
5706 assert!(msg.contains("sliding window"), "{msg}");
5707 assert!(msg.contains("yarn"), "{msg}");
5708 }
5709 other => panic!("olmo2 with a window AND yarn must refuse, got {other:?}"),
5710 }
5711
5712 // A window with no scaling: both of llama.cpp's RoPE branches
5713 // reduce to the same plain rotation, and the difference is
5714 // masking alone, which frink implements.
5715 let mut window_only = base();
5716 window_only.push(("olmo2.attention.sliding_window", Kv::U32(3)));
5717 let file = open_metadata_gguf("olmo2_swa_only", &window_only);
5718 let config = ModelConfig::from_gguf(&file).expect("a window with no scaling must load");
5719 assert_eq!(config.sliding_window, Some(3));
5720 // olmo2.cpp:9-11: the period defaults to 4 and `set_swa_pattern`
5721 // leaves `dense_first` false.
5722 assert_eq!(
5723 config.swa_layers,
5724 crate::swa_layers::SwaLayers::period(4, false)
5725 );
5726
5727 // Scaling with no window: one RoPE for the whole model, which is
5728 // what frink carries.
5729 let mut scaling_only = base();
5730 scaling_only.push(("olmo2.rope.scaling.type", Kv::Str("yarn")));
5731 scaling_only.push(("olmo2.rope.scaling.factor", Kv::F32(4.0)));
5732 let file = open_metadata_gguf("olmo2_yarn_only", &scaling_only);
5733 let config = ModelConfig::from_gguf(&file).expect("scaling with no window must load");
5734 assert_eq!(config.sliding_window, None);
5735 }
5736
5737 /// The hyper-parameters a real Gemma-3 GGUF header carries for one
5738 /// size. `block_count` is the field llama.cpp's `LLM_TYPE_27B`
5739 /// switch reads (`gemma3.cpp:20-28`), so it is never a free
5740 /// parameter here.
5741 ///
5742 /// `linear_factor` adds the pair `conversion/base.py:1222-1230`
5743 /// writes from `rope_parameters["full_attention"]` -- and only from
5744 /// there: its own comment is "TODO: Handle sliding_attention
5745 /// similarly when models start implementing it", so the sliding
5746 /// layers get no scaling key at all.
5747 fn gemma3_config(
5748 tag: &str,
5749 n_layers: u32,
5750 hidden_dim: u32,
5751 n_heads: u32,
5752 head_dim: u32,
5753 linear_factor: Option<f32>,
5754 ) -> ModelConfig {
5755 let mut kvs: Vec<(&str, Kv)> = vec![
5756 ("general.architecture", Kv::Str("gemma3")),
5757 ("gemma3.block_count", Kv::U32(n_layers)),
5758 ("gemma3.embedding_length", Kv::U32(hidden_dim)),
5759 ("gemma3.attention.head_count", Kv::U32(n_heads)),
5760 ("gemma3.attention.head_count_kv", Kv::U32(n_heads)),
5761 ("gemma3.attention.key_length", Kv::U32(head_dim)),
5762 ("gemma3.attention.value_length", Kv::U32(head_dim)),
5763 // Global layers rotate at 1e6; the sliding ones fall back to
5764 // llama.cpp's `rope_freq_base_train_swa` default of 10000,
5765 // because `gemma3.cpp:11` reads only the BASE key.
5766 ("gemma3.rope.freq_base", Kv::F32(1_000_000.0)),
5767 ("gemma3.attention.sliding_window", Kv::U32(1024)),
5768 ("gemma3.attention.sliding_window_pattern", Kv::U32(6)),
5769 ];
5770 if let Some(factor) = linear_factor {
5771 kvs.push(("gemma3.rope.scaling.type", Kv::Str("linear")));
5772 kvs.push(("gemma3.rope.scaling.factor", Kv::F32(factor)));
5773 }
5774 ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("gemma3 fixture must load")
5775 }
5776
5777 /// The 27B attention scale reaches `ModelConfig`, and no other
5778 /// Gemma-3 size acquires one.
5779 ///
5780 /// `capability::attention_scale_override` is where the arithmetic is
5781 /// checked; this pins that the LOADER calls it with this file's own
5782 /// numbers. That step is the one that shipped broken: the function
5783 /// did not exist and `attention_scale` was the literal `None`, under
5784 /// a comment naming the exception. A helper nobody calls looks
5785 /// exactly like a fix.
5786 #[test]
5787 fn a_gemma3_27b_header_sets_the_attention_scale_and_no_smaller_size_does() {
5788 // Gemma-3-27B: 62 layers, n_embd 5376, 32 heads, head_dim 128.
5789 let big = gemma3_config("g3_27b_scale", 62, 5376, 32, 128, Some(8.0));
5790 let want = 1.0f32 / (5376.0f32 / 32.0).sqrt();
5791 let got = big
5792 .attention_scale
5793 .expect("Gemma-3-27B is llama.cpp's LLM_TYPE_27B");
5794 assert!(
5795 (got - want).abs() < 1e-7,
5796 "want 1/sqrt(168) = {want}, got {got}"
5797 );
5798 // The bug's magnitude: scores were sqrt(168/128) = 1.146x too
5799 // large without this.
5800 let kernel = 1.0f32 / 128.0f32.sqrt();
5801 assert!((kernel / got - (168.0f32 / 128.0).sqrt()).abs() < 1e-5);
5802
5803 // Gemma-3-1B and -4B take llama.cpp's other branch, which is the
5804 // scale the attention kernels already apply. A `Some` here would
5805 // double-scale them.
5806 for (tag, n_layers, hidden, heads) in
5807 [("g3_1b_scale", 26, 1152, 4), ("g3_4b_scale", 34, 2560, 8)]
5808 {
5809 let cfg = gemma3_config(tag, n_layers, hidden, heads, 256, None);
5810 assert_eq!(
5811 cfg.attention_scale, None,
5812 "{tag} must keep the kernels' own 1/sqrt(head_dim)"
5813 );
5814 }
5815 }
5816
5817 /// Gemma-3's declared linear scaling reaches the FULL-ATTENTION
5818 /// layers only, and the sliding ones rope unscaled.
5819 ///
5820 /// `gemma3.cpp:11` reads `LLM_KV_ROPE_FREQ_BASE_SWA` and nothing
5821 /// else, so `rope_freq_scale_train_swa` keeps its `1.0f` default
5822 /// (`src/llama-hparams.h:129`) while `get_rope_freq_scale`
5823 /// (`llama-model.cpp:2033-2035`) hands the trained scale to the full
5824 /// layers. The converter agrees: `conversion/base.py:1222-1230`
5825 /// takes the factor from `rope_parameters["full_attention"]` and
5826 /// writes nothing for the sliding half.
5827 ///
5828 /// frink folded the factor into ONE global `rope_freqs` vector, so
5829 /// Gemma-3-4B/12B/27B rotated five layers in six at `p/8`.
5830 #[test]
5831 fn gemma3_linear_scaling_reaches_the_full_layers_and_not_the_sliding_ones() {
5832 // Gemma-3-4B: 34 layers, head_dim 256, `rope_scaling: linear 8`.
5833 let cfg = gemma3_config("g3_4b_rope", 34, 2560, 8, 256, Some(8.0));
5834 let freqs = cfg
5835 .rope_freqs
5836 .as_ref()
5837 .expect("declared linear scaling must produce per-band divisors");
5838 assert!(
5839 freqs.full.iter().all(|f| (*f - 8.0).abs() < 1e-6),
5840 "full-attention layers divide every band by the trained factor: {:?}",
5841 freqs.full
5842 );
5843 let swa = freqs
5844 .swa
5845 .as_ref()
5846 .expect("gemma3 does not assign rope_freq_scale_train_swa, so 1.0 applies");
5847 assert!(
5848 swa.iter().all(|f| (*f - 1.0).abs() < 1e-6),
5849 "sliding layers rope at the raw position: {swa:?}"
5850 );
5851 assert_eq!(swa.len(), freqs.full.len(), "one divisor per rotated pair");
5852
5853 // Period 6, last-dense (`capability::default_swa_layout`), so
5854 // layer 5 is the full-attention one and 0..=4 slide. The phase
5855 // matters: getting it wrong swaps which five-sixths are wrong.
5856 assert!(cfg.layer_sliding_window(0).is_some());
5857 assert!(cfg.layer_sliding_window(5).is_none());
5858 assert_eq!(
5859 cfg.layer_rope(0),
5860 Some(crate::config::LayerRopeParams {
5861 theta: 10_000.0,
5862 freq_factors: Some(&[1.0f32; 128][..]),
5863 rot_dim: None,
5864 })
5865 );
5866 assert_eq!(
5867 cfg.layer_rope(5),
5868 Some(crate::config::LayerRopeParams {
5869 theta: 1_000_000.0,
5870 freq_factors: Some(&[8.0f32; 128][..]),
5871 rot_dim: None,
5872 })
5873 );
5874 assert!(
5875 cfg.rope_freqs_vary_by_layer(),
5876 "the fused Metal stacks take one divisor slice for a whole run \
5877 and so must refuse this model"
5878 );
5879
5880 // Gemma-3-1B declares no scaling at all -- the audited fixture,
5881 // and the reason this was invisible. Nothing to split, so no
5882 // per-layer set and no Metal refusal.
5883 let plain = gemma3_config("g3_1b_rope", 26, 1152, 4, 256, None);
5884 assert!(plain.rope_freqs.is_none());
5885 assert!(!plain.rope_freqs_vary_by_layer());
5886 }
5887
5888 /// Gemma-2 is the counter-case, and it is why the SWA scale needs
5889 /// its own table rather than reusing `swa_rope_base_follows_model`.
5890 ///
5891 /// `gemma2.cpp:10-11` assigns BOTH `rope_freq_base_train_swa` and
5892 /// `rope_freq_scale_train_swa` from the model's trained values, so
5893 /// its sliding layers keep the declared scaling. Splitting them here
5894 /// would be the same bug pointed the other way.
5895 #[test]
5896 fn gemma2_sliding_layers_inherit_the_trained_rope_scale() {
5897 let cfg = ModelConfig::from_gguf(&open_metadata_gguf(
5898 "g2_rope",
5899 &[
5900 ("general.architecture", Kv::Str("gemma2")),
5901 ("gemma2.block_count", Kv::U32(26)),
5902 ("gemma2.embedding_length", Kv::U32(2304)),
5903 ("gemma2.attention.head_count", Kv::U32(8)),
5904 ("gemma2.attention.head_count_kv", Kv::U32(4)),
5905 ("gemma2.attention.key_length", Kv::U32(256)),
5906 ("gemma2.attention.value_length", Kv::U32(256)),
5907 ("gemma2.rope.freq_base", Kv::F32(10_000.0)),
5908 ("gemma2.attention.sliding_window", Kv::U32(4096)),
5909 ("gemma2.rope.scaling.type", Kv::Str("linear")),
5910 ("gemma2.rope.scaling.factor", Kv::F32(8.0)),
5911 ],
5912 ))
5913 .expect("gemma2 fixture must load");
5914
5915 let freqs = cfg.rope_freqs.as_ref().expect("linear scaling declared");
5916 assert_eq!(
5917 freqs.swa, None,
5918 "gemma2.cpp:11 assigns rope_freq_scale_train_swa from the trained scale"
5919 );
5920 assert!(!cfg.rope_freqs_vary_by_layer());
5921 // Period 2, last-dense: layer 0 slides, layer 1 does not, and
5922 // both get the same divisors.
5923 assert!(cfg.layer_sliding_window(0).is_some());
5924 assert!(cfg.layer_sliding_window(1).is_none());
5925 assert_eq!(cfg.layer_rope(0), cfg.layer_rope(1));
5926
5927 // The two tables really are different: this is the pair that
5928 // must not be collapsed into one.
5929 assert!(crate::capability::swa_rope_scale_follows_model("gemma2"));
5930 assert!(!crate::capability::swa_rope_scale_follows_model("gemma3"));
5931 for arch in ["olmo2", "laguna"] {
5932 assert!(
5933 crate::capability::swa_rope_base_follows_model(arch),
5934 "{arch} seeds the SWA base from the model"
5935 );
5936 assert!(
5937 !crate::capability::swa_rope_scale_follows_model(arch),
5938 "{arch} pins the SWA scale to 1.0 (olmo2.cpp:14, laguna.cpp:48)"
5939 );
5940 }
5941 }
5942}