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 let arch = file
2686 .metadata_str("general.architecture")
2687 .unwrap_or_default()
2688 .to_string();
2689 let is_gpt_oss = arch == "gpt-oss";
2690 // A `<projection>.scale` companion is a multiply llama.cpp
2691 // applies and frink does not; refused by name here, before
2692 // the unread-tensor gate can be talked past
2693 // (`crate::weight_scales`).
2694 crate::weight_scales::refuse_weight_scale_tensors(
2695 &arch,
2696 file.tensors().map(|(_, t)| t.name.as_str()),
2697 )?;
2698 // Which tensor each of the five norm sites is stored under and
2699 // which FUNCTION norms it, resolved ONCE. Four shapes reach this
2700 // loader -- the plain pre-norm layer, the post-norm-only
2701 // topology (`olmo2`, `exaone4`), the non-parametric LayerNorm
2702 // (`olmo`) and the weighted LayerNorm (`dbrx`) -- and three
2703 // architectures keep a norm under a name another architecture
2704 // uses for a different site (`gpt-oss` / `seed_oss`, `dbrx`,
2705 // `grok`). `crate::norm_sites` is the one table for all of it;
2706 // this loader used to restate the decision at every site.
2707 let norm_sites = crate::norm_sites::NormSites::with_function(&arch, config.norm_function);
2708 let mut gpt_oss_layers: Vec<crate::decoder::GptOssLayer> = Vec::new();
2709
2710 // One store for the whole model (keys are (layer, expert)),
2711 // built up-front with every stored expert's segments; created
2712 // only when the cache is enabled AND some layer can use it.
2713 let mut store_segments: std::collections::HashMap<ExpertKey, [(usize, u64, usize); 3]> =
2714 std::collections::HashMap::new();
2715 let mut stored_layouts: Vec<Option<Vec<StoredExpertLayout>>> = Vec::new();
2716
2717 // Loaded like any other weight matrix: a quantized embedding
2718 // table stays quantized (zero-copy mmap) and token lookup
2719 // dequantizes one row via `WeightMatrix::dequant_row`, instead
2720 // of the whole vocabulary tensor being widened to f32 up front.
2721 let embedding = load_weight_matrix(&file, "token_embd.weight")?;
2722 // The learned position table (`crate::position_embd`), one row
2723 // per trained position, for the graphs that add one.
2724 let position_embd = crate::position_embd::load_position_embd(
2725 &file,
2726 &arch,
2727 config.hidden_dim,
2728 metadata_u64_any(&file, &[format!("{arch}.context_length")]).map(|v| v as usize),
2729 )?;
2730
2731 // PHYSICAL layers: the blocks the file holds tensors for. A
2732 // looped model (`crate::layer_loops`) has more logical layers
2733 // than this, and they run these same weights.
2734 let n_physical = config
2735 .layer_loops
2736 .map_or(config.n_layers, |loops| loops.physical_layers());
2737 let mut layers = Vec::with_capacity(n_physical);
2738 let mut refined_qk_norm = config.qk_norm_style;
2739 for l in 0..n_physical {
2740 // THIS layer's head counts and FFN width. Uniform for every
2741 // architecture but the per-layer ones (`crate::layer_shapes`),
2742 // and the loader reads the shape rather than the scalars so
2743 // that a deci / openelm layer is sized by its own header.
2744 let shape = config.layer_shape(l);
2745 // Whether THIS layer's FFN reads the layer input rather
2746 // than the post-attention residual, and under which norm
2747 // (`crate::parallel_residual`).
2748 let parallel = crate::parallel_residual::layer_parallel_norm(&file, &arch, l);
2749 // THIS layer's norm slots: the architecture's row, with
2750 // Falcon-40B's `attn_norm_2` crossing the two pre-norm names
2751 // on the layers that carry it (`crate::norm_sites`).
2752 let layer_sites = norm_sites.for_layer(&arch, &file, l);
2753 // BitNet's two inner norms, REQUIRED when the architecture
2754 // has them and untouched otherwise (`crate::sub_norms`).
2755 let sub_norms = crate::sub_norms::load_sub_norms(
2756 &file,
2757 &arch,
2758 config.block_sub_norms,
2759 l,
2760 config.hidden_dim,
2761 shape.ffn_dim,
2762 )?;
2763 let attn = match shape.attention {
2764 crate::layer_shapes::AttnShape::Gqa { n_heads, .. } => {
2765 // Q/K/V and their biases come out of ONE decision about
2766 // which spelling this layer uses -- see `qkv_fused`. They
2767 // used to be resolved independently, and a checkpoint that
2768 // fused both (ChatGLM, Qwen-1) had its bias dropped.
2769 let crate::qkv_fused::QkvProjections {
2770 q: q_proj,
2771 k: k_proj,
2772 v: v_proj,
2773 q_bias,
2774 k_bias,
2775 v_bias,
2776 } = crate::qkv_fused::load_fused_or_split_qkv(&file, l, &config)?;
2777 let q_norm =
2778 load_f32_vec_optional(&file, &format!("blk.{l}.attn_q_norm.weight"))?;
2779 let k_norm =
2780 load_f32_vec_optional(&file, &format!("blk.{l}.attn_k_norm.weight"))?;
2781 // The per-head LAYERNORM (`crate::qk_layer_norm`), whose
2782 // weight is `n_heads * head_dim` long and would pass
2783 // the length rule below as `WholeVector`.
2784 if let Some(reason) = crate::qk_layer_norm::per_head_layer_norm_refusal(
2785 &arch,
2786 l,
2787 q_norm.is_some() || k_norm.is_some(),
2788 ) {
2789 return Err(LoadError::UnsupportedFeature(
2790 config.name.to_string(),
2791 reason,
2792 ));
2793 }
2794 // Refine WholeVector vs PerHead from the first observed norm length.
2795 // The per-head SCALAR gain is decided by architecture first
2796 // (`capability::PER_HEAD_SCALAR_QK_GAIN`): its length is
2797 // `n_heads`, which a length test alone could confuse with
2798 // `head_dim`.
2799 if let Some(ref w) = q_norm {
2800 if crate::capability::uses_per_head_scalar_qk_gain(&arch) {
2801 if w.len() != n_heads {
2802 return Err(LoadError::UnsupportedFeature(
2803 config.name.to_string(),
2804 format!(
2805 "blk.{l}.attn_q_norm.weight length {} is not one gain per \
2806 head (n_heads={n_heads}; talkie.cpp:26 creates it {{1, \
2807 n_head}})",
2808 w.len()
2809 ),
2810 ));
2811 }
2812 refined_qk_norm = crate::capability::QkNormStyle::PerHeadScalar;
2813 } else if crate::capability::uses_per_head_distinct_qk_norm(&arch) {
2814 // `plamo2.cpp:92-93`: `{head_dim, n_head}`, one
2815 // row per head, RMS per head. The same length as
2816 // a whole-vector weight, so the architecture
2817 // decides (`capability::PER_HEAD_DISTINCT_QK_NORM`).
2818 if w.len() != n_heads * config.head_dim {
2819 return Err(LoadError::UnsupportedFeature(
2820 config.name.to_string(),
2821 format!(
2822 "blk.{l}.attn_q_norm.weight length {} is not one row per \
2823 head (n_heads={n_heads} x head_dim={}; plamo2.cpp:92 \
2824 creates it {{head_dim, n_head}})",
2825 w.len(),
2826 config.head_dim
2827 ),
2828 ));
2829 }
2830 refined_qk_norm = crate::capability::QkNormStyle::PerHeadDistinct;
2831 } else if w.len() == config.head_dim {
2832 refined_qk_norm = crate::capability::QkNormStyle::PerHead;
2833 } else if w.len() == n_heads * config.head_dim {
2834 refined_qk_norm = crate::capability::QkNormStyle::WholeVector;
2835 } else {
2836 return Err(LoadError::UnsupportedFeature(
2837 config.name.to_string(),
2838 format!(
2839 "blk.{l}.attn_q_norm.weight length {} matches neither \
2840 head_dim={} nor n_heads*head_dim={}",
2841 w.len(),
2842 config.head_dim,
2843 n_heads * config.head_dim
2844 ),
2845 ));
2846 }
2847 }
2848 let attn = AttnWeights {
2849 q_proj,
2850 k_proj,
2851 v_proj,
2852 o_proj: load_weight_matrix(&file, &format!("blk.{l}.attn_output.weight"))?,
2853 // Which tensor, which function, and whether there is a
2854 // norm here at all: all three answered by the table.
2855 norm_weight: layer_sites.load_pre_norm(layer_sites.attn, &file, Some(l))?,
2856 q_norm,
2857 k_norm,
2858 // Qwen2/Qwen2-MoE-family real QKV bias (`attn_{q,k,v}.bias`,
2859 // real config `qkv_bias`, `o_proj` has none) -- see
2860 // `AttnWeights::q_bias`'s doc comment. Resolved above,
2861 // alongside the projections they belong to, because a
2862 // file that fuses the weight fuses the bias too.
2863 q_bias,
2864 k_bias,
2865 v_bias,
2866 post_attn_norm: crate::norm_sites::NormSites::load_post_norm(
2867 norm_sites.post_attn,
2868 &file,
2869 l,
2870 )?,
2871 post_ffn_norm: crate::norm_sites::NormSites::load_post_norm(
2872 norm_sites.post_ffn,
2873 &file,
2874 l,
2875 )?,
2876 // The architecture table decides whether there is
2877 // a gate and how it is applied; the tensor decides
2878 // its width. See `crate::attn_gate`.
2879 output_gate: crate::attn_gate::AttnGate::load(
2880 &file,
2881 &arch,
2882 l,
2883 n_heads,
2884 config.head_dim,
2885 config.hidden_dim,
2886 )?,
2887 // The TENSOR decides. Four llama.cpp graphs pass it
2888 // into the one `build_attn_mha`; on the generic
2889 // path a file that has it gets the sink term and
2890 // a file that does not gets none, whatever the
2891 // architecture string. gpt-oss's requirement is
2892 // checked where its side table loads.
2893 sinks: load_attn_sinks(&file, l, n_heads)?,
2894 attn_sub_norm: sub_norms.as_ref().map(|n| n.attn.clone()),
2895 o_scale: crate::weight_scales::load_projection_gain(
2896 &file,
2897 &arch,
2898 l,
2899 "attn_output",
2900 )?,
2901 o_bias: crate::proj_bias::load_attn_out_bias(
2902 &file,
2903 &arch,
2904 l,
2905 config.hidden_dim,
2906 )?,
2907 shortconv: None,
2908 // falcon-h1.cpp:55-71: the Mamba-2 block beside
2909 // attention on every layer (`crate::mamba2::
2910 // PARALLEL_WITH_ATTENTION`).
2911 ssm: if config.parallel_ssm {
2912 Some(crate::ssm_block::SsmBlock::Mamba2(
2913 crate::mamba2::Mamba2::load(&file, &arch, l, config.hidden_dim)?,
2914 ))
2915 } else {
2916 None
2917 },
2918 q_gate_interleaved: crate::attn_gate::q_gate_interleaved(&arch),
2919 };
2920 crate::layer_shapes::check_gqa_projection_widths(
2921 l,
2922 shape.attention,
2923 config.head_dim,
2924 config.v_head_dim(),
2925 config.hidden_dim,
2926 &attn,
2927 )?;
2928 attn
2929 }
2930 other => crate::layer_shapes::load_non_gqa_attention(
2931 other,
2932 &file,
2933 &arch,
2934 l,
2935 &norm_sites,
2936 &config,
2937 )?,
2938 };
2939
2940 // Leading dense layers (see ModelConfig::layer_is_dense's
2941 // doc comment) load from the plain dense tensor names
2942 // regardless of this model's global MoE topology, matching
2943 // the DeepSeek-2/3-family convention found in
2944 // ik_llama.cpp's source. A model with n_experts<=1
2945 // globally (the dense test fixture) is dense on every
2946 // layer either way.
2947 // A layer with NO FFN at all (`ffn_dim 0`: deci's, Nemotron-H's
2948 // block-only layers) takes the dense arm, whose loader answers
2949 // `absent_ffn` for that width, whatever the model's MoE says.
2950 let is_dense_layer = config.layer_is_dense(l)
2951 || config.moe.n_experts <= 1
2952 || shape.ffn_dim == 0
2953 || crate::moe_interleave::dense_by_router_absence(&arch, &file, l);
2954 // The ungated experts (`nemotron-h.cpp:82-86,209-215`: a null
2955 // gate into `build_moe_ffn`, `LLM_FFN_RELU_SQR`) are spelled
2956 // the way the dense ungated FFN is (`load_dense_expert`): the
2957 // gate ALIASED to `up`, so `relu(up)^2` runs through the gated
2958 // body with no branch. A file that carries a gate anyway is
2959 // refused, as the dense loader refuses one.
2960 let routed_gate_name = if config.ffn_is_ungated() && !is_dense_layer {
2961 if file
2962 .find_tensor(&format!("blk.{l}.ffn_gate_exps.weight"))
2963 .is_some()
2964 {
2965 return Err(LoadError::UnsupportedFeature(
2966 arch.clone(),
2967 format!(
2968 "blk.{l}.ffn_gate_exps.weight is present but this architecture's \
2969 experts are ungated ({:?}: a null gate into build_moe_ffn, \
2970 nemotron-h.cpp:212)",
2971 config.ffn_activation
2972 ),
2973 ));
2974 }
2975 format!("blk.{l}.ffn_up_exps.weight")
2976 } else {
2977 format!("blk.{l}.ffn_gate_exps.weight")
2978 };
2979 // The inner FFN norm has a site in the dense body only
2980 // (`build_ffn` with a NULL down, `bitnet.cpp:127-141`);
2981 // `build_moe_ffn` has none, so a routed layer that carried
2982 // one would have nowhere to apply it.
2983 if sub_norms.is_some() && !is_dense_layer {
2984 return Err(LoadError::UnsupportedFeature(
2985 arch.clone(),
2986 format!(
2987 "blk.{l}.ffn_sub_norm on a MoE layer: llama.cpp applies the inner FFN \
2988 norm in the dense `build_ffn` body only (bitnet.cpp:127-141), and no \
2989 routed-expert graph has that site"
2990 ),
2991 ));
2992 }
2993 let n_experts = if is_dense_layer {
2994 1
2995 } else {
2996 config.moe.n_experts
2997 };
2998 let experts: ExpertBacking = if is_dense_layer {
2999 ExpertBacking::Resident(vec![load_dense_expert(&file, l, &config, shape.ffn_dim)?])
3000 } else {
3001 // Try store-backed layouts first when the cache is
3002 // enabled; fall back to resident when any of the three
3003 // tensors isn't a supported quantized dtype.
3004 let stored = if expert_cache_bytes.is_some() {
3005 let g = stored_expert_specs(&file, &routed_gate_name, n_experts)?;
3006 let u = stored_expert_specs(
3007 &file,
3008 &format!("blk.{l}.ffn_up_exps.weight"),
3009 n_experts,
3010 )?;
3011 let d = stored_expert_specs(
3012 &file,
3013 &format!("blk.{l}.ffn_down_exps.weight"),
3014 n_experts,
3015 )?;
3016 match (g, u, d) {
3017 (Some(gt), Some(ut), Some(dt)) => {
3018 let mut layouts = Vec::with_capacity(n_experts);
3019 for e in 0..n_experts {
3020 let key = ExpertKey {
3021 layer: l as u32,
3022 expert: e as u32,
3023 };
3024 store_segments.insert(
3025 key,
3026 [
3027 (gt.shard, gt.per_expert[e].0, gt.per_expert[e].1),
3028 (ut.shard, ut.per_expert[e].0, ut.per_expert[e].1),
3029 (dt.shard, dt.per_expert[e].0, dt.per_expert[e].1),
3030 ],
3031 );
3032 let mut gate = gt.spec;
3033 let mut up = ut.spec;
3034 let mut down = dt.spec;
3035 gate.offset = 0;
3036 up.offset = gate.len;
3037 down.offset = gate.len + up.len;
3038 layouts.push(StoredExpertLayout { gate, up, down });
3039 }
3040 Some(layouts)
3041 }
3042 _ => None,
3043 }
3044 } else {
3045 None
3046 };
3047 match stored {
3048 Some(layouts) => {
3049 // Placeholder; the shared store is attached in a
3050 // second pass below once every layer's segments
3051 // are collected.
3052 stored_layouts.push(Some(layouts));
3053 ExpertBacking::Resident(Vec::new())
3054 }
3055 None => {
3056 let gates = split_expert_tensor(&file, &routed_gate_name, n_experts)?;
3057 let ups = split_expert_tensor(
3058 &file,
3059 &format!("blk.{l}.ffn_up_exps.weight"),
3060 n_experts,
3061 )?;
3062 let downs = split_expert_tensor(
3063 &file,
3064 &format!("blk.{l}.ffn_down_exps.weight"),
3065 n_experts,
3066 )?;
3067 ExpertBacking::Resident(
3068 gates
3069 .into_iter()
3070 .zip(ups)
3071 .zip(downs)
3072 .map(|((gate, up), down)| ExpertWeights { gate, up, down })
3073 .collect(),
3074 )
3075 }
3076 }
3077 };
3078 if stored_layouts.len() < layers.len() + 1 {
3079 stored_layouts.push(None);
3080 }
3081
3082 let mut shared_experts: Vec<ExpertWeights> =
3083 if config.moe.n_shared_experts > 0 && !is_dense_layer {
3084 // The shared expert takes the architecture's dense
3085 // activation, so an ungated one aliases its gate as
3086 // `load_dense_expert` does (`nemotron-h.cpp:222-227`).
3087 let shexp_gate = if config.ffn_is_ungated() {
3088 if file
3089 .find_tensor(&format!("blk.{l}.ffn_gate_shexp.weight"))
3090 .is_some()
3091 {
3092 return Err(LoadError::UnsupportedFeature(
3093 arch.clone(),
3094 format!(
3095 "blk.{l}.ffn_gate_shexp.weight is present but this \
3096 architecture's shared expert is ungated"
3097 ),
3098 ));
3099 }
3100 format!("blk.{l}.ffn_up_shexp.weight")
3101 } else {
3102 format!("blk.{l}.ffn_gate_shexp.weight")
3103 };
3104 vec![ExpertWeights {
3105 gate: load_weight_matrix(&file, &shexp_gate)?,
3106 up: load_weight_matrix(&file, &format!("blk.{l}.ffn_up_shexp.weight"))?,
3107 down: load_weight_matrix(&file, &format!("blk.{l}.ffn_down_shexp.weight"))?,
3108 }]
3109 } else {
3110 Vec::new()
3111 };
3112 // A dense FFN SUMMED with the experts (Grok-2, Arctic) is the
3113 // shared-expert slot under the dense names, plus the row's
3114 // scale on the sum (`crate::parallel_dense_ffn`). Decided per
3115 // layer: Grok-1's layers have no triple and take neither.
3116 // ...and the same scale under the `_shexp` names
3117 // (`SHARED_EXPERT_SUM_SCALE`, cohere2moe's `* 0.5`), on a
3118 // layer that loaded a shared expert above.
3119 let parallel_sum_scale = if is_dense_layer {
3120 None
3121 } else {
3122 match crate::parallel_dense_ffn::parallel_dense_for_layer(&arch, &file, l)? {
3123 Some(row) => {
3124 shared_experts.push(ExpertWeights {
3125 gate: load_weight_matrix(&file, &format!("blk.{l}.ffn_gate.weight"))?,
3126 up: load_weight_matrix(&file, &format!("blk.{l}.ffn_up.weight"))?,
3127 down: load_weight_matrix(&file, &format!("blk.{l}.ffn_down.weight"))?,
3128 });
3129 row.sum_scale
3130 }
3131 None => crate::parallel_dense_ffn::shared_expert_sum_scale(
3132 &arch,
3133 !shared_experts.is_empty(),
3134 ),
3135 }
3136 };
3137 // Arctic's second per-layer norm, the routed branch's operand
3138 // (`crate::router_input::RouterInput::NormedLayerInput`):
3139 // REQUIRED on its routed layers, unread everywhere else.
3140 let exps_norm = if config.router_input.needs_exps_norm() && !is_dense_layer {
3141 Some(load_f32_vec(
3142 &file,
3143 &format!("blk.{l}.ffn_norm_exps.weight"),
3144 )?)
3145 } else {
3146 None
3147 };
3148
3149 let router = if !is_dense_layer {
3150 load_weight_matrix(&file, &format!("blk.{l}.ffn_gate_inp.weight"))?
3151 } else {
3152 // dense layer: no real router; a zero [1, hidden] matrix
3153 // always selects the single expert deterministically.
3154 WeightMatrix::F32(Tensor::zeros(vec![1, config.hidden_dim]))
3155 };
3156
3157 let n_for_counts = match &experts {
3158 ExpertBacking::Resident(v) if v.is_empty() => n_experts,
3159 other => other.n_experts(),
3160 };
3161 let activation_counts = (0..n_for_counts)
3162 .map(|_| std::sync::atomic::AtomicU64::new(0))
3163 .collect();
3164 // Qwen2-MoE-specific real tensor (`blk.N.ffn_gate_inp_shexp.weight`,
3165 // real on-disk shape `[hidden_dim]`, confirmed against
3166 // llama.cpp's real `qwen2moe.cpp`) -- see
3167 // `MoeWeights::shared_expert_gate`'s doc comment. Presence
3168 // of the tensor itself is the real signal (not an
3169 // architecture-name list): every other supported
3170 // architecture's checkpoints simply don't carry this
3171 // tensor, so this naturally stays `None` there.
3172 let shared_expert_gate = if is_dense_layer {
3173 None
3174 } else {
3175 load_f32_vec_optional(&file, &format!("blk.{l}.ffn_gate_inp_shexp.weight"))?
3176 };
3177 #[cfg(feature = "metal")]
3178 let packed_q4 = match &experts {
3179 ExpertBacking::Resident(v) if !v.is_empty() => try_build_moe_packed_q4_planes(v),
3180 _ => None,
3181 };
3182 // DeepSeek-V3's aux-loss-free selection bias. The on-disk
3183 // name carries no `ffn_` prefix -- llama.cpp's
3184 // `LLM_TENSOR_FFN_EXP_PROBS_B` maps to `blk.%d.exp_probs_b`
3185 // (`llama-arch.cpp:416`, `gguf-py/gguf/constants.py:1240`).
3186 // Optional: only the DeepSeek-V3-lineage MoE recipes carry
3187 // it, and this same generic loader serves OLMoE / Qwen2-MoE /
3188 // Mixtral, which do not.
3189 let exp_probs_bias = if is_dense_layer {
3190 None
3191 } else {
3192 load_f32_vec_optional(&file, &format!("blk.{l}.exp_probs_b.bias"))?
3193 };
3194 if let Some(bias) = &exp_probs_bias {
3195 if bias.len() != config.moe.n_experts {
3196 return Err(LoadError::UnsupportedFeature(
3197 arch.clone(),
3198 format!(
3199 "blk.{l}.exp_probs_b.bias has {} entries but the model has {} experts",
3200 bias.len(),
3201 config.moe.n_experts
3202 ),
3203 ));
3204 }
3205 // Grouped selection masks the *biased* scores before the
3206 // global top-k (`build_moe_ffn`, the `n_expert_groups > 1`
3207 // block). frink's `route_top_k_grouped` takes a fixed
3208 // count from every group instead, which is a different
3209 // algorithm, so combining the two here would be a guess.
3210 // Refuse rather than route wrongly.
3211 if config.moe.expert_group_count.is_some() {
3212 return Err(LoadError::UnsupportedFeature(
3213 arch.clone(),
3214 format!(
3215 "blk.{l}.exp_probs_b.bias together with expert groups \
3216 ({:?}): llama.cpp masks the biased scores per group \
3217 before a global top-k, which is not the per-group \
3218 top-k frink implements",
3219 config.moe.expert_group_count
3220 ),
3221 ));
3222 }
3223 }
3224 let moe = MoeWeights {
3225 router,
3226 experts,
3227 shared_experts,
3228 shared_expert_gate,
3229 exp_probs_bias,
3230 exps_norm,
3231 parallel_sum_scale,
3232 // The dense FFN's biases (`crate::proj_bias`), on a dense
3233 // layer; a routed layer's experts carry none on the
3234 // generic path (gpt-oss's are its side table's).
3235 dense_bias: if is_dense_layer && shape.ffn_dim > 0 {
3236 let bias = crate::proj_bias::load_dense_ffn_bias(
3237 &file,
3238 &arch,
3239 l,
3240 config.hidden_dim,
3241 shape.ffn_dim,
3242 config.ffn_is_ungated(),
3243 )?;
3244 if bias.is_some() && sub_norms.is_some() {
3245 return Err(LoadError::UnsupportedFeature(
3246 arch.clone(),
3247 format!(
3248 "layer {l} has both an inner FFN norm and FFN biases; no llama.cpp \
3249 graph has both and the dense body has one arm for each"
3250 ),
3251 ));
3252 }
3253 bias
3254 } else {
3255 None
3256 },
3257 ffn_sub_norm: sub_norms.map(|n| n.ffn),
3258 down_scale: {
3259 let gain =
3260 crate::weight_scales::load_projection_gain(&file, &arch, l, "ffn_down")?;
3261 if gain.is_some() && !is_dense_layer {
3262 return Err(LoadError::UnsupportedFeature(
3263 arch.clone(),
3264 format!(
3265 "blk.{l}.ffn_down.scale on a MoE layer: the routed experts' \
3266 scales are `ffn_down_exps.scale`, one per expert, which is \
3267 not applied here"
3268 ),
3269 ));
3270 }
3271 gain
3272 },
3273 // The same table as the attention slot, so the two
3274 // pre-norms cannot disagree about the function, and the
3275 // pre-FFN tensor's NAME comes from the same row that
3276 // decided the post-attention slot must not read it. An
3277 // FFN-free layer (`deci.cpp:52-54`) has no such tensor.
3278 // A parallel layer with ONE shared norm has no pre-FFN
3279 // tensor and no pre-FFN norm: the FFN reads the vector
3280 // attention read (`crate::parallel_residual`).
3281 norm_weight: if shape.ffn_dim == 0
3282 || parallel == Some(crate::parallel_residual::ParallelNorm::SharedNorm)
3283 {
3284 NormOp::None
3285 } else {
3286 layer_sites.load_pre_norm(layer_sites.ffn, &file, Some(l))?
3287 },
3288 parallel,
3289 activation_counts,
3290 #[cfg(feature = "metal")]
3291 packed_q4,
3292 };
3293
3294 if is_gpt_oss {
3295 gpt_oss_layers.push(load_gpt_oss_layer(&file, l, &config, attn.sinks.is_some())?);
3296 }
3297
3298 // Talkie's per-layer skip scalar (`crate::skip_stream`);
3299 // REQUIRED there, untouched everywhere else.
3300 let out_scale =
3301 crate::skip_stream::load_out_scale(&file, &arch, config.skip_stream, l)?;
3302 layers.push(LayerWeights {
3303 attn,
3304 moe,
3305 out_scale,
3306 });
3307 }
3308
3309 // `olmo.cpp:15-36` creates no `output_norm` at all and
3310 // `:128-130` norms the final hidden state with a null weight, so
3311 // asking for the tensor would refuse every real OLMo-1 file;
3312 // the table's function decides whether the read happens.
3313 let final_norm = norm_sites.load_pre_norm(norm_sites.output, &file, None)?;
3314 // `hrm-text.cpp:46` creates `hrm_z_l_init` REQUIRED, and only
3315 // that graph does (`crate::hrm`): the learned LOW stream, one
3316 // `[n_embd]` row broadcast over the tokens at `:182`.
3317 let hrm_z_l_init = match config.layer_loops {
3318 Some(crate::layer_loops::LayerLoops::Hrm { .. }) => {
3319 Some(load_f32_vec(&file, "hrm.z_l_init")?)
3320 }
3321 _ => None,
3322 };
3323 // The embedding norm (`norm_sites::EMBEDDING_NORM_ARCHITECTURES`),
3324 // `NormOp::None` where the site is absent.
3325 // Two answers, one field: a STORED embedding norm (`bloom`) or
3326 // a weightless one (`muse-glimmer.cpp:69`), and the tables that
3327 // decide them are disjoint by construction
3328 // (`norm_sites::WEIGHTLESS_EMBEDDING_NORM`).
3329 let embedding_norm = if crate::norm_sites::weightless_embedding_norm(&arch) {
3330 crate::norm::NormOp::RmsNoParams
3331 } else {
3332 norm_sites.load_pre_norm(norm_sites.embedding, &file, None)?
3333 };
3334 // Many small Llama/Gemma-family GGUFs tie the lm-head to
3335 // `token_embd.weight` and omit `output.weight` (llama.cpp
3336 // `llama_model_loader` falls back the same way). Prefer the
3337 // explicit head when present.
3338 let output_head = match load_weight_matrix(&file, "output.weight") {
3339 Ok(w) => w,
3340 Err(_) => load_weight_matrix(&file, "token_embd.weight")?,
3341 };
3342 // `output.bias` for the graphs that create it (`crate::proj_bias`).
3343 let output_bias = crate::proj_bias::load_output_bias(&file, &arch, output_head.rows())?;
3344
3345 // Second pass: attach the one shared store to every
3346 // store-backed layer. Opening the shard files fresh (plain
3347 // `File` handles for positional reads, not mmaps) keeps the
3348 // stored experts' bytes out of the process's mapped footprint
3349 // entirely.
3350 if !store_segments.is_empty() {
3351 let budget = expert_cache_bytes
3352 .expect("store_segments only populated when a cache budget is set")
3353 as usize;
3354 let files: Result<Vec<std::fs::File>, std::io::Error> =
3355 file.shard_paths().iter().map(std::fs::File::open).collect();
3356 let files = files.map_err(GgufError::from)?;
3357 let store = std::sync::Arc::new(ExpertStore::new(
3358 GgufExpertSource {
3359 files,
3360 segments: store_segments,
3361 },
3362 budget,
3363 ));
3364 for (l, layer) in layers.iter_mut().enumerate() {
3365 if let Some(layouts) = stored_layouts.get_mut(l).and_then(Option::take) {
3366 layer.moe.experts = ExpertBacking::Stored {
3367 store: std::sync::Arc::clone(&store),
3368 layouts,
3369 layer: l as u32,
3370 };
3371 }
3372 }
3373 }
3374
3375 config.qk_norm_style = refined_qk_norm;
3376
3377 let family = crate::capability::resolve_profile(
3378 file.metadata_str("general.architecture").unwrap_or("llama"),
3379 )
3380 .map(|p| p.family)
3381 .unwrap_or(crate::capability::DecoderFamily::StandardGqa);
3382 let memory_kind = crate::capability::resolve_profile(
3383 file.metadata_str("general.architecture").unwrap_or("llama"),
3384 )
3385 .map(|p| p.memory)
3386 .unwrap_or(crate::capability::MemoryKind::KvGqa);
3387 let execution_plan = crate::execution_plan::ExecutionPlan::from_config(
3388 &config,
3389 family,
3390 memory_kind,
3391 crate::execution_plan::ExecutionPlan::probe_metal_caps(),
3392 );
3393
3394 let alibi_slopes = crate::decoder::config_alibi_slopes(&config);
3395 let decoder = Decoder {
3396 config,
3397 embedding,
3398 position_embd,
3399 embedding_norm,
3400 // `hrm-text.cpp:46` creates it REQUIRED, and only that
3401 // graph does (`crate::hrm`); the loader reads it for the
3402 // architecture whose schedule needs it and for no other.
3403 hrm_z_l_init,
3404 alibi_slopes,
3405 layers,
3406 final_norm,
3407 output_head,
3408 output_bias,
3409 gpu_vram_budget_bytes: None,
3410 gpt_oss: if is_gpt_oss {
3411 Some(crate::decoder::GptOssWeights {
3412 layers: gpt_oss_layers,
3413 })
3414 } else {
3415 None
3416 },
3417 qk_norm_after_rope: QK_NORM_AFTER_ROPE_ARCHITECTURES.contains(&arch.as_str()),
3418 #[cfg(feature = "metal")]
3419 metal_attn_kv: std::sync::Mutex::new(None),
3420 execution_plan,
3421 kv_window: crate::decoder::KvWindowPolicy::from_env(),
3422 plan_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
3423 lora_adapters: Vec::new(),
3424 };
3425 // Resolve every kernel the model will need while we still have a
3426 // load-time error path to report it on, then seal: from here a
3427 // lookup that misses is an unpredicted slow path and says so.
3428 decoder.probe_kernels();
3429 frink_core::kernel_registry::seal_or_error()
3430 .map_err(|e| LoadError::StrictKernels(e.to_string()))?;
3431 // `ModelConfig` is parsed from a *different* handle on the same
3432 // file (the CLI opens its own `GgufFile`, then hands the config
3433 // here), so the model-level tensors it consumed were recorded on
3434 // that handle, not this one. Replay them before the gate, or
3435 // every Llama-3.x checkpoint reads as carrying an unread
3436 // `rope_freqs.weight` it in fact uses on every RoPE call.
3437 for name in crate::config::MODEL_LEVEL_TENSORS_READ_BY_CONFIG {
3438 file.note_consumed(name);
3439 }
3440 // The NextN/MTP blocks llama.cpp creates `TENSOR_SKIP` and never
3441 // runs (`crate::mtp_blocks`): deliberately unread, and said so,
3442 // rather than left for the gate below to report as a term the
3443 // graph is missing. The range is the config's, so the layer
3444 // loop above and this mark cannot disagree about where the
3445 // trunk ends.
3446 let skipped = crate::mtp_blocks::note_mtp_blocks_skipped(
3447 &file,
3448 &crate::mtp_blocks::TrunkLayers {
3449 block_count: n_physical + decoder.config.n_mtp_blocks,
3450 n_layers: n_physical,
3451 n_mtp_blocks: decoder.config.n_mtp_blocks,
3452 },
3453 );
3454 if skipped > 0 {
3455 eprintln!(
3456 "frink: skipping {} NextN/MTP block(s) after layer {} ({skipped} tensors), as \
3457 llama.cpp does",
3458 decoder.config.n_mtp_blocks,
3459 n_physical - 1
3460 );
3461 }
3462 // Slots llama.cpp creates and never reads (`crate::unread_tensors`):
3463 // ignored as upstream ignores them, and said so.
3464 let ignored = crate::unread_tensors::note_unread_layer_tensors(&file, &arch, n_physical);
3465 if !ignored.is_empty() {
3466 eprintln!(
3467 "frink: ignoring {} tensor(s) llama.cpp creates and never reads for `{}` \
3468 (first: {}), as llama.cpp does",
3469 ignored.len(),
3470 arch,
3471 ignored[0]
3472 );
3473 }
3474 assert_every_tensor_consumed(&file)?;
3475 Ok(decoder)
3476 }
3477}
3478
3479/// Tensor-name prefixes a text-generation load legitimately never
3480/// reads. Everything here is consumed by a *different* code path, not by
3481/// nothing: multimodal projector planes belong to `mmproj`, and the
3482/// per-shard split bookkeeping is metadata, not weights.
3483const IGNORED_TENSOR_PREFIXES: &[&str] = &["mm.", "v.", "mmproj.", "resampler.", "audio."];
3484
3485/// Fails the load when the checkpoint carries tensors this build never
3486/// looked at.
3487///
3488/// A tensor nobody reads is not a harmless extra: it is a term of the
3489/// real graph that ours is missing. gpt-oss ships `blk.N.attn_sinks`
3490/// and frink has no attention-sink code anywhere, so the file loads,
3491/// runs at full speed, and emits a different distribution than the model
3492/// it claims to be; the newer MoE recipes ship `ffn_exp_probs_b` the
3493/// same way. Both are silent today, and both are exactly what the
3494/// architecture registry cannot catch, because the architecture *string*
3495/// is one frink does support -- it is the checkpoint that carries more
3496/// than the registry entry promises.
3497///
3498/// This is deliberately the last check in the load: by here every loader
3499/// arm has had its chance to ask for what it needs, so what is left over
3500/// is what nothing in this build knows about.
3501///
3502/// `FRINK_ALLOW_UNKNOWN_TENSORS=1` downgrades it to a warning, for the
3503/// case where a human has decided the missing term does not matter (a
3504/// bias tensor of zeros, an auxiliary head that never runs). The default
3505/// is refusal: a wrong answer is worse than no answer.
3506pub fn assert_every_tensor_consumed(file: &ShardedGguf) -> Result<(), LoadError> {
3507 let mut left: Vec<String> = file
3508 .unconsumed_tensors()
3509 .into_iter()
3510 .filter(|n| !IGNORED_TENSOR_PREFIXES.iter().any(|p| n.starts_with(p)))
3511 .collect();
3512 if left.is_empty() {
3513 return Ok(());
3514 }
3515 left.sort();
3516 let shown = left.iter().take(8).cloned().collect::<Vec<_>>().join(", ");
3517 let listing = if left.len() > 8 {
3518 format!("{shown}, … (+{} more)", left.len() - 8)
3519 } else {
3520 shown
3521 };
3522 if matches!(
3523 std::env::var("FRINK_ALLOW_UNKNOWN_TENSORS").ok().as_deref(),
3524 Some("1") | Some("true") | Some("on")
3525 ) {
3526 eprintln!(
3527 "frink: WARNING -- {} tensor(s) in this checkpoint are never read \
3528 ({listing}); output may be wrong (FRINK_ALLOW_UNKNOWN_TENSORS=1)",
3529 left.len()
3530 );
3531 return Ok(());
3532 }
3533 Err(LoadError::UnconsumedTensors(left.len(), listing))
3534}
3535
3536#[cfg(test)]
3537mod tests {
3538
3539 /// A quantized 1-D tensor loads through the shared helper.
3540 ///
3541 /// This used to be six copies of `load_f32_vec`, and they had
3542 /// drifted badly: this one decoded twenty dtypes while the five
3543 /// architecture loaders decoded three (F32/F16/BF16). A quantizer
3544 /// that emits a Q8_0 norm or bias -- ordinary for aggressive
3545 /// quants -- loaded on the generic path and was rejected with
3546 /// `UnsupportedDtype` on GLM-5.2, Kimi, DeepSeek-MLA, Gemma-4 and
3547 /// the hybrid stack.
3548 ///
3549 /// This file's own comment predicted exactly that, about the same
3550 /// split one level down: "a dtype frink can decode should never be
3551 /// rejected here just because the *other* dispatch table below
3552 /// knows it -- that split is how a supported format turns into a
3553 /// load failure on the one checkpoint that uses it."
3554 #[test]
3555 fn a_quantized_one_dimensional_tensor_widens_through_the_shared_helper() {
3556 let values: Vec<f32> = (0..64).map(|i| (i as f32 - 32.0) * 0.25).collect();
3557 let quantized = frink_quant::quantize_q8_0(&values);
3558
3559 struct OneTensor {
3560 info: TensorInfo,
3561 bytes: Vec<u8>,
3562 }
3563 impl TensorSource for OneTensor {
3564 fn metadata(&self, _key: &str) -> Option<&frink_gguf::GgufValue> {
3565 None
3566 }
3567 fn find_tensor(&self, name: &str) -> Option<&TensorInfo> {
3568 (name == self.info.name).then_some(&self.info)
3569 }
3570 fn tensor_bytes(&self, _name: &str) -> Result<&[u8], GgufError> {
3571 Ok(&self.bytes)
3572 }
3573 fn tensor_mapped_range(
3574 &self,
3575 name: &str,
3576 ) -> Result<
3577 (
3578 std::sync::Arc<frink_gguf::MmapHandle>,
3579 std::ops::Range<usize>,
3580 ),
3581 GgufError,
3582 > {
3583 // Never reached: `load_f32_vec` widens from bytes.
3584 Err(GgufError::TensorNotFound(name.to_string()))
3585 }
3586 }
3587
3588 let source = OneTensor {
3589 info: TensorInfo {
3590 name: "blk.0.attn_norm.weight".to_string(),
3591 shape: vec![64],
3592 dtype: GgmlType::Q8_0,
3593 offset: 0,
3594 },
3595 bytes: quantized,
3596 };
3597
3598 let widened = load_f32_vec(&source, "blk.0.attn_norm.weight")
3599 .expect("a Q8_0 norm must load, not report an unsupported dtype");
3600 assert_eq!(widened.len(), values.len());
3601 for (got, want) in widened.iter().zip(values.iter()) {
3602 assert!(
3603 (got - want).abs() < 0.05,
3604 "q8_0 round trip: got {got}, want {want}"
3605 );
3606 }
3607 }
3608 use super::*;
3609 use byteorder::{LittleEndian, WriteBytesExt};
3610 use std::io::Write;
3611
3612 fn write_string(buf: &mut Vec<u8>, s: &str) {
3613 buf.write_u64::<LittleEndian>(s.len() as u64).unwrap();
3614 buf.write_all(s.as_bytes()).unwrap();
3615 }
3616
3617 fn write_kv_str(buf: &mut Vec<u8>, key: &str, val: &str) {
3618 write_string(buf, key);
3619 buf.write_u32::<LittleEndian>(8).unwrap(); // type = string
3620 write_string(buf, val);
3621 }
3622
3623 /// A minimal, tensor-free GGUF byte buffer declaring only
3624 /// `general.architecture` (no `{arch}.block_count` or any other
3625 /// hparam key) -- the shape a stripped-down or malformed file might
3626 /// take, and the exact case `ModelConfig::from_gguf` must reject
3627 /// loudly rather than silently default around.
3628 fn build_arch_only_gguf(arch: &str) -> Vec<u8> {
3629 let mut buf = Vec::new();
3630 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3631 .unwrap();
3632 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3633 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3634 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
3635 write_kv_str(&mut buf, "general.architecture", arch);
3636 buf
3637 }
3638
3639 #[test]
3640 fn model_config_from_gguf_fails_loudly_when_required_hparams_are_missing() {
3641 let tmp =
3642 std::env::temp_dir().join(format!("frink_test_arch_only_{}.gguf", std::process::id()));
3643 // Use a registered architecture so the failure is MissingHparam,
3644 // not UnsupportedArchitecture.
3645 std::fs::write(&tmp, build_arch_only_gguf("llama")).unwrap();
3646 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3647 std::fs::remove_file(&tmp).ok();
3648
3649 match ModelConfig::from_gguf(&file) {
3650 Err(LoadError::MissingHparam(key)) => {
3651 assert_eq!(key, "llama.block_count");
3652 }
3653 other => panic!(
3654 "expected LoadError::MissingHparam for a file with no hparam keys, got {other:?}"
3655 ),
3656 }
3657 }
3658
3659 #[test]
3660 fn model_config_from_gguf_fails_closed_on_unknown_architecture() {
3661 let tmp = std::env::temp_dir().join(format!(
3662 "frink_test_unknown_arch_{}.gguf",
3663 std::process::id()
3664 ));
3665 std::fs::write(&tmp, build_arch_only_gguf("bogus-arch-with-no-hparams")).unwrap();
3666 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3667 std::fs::remove_file(&tmp).ok();
3668
3669 match ModelConfig::from_gguf(&file) {
3670 Err(LoadError::UnsupportedArchitecture(arch)) => {
3671 assert_eq!(arch, "bogus-arch-with-no-hparams");
3672 }
3673 other => panic!(
3674 "expected LoadError::UnsupportedArchitecture for an unregistered arch, got {other:?}"
3675 ),
3676 }
3677 }
3678
3679 fn write_kv_f32(buf: &mut Vec<u8>, key: &str, val: f32) {
3680 write_string(buf, key);
3681 buf.write_u32::<LittleEndian>(6).unwrap(); // type = float32
3682 buf.write_f32::<LittleEndian>(val).unwrap();
3683 }
3684
3685 /// `arch` plus one f32 hparam, so a metadata-only feature gate can be
3686 /// exercised without building a whole checkpoint.
3687 fn build_arch_plus_f32_gguf(arch: &str, key: &str, val: f32) -> Vec<u8> {
3688 let mut buf = Vec::new();
3689 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3690 .unwrap();
3691 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3692 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3693 buf.write_u64::<LittleEndian>(2).unwrap(); // kv_count
3694 write_kv_str(&mut buf, "general.architecture", arch);
3695 write_kv_f32(&mut buf, key, val);
3696 buf
3697 }
3698
3699 fn config_error_for(arch: &str, key: &str, val: f32, tag: &str) -> LoadError {
3700 let tmp = std::env::temp_dir().join(format!("frink_test_scale_{tag}.gguf"));
3701 std::fs::write(&tmp, build_arch_plus_f32_gguf(arch, key, val)).unwrap();
3702 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
3703 std::fs::remove_file(&tmp).ok();
3704 ModelConfig::from_gguf(&file).expect_err("must not succeed")
3705 }
3706
3707 /// Granite / MiniCPM / Command-R multipliers are hparams, not
3708 /// tensors, so `assert_every_tensor_consumed` cannot see them: a
3709 /// checkpoint declaring one loads, runs at full speed, and computes
3710 /// a differently-scaled graph than it was trained as. An
3711 /// architecture whose reference graph does not apply one must refuse
3712 /// it by name.
3713 ///
3714 /// Driven on `llama` rather than on `granite`, and that swap is the
3715 /// point: `granite` APPLIES all four now
3716 /// (`crate::scalar_multipliers`), so leaving the case here would
3717 /// have turned this test into a test of nothing the day the feature
3718 /// landed. llama.cpp's llama graph reads none of the four keys, so a
3719 /// `llama` checkpoint declaring one is exactly the silent divergence
3720 /// the gate exists for.
3721 #[test]
3722 fn a_declared_multiplier_this_decoder_does_not_apply_is_refused_by_name() {
3723 for (key, val) in [
3724 ("llama.logit_scale", 6.0f32),
3725 ("llama.residual_scale", 0.22),
3726 ("llama.embedding_scale", 12.0),
3727 ("llama.attention.scale", 0.015_625),
3728 ] {
3729 let tag = key.replace('.', "_");
3730 match config_error_for("llama", key, val, &tag) {
3731 LoadError::UnsupportedFeature(arch, msg) => {
3732 assert_eq!(arch, "llama");
3733 assert!(msg.contains(key), "error must name the key: {msg}");
3734 }
3735 other => panic!("expected UnsupportedFeature for {key}, got {other:?}"),
3736 }
3737 }
3738 }
3739
3740 /// The complement, and the half that would otherwise have gone
3741 /// missing: `granite` must NOT be refused for the keys its graph
3742 /// applies.
3743 ///
3744 /// The refusal list and the implementation are two views of ONE
3745 /// table (`scalar_multipliers::multiplier_support`), so this test
3746 /// and the one above cannot both pass while they disagree -- which
3747 /// is the whole value of deriving the list rather than restating it.
3748 #[test]
3749 fn granite_is_not_refused_for_the_multipliers_it_applies() {
3750 for (key, val) in [
3751 ("granite.logit_scale", 8.0f32),
3752 ("granite.residual_scale", 0.22),
3753 ("granite.embedding_scale", 12.0),
3754 ("granite.attention.scale", 0.015_625),
3755 ] {
3756 let tag = format!("granite_ok_{}", key.replace('.', "_"));
3757 // The file carries no `block_count`, so the load still fails
3758 // -- but on the *missing hparam*, having passed this gate.
3759 match config_error_for("granite", key, val, &tag) {
3760 LoadError::MissingHparam(k) => assert_eq!(k, "granite.block_count"),
3761 other => panic!("{key}={val} must pass the scaling gate, got {other:?}"),
3762 }
3763 }
3764 }
3765
3766 /// The gate must not fire on a multiplier that is a no-op. A file
3767 /// writing `residual_scale = 1.0` describes the graph frink already
3768 /// computes, and refusing it would be a false alarm. llama.cpp's
3769 /// `f_attention_scale` uses `0.0` rather than `1.0` as its "unset"
3770 /// sentinel, so the two are checked against their own no-op values.
3771 #[test]
3772 fn a_multiplier_that_is_a_no_op_is_not_refused() {
3773 for (key, val) in [
3774 ("llama.logit_scale", 1.0f32),
3775 ("llama.residual_scale", 1.0),
3776 ("llama.embedding_scale", 1.0),
3777 ("llama.attention.scale", 0.0),
3778 ] {
3779 let tag = format!("noop_{}", key.replace('.', "_"));
3780 // The file carries no `block_count`, so the load still fails
3781 // -- but on the *missing hparam*, having passed this gate.
3782 match config_error_for("llama", key, val, &tag) {
3783 LoadError::MissingHparam(k) => assert_eq!(k, "llama.block_count"),
3784 other => panic!("no-op {key}={val} must pass the scaling gate, got {other:?}"),
3785 }
3786 }
3787 }
3788
3789 /// One GGUF metadata value, in the three types these header-only
3790 /// fixtures need.
3791 enum Kv<'a> {
3792 Str(&'a str),
3793 U32(u32),
3794 F32(f32),
3795 /// A uint32 ARRAY. Only one gate needs it -- the sliding-window
3796 /// pattern, which llama.cpp reads with `ml.get_key_or_arr` --
3797 /// and without it that gate could only be tested through a
3798 /// value of some other type, which is not the case it exists
3799 /// for.
3800 Arr32(&'a [u32]),
3801 }
3802
3803 /// A tensor-free GGUF carrying exactly `kvs` -- enough for
3804 /// `ModelConfig::from_gguf` to run without a single weight on disk.
3805 fn build_metadata_gguf(kvs: &[(&str, Kv)]) -> Vec<u8> {
3806 let mut buf = Vec::new();
3807 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
3808 .unwrap();
3809 buf.write_u32::<LittleEndian>(3).unwrap(); // version
3810 buf.write_u64::<LittleEndian>(0).unwrap(); // tensor_count
3811 buf.write_u64::<LittleEndian>(kvs.len() as u64).unwrap();
3812 for (k, v) in kvs {
3813 match v {
3814 Kv::Str(s) => write_kv_str(&mut buf, k, s),
3815 Kv::U32(n) => {
3816 write_string(&mut buf, k);
3817 buf.write_u32::<LittleEndian>(4).unwrap(); // type = uint32
3818 buf.write_u32::<LittleEndian>(*n).unwrap();
3819 }
3820 Kv::F32(f) => write_kv_f32(&mut buf, k, *f),
3821 Kv::Arr32(values) => {
3822 write_string(&mut buf, k);
3823 buf.write_u32::<LittleEndian>(9).unwrap(); // type = array
3824 buf.write_u32::<LittleEndian>(4).unwrap(); // element type = uint32
3825 buf.write_u64::<LittleEndian>(values.len() as u64).unwrap();
3826 for v in *values {
3827 buf.write_u32::<LittleEndian>(*v).unwrap();
3828 }
3829 }
3830 }
3831 }
3832 buf
3833 }
3834
3835 fn open_metadata_gguf(tag: &str, kvs: &[(&str, Kv)]) -> frink_gguf::GgufFile {
3836 let tmp = std::env::temp_dir().join(format!("frink_test_meta_{tag}.gguf"));
3837 std::fs::write(&tmp, build_metadata_gguf(kvs)).unwrap();
3838 let file = frink_gguf::GgufFile::open(&tmp).expect("header-only file must parse");
3839 std::fs::remove_file(&tmp).ok();
3840 file
3841 }
3842
3843 /// A minimal `llama` hparam set (64-wide single head, base 10000)
3844 /// plus whatever RoPE-scaling keys a test wants to add.
3845 fn llama_config_with(tag: &str, extra: &[(&str, Kv)]) -> ModelConfig {
3846 let mut kvs: Vec<(&str, Kv)> = vec![
3847 ("general.architecture", Kv::Str("llama")),
3848 ("llama.block_count", Kv::U32(1)),
3849 ("llama.embedding_length", Kv::U32(64)),
3850 ("llama.attention.head_count", Kv::U32(1)),
3851 ("llama.attention.head_count_kv", Kv::U32(1)),
3852 ("llama.attention.key_length", Kv::U32(64)),
3853 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3854 ];
3855 for (k, v) in extra {
3856 kvs.push((
3857 k,
3858 match v {
3859 Kv::Str(s) => Kv::Str(s),
3860 Kv::U32(n) => Kv::U32(*n),
3861 Kv::F32(f) => Kv::F32(*f),
3862 Kv::Arr32(a) => Kv::Arr32(a),
3863 },
3864 ));
3865 }
3866 ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("fixture must load")
3867 }
3868
3869 /// Builds a config for an arbitrary architecture tag, returning the
3870 /// error rather than unwrapping it.
3871 /// llama.cpp chooses the FFN gate activation PER ARCHITECTURE;
3872 /// frink chose it per family. Those are different partitions, and
3873 /// `grok` is where they disagree: `src/models/grok.cpp:165` passes
3874 /// `LLM_FFN_GELU` to `build_moe_ffn`, while `grok` is
3875 /// `DecoderFamily::StandardGqa` and so was handed SwiGLU -- a
3876 /// different FFN on every layer.
3877 ///
3878 /// It was pinned here while `grok` still refused, because the
3879 /// failure mode is that auditing it later makes it silently wrong,
3880 /// and an audit is exactly when nobody thinks to re-check the
3881 /// activation. `grok` is audited now (tests/grok_graphs.rs), and the
3882 /// fixture's GELU experts are what that suite compares.
3883 #[test]
3884 fn the_ffn_activation_follows_the_architecture_not_the_family() {
3885 use crate::capability::uses_geglu;
3886 use crate::config::FfnActivation;
3887
3888 assert!(uses_geglu("grok"), "grok's MoE FFN gate is GELU upstream");
3889 // Same family, SiLU upstream (`src/models/dbrx.cpp:122`), so the
3890 // family rule alone cannot be what selects grok.
3891 assert!(!uses_geglu("dbrx"));
3892 assert!(!uses_geglu("llama"));
3893
3894 // The Gemma lineage keeps its GELU through the FAMILY rule, so
3895 // the new per-architecture arm must not have displaced it.
3896 // gemma2/gemma3 only: `gemma` v1 is unaudited and refuses, so
3897 // it cannot be loaded to check its activation.
3898 for gemma in ["gemma2", "gemma3"] {
3899 assert!(
3900 !uses_geglu(gemma),
3901 "{gemma} is GELU via GemmaFamily; listing it here too \
3902 would hide a later regression in the family rule"
3903 );
3904 assert_eq!(
3905 config_for_arch(gemma).expect("gemma loads").ffn_activation,
3906 FfnActivation::Gelu,
3907 "{gemma}"
3908 );
3909 }
3910
3911 // And a plain SwiGLU architecture stays SwiGLU.
3912 assert_eq!(
3913 config_for_arch("llama")
3914 .expect("llama loads")
3915 .ffn_activation,
3916 FfnActivation::Swiglu
3917 );
3918
3919 // The ungated ReLU-squared row, and the four that share its FFN
3920 // and refuse for something else (`capability::uses_relu_sqr`).
3921 assert_eq!(
3922 config_for_arch("arcee")
3923 .expect("arcee loads")
3924 .ffn_activation,
3925 FfnActivation::ReluSqr
3926 );
3927 for shared in ["plm", "nemotron", "jais2", "nemotron_h"] {
3928 assert!(crate::capability::uses_relu_sqr(shared), "{shared}");
3929 }
3930 assert!(!crate::capability::uses_relu_sqr("llama"));
3931 }
3932
3933 /// A per-layer array whose entries differ, on an architecture whose
3934 /// llama.cpp graph reads layer 0, is refused naming the table; the
3935 /// same arrays with equal entries are the uniform model, for any
3936 /// architecture, because a converter may spell a scalar as a list.
3937 ///
3938 /// Reachability, not only `LayerShapes::resolve`'s own unit test:
3939 /// this goes through `from_gguf` on a header-only file, which is
3940 /// where `openelm` used to die on `MissingHparam` for a key its
3941 /// file carried.
3942 #[test]
3943 fn a_varying_per_layer_array_is_refused_on_a_layer_zero_architecture_and_equal_ones_are_uniform(
3944 ) {
3945 let kvs = [
3946 ("general.architecture", Kv::Str("llama")),
3947 ("llama.block_count", Kv::U32(2)),
3948 ("llama.embedding_length", Kv::U32(64)),
3949 ("llama.attention.head_count", Kv::Arr32(&[2, 2])),
3950 ("llama.attention.head_count_kv", Kv::Arr32(&[2, 1])),
3951 ("llama.attention.key_length", Kv::U32(32)),
3952 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3953 ];
3954 let err = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_vary", &kvs))
3955 .expect_err("llama takes layer 0 upstream");
3956 let msg = err.to_string();
3957 assert!(msg.contains("PER_LAYER_SHAPE_ARCHS"), "{msg}");
3958 assert!(msg.contains("LLAMA_LOAD_LOCALS"), "{msg}");
3959
3960 let kvs = [
3961 ("general.architecture", Kv::Str("llama")),
3962 ("llama.block_count", Kv::U32(2)),
3963 ("llama.embedding_length", Kv::U32(64)),
3964 ("llama.attention.head_count", Kv::Arr32(&[2, 2])),
3965 ("llama.attention.head_count_kv", Kv::Arr32(&[1, 1])),
3966 ("llama.attention.key_length", Kv::U32(32)),
3967 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3968 ];
3969 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_equal", &kvs))
3970 .expect("equal arrays are the uniform model");
3971 assert!(cfg.layer_shapes.is_uniform());
3972 assert_eq!((cfg.n_heads, cfg.n_kv_heads), (2, 1));
3973
3974 // An array of the wrong length is refused as llama.cpp refuses
3975 // it (`key has wrong array length`).
3976 let kvs = [
3977 ("general.architecture", Kv::Str("llama")),
3978 ("llama.block_count", Kv::U32(2)),
3979 ("llama.embedding_length", Kv::U32(64)),
3980 ("llama.attention.head_count", Kv::Arr32(&[2, 2, 2])),
3981 ("llama.attention.key_length", Kv::U32(32)),
3982 ("llama.rope.freq_base", Kv::F32(10_000.0)),
3983 ];
3984 let err = ModelConfig::from_gguf(&open_metadata_gguf("layer_shapes_len", &kvs))
3985 .expect_err("three entries for two layers");
3986 assert!(err.to_string().contains("wrong array length"), "{err}");
3987 }
3988
3989 /// The no-renormalise list is keyed on what llama.cpp's GRAPH does,
3990 /// not on what a GGUF says, because for these architectures the
3991 /// GGUF says nothing.
3992 ///
3993 /// `expert_weights_norm` is only written by converters that set it.
3994 /// `deepseek.cpp:145` passes `norm_w=false`, and
3995 /// `conversion/deepseek.py`'s `DeepseekModel` never writes the key
3996 /// -- only `DeepseekV2Model` does. So a real `deepseek` checkpoint
3997 /// carries no key at all and frink fell through to its default,
3998 /// renormalising the selected experts' softmax weights where
3999 /// llama.cpp leaves them alone.
4000 ///
4001 /// The same mistake made OLMoE emit garbage, which is why that list
4002 /// exists. This pins the membership so a later edit cannot quietly
4003 /// drop a name back into the renormalising default.
4004 #[test]
4005 fn the_architectures_llama_cpp_does_not_renormalise_are_pinned() {
4006 for arch in ["deepseek", "olmoe", "qwen2moe"] {
4007 assert!(
4008 NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&arch),
4009 "{arch} passes norm_w=false in llama.cpp and must not be renormalised"
4010 );
4011 }
4012 // `deepseek2` is a DIFFERENT architecture whose converter DOES
4013 // write the key, so it must not be on this list -- it gets its
4014 // answer from the file.
4015 assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"deepseek2"));
4016 assert!(!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen3moe"));
4017 }
4018
4019 /// Every architecture llama.cpp defaults to SIGMOID gating must be
4020 /// on the list, because for these the GGUF carries no key to say so.
4021 ///
4022 /// Each of these reads `LLM_KV_EXPERT_GATING_FUNC` as optional and
4023 /// then sets SIGMOID when it is absent, so a converted checkpoint
4024 /// has nothing in it that would correct frink's softmax default.
4025 /// Same shape as the `deepseek` top-k renormalisation bug, and as
4026 /// `phi3`'s sliding window: the file is silent and the architecture
4027 /// decides.
4028 /// The literal table and its name list are two spellings of one
4029 /// fact; this is what keeps them one.
4030 #[test]
4031 fn the_gating_literal_names_are_the_gating_literal_table() {
4032 let from_table: Vec<&str> = GATING_LITERAL_ARCHITECTURES
4033 .iter()
4034 .map(|(n, _)| *n)
4035 .collect();
4036 assert_eq!(from_table, GATING_LITERAL_NAMES);
4037 // `mimo2.cpp:227` passes the SIGMOID literal, so the key is
4038 // never read there; a hand-written SOFTMAX key must not turn it.
4039 assert!(matches!(
4040 GATING_LITERAL_ARCHITECTURES
4041 .iter()
4042 .find(|(n, _)| *n == "mimo2")
4043 .map(|(_, g)| *g),
4044 Some(GatingFunction::Sigmoid)
4045 ));
4046 }
4047
4048 #[test]
4049 fn the_architectures_llama_cpp_defaults_to_sigmoid_gating_are_pinned() {
4050 for arch in ["afmoe", "deepseek2", "glm4moe", "laguna", "step35"] {
4051 assert!(
4052 SIGMOID_GATING_ARCHITECTURES.contains(&arch),
4053 "{arch} sets SIGMOID when the gating key is absent"
4054 );
4055 }
4056 // Architectures that HARDCODE softmax must stay off it, or the
4057 // fix becomes the opposite bug: `ernie4-5-moe.cpp:90` and
4058 // `qwen3moe` both gate with softmax unconditionally.
4059 for softmax in ["ernie4_5-moe", "qwen3moe", "olmoe", "llama"] {
4060 assert!(
4061 !SIGMOID_GATING_ARCHITECTURES.contains(&softmax),
4062 "{softmax} does not default to sigmoid"
4063 );
4064 }
4065 }
4066
4067 /// Every name in every architecture-keyed behaviour table is a name
4068 /// the catalog actually resolves, on the generic-GQA path.
4069 ///
4070 /// These five tables are the repo's dominant bug shape in its purest
4071 /// form: five lists of strings that have to agree with a sixth
4072 /// structure (`capability::architecture_catalog`) about what an
4073 /// architecture is called, with nothing checking it. A typo, a
4074 /// hyphen where the GGUF has an underscore, or a name that later
4075 /// moves to a dedicated stack all produce the same thing -- an entry
4076 /// that reads as coverage and can never fire. This repo has shipped
4077 /// exactly that once already, in `unsupported_feature_keys`, keyed
4078 /// on a GGUF spelling no converter writes.
4079 ///
4080 /// The generic-path check is the second half and the sharper one: a
4081 /// behaviour flag on an architecture that is `DedicatedOnly` or
4082 /// `Deferred` never reaches this loader, so it is dead text.
4083 ///
4084 /// Sabotage to confirm: add `"seedoss"` to any list below.
4085 #[test]
4086 fn every_architecture_keyed_behaviour_table_names_a_real_generic_row() {
4087 let tables: &[(&str, &[&str])] = &[
4088 ("SIGMOID_GATING_ARCHITECTURES", SIGMOID_GATING_ARCHITECTURES),
4089 ("GATING_LITERAL_ARCHITECTURES", GATING_LITERAL_NAMES),
4090 ("EXPERT_WEIGHTS_SCALE_READERS", EXPERT_WEIGHTS_SCALE_READERS),
4091 ("EXPERT_WEIGHTS_NORM_READERS", EXPERT_WEIGHTS_NORM_READERS),
4092 (
4093 "NO_TOPK_RENORMALIZE_ARCHITECTURES",
4094 NO_TOPK_RENORMALIZE_ARCHITECTURES,
4095 ),
4096 (
4097 "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
4098 crate::norm_sites::PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
4099 ),
4100 (
4101 "PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM",
4102 crate::norm_sites::PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM,
4103 ),
4104 (
4105 "POST_NORMS_UNDER_GROK_NAMES",
4106 crate::norm_sites::POST_NORMS_UNDER_GROK_NAMES,
4107 ),
4108 (
4109 "ATTN_NORM_2_FEEDS_ATTENTION",
4110 crate::norm_sites::ATTN_NORM_2_FEEDS_ATTENTION,
4111 ),
4112 ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
4113 (
4114 "QK_NORM_AFTER_ROPE_ARCHITECTURES",
4115 QK_NORM_AFTER_ROPE_ARCHITECTURES,
4116 ),
4117 ];
4118 for (table, names) in tables {
4119 for arch in *names {
4120 let profile = crate::capability::resolve_profile(arch).unwrap_or_else(|| {
4121 panic!("{table} names `{arch}`, which the catalog does not have")
4122 });
4123 if matches!(profile.path, crate::capability::ArchPath::GenericGqa { .. }) {
4124 continue;
4125 }
4126 // Not a generic row, so the entry cannot fire HERE.
4127 // That is allowed only when something else is named as
4128 // applying the behaviour instead. An unexplained dead
4129 // entry still fails, which is the whole point.
4130 let owner = DEDICATED_OWNS_ITS_BEHAVIOUR
4131 .iter()
4132 .find(|(name, _)| name == arch)
4133 .map(|(_, owner)| *owner);
4134 assert!(
4135 owner.is_some(),
4136 "{table} names `{arch}`, which resolves to {:?} and never reaches this \
4137 loader, so the entry cannot fire. Either drop it, or add it to \
4138 DEDICATED_OWNS_ITS_BEHAVIOUR naming what applies the behaviour instead",
4139 profile.path
4140 );
4141 }
4142 }
4143 }
4144
4145 /// The three tables that describe how a layer is BUILT, rather than
4146 /// how it is routed, only carry architectures that are audited.
4147 ///
4148 /// The distinction matters and is not pedantry. A routing default
4149 /// (`SIGMOID_GATING_ARCHITECTURES`, `NO_TOPK_RENORMALIZE_ARCHITECTURES`)
4150 /// is allowed to name an architecture that still refuses: it is
4151 /// written down ahead of time so a later admission inherits the
4152 /// right answer, and the tables say so. But the three below change
4153 /// which TENSOR a layer reads and in what order -- and each was
4154 /// added for exactly one architecture, whose fixture is the only
4155 /// thing proving the change is right. A fourth name appearing here
4156 /// without evidence would be a claim about a graph nobody read,
4157 /// carried by a list whose doc comment cites two.
4158 #[test]
4159 fn the_layer_shape_tables_only_name_audited_architectures() {
4160 for (table, names) in [
4161 (
4162 "PRE_FFN_NORM_IS_POST_ATTENTION_NORM",
4163 crate::norm_sites::PRE_FFN_NORM_IS_POST_ATTENTION_NORM,
4164 ),
4165 (
4166 "PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM",
4167 crate::norm_sites::PRE_FFN_NORM_IS_ATTN_OUTPUT_NORM,
4168 ),
4169 (
4170 "POST_NORMS_UNDER_GROK_NAMES",
4171 crate::norm_sites::POST_NORMS_UNDER_GROK_NAMES,
4172 ),
4173 (
4174 "ATTN_NORM_2_FEEDS_ATTENTION",
4175 crate::norm_sites::ATTN_NORM_2_FEEDS_ATTENTION,
4176 ),
4177 ("LEADING_DENSE_KEY_IS_INERT", LEADING_DENSE_KEY_IS_INERT),
4178 (
4179 "QK_NORM_AFTER_ROPE_ARCHITECTURES",
4180 QK_NORM_AFTER_ROPE_ARCHITECTURES,
4181 ),
4182 ] {
4183 for arch in names {
4184 assert!(
4185 crate::capability::is_audited_generic(arch),
4186 "{table} names `{arch}`, which is not in AUDITED_GENERIC_GQA. Either it \
4187 has a fixture proving the change is right -- audit it -- or the entry \
4188 is a guess about a graph"
4189 );
4190 }
4191 }
4192 }
4193
4194 fn config_for_arch(arch: &'static str) -> Result<ModelConfig, LoadError> {
4195 // The per-arch hyperparameter keys are looked up by the arch's
4196 // own prefix, so they have to be built for the arch under test.
4197 let keys: Vec<String> = [
4198 "block_count",
4199 "embedding_length",
4200 "attention.head_count",
4201 "attention.head_count_kv",
4202 "attention.key_length",
4203 ]
4204 .iter()
4205 .map(|k| format!("{arch}.{k}"))
4206 .collect();
4207 let theta = format!("{arch}.rope.freq_base");
4208 let kvs: Vec<(&str, Kv)> = vec![
4209 ("general.architecture", Kv::Str(arch)),
4210 (keys[0].as_str(), Kv::U32(1)),
4211 (keys[1].as_str(), Kv::U32(64)),
4212 (keys[2].as_str(), Kv::U32(1)),
4213 (keys[3].as_str(), Kv::U32(1)),
4214 (keys[4].as_str(), Kv::U32(64)),
4215 (theta.as_str(), Kv::F32(10_000.0)),
4216 ];
4217 ModelConfig::from_gguf(&open_metadata_gguf(arch, &kvs))
4218 }
4219
4220 /// The generic path is OPT-IN, and this is what proves it.
4221 ///
4222 /// An architecture nobody has checked used to FALL ONTO generic GQA
4223 /// and run. Five did exactly that and computed the wrong thing for
4224 /// the life of the project. The refusal exists; nothing tested it,
4225 /// so a reordering or an unevidenced addition to
4226 /// `AUDITED_GENERIC_GQA` would have gone unnoticed.
4227 #[test]
4228 fn an_unaudited_generic_architecture_refuses_rather_than_guessing() {
4229 // `grovemoe` is on the generic path and is not in the audited
4230 // list. It is the sixth name to hold this slot: `starcoder` was
4231 // first, until an audit found it REQUIRES a fused
4232 // `attn_qkv.bias` and a learned `position_embd` the generic
4233 // decoder has no slot for, so it refuses for a stronger reason;
4234 // then `xverse`, until it was admitted with a libllama-golden
4235 // fixture (`tests/fixture_away_graphs.rs`); then `nanbeige`,
4236 // until the layer loop became `crate::layer_loops`; then
4237 // `talkie`, until `crate::skip_stream`; then `arctic`, until
4238 // `crate::parallel_dense_ffn`. `grovemoe` runs a SECOND expert
4239 // bank (`src/models/grovemoe.cpp:57-59,137-164`) whose upstream
4240 // graph diverges from the reference, and its blocker is
4241 // invisible in metadata, so nothing but this gate stops it.
4242 assert!(
4243 !crate::capability::is_audited_generic("grovemoe"),
4244 "this test needs an arch that is generic AND unaudited"
4245 );
4246 match config_for_arch("grovemoe") {
4247 Err(LoadError::UnauditedArchitecture(name, ..)) => assert_eq!(name, "grovemoe"),
4248 other => panic!("expected an unaudited refusal, got {other:?}"),
4249 }
4250 }
4251
4252 /// An architecture with evidence still loads, or the inversion would
4253 /// have turned every model off.
4254 #[test]
4255 fn an_audited_architecture_still_loads() {
4256 assert!(crate::capability::is_audited_generic("llama"));
4257 assert!(config_for_arch("llama").is_ok());
4258 }
4259
4260 /// A NAMED problem must outrank "unaudited".
4261 ///
4262 /// `grovemoe` is unaudited AND names its second expert bank; a
4263 /// `llama4` file declaring a window of zero names the branch
4264 /// libllama aborts on (`crate::chunked_swa`), and that is what its
4265 /// refusal should say. Reporting "unaudited" instead would be true
4266 /// and far less useful, and it is the ordering the loader's own
4267 /// comment claims. Nothing checked that claim. (`gpt2`, `bloom` and
4268 /// then a plain `llama4` were the example until each was served.)
4269 #[test]
4270 fn a_named_refusal_outranks_the_unaudited_one() {
4271 let kvs: Vec<(&str, Kv)> = vec![
4272 ("general.architecture", Kv::Str("llama4")),
4273 ("llama4.block_count", Kv::U32(1)),
4274 ("llama4.embedding_length", Kv::U32(64)),
4275 ("llama4.attention.head_count", Kv::U32(1)),
4276 ("llama4.attention.head_count_kv", Kv::U32(1)),
4277 ("llama4.attention.key_length", Kv::U32(64)),
4278 ("llama4.rope.freq_base", Kv::F32(10_000.0)),
4279 ("llama4.expert_count", Kv::U32(16)),
4280 ("llama4.interleave_moe_layer_step", Kv::U32(1)),
4281 ("llama4.attention.sliding_window", Kv::U32(0)),
4282 ];
4283 let err = ModelConfig::from_gguf(&open_metadata_gguf("llama4", &kvs))
4284 .expect_err("a zero window must refuse");
4285 assert!(
4286 !matches!(err, LoadError::UnauditedArchitecture(..)),
4287 "llama4 should report its own reason, not that nobody audited it: {err:?}"
4288 );
4289 assert!(err.to_string().contains("llama-graph.cpp:159"), "{err}");
4290 }
4291
4292 /// A checkpoint that declares YaRN gets the per-band divisors the
4293 /// reference's `"yarn"` arm implies, folded into `rope_freqs` so the
4294 /// existing RoPE kernels apply them. Expected values are hand-derived
4295 /// from `_find_correction_dim` for this fixture (rotary width 64,
4296 /// base 10000, original context 131072): `low = 22`, `high = 35`.
4297 ///
4298 /// Before this, frink read neither `rope.scaling.type` nor
4299 /// `rope.scaling.factor`, so this file roped exactly like an
4300 /// unscaled one -- correct near position 0, progressively wrong
4301 /// further in.
4302 #[test]
4303 fn a_gguf_declaring_yarn_gets_its_rope_frequencies_rewritten() {
4304 let cfg = llama_config_with(
4305 "yarn",
4306 &[
4307 ("llama.rope.scaling.type", Kv::Str("yarn")),
4308 ("llama.rope.scaling.factor", Kv::F32(8.0)),
4309 (
4310 "llama.rope.scaling.original_context_length",
4311 Kv::U32(131_072),
4312 ),
4313 ],
4314 );
4315 let factors = cfg
4316 .rope_freqs
4317 .expect("a YaRN checkpoint must carry rewritten per-band frequencies")
4318 .full;
4319 assert_eq!(factors.len(), 32, "one divisor per rotation band");
4320 assert!(
4321 (factors[0] - 1.0).abs() < 1e-6,
4322 "the fastest band is left extrapolated, got {}",
4323 factors[0]
4324 );
4325 let ramp = (31.0 - 22.0) / (35.0 - 22.0);
4326 let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
4327 assert!(
4328 (factors[31] - want).abs() < 1e-4,
4329 "slowest band: got {}, reference {want}",
4330 factors[31]
4331 );
4332 }
4333
4334 /// The rewrite must not fire on a file that did not ask for it. A
4335 /// scaling type frink does not implement (`linear`, `longrope`) is
4336 /// left exactly as it was rather than being roped as YaRN, which
4337 /// would be a new kind of wrong rather than the current known one.
4338 /// `rope.scaling.type = "linear"` must actually scale.
4339 ///
4340 /// Rotating position `p/s` is the same as rotating `p` with every
4341 /// band's frequency divided by `s`, and `rope_freqs` is exactly a
4342 /// per-band frequency divisor, so a uniform vector of `s` expresses
4343 /// linear scaling with no new code on the RoPE paths.
4344 ///
4345 /// Before this, the scaling type was compared against "yarn" and
4346 /// anything else returned None, so such a file loaded and roped at
4347 /// unscaled positions: a different model, no error.
4348 #[test]
4349 fn linear_scaling_is_applied_as_a_uniform_frequency_divisor() {
4350 let cfg = llama_config_with(
4351 "linear",
4352 &[
4353 ("llama.rope.scaling.type", Kv::Str("linear")),
4354 ("llama.rope.scaling.factor", Kv::F32(4.0)),
4355 ],
4356 );
4357 let freqs = &cfg
4358 .rope_freqs
4359 .as_ref()
4360 .expect("linear scaling must produce frequency factors")
4361 .full;
4362 assert_eq!(freqs.len(), cfg.head_dim / 2, "one factor per rotated pair");
4363 assert!(
4364 freqs.iter().all(|f| (*f - 4.0).abs() < 1e-6),
4365 "linear scaling is uniform across bands, unlike YaRN: got {freqs:?}"
4366 );
4367 }
4368
4369 /// A factor that corrects nothing is not a correction.
4370 #[test]
4371 fn a_linear_factor_of_one_is_treated_as_absent() {
4372 assert!(llama_config_with(
4373 "linear_one",
4374 &[
4375 ("llama.rope.scaling.type", Kv::Str("linear")),
4376 ("llama.rope.scaling.factor", Kv::F32(1.0)),
4377 ],
4378 )
4379 .rope_freqs
4380 .is_none());
4381 }
4382
4383 #[test]
4384 fn a_gguf_without_yarn_scaling_keeps_its_rope_frequencies_untouched() {
4385 assert!(llama_config_with("noscale", &[]).rope_freqs.is_none());
4386 // Linear scaling is NOT "no scaling". It used to land here,
4387 // asserted as `is_none()`, on the reasoning that leaving
4388 // positions alone beat roping them wrong in a new way. Both are
4389 // wrong output: llama.cpp divides the positions by the factor.
4390 // See `linear_scaling_is_applied_as_a_uniform_frequency_divisor`.
4391 // YaRN with a no-op factor is not a correction either.
4392 assert!(llama_config_with(
4393 "yarn_factor_one",
4394 &[
4395 ("llama.rope.scaling.type", Kv::Str("yarn")),
4396 ("llama.rope.scaling.factor", Kv::F32(1.0)),
4397 (
4398 "llama.rope.scaling.original_context_length",
4399 Kv::U32(131_072),
4400 ),
4401 ],
4402 )
4403 .rope_freqs
4404 .is_none());
4405 }
4406
4407 /// The correction range is measured against the context the
4408 /// checkpoint was *trained* at, so a file that declares YaRN without
4409 /// `rope.scaling.original_context_length` leaves the rotation alone
4410 /// rather than inventing a trained length (`context_length` on such
4411 /// a file is the *extended* one, which would put the ramp in the
4412 /// wrong place at every band).
4413 #[test]
4414 fn yarn_without_an_original_context_length_is_not_guessed_at() {
4415 let cfg = llama_config_with(
4416 "yarn_noctx",
4417 &[
4418 ("llama.rope.scaling.type", Kv::Str("yarn")),
4419 ("llama.rope.scaling.factor", Kv::F32(8.0)),
4420 ],
4421 );
4422 assert!(cfg.rope_freqs.is_none());
4423 }
4424
4425 /// `general.sampling.*` is the checkpoint's own recommendation, and
4426 /// only the keys the file carries become one: a file naming just
4427 /// `top_k` must leave temperature and top_p to the server's
4428 /// defaults.
4429 #[test]
4430 fn gguf_sampling_metadata_is_read_as_the_checkpoints_recommendation() {
4431 use crate::sampling::RecommendedSampling;
4432 let full = RecommendedSampling::from_gguf(&open_metadata_gguf(
4433 "sampling_full",
4434 &[
4435 ("general.architecture", Kv::Str("llama")),
4436 ("general.sampling.temp", Kv::F32(1.0)),
4437 ("general.sampling.top_k", Kv::U32(20)),
4438 ("general.sampling.top_p", Kv::F32(0.95)),
4439 ],
4440 ));
4441 assert_eq!(
4442 full,
4443 RecommendedSampling {
4444 temperature: Some(1.0),
4445 top_p: Some(0.95),
4446 top_k: Some(20),
4447 }
4448 );
4449
4450 let partial = RecommendedSampling::from_gguf(&open_metadata_gguf(
4451 "sampling_partial",
4452 &[
4453 ("general.architecture", Kv::Str("llama")),
4454 ("general.sampling.top_k", Kv::U32(40)),
4455 ],
4456 ));
4457 assert_eq!(partial.top_k, Some(40));
4458 assert_eq!(partial.temperature, None);
4459 assert_eq!(partial.top_p, None);
4460 }
4461
4462 /// A converter that wrote `temp = 1` stores a GGUF integer, not a
4463 /// float. Dropping it would serve a checkpoint that asked for
4464 /// temperature 1.0 at the framework's greedy default -- the
4465 /// repetition-loop failure the recommendation exists to prevent.
4466 #[test]
4467 fn an_integer_valued_sampling_temp_is_still_a_recommendation() {
4468 let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
4469 "sampling_int_temp",
4470 &[
4471 ("general.architecture", Kv::Str("llama")),
4472 ("general.sampling.temp", Kv::U32(1)),
4473 ],
4474 ));
4475 assert_eq!(recommended.temperature, Some(1.0));
4476 }
4477
4478 /// The overwhelming majority of checkpoints recommend nothing, and
4479 /// those must keep frink's existing defaults exactly.
4480 #[test]
4481 fn a_gguf_without_sampling_metadata_recommends_nothing() {
4482 let recommended = crate::sampling::RecommendedSampling::from_gguf(&open_metadata_gguf(
4483 "sampling_absent",
4484 &[("general.architecture", Kv::Str("llama"))],
4485 ));
4486 assert!(recommended.is_empty());
4487 }
4488
4489 #[test]
4490 fn model_config_from_gguf_rejects_dedicated_architectures() {
4491 let tmp = std::env::temp_dir().join(format!(
4492 "frink_test_dedicated_arch_{}.gguf",
4493 std::process::id()
4494 ));
4495 std::fs::write(&tmp, build_arch_only_gguf("deepseek4")).unwrap();
4496 let file = frink_gguf::GgufFile::open(&tmp).expect("minimal header must still parse");
4497 std::fs::remove_file(&tmp).ok();
4498
4499 match ModelConfig::from_gguf(&file) {
4500 Err(LoadError::DedicatedArchitectureRequired(arch, _)) => {
4501 assert_eq!(arch, "deepseek4");
4502 }
4503 other => panic!(
4504 "expected LoadError::DedicatedArchitectureRequired for deepseek4, got {other:?}"
4505 ),
4506 }
4507 }
4508
4509 /// The same Q5_K block bytes cross-validated against an independent
4510 /// Python reference in `frink-quant`'s own tests, reused here for
4511 /// the same full-path proof as the Q6_K test below.
4512 #[rustfmt::skip]
4513 const Q5_K_TEST_BLOCK: [u8; 176] = [
4514 0x66, 0x2a, 0x66, 0x2a, 0x01, 0x01, 0x01, 0x01, 0x4f, 0x4b, 0x10, 0x12, 0x41, 0xe2, 0xc1,
4515 0xb1, 0x72, 0x2f, 0x20, 0x07, 0x31, 0x0c, 0x38, 0xb3, 0x9c, 0xb8, 0xad, 0x2f, 0x9a, 0xea,
4516 0x17, 0xd0, 0xee, 0x93, 0x9e, 0x3e, 0x74, 0xbb, 0x28, 0x18, 0x39, 0x25, 0xb6, 0x09, 0x18,
4517 0x29, 0x1c, 0x1d, 0x29, 0x41, 0x40, 0x0a, 0x74, 0x7d, 0xfd, 0x21, 0xdd, 0x6d, 0x45, 0x73,
4518 0x0e, 0x1e, 0xc0, 0x4a, 0xfc, 0xf3, 0x8e, 0x24, 0x6b, 0x34, 0x7d, 0xbe, 0x94, 0xde, 0x59,
4519 0x7a, 0x35, 0x30, 0x36, 0x0a, 0xf9, 0x4a, 0x9b, 0xa2, 0x26, 0x21, 0xa2, 0xfa, 0xdf, 0x4b,
4520 0x29, 0x64, 0x6f, 0xbb, 0xca, 0x0f, 0x3c, 0xda, 0x20, 0xf4, 0x93, 0x86, 0xab, 0x6e, 0xb9,
4521 0xe5, 0xd5, 0xa0, 0x82, 0xd6, 0x41, 0xff, 0x12, 0xbc, 0x34, 0xbb, 0xab, 0xb8, 0x20, 0x2f,
4522 0xbb, 0x5f, 0x0c, 0x10, 0xcf, 0x49, 0xc5, 0x86, 0x5c, 0xdf, 0xff, 0x78, 0x44, 0x26, 0x3b,
4523 0xc2, 0x23, 0x3d, 0x2b, 0xe9, 0x00, 0x12, 0xf8, 0xea, 0xe2, 0x9e, 0x5e, 0x50, 0x20, 0x9f,
4524 0x9d, 0x8d, 0x7d, 0x7f, 0xcc, 0x1d, 0x0e, 0x13, 0xf8, 0xc2, 0xf1, 0x3d, 0x08, 0x2f, 0x23,
4525 0x13, 0xac, 0x0d, 0xa7, 0xe7, 0x20, 0xa3, 0x90, 0xb7, 0xc8, 0x28,
4526 ];
4527
4528 fn build_single_q5_k_tensor_gguf() -> Vec<u8> {
4529 let mut buf = Vec::new();
4530 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4531 .unwrap();
4532 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4533 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4534 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4535
4536 write_kv_str(&mut buf, "general.architecture", "frink-q5k-test");
4537
4538 write_string(&mut buf, "test.weight");
4539 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4540 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols,
4541 // rows] -- reversed from the semantic [rows, cols] this tensor
4542 // represents (1 row, 256 cols / 1 Q5_K block).
4543 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q5_K block)
4544 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4545 buf.write_u32::<LittleEndian>(13).unwrap(); // dtype tag: Q5_K
4546 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4547
4548 while buf.len() % 32 != 0 {
4549 buf.push(0);
4550 }
4551 buf.extend_from_slice(&Q5_K_TEST_BLOCK);
4552 buf
4553 }
4554
4555 /// How far a fused dot may sit from an exact dequantized dot.
4556 ///
4557 /// Two regimes, and one fixed number cannot describe both. With
4558 /// `FRINK_CPU_INT_DOT` off the activation stays f32 and only
4559 /// rounding separates the two. With it on, the activation is
4560 /// quantized to int8 at `d = amax / 127`, which is the flag both
4561 /// binaries turn on by default and the reason the Q5_K and Q6_K
4562 /// cases failed against a flat `1e-2`.
4563 ///
4564 /// The bound grows with the L2 norm of the row, NOT the L1. Each
4565 /// element carries an independent rounding of up to `d/2`, so the
4566 /// dot's error is a sum of independent terms whose standard
4567 /// deviation is `d/sqrt(12) * ||w||_2`. Bounding by the worst case
4568 /// `d/2 * ||w||_1` instead assumes every rounding aligns with its
4569 /// weight's sign, which on this fixture gives 0.347 against a dot
4570 /// of 2.77: 12% of the value, loose enough that injecting a 5%
4571 /// error still passed. Measured here, the real error is 1.8 sigma,
4572 /// so four sigma keeps better than 2x headroom while still failing
4573 /// that 5% injection.
4574 fn fused_dot_tolerance(weights: &[f32], x: &[f32], exact_bound: f32) -> f32 {
4575 if !frink_core::weight_matrix::cpu_int_dot_for(
4576 frink_core::weight_matrix::IntDotShape::Matvec,
4577 ) {
4578 return exact_bound;
4579 }
4580 let amax = x.iter().fold(0.0f32, |a, v| a.max(v.abs()));
4581 let l2 = weights.iter().map(|w| w * w).sum::<f32>().sqrt();
4582 4.0 * (amax / 127.0) / 12f32.sqrt() * l2 + exact_bound
4583 }
4584
4585 #[test]
4586 fn load_weight_matrix_handles_a_real_on_disk_q5_k_tensor_end_to_end() {
4587 let tmp =
4588 std::env::temp_dir().join(format!("frink_test_q5k_tensor_{}.gguf", std::process::id()));
4589 std::fs::write(&tmp, build_single_q5_k_tensor_gguf()).unwrap();
4590 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q5_K GGUF file must parse");
4591 std::fs::remove_file(&tmp).ok();
4592
4593 let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_K tensor must load");
4594 assert_eq!(matrix.rows(), 1);
4595 assert_eq!(matrix.cols(), 256);
4596 match &matrix {
4597 WeightMatrix::Quantized { kind, data, .. } => {
4598 assert_eq!(*kind, QuantKind::Q5K);
4599 assert!(
4600 data.is_mapped(),
4601 "Q5_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
4602 );
4603 }
4604 _ => panic!("expected a Quantized matrix for a Q5_K tensor"),
4605 }
4606
4607 let expected = frink_quant::dequant_q5_k(&Q5_K_TEST_BLOCK).unwrap();
4608 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4609 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4610
4611 let got = matrix.apply(&x);
4612 assert_eq!(got.len(), 1);
4613 assert!(
4614 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
4615 "end-to-end loaded+applied Q5_K matrix diverged from direct dequant: got={} expected={}",
4616 got[0],
4617 expected_dot
4618 );
4619 }
4620
4621 /// The same Q6_K block bytes cross-validated against an independent
4622 /// Python reference in `frink-quant`'s own tests; reused here to
4623 /// prove the *full*
4624 /// path -- real on-disk GGUF bytes, parsed by `frink-gguf`, read
4625 /// through `GgufFile::tensor_mapped_range`, dispatched by
4626 /// `WeightMatrix::apply` to `frink_quant::dot_q6_k_f32` -- produces
4627 /// the same result as directly dequantizing those bytes, not just
4628 /// that the isolated kernel is correct in unit-test isolation.
4629 #[rustfmt::skip]
4630 const Q6_K_TEST_BLOCK: [u8; 210] = [
4631 0xe0, 0xa5, 0x40, 0x5c, 0x8d, 0x3a, 0x0a, 0x26, 0xfb, 0x4b, 0x6e, 0x9a, 0xdf, 0x3e, 0xa3,
4632 0xc4, 0xf8, 0x2b, 0x1d, 0x95, 0x76, 0x7d, 0x3b, 0xcd, 0xfd, 0xef, 0xc2, 0x0b, 0x07, 0x63,
4633 0x29, 0xfb, 0x81, 0x57, 0xbe, 0xbe, 0x06, 0xf7, 0x3a, 0x92, 0xc4, 0x43, 0xff, 0xad, 0xac,
4634 0x7e, 0x0f, 0x00, 0x2a, 0x4f, 0xf0, 0xf8, 0xa9, 0xfa, 0x3c, 0x90, 0x6d, 0x73, 0x2d, 0x5a,
4635 0xe6, 0xc6, 0x46, 0xf2, 0x0d, 0x55, 0x4c, 0x25, 0x38, 0x71, 0x2b, 0x35, 0x38, 0x82, 0x16,
4636 0x37, 0x5f, 0x32, 0x61, 0x02, 0xdd, 0x2f, 0x6f, 0x7b, 0x1f, 0xb4, 0x1a, 0x1b, 0x3e, 0x4f,
4637 0x11, 0xa3, 0x17, 0x40, 0x5a, 0x5f, 0x76, 0xcd, 0x19, 0x27, 0x9b, 0xc7, 0xc8, 0xf7, 0xf7,
4638 0xee, 0xf4, 0x86, 0xd9, 0xfd, 0xa7, 0xfe, 0x9e, 0xac, 0x70, 0x53, 0x5b, 0x76, 0xfb, 0x39,
4639 0xf8, 0x4b, 0x98, 0xfe, 0xd0, 0x06, 0x21, 0x4c, 0x4d, 0xbe, 0x10, 0x2b, 0x06, 0x65, 0xc9,
4640 0x5e, 0xf9, 0x95, 0x72, 0xae, 0x99, 0xd9, 0x7e, 0x15, 0xbd, 0x5e, 0x6d, 0xe8, 0x25, 0x8a,
4641 0xd5, 0x99, 0xc6, 0x6b, 0x69, 0xc7, 0x84, 0xc6, 0xa4, 0xf7, 0xb9, 0x6d, 0x68, 0x45, 0x0e,
4642 0x65, 0x69, 0xeb, 0xe6, 0xeb, 0xe9, 0x28, 0xa6, 0xb9, 0x96, 0xf2, 0xe8, 0xa7, 0x9b, 0x6e,
4643 0x79, 0x8a, 0x68, 0x65, 0x59, 0x98, 0x8b, 0x44, 0x41, 0x98, 0x9a, 0x56, 0x01, 0x01, 0x01,
4644 0x02, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x01, 0x02, 0x1f, 0x25,
4645 ];
4646
4647 fn build_single_q6_k_tensor_gguf() -> Vec<u8> {
4648 let mut buf = Vec::new();
4649 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4650 .unwrap();
4651 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4652 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4653 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4654
4655 write_kv_str(&mut buf, "general.architecture", "frink-q6k-test");
4656
4657 write_string(&mut buf, "test.weight");
4658 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4659 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4660 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q6_K block)
4661 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4662 buf.write_u32::<LittleEndian>(14).unwrap(); // dtype tag: Q6_K
4663 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4664
4665 while buf.len() % 32 != 0 {
4666 buf.push(0);
4667 }
4668 buf.extend_from_slice(&Q6_K_TEST_BLOCK);
4669 buf
4670 }
4671
4672 #[test]
4673 fn load_weight_matrix_handles_a_real_on_disk_q6_k_tensor_end_to_end() {
4674 let tmp =
4675 std::env::temp_dir().join(format!("frink_test_q6k_tensor_{}.gguf", std::process::id()));
4676 std::fs::write(&tmp, build_single_q6_k_tensor_gguf()).unwrap();
4677 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q6_K GGUF file must parse");
4678 std::fs::remove_file(&tmp).ok();
4679
4680 let matrix = load_weight_matrix(&file, "test.weight").expect("Q6_K tensor must load");
4681 assert_eq!(matrix.rows(), 1);
4682 assert_eq!(matrix.cols(), 256);
4683 match &matrix {
4684 WeightMatrix::Quantized { kind, data, .. } => {
4685 assert_eq!(*kind, QuantKind::Q6K);
4686 assert!(
4687 data.is_mapped(),
4688 "Q6_K tensors should take the zero-copy mmap path, same as Q8_0/Q4_0"
4689 );
4690 }
4691 _ => panic!("expected a Quantized matrix for a Q6_K tensor"),
4692 }
4693
4694 let expected = frink_quant::dequant_q6_k(&Q6_K_TEST_BLOCK).unwrap();
4695 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4696 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4697
4698 let got = matrix.apply(&x);
4699 assert_eq!(got.len(), 1);
4700 assert!(
4701 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-2),
4702 "end-to-end loaded+applied Q6_K matrix diverged from direct dequant: got={} expected={}",
4703 got[0],
4704 expected_dot
4705 );
4706 }
4707
4708 fn build_single_bf16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
4709 let mut buf = Vec::new();
4710 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4711 .unwrap();
4712 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4713 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4714 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4715
4716 write_kv_str(&mut buf, "general.architecture", "frink-bf16-test");
4717
4718 write_string(&mut buf, "test.weight");
4719 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4720 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4721 buf.write_u64::<LittleEndian>(cols).unwrap();
4722 buf.write_u64::<LittleEndian>(rows).unwrap();
4723 buf.write_u32::<LittleEndian>(30).unwrap(); // dtype tag: BF16
4724 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4725
4726 while buf.len() % 32 != 0 {
4727 buf.push(0);
4728 }
4729 for &v in values {
4730 // Real bf16 truncation (round-toward-zero, matching a real
4731 // writer closely enough for round-trip test purposes): top
4732 // 16 bits of the f32 bit pattern.
4733 let bf16_bits = (v.to_bits() >> 16) as u16;
4734 buf.extend_from_slice(&bf16_bits.to_le_bytes());
4735 }
4736 buf
4737 }
4738
4739 #[test]
4740 fn load_weight_matrix_handles_a_real_on_disk_bf16_tensor_end_to_end() {
4741 // Values with zero low-mantissa bits, so f32->bf16 truncation
4742 // is lossless and this is an exact-equality check.
4743 let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
4744 let tmp = std::env::temp_dir().join(format!(
4745 "frink_test_bf16_tensor_{}.gguf",
4746 std::process::id()
4747 ));
4748 std::fs::write(&tmp, build_single_bf16_tensor_gguf(2, 3, &values)).unwrap();
4749 let file = frink_gguf::GgufFile::open(&tmp).expect("real BF16 GGUF file must parse");
4750 std::fs::remove_file(&tmp).ok();
4751
4752 let matrix = load_weight_matrix(&file, "test.weight").expect("BF16 tensor must load");
4753 assert_eq!(matrix.rows(), 2);
4754 assert_eq!(matrix.cols(), 3);
4755 match &matrix {
4756 WeightMatrix::F32(tensor) => {
4757 assert_eq!(tensor.data, values, "BF16 must widen to f32 exactly");
4758 }
4759 _ => panic!("expected an F32 matrix for a BF16 tensor (no fused dot kernel for it)"),
4760 }
4761 }
4762
4763 fn build_single_f16_tensor_gguf(rows: u64, cols: u64, values: &[f32]) -> Vec<u8> {
4764 let mut buf = Vec::new();
4765 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4766 .unwrap();
4767 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4768 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4769 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4770
4771 write_kv_str(&mut buf, "general.architecture", "frink-f16-test");
4772
4773 write_string(&mut buf, "test.weight");
4774 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4775 buf.write_u64::<LittleEndian>(cols).unwrap();
4776 buf.write_u64::<LittleEndian>(rows).unwrap();
4777 buf.write_u32::<LittleEndian>(1).unwrap(); // dtype tag: F16
4778 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4779
4780 while buf.len() % 32 != 0 {
4781 buf.push(0);
4782 }
4783 for &v in values {
4784 buf.extend_from_slice(&half::f16::from_f32(v).to_le_bytes());
4785 }
4786 buf
4787 }
4788
4789 /// `GgmlType::F16` was parsed and sized but had no dequant arm in any
4790 /// of the seven loaders, so every `*-f16.gguf` was a hard
4791 /// `UnsupportedDtype`. Values are exactly representable in f16, so
4792 /// this is an exact-equality check.
4793 #[test]
4794 fn load_weight_matrix_handles_a_real_on_disk_f16_tensor_end_to_end() {
4795 let values: Vec<f32> = vec![1.0, -2.5, 0.0, 4.0, -8.0, 16.0];
4796 let tmp =
4797 std::env::temp_dir().join(format!("frink_test_f16_tensor_{}.gguf", std::process::id()));
4798 std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
4799 let file = frink_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
4800 std::fs::remove_file(&tmp).ok();
4801
4802 let matrix = load_weight_matrix(&file, "test.weight").expect("F16 tensor must load");
4803 assert_eq!(matrix.rows(), 2);
4804 assert_eq!(matrix.cols(), 3);
4805 match &matrix {
4806 WeightMatrix::F32(tensor) => {
4807 assert_eq!(tensor.data, values, "F16 must widen to f32 exactly");
4808 }
4809 _ => panic!("expected an F32 matrix for an F16 tensor (no fused dot kernel for it)"),
4810 }
4811
4812 // The same tensor read as a plain vector (norm weights, biases and
4813 // the router all take this path, not `load_weight_matrix`).
4814 let tmp =
4815 std::env::temp_dir().join(format!("frink_test_f16_vec_{}.gguf", std::process::id()));
4816 std::fs::write(&tmp, build_single_f16_tensor_gguf(2, 3, &values)).unwrap();
4817 let file = frink_gguf::GgufFile::open(&tmp).expect("real F16 GGUF file must parse");
4818 std::fs::remove_file(&tmp).ok();
4819 assert_eq!(load_f32_vec(&file, "test.weight").unwrap(), values);
4820 }
4821
4822 fn build_single_q5_1_tensor_gguf() -> Vec<u8> {
4823 let mut buf = Vec::new();
4824 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4825 .unwrap();
4826 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4827 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4828 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4829
4830 write_kv_str(&mut buf, "general.architecture", "frink-q5-1-test");
4831
4832 write_string(&mut buf, "test.weight");
4833 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4834 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4835 buf.write_u64::<LittleEndian>(32).unwrap(); // cols (1 Q5_1 block)
4836 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4837 buf.write_u32::<LittleEndian>(7).unwrap(); // dtype tag: Q5_1
4838 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4839
4840 while buf.len() % 32 != 0 {
4841 buf.push(0);
4842 }
4843 // d=0.25 (f16 0x3400), m=1.5 (f16 0x3E00) -- both exact in f16,
4844 // hand-verified bit patterns to avoid pulling in the `half`
4845 // crate just for two test constants. qh varied, qs a real
4846 // (non-degenerate) pattern.
4847 buf.extend_from_slice(&0x3400u16.to_le_bytes());
4848 buf.extend_from_slice(&0x3E00u16.to_le_bytes());
4849 buf.extend_from_slice(&[0x9au8, 0x3c, 0xf0, 0x0f]);
4850 buf.extend_from_slice(&(0..16u8).map(|i| i | ((15 - i) << 4)).collect::<Vec<u8>>());
4851 buf
4852 }
4853
4854 #[test]
4855 fn load_weight_matrix_handles_a_real_on_disk_q5_1_tensor_end_to_end() {
4856 let tmp = std::env::temp_dir().join(format!(
4857 "frink_test_q5_1_tensor_{}.gguf",
4858 std::process::id()
4859 ));
4860 std::fs::write(&tmp, build_single_q5_1_tensor_gguf()).unwrap();
4861 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q5_1 GGUF file must parse");
4862 std::fs::remove_file(&tmp).ok();
4863
4864 let matrix = load_weight_matrix(&file, "test.weight").expect("Q5_1 tensor must load");
4865 assert_eq!(matrix.rows(), 1);
4866 assert_eq!(matrix.cols(), 32);
4867 let raw = file.tensor_bytes("test.weight").unwrap();
4868 let expected = frink_quant::dequant_q5_1(raw).unwrap();
4869 match &matrix {
4870 WeightMatrix::Quantized { kind, data, .. } => {
4871 assert_eq!(*kind, QuantKind::Q5_1);
4872 assert!(data.is_mapped());
4873 }
4874 _ => panic!("expected a Quantized matrix for a Q5_1 tensor"),
4875 }
4876
4877 let x: Vec<f32> = (0..32).map(|i| ((i as f32) * 0.017).cos()).collect();
4878 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4879 let got = matrix.apply(&x);
4880 assert_eq!(got.len(), 1);
4881 assert!(
4882 (got[0] - expected_dot).abs() < 1e-2,
4883 "end-to-end loaded+applied Q5_1 matrix diverged from direct dequant: got={} expected={}",
4884 got[0],
4885 expected_dot
4886 );
4887 }
4888
4889 // Same bytes as frink-quant's own Q3_K_TEST_BLOCK (Python-cross-
4890 // validated there); duplicated here to build a real on-disk GGUF
4891 // file, matching this file's existing per-format test convention
4892 // (see Q6_K_TEST_BLOCK above).
4893 const Q3_K_TEST_BLOCK: [u8; 110] = [
4894 0x56, 0xf2, 0xb4, 0x2b, 0xd5, 0x6f, 0x51, 0x71, 0x3c, 0x0a, 0xb9, 0x1d, 0xd0, 0xb9, 0x3b,
4895 0xb3, 0x0f, 0xff, 0x8c, 0xb2, 0x83, 0x3a, 0x3d, 0x24, 0xb1, 0x12, 0x56, 0xe3, 0x23, 0x54,
4896 0xf2, 0xfa, 0x7f, 0xdf, 0x31, 0xe1, 0x18, 0x26, 0x6e, 0xcd, 0x5b, 0x38, 0xee, 0xbd, 0x9f,
4897 0x8c, 0x57, 0x47, 0x0b, 0x11, 0xcb, 0xfb, 0xb4, 0x83, 0xa0, 0x4e, 0x0b, 0xd4, 0xa7, 0x85,
4898 0xe0, 0x60, 0xf3, 0xb3, 0xe3, 0x95, 0x43, 0xc6, 0x05, 0x05, 0x77, 0x53, 0xed, 0x23, 0xcc,
4899 0x6a, 0x0e, 0x89, 0xa1, 0x79, 0x85, 0xf6, 0x6e, 0x5a, 0x23, 0x63, 0xbe, 0x53, 0xfa, 0xa2,
4900 0x2b, 0xe9, 0xcd, 0xce, 0xf8, 0x3d, 0x6f, 0xd0, 0x42, 0x6e, 0x3b, 0x7f, 0x23, 0x26, 0xd3,
4901 0xb9, 0x18, 0xbf, 0xa4, 0x34,
4902 ];
4903
4904 fn build_single_q3_k_tensor_gguf() -> Vec<u8> {
4905 let mut buf = Vec::new();
4906 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4907 .unwrap();
4908 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4909 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4910 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4911
4912 write_kv_str(&mut buf, "general.architecture", "frink-q3k-test");
4913
4914 write_string(&mut buf, "test.weight");
4915 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4916 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4917 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 Q3_K block)
4918 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4919 buf.write_u32::<LittleEndian>(11).unwrap(); // dtype tag: Q3_K
4920 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4921
4922 while buf.len() % 32 != 0 {
4923 buf.push(0);
4924 }
4925 buf.extend_from_slice(&Q3_K_TEST_BLOCK);
4926 buf
4927 }
4928
4929 #[test]
4930 fn load_weight_matrix_handles_a_real_on_disk_q3_k_tensor_end_to_end() {
4931 let tmp =
4932 std::env::temp_dir().join(format!("frink_test_q3k_tensor_{}.gguf", std::process::id()));
4933 std::fs::write(&tmp, build_single_q3_k_tensor_gguf()).unwrap();
4934 let file = frink_gguf::GgufFile::open(&tmp).expect("real Q3_K GGUF file must parse");
4935 std::fs::remove_file(&tmp).ok();
4936
4937 let matrix = load_weight_matrix(&file, "test.weight").expect("Q3_K tensor must load");
4938 assert_eq!(matrix.rows(), 1);
4939 assert_eq!(matrix.cols(), 256);
4940 match &matrix {
4941 WeightMatrix::Quantized { kind, data, .. } => {
4942 assert_eq!(*kind, QuantKind::Q3K);
4943 assert!(data.is_mapped());
4944 }
4945 _ => panic!("expected a Quantized matrix for a Q3_K tensor"),
4946 }
4947
4948 let expected = frink_quant::dequant_q3_k(&Q3_K_TEST_BLOCK).unwrap();
4949 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
4950 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
4951
4952 let got = matrix.apply(&x);
4953 assert_eq!(got.len(), 1);
4954 assert!(
4955 (got[0] - expected_dot).abs() < fused_dot_tolerance(&expected, &x, 1e-1),
4956 "end-to-end loaded+applied Q3_K matrix diverged from direct dequant: got={} expected={}",
4957 got[0],
4958 expected_dot
4959 );
4960 }
4961
4962 // Same bytes as frink-quant's own IQ4_XS_TEST_BLOCK (Python-cross-
4963 // validated there); duplicated here to build a real on-disk GGUF
4964 // file, matching this file's existing per-format test convention.
4965 const IQ4_XS_TEST_BLOCK: [u8; 136] = [
4966 0x5c, 0x33, 0xb4, 0x39, 0xd1, 0x64, 0x97, 0x82, 0xcb, 0xbd, 0x88, 0x95, 0xf3, 0x60, 0x2a,
4967 0xb5, 0xe7, 0x24, 0xd3, 0xee, 0xfe, 0x71, 0x13, 0xbe, 0x70, 0x84, 0x48, 0x79, 0x7b, 0x3e,
4968 0xf0, 0x55, 0xdc, 0xb2, 0xb2, 0xde, 0x32, 0xa1, 0x5b, 0x02, 0x01, 0xdc, 0x2a, 0xbb, 0xf7,
4969 0x0b, 0x8a, 0x88, 0xdd, 0x0b, 0x02, 0x7e, 0x5e, 0x76, 0x87, 0x30, 0x1e, 0x1c, 0xcf, 0x48,
4970 0xd7, 0x61, 0xf3, 0x51, 0x52, 0x17, 0x98, 0x0a, 0x87, 0xcf, 0x02, 0x91, 0xc8, 0xee, 0xc0,
4971 0x91, 0x69, 0x2a, 0x4f, 0x64, 0x68, 0xa7, 0xb2, 0xe6, 0x98, 0x21, 0x81, 0x75, 0x53, 0x2a,
4972 0x8d, 0x12, 0xae, 0xe0, 0xea, 0x0c, 0x75, 0xff, 0x22, 0x5e, 0x25, 0x19, 0xda, 0x2e, 0x51,
4973 0x4e, 0x81, 0xdc, 0x0e, 0x78, 0x86, 0xd7, 0x58, 0xb5, 0xb7, 0xf6, 0x45, 0xa9, 0x0a, 0x83,
4974 0xfd, 0x2a, 0x12, 0x7d, 0xf0, 0x12, 0x97, 0xe2, 0xfe, 0xf4, 0xd0, 0xa2, 0x11, 0x14, 0x78,
4975 0xdb,
4976 ];
4977
4978 fn build_single_iq4_xs_tensor_gguf() -> Vec<u8> {
4979 let mut buf = Vec::new();
4980 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
4981 .unwrap();
4982 buf.write_u32::<LittleEndian>(3).unwrap(); // version
4983 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
4984 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
4985
4986 write_kv_str(&mut buf, "general.architecture", "frink-iq4xs-test");
4987
4988 write_string(&mut buf, "test.weight");
4989 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
4990 // Real GGUF ne[] order is fastest-varying-first, i.e. [cols, rows].
4991 buf.write_u64::<LittleEndian>(256).unwrap(); // cols (1 IQ4_XS block)
4992 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
4993 buf.write_u32::<LittleEndian>(23).unwrap(); // dtype tag: IQ4_XS
4994 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
4995
4996 while buf.len() % 32 != 0 {
4997 buf.push(0);
4998 }
4999 buf.extend_from_slice(&IQ4_XS_TEST_BLOCK);
5000 buf
5001 }
5002
5003 #[test]
5004 fn load_weight_matrix_handles_a_real_on_disk_iq4_xs_tensor_end_to_end() {
5005 let tmp = std::env::temp_dir().join(format!(
5006 "frink_test_iq4xs_tensor_{}.gguf",
5007 std::process::id()
5008 ));
5009 std::fs::write(&tmp, build_single_iq4_xs_tensor_gguf()).unwrap();
5010 let file = frink_gguf::GgufFile::open(&tmp).expect("real IQ4_XS GGUF file must parse");
5011 std::fs::remove_file(&tmp).ok();
5012
5013 let matrix = load_weight_matrix(&file, "test.weight").expect("IQ4_XS tensor must load");
5014 assert_eq!(matrix.rows(), 1);
5015 assert_eq!(matrix.cols(), 256);
5016 match &matrix {
5017 WeightMatrix::Quantized { kind, data, .. } => {
5018 assert_eq!(*kind, QuantKind::IQ4XS);
5019 assert!(data.is_mapped());
5020 }
5021 _ => panic!("expected a Quantized matrix for an IQ4_XS tensor"),
5022 }
5023
5024 let expected = frink_quant::dequant_iq4_xs(&IQ4_XS_TEST_BLOCK).unwrap();
5025 let x: Vec<f32> = (0..256).map(|i| ((i as f32) * 0.013).sin()).collect();
5026 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
5027
5028 let got = matrix.apply(&x);
5029 assert_eq!(got.len(), 1);
5030 assert!(
5031 (got[0] - expected_dot).abs() < 1e-1,
5032 "end-to-end loaded+applied IQ4_XS matrix diverged from direct dequant: got={} expected={}",
5033 got[0],
5034 expected_dot
5035 );
5036 }
5037
5038 // Same bytes as frink-quant's own IQ low-bit test blocks
5039 // (Python-cross-validated there against the real compiled ggml
5040 // implementation), duplicated as literals for the same reason as
5041 // IQ4_XS_TEST_BLOCK above.
5042 const IQ1_S_TEST_BLOCK: [u8; 50] = [
5043 0x0a, 0x2f, 0xfa, 0x06, 0x1e, 0x37, 0x6f, 0xe3, 0x62, 0xd0, 0xb6, 0xa4, 0x25, 0xae, 0x76,
5044 0x14, 0x72, 0x5b, 0xfa, 0x05, 0xd1, 0xf1, 0x2a, 0x4c, 0xad, 0x29, 0xae, 0xf4, 0xcf, 0x0c,
5045 0x96, 0x51, 0x58, 0x03, 0x6d, 0xd3, 0x10, 0x92, 0x70, 0xff, 0x61, 0x58, 0xc8, 0x30, 0x25,
5046 0x64, 0x49, 0x85, 0xc0, 0x24,
5047 ];
5048 const IQ2_XXS_TEST_BLOCK: [u8; 66] = [
5049 0x29, 0x30, 0xd9, 0x33, 0x95, 0x4c, 0x08, 0x1e, 0xad, 0x79, 0x49, 0xf2, 0x8d, 0x5f, 0x93,
5050 0xea, 0x78, 0x18, 0x98, 0xb9, 0x94, 0x14, 0xad, 0xce, 0xca, 0x1d, 0xab, 0x81, 0x53, 0x4a,
5051 0x68, 0xd0, 0x59, 0x96, 0x36, 0x5d, 0xbe, 0x20, 0xc4, 0xff, 0xe4, 0x2c, 0xcd, 0x2f, 0x4f,
5052 0x4f, 0x67, 0x53, 0xc6, 0xd5, 0xa2, 0xfb, 0xc7, 0xf3, 0xe2, 0x6b, 0xf1, 0x99, 0x23, 0x1e,
5053 0x2d, 0x5e, 0x8c, 0x78, 0xc2, 0x31,
5054 ];
5055 const IQ3_XXS_TEST_BLOCK: [u8; 98] = [
5056 0x71, 0x31, 0x16, 0x0a, 0x79, 0x04, 0x5d, 0x87, 0xae, 0x2a, 0x4a, 0x43, 0xfd, 0x02, 0xba,
5057 0x6c, 0x10, 0x42, 0x80, 0xe5, 0x1d, 0x08, 0x22, 0xcb, 0x21, 0x54, 0xf9, 0xaa, 0x8e, 0xc2,
5058 0xf2, 0x34, 0x66, 0x1e, 0x2a, 0xef, 0x19, 0xae, 0x48, 0x47, 0x29, 0xa0, 0x72, 0xd1, 0x31,
5059 0xc0, 0x65, 0x49, 0xde, 0x79, 0x32, 0xe6, 0x4d, 0xb6, 0x55, 0x3f, 0x4d, 0xf1, 0x18, 0xbb,
5060 0x18, 0x59, 0x4c, 0x31, 0xa3, 0xb2, 0x34, 0xdd, 0xf6, 0x4a, 0x91, 0x51, 0x3f, 0x3e, 0x40,
5061 0x69, 0xad, 0xbf, 0x1a, 0xd0, 0x05, 0xfb, 0xbe, 0x8b, 0x0b, 0xdd, 0xdf, 0x7d, 0x94, 0x74,
5062 0x92, 0x3e, 0xff, 0x04, 0x2a, 0xc4, 0xea, 0xc9,
5063 ];
5064
5065 #[rustfmt::skip]
5066 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];
5067
5068 /// A live ggml type this build has no kernel for must be REFUSED BY
5069 /// NAME at execution, having been sized correctly at parse.
5070 ///
5071 /// Before `TQ2_0` was recognized, tag 35 was `Other(35)`, which had
5072 /// no block layout: the tensor's size was unknown, so `tensor_bytes`
5073 /// could not even hand back the row, and the error named a number.
5074 /// Now the file parses, the tensor measures 66 bytes per 256
5075 /// elements, and the stop happens where it belongs -- at the point
5076 /// something wants to multiply by it -- naming `TQ2_0`.
5077 #[test]
5078 fn a_recognized_but_unimplemented_ggml_type_refuses_by_name_after_sizing_correctly() {
5079 // 256 elements of TQ2_0 = one 66-byte block.
5080 let block = pseudo_iq_block(66, 0x0720_5eed);
5081 let tmp =
5082 std::env::temp_dir().join(format!("frink_test_tq2_0_{}.gguf", std::process::id()));
5083 std::fs::write(
5084 &tmp,
5085 build_single_iq_lowbit_tensor_gguf("tq2test", 35, 256, &block),
5086 )
5087 .unwrap();
5088 let file = frink_gguf::GgufFile::open(&tmp).expect("a TQ2_0 file must still parse");
5089 std::fs::remove_file(&tmp).ok();
5090
5091 // Sized, not zero: the size estimate is right even though the
5092 // kernel is missing.
5093 let info = file.find_tensor("test.weight").expect("tensor present");
5094 assert_eq!(info.dtype, GgmlType::TQ2_0);
5095 assert_eq!(info.byte_len(), Some(66));
5096 assert_eq!(
5097 file.tensor_bytes("test.weight").map(<[u8]>::len).ok(),
5098 Some(66)
5099 );
5100
5101 match load_weight_matrix(&file, "test.weight") {
5102 Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
5103 assert_eq!(name, "test.weight");
5104 }
5105 Err(other) => panic!("TQ2_0 must be refused by name, got {other:?}"),
5106 Ok(_) => panic!("TQ2_0 must be refused, not loaded as some other kind"),
5107 }
5108 }
5109
5110 /// An MXFP4 norm/bias must widen, not be refused.
5111 ///
5112 /// `load_weight_matrix` accepts MXFP4 as a 2-D weight and
5113 /// `load_moe_expert_matrices` accepts it as an expert tensor, and
5114 /// `WeightMatrix::dequant` calls `dequant_mxfp4_gguf` on both. One
5115 /// missing arm in `widen_plain_float` made the *1-D* tensors of the
5116 /// exact same dtype a hard `UnsupportedDtype` -- the split that
5117 /// turns a supported format into a load failure on the one
5118 /// checkpoint that uses it.
5119 #[test]
5120 fn an_mxfp4_one_dimensional_tensor_widens_instead_of_being_refused() {
5121 let expected = frink_quant::dequant_mxfp4_gguf(&MXFP4_GGUF_TEST_BLOCKS)
5122 .expect("the fixture blocks must dequantize");
5123 let cols = expected.len();
5124 let tmp =
5125 std::env::temp_dir().join(format!("frink_test_mxfp4_norm_{}.gguf", std::process::id()));
5126 std::fs::write(
5127 &tmp,
5128 build_single_iq_lowbit_tensor_gguf(
5129 "mxfp4norm",
5130 39,
5131 cols as u64,
5132 &MXFP4_GGUF_TEST_BLOCKS,
5133 ),
5134 )
5135 .unwrap();
5136 let file = frink_gguf::GgufFile::open(&tmp).expect("file must parse");
5137 std::fs::remove_file(&tmp).ok();
5138
5139 let got = load_f32_vec(&file, "test.weight")
5140 .expect("an MXFP4 norm must load, not report an unsupported dtype");
5141 assert_eq!(got, expected);
5142
5143 // Same arm, reached directly: `widen_plain_float` is the shared
5144 // helper the six architecture loaders call, so its table is the
5145 // one that has to know MXFP4.
5146 let direct = widen_plain_float(GgmlType::MXFP4, &MXFP4_GGUF_TEST_BLOCKS, "test.weight")
5147 .expect("widen_plain_float must widen MXFP4");
5148 assert_eq!(direct, expected);
5149
5150 // And the refusal still works for a dtype that genuinely has no
5151 // widening path, so this test cannot pass by making everything
5152 // succeed.
5153 match widen_plain_float(GgmlType::TQ2_0, &MXFP4_GGUF_TEST_BLOCKS, "test.weight") {
5154 Err(LoadError::UnsupportedDtype(name, GgmlType::TQ2_0)) => {
5155 assert_eq!(name, "test.weight");
5156 }
5157 other => panic!("TQ2_0 must be refused by name, got {other:?}"),
5158 }
5159 }
5160
5161 fn build_single_iq_lowbit_tensor_gguf(
5162 arch: &str,
5163 tag: u32,
5164 cols: u64,
5165 block: &[u8],
5166 ) -> Vec<u8> {
5167 let mut buf = Vec::new();
5168 buf.write_u32::<LittleEndian>(frink_gguf::GGUF_MAGIC)
5169 .unwrap();
5170 buf.write_u32::<LittleEndian>(3).unwrap(); // version
5171 buf.write_u64::<LittleEndian>(1).unwrap(); // tensor_count
5172 buf.write_u64::<LittleEndian>(1).unwrap(); // kv_count
5173 write_kv_str(&mut buf, "general.architecture", arch);
5174 write_string(&mut buf, "test.weight");
5175 buf.write_u32::<LittleEndian>(2).unwrap(); // n_dims
5176 buf.write_u64::<LittleEndian>(cols).unwrap();
5177 buf.write_u64::<LittleEndian>(1).unwrap(); // rows
5178 buf.write_u32::<LittleEndian>(tag).unwrap();
5179 buf.write_u64::<LittleEndian>(0).unwrap(); // offset
5180 while buf.len() % 32 != 0 {
5181 buf.push(0);
5182 }
5183 buf.extend_from_slice(block);
5184 buf
5185 }
5186
5187 /// A structurally valid block of `len` bytes for any of the
5188 /// codebook-grid formats: every bit pattern is a legal code in all
5189 /// of them (the grid indices are bounded by their own bit widths),
5190 /// so a deterministic byte fill is a real block, not a fixture that
5191 /// happens to avoid the interesting paths. Only the f16 scale needs
5192 /// pinning, and only so the comparison below can't be NaN-vs-NaN.
5193 fn pseudo_iq_block(len: usize, seed: u32) -> Vec<u8> {
5194 let mut s = seed;
5195 let mut out = Vec::with_capacity(len);
5196 for _ in 0..len {
5197 s ^= s << 13;
5198 s ^= s >> 17;
5199 s ^= s << 5;
5200 out.push((s >> 24) as u8);
5201 }
5202 out
5203 }
5204
5205 /// End-to-end load+apply for the codebook-grid low-bit formats the
5206 /// published Dynamic GGUFs are built from: a real on-disk tensor of
5207 /// each type must load zero-copy as the right `QuantKind` and
5208 /// produce the same matvec result as dequantizing the block
5209 /// directly. That is the property this test exists for -- the
5210 /// *values* are pinned against real ggml in `frink-quant`; what
5211 /// can only break here is the tag -> kind -> block-stride chain,
5212 /// and a wrong stride silently reads the neighbouring row.
5213 /// Dtype tags (19/29/16/17/22/18/21/39) verified against ggml.h's
5214 /// enum ggml_type.
5215 #[test]
5216 fn load_weight_matrix_handles_real_on_disk_iq_lowbit_tensors_end_to_end() {
5217 type DequantFn = fn(&[u8]) -> Result<Vec<f32>, frink_quant::QuantError>;
5218 // IQ1_M carries no f16 scale field; its scale is reassembled
5219 // from the four scale words' top nibbles, and the top nibble of
5220 // the last one supplies the f16 sign + high exponent bits.
5221 // Pinning it to 0x2 keeps the exponent out of the all-ones
5222 // NaN/Inf pattern whatever the rest of the fill does. The other
5223 // three do carry a leading f16 `d`, pinned for the same reason.
5224 let mut iq1m = pseudo_iq_block(frink_quant::IQ1_M_BLOCK_BYTES, 0x2907_31A0);
5225 iq1m[55] = (iq1m[55] & 0x0F) | 0x20;
5226 let mut iq2xs = pseudo_iq_block(frink_quant::IQ2_XS_BLOCK_BYTES, 0x2107_31A1);
5227 let mut iq2s = pseudo_iq_block(frink_quant::IQ2_S_BLOCK_BYTES, 0x2207_31A2);
5228 let mut iq3s = pseudo_iq_block(frink_quant::IQ3_S_BLOCK_BYTES, 0x2307_31A3);
5229 for blk in [&mut iq2xs, &mut iq2s, &mut iq3s] {
5230 blk[0..2].copy_from_slice(&half::f16::from_f32(0.115).to_le_bytes());
5231 }
5232 let cases: [(&str, u32, &[u8], QuantKind, DequantFn); 8] = [
5233 (
5234 "iq1s",
5235 19,
5236 &IQ1_S_TEST_BLOCK,
5237 QuantKind::IQ1S,
5238 frink_quant::dequant_iq1_s,
5239 ),
5240 (
5241 "iq1m",
5242 29,
5243 &iq1m,
5244 QuantKind::IQ1M,
5245 frink_quant::dequant_iq1_m,
5246 ),
5247 (
5248 "iq2xxs",
5249 16,
5250 &IQ2_XXS_TEST_BLOCK,
5251 QuantKind::IQ2XXS,
5252 frink_quant::dequant_iq2_xxs,
5253 ),
5254 (
5255 "iq2xs",
5256 17,
5257 &iq2xs,
5258 QuantKind::IQ2XS,
5259 frink_quant::dequant_iq2_xs,
5260 ),
5261 (
5262 "iq2s",
5263 22,
5264 &iq2s,
5265 QuantKind::IQ2S,
5266 frink_quant::dequant_iq2_s,
5267 ),
5268 (
5269 "iq3xxs",
5270 18,
5271 &IQ3_XXS_TEST_BLOCK,
5272 QuantKind::IQ3XXS,
5273 frink_quant::dequant_iq3_xxs,
5274 ),
5275 (
5276 "iq3s",
5277 21,
5278 &iq3s,
5279 QuantKind::IQ3S,
5280 frink_quant::dequant_iq3_s,
5281 ),
5282 (
5283 "mxfp4_gguf",
5284 39,
5285 &MXFP4_GGUF_TEST_BLOCKS,
5286 QuantKind::Mxfp4Gguf,
5287 frink_quant::dequant_mxfp4_gguf,
5288 ),
5289 ];
5290 for (name, tag, block, kind, dequant) in cases {
5291 let expected = dequant(block).unwrap();
5292 let cols = expected.len();
5293 let tmp = std::env::temp_dir().join(format!("frink_test_{name}_tensor.gguf"));
5294 std::fs::write(
5295 &tmp,
5296 build_single_iq_lowbit_tensor_gguf(name, tag, cols as u64, block),
5297 )
5298 .unwrap();
5299 let file = frink_gguf::GgufFile::open(&tmp).expect("file must parse");
5300 std::fs::remove_file(&tmp).ok();
5301
5302 let matrix =
5303 load_weight_matrix(&file, "test.weight").expect("low-bit tensor must load");
5304 assert_eq!((matrix.rows(), matrix.cols()), (1, cols), "{name}");
5305 match &matrix {
5306 WeightMatrix::Quantized { kind: k, data, .. } => {
5307 assert_eq!(*k, kind, "{name}");
5308 assert!(data.is_mapped(), "{name} must load zero-copy");
5309 }
5310 _ => panic!("expected a Quantized matrix for {name}"),
5311 }
5312
5313 let x: Vec<f32> = (0..cols).map(|i| ((i as f32) * 0.013).sin()).collect();
5314 let expected_dot: f32 = expected.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
5315 let got = matrix.apply(&x);
5316 assert!(
5317 (got[0] - expected_dot).abs() < 1e-1,
5318 "{name}: loaded+applied diverged from direct dequant: got={} expected={}",
5319 got[0],
5320 expected_dot
5321 );
5322 }
5323 }
5324
5325 #[test]
5326 fn qwen2moe_disables_topk_renorm() {
5327 assert!(
5328 NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(&"qwen2moe"),
5329 "qwen2moe must have norm_topk_prob=false (llama.cpp build_moe_ffn norm_w=false)"
5330 );
5331 }
5332
5333 /// The `LLAMA_ROPE_TYPE_NONE` group used to be refused by name here;
5334 /// every row is served now, positioned the way its graph positions
5335 /// (`crate::position_embd`, `crate::alibi`), and what this pins is
5336 /// that not one of them reaches a rotation: the rule is
5337 /// `RopeLayers::Never` for each, at any depth it takes.
5338 #[test]
5339 fn an_architecture_with_no_rope_rotates_nothing() {
5340 for (arch, n_layers) in [
5341 ("gpt2", 12),
5342 ("mpt", 32),
5343 ("refact", 32),
5344 ("bloom", 30),
5345 ("jais", 40),
5346 ("baichuan", 40),
5347 ] {
5348 assert_eq!(
5349 crate::rope_layers::rope_layers(arch, n_layers, false, 0),
5350 crate::rope_layers::RopeLayers::Never,
5351 "{arch} positions without RoPE and must rotate nothing"
5352 );
5353 assert!(crate::capability::is_audited_generic(arch), "{arch}");
5354 }
5355 }
5356
5357 /// A per-layer sliding-window ARRAY on an architecture whose graph
5358 /// reads the key as a scalar is IGNORED and the seeded period
5359 /// stands, exactly as llama.cpp does; a scalar still overrides the
5360 /// period.
5361 ///
5362 /// Three generations of this gate. `capability::
5363 /// unsupported_feature_keys` refused the key outright with the
5364 /// reason "not implemented in the generic decoder", which was
5365 /// false. Then the loader refused the ARRAY form for every
5366 /// architecture, on the reasoning that honouring it as a period was
5367 /// impossible and ignoring it would substitute the seed for the
5368 /// file's layout -- which is TRUE and is ALSO what llama.cpp does:
5369 /// `get_key_or_arr(kid, swa_period, false)` returns false on an
5370 /// array (`llama-model-loader.cpp:502-507`) and `plamo3.cpp:9-11`
5371 /// keeps its 8. Every real EXAONE-4 32B, EXAONE-MoE and Olmo-3
5372 /// export carries the array (`conversion/exaone.py:84`,
5373 /// `olmo.py:59-66`) and was refused over a value upstream never
5374 /// reads. `crate::swa_layers` carries which graphs read which form;
5375 /// the array-HONOURED mode has its own fixture in
5376 /// `tests/window_array_graphs.rs`.
5377 #[test]
5378 fn an_array_valued_sliding_window_pattern_is_ignored_where_llama_cpp_ignores_it() {
5379 // Disagrees with plamo3's seeded last-dense 8 on layers 0..3,
5380 // so honouring it would be visible.
5381 let pattern: [u32; 4] = [0, 0, 0, 0];
5382 let kvs: Vec<(&str, Kv)> = vec![
5383 ("general.architecture", Kv::Str("plamo3")),
5384 ("plamo3.block_count", Kv::U32(4)),
5385 ("plamo3.embedding_length", Kv::U32(64)),
5386 ("plamo3.attention.head_count", Kv::U32(1)),
5387 ("plamo3.attention.head_count_kv", Kv::U32(1)),
5388 ("plamo3.attention.key_length", Kv::U32(64)),
5389 ("plamo3.rope.freq_base", Kv::F32(10_000.0)),
5390 ("plamo3.attention.sliding_window", Kv::U32(3)),
5391 (
5392 "plamo3.attention.sliding_window_pattern",
5393 Kv::Arr32(&pattern),
5394 ),
5395 ];
5396 let file = open_metadata_gguf("swa_pattern_array", &kvs);
5397 let config = ModelConfig::from_gguf(&file).expect("the array is not a refusal");
5398 assert_eq!(
5399 config.swa_layers,
5400 crate::swa_layers::SwaLayers::period(8, false),
5401 "plamo3.cpp:9-11 seeds 8 and the scalar overload ignores an array"
5402 );
5403 assert_eq!(config.layer_sliding_window(0), Some(3));
5404 assert_eq!(config.layer_sliding_window(3), Some(3));
5405
5406 // And the scalar spelling of the same key overrides the seed.
5407 let mut scalar = kvs;
5408 scalar.pop();
5409 scalar.push(("plamo3.attention.sliding_window_pattern", Kv::U32(2)));
5410 let file = open_metadata_gguf("swa_pattern_scalar", &scalar);
5411 let config = ModelConfig::from_gguf(&file).expect("a scalar period must load");
5412 assert_eq!(
5413 config.swa_layers,
5414 crate::swa_layers::SwaLayers::period(2, false)
5415 );
5416 assert_eq!(config.layer_sliding_window(0), Some(3));
5417 assert_eq!(config.layer_sliding_window(1), None);
5418 }
5419
5420 /// Baichuan is one `general.architecture` string covering two
5421 /// positional schemes, and llama.cpp picks between them on the layer
5422 /// count alone (`src/models/baichuan.cpp:11-14`, with its own "TODO:
5423 /// become GGUF KV parameter"). The 13B used to be refused HERE; it is
5424 /// served now, and what this pins is that the two schemes are still
5425 /// told apart by the count, on both tables that must agree about it
5426 /// (`crate::alibi`, `crate::rope_layers`).
5427 #[test]
5428 fn baichuan_13b_positions_by_alibi_and_the_7b_rotates() {
5429 assert_eq!(
5430 crate::alibi::max_alibi_bias("baichuan", 40, None),
5431 Some(8.0)
5432 );
5433 assert_eq!(
5434 crate::rope_layers::rope_layers("baichuan", 40, false, 0),
5435 crate::rope_layers::RopeLayers::Never
5436 );
5437 assert_eq!(crate::alibi::max_alibi_bias("baichuan", 32, None), None);
5438 assert_eq!(
5439 crate::rope_layers::rope_layers("baichuan", 32, false, 0),
5440 crate::rope_layers::RopeLayers::All
5441 );
5442 // Both sizes pass the header stage and fail on the next missing
5443 // hparam, which is what proves neither is gated here any more.
5444 for (name, n) in [("baichuan13b", 40u32), ("baichuan7b", 32)] {
5445 let file = open_metadata_gguf(
5446 name,
5447 &[
5448 ("general.architecture", Kv::Str("baichuan")),
5449 ("baichuan.block_count", Kv::U32(n)),
5450 ],
5451 );
5452 match ModelConfig::from_gguf(&file) {
5453 Err(LoadError::MissingHparam(key)) => assert_eq!(key, "baichuan.embedding_length"),
5454 other => panic!("{name} must pass the header stage, got {other:?}"),
5455 }
5456 }
5457 }
5458
5459 /// EXAONE-4 is ONE architecture string over TWO graphs, and
5460 /// llama.cpp picks between them off the LAYER COUNT with no GGUF key
5461 /// involved. It used to be refused for it; both sizes run now, and
5462 /// this is the test that says they run DIFFERENTLY.
5463 ///
5464 /// `exaone4.cpp:4-9` wraps the entire SWA setup in
5465 /// `if (hparams.n_layer() == 64)`, and :116 then gates rotation on
5466 /// it -- `use_rope = is_swa(il) || swa_type == NONE`. So:
5467 ///
5468 /// * 64 layers: a window, `set_swa_pattern(4)` last-dense, and the
5469 /// FULL-ATTENTION layer of every period gets no rotation at all.
5470 /// * 30 layers: no window whatever the file declares, and every
5471 /// layer rotates.
5472 ///
5473 /// Both halves are here because the gate is a layer-count EQUALITY.
5474 /// A one-sided version would pass while windowing the 1.2B off a key
5475 /// llama.cpp never reaches, which is the divergence
5476 /// `capability::swa_disabled_by_arch` was extended to stop -- and it
5477 /// would then rope three layers in four of the 1.2B not at all.
5478 #[test]
5479 fn the_two_exaone4_sizes_get_different_windows_and_different_rotation() {
5480 // `Kv` is not `Clone`, so the shared header is a builder rather
5481 // than a value; both sizes must read from one list or the test
5482 // compares two transcriptions.
5483 let base = |n_layers: u32| -> Vec<(&str, Kv)> {
5484 vec![
5485 ("general.architecture", Kv::Str("exaone4")),
5486 ("exaone4.block_count", Kv::U32(n_layers)),
5487 ("exaone4.embedding_length", Kv::U32(32)),
5488 ("exaone4.attention.head_count", Kv::U32(4)),
5489 ("exaone4.attention.head_count_kv", Kv::U32(2)),
5490 ("exaone4.attention.key_length", Kv::U32(8)),
5491 ("exaone4.attention.value_length", Kv::U32(8)),
5492 ("exaone4.rope.freq_base", Kv::F32(10_000.0)),
5493 // The SAME declared window for both sizes: that is the
5494 // whole point. Only the layer count may change the
5495 // answer.
5496 ("exaone4.attention.sliding_window", Kv::U32(4096)),
5497 ]
5498 };
5499
5500 let file = open_metadata_gguf("exaone4_32b", &base(64));
5501 let cfg = ModelConfig::from_gguf(&file).expect("EXAONE-4 32B loads");
5502 assert_eq!(cfg.sliding_window, Some(4096));
5503 assert_eq!(
5504 cfg.swa_layers,
5505 crate::swa_layers::SwaLayers::period(4, false),
5506 "exaone4.cpp:7-9, and set_swa_pattern's default phase"
5507 );
5508 for il in 0..64 {
5509 assert_eq!(
5510 cfg.layer_rotates(il),
5511 il % 4 != 3,
5512 "layer {il} of EXAONE-4 32B: only the sliding layers rotate"
5513 );
5514 }
5515
5516 let file = open_metadata_gguf("exaone4_1_2b", &base(30));
5517 let cfg = ModelConfig::from_gguf(&file).expect("EXAONE-4 1.2B loads");
5518 assert_eq!(
5519 cfg.sliding_window, None,
5520 "exaone4.cpp:4 never reaches set_swa_pattern below 64 layers, \
5521 so the declared window is dead metadata"
5522 );
5523 for il in 0..30 {
5524 assert!(cfg.layer_rotates(il), "layer {il} of EXAONE-4 1.2B");
5525 }
5526 }
5527
5528 /// NextN/MTP blocks are inside `block_count` and llama.cpp skips
5529 /// them (`n_layer = n_layer_all - n_layer_nextn`, llama-hparams.cpp
5530 /// :280-282). For a graph that reads the key (`exaone-moe.cpp:23`)
5531 /// the trunk is what loads; the key is written as `0` by
5532 /// `conversion/exaone.py:146` for every EXAONE-MoE export without an
5533 /// MTP head, so zero must be the whole file. A nonzero value on a
5534 /// graph that does NOT read the key stays refused
5535 /// (`mtp_blocks::tests`).
5536 ///
5537 /// The second half pins the ORDER of two reads in `exaone4.cpp`:
5538 /// `:4` tests `n_layer() == 64` before `:18` reads the key, so it
5539 /// sees `block_count`. A 64-trunk file with one MTP block appended
5540 /// is 65 there and gets NO window in llama.cpp; frink feeds
5541 /// `block_count` to the same gate and gets the same answer.
5542 #[test]
5543 fn nextn_predict_layers_subtracts_the_trunk_for_a_reader_and_zero_is_the_whole_file() {
5544 let base = |nextn: u32| -> Vec<(&str, Kv)> {
5545 vec![
5546 ("general.architecture", Kv::Str("exaone-moe")),
5547 ("exaone-moe.block_count", Kv::U32(5)),
5548 ("exaone-moe.nextn_predict_layers", Kv::U32(nextn)),
5549 ("exaone-moe.embedding_length", Kv::U32(32)),
5550 ("exaone-moe.attention.head_count", Kv::U32(4)),
5551 ("exaone-moe.attention.head_count_kv", Kv::U32(2)),
5552 ("exaone-moe.attention.key_length", Kv::U32(8)),
5553 ("exaone-moe.attention.value_length", Kv::U32(8)),
5554 ("exaone-moe.rope.freq_base", Kv::F32(10_000.0)),
5555 ("exaone-moe.attention.sliding_window", Kv::U32(128)),
5556 ("exaone-moe.expert_count", Kv::U32(4)),
5557 ("exaone-moe.expert_used_count", Kv::U32(2)),
5558 ("exaone-moe.expert_gating_func", Kv::U32(2)),
5559 ]
5560 };
5561 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("exaone_moe_mtp", &base(1)))
5562 .expect("a reader with an MTP block loads its trunk");
5563 assert_eq!((cfg.n_layers, cfg.n_mtp_blocks), (4, 1));
5564 let cfg = ModelConfig::from_gguf(&open_metadata_gguf("exaone_moe_no_mtp", &base(0)))
5565 .expect("zero is the whole file");
5566 assert_eq!((cfg.n_layers, cfg.n_mtp_blocks), (5, 0));
5567
5568 // `exaone4.cpp:4` before `:18`: 64 trunk layers plus one MTP
5569 // block is NOT the 32B to llama.cpp.
5570 let exaone4 = |block_count: u32, nextn: u32| -> Vec<(&str, Kv)> {
5571 vec![
5572 ("general.architecture", Kv::Str("exaone4")),
5573 ("exaone4.block_count", Kv::U32(block_count)),
5574 ("exaone4.nextn_predict_layers", Kv::U32(nextn)),
5575 ("exaone4.embedding_length", Kv::U32(32)),
5576 ("exaone4.attention.head_count", Kv::U32(4)),
5577 ("exaone4.attention.head_count_kv", Kv::U32(2)),
5578 ("exaone4.attention.key_length", Kv::U32(8)),
5579 ("exaone4.attention.value_length", Kv::U32(8)),
5580 ("exaone4.rope.freq_base", Kv::F32(10_000.0)),
5581 ("exaone4.attention.sliding_window", Kv::U32(4096)),
5582 ]
5583 };
5584 let with_mtp =
5585 ModelConfig::from_gguf(&open_metadata_gguf("exaone4_65", &exaone4(65, 1))).unwrap();
5586 assert_eq!((with_mtp.n_layers, with_mtp.n_mtp_blocks), (64, 1));
5587 assert_eq!(
5588 with_mtp.sliding_window, None,
5589 "exaone4.cpp:4 sees n_layer_all = 65 and never reaches set_swa_pattern"
5590 );
5591 let without =
5592 ModelConfig::from_gguf(&open_metadata_gguf("exaone4_64", &exaone4(64, 0))).unwrap();
5593 assert_eq!(
5594 without.sliding_window,
5595 Some(4096),
5596 "the same trunk without the block is the 32B"
5597 );
5598 }
5599
5600 /// `expert_used_count` is scalar-or-array upstream, and the array
5601 /// spelling used to fall through to a DEFAULT of 2 here.
5602 ///
5603 /// `llama-model.cpp:1266` reads the key with `get_key_or_arr` in
5604 /// the common loader -- every architecture, `n_layer_all` entries
5605 /// -- and `conversion/nemotron.py:574` writes a list for
5606 /// Nemotron-H Puzzle, whose architecture (`nemotron_h`) frink
5607 /// serves. Before this test, `metadata_u64` answered `None` for an
5608 /// array value, the `unwrap_or_else` below it pushed a best-effort
5609 /// note, and the model routed top-2 on every layer whatever the
5610 /// file declared: the silent-wrong class, not a refusal.
5611 ///
5612 /// Both arms are pinned, because a reader that honoured the
5613 /// uniform case and silently averaged the varying one would pass
5614 /// half of this.
5615 #[test]
5616 fn a_per_layer_expert_used_count_is_honoured_when_uniform_and_refused_when_not() {
5617 fn file(used: Kv<'_>) -> Vec<(&'static str, Kv<'_>)> {
5618 vec![
5619 ("general.architecture", Kv::Str("llama")),
5620 ("llama.block_count", Kv::U32(2)),
5621 ("llama.embedding_length", Kv::U32(32)),
5622 ("llama.attention.head_count", Kv::U32(4)),
5623 ("llama.attention.head_count_kv", Kv::U32(2)),
5624 ("llama.attention.key_length", Kv::U32(8)),
5625 ("llama.attention.value_length", Kv::U32(8)),
5626 ("llama.rope.freq_base", Kv::F32(10_000.0)),
5627 ("llama.expert_count", Kv::U32(8)),
5628 ("llama.expert_used_count", used),
5629 ]
5630 }
5631 let scalar = ModelConfig::from_gguf(&open_metadata_gguf(
5632 "experts_used_scalar",
5633 &file(Kv::U32(3)),
5634 ))
5635 .expect("the scalar spelling loads");
5636 assert_eq!(scalar.moe.n_experts_active, 3);
5637
5638 let uniform = ModelConfig::from_gguf(&open_metadata_gguf(
5639 "experts_used_uniform",
5640 &file(Kv::Arr32(&[3, 3])),
5641 ))
5642 .expect("a uniform array is that one value");
5643 assert_eq!(
5644 uniform.moe.n_experts_active, 3,
5645 "an array of one repeated value is the scalar, not the default of 2"
5646 );
5647
5648 let err = ModelConfig::from_gguf(&open_metadata_gguf(
5649 "experts_used_varying",
5650 &file(Kv::Arr32(&[3, 5])),
5651 ))
5652 .expect_err("a varying array has no single top-k and must stop");
5653 let msg = format!("{err}");
5654 assert!(
5655 msg.contains("expert_used_count") && msg.contains("PER-LAYER"),
5656 "the refusal must name the key and what is wrong with it: {msg}"
5657 );
5658 }
5659
5660 /// An `olmo2` file carrying BOTH a sliding window and a RoPE
5661 /// scaling ropes its two kinds of layer differently, and frink
5662 /// carries one scaling for the whole model.
5663 ///
5664 /// `olmo2.cpp:120-134` runs the sliding layers with the scaling
5665 /// switched off -- `freq_scale = 1`, `ext_factor = 0`,
5666 /// `attn_factor = 1`, and the comment above it says so in as many
5667 /// words -- while :136-146 gives the full-attention layers the
5668 /// model's own. Rotating half the layers at a magnitude the
5669 /// checkpoint never trained at is the ALiBi class of divergence and
5670 /// runs fluently.
5671 ///
5672 /// Both negative halves are here because the gate is a CONJUNCTION
5673 /// and a gate that fires on either half alone would refuse every
5674 /// OLMo-2 checkpoint ever published.
5675 #[test]
5676 fn olmo2_is_refused_only_when_it_has_a_window_and_a_rope_scaling_together() {
5677 // `Kv` is not `Clone`, so the shared header is a builder
5678 // rather than a value -- which also keeps the three cases
5679 // reading from one list instead of three transcriptions.
5680 let base = || -> Vec<(&str, Kv)> {
5681 vec![
5682 ("general.architecture", Kv::Str("olmo2")),
5683 ("olmo2.block_count", Kv::U32(2)),
5684 ("olmo2.embedding_length", Kv::U32(24)),
5685 ("olmo2.attention.head_count", Kv::U32(4)),
5686 ("olmo2.attention.head_count_kv", Kv::U32(2)),
5687 ("olmo2.attention.key_length", Kv::U32(6)),
5688 ("olmo2.attention.value_length", Kv::U32(6)),
5689 ("olmo2.rope.freq_base", Kv::F32(10_000.0)),
5690 ]
5691 };
5692
5693 let mut both = base();
5694 both.push(("olmo2.attention.sliding_window", Kv::U32(3)));
5695 both.push(("olmo2.rope.scaling.type", Kv::Str("yarn")));
5696 both.push(("olmo2.rope.scaling.factor", Kv::F32(4.0)));
5697 let file = open_metadata_gguf("olmo2_swa_yarn", &both);
5698 match ModelConfig::from_gguf(&file) {
5699 Err(LoadError::UnsupportedFeature(arch, msg)) => {
5700 assert_eq!(arch, "olmo2");
5701 assert!(msg.contains("sliding window"), "{msg}");
5702 assert!(msg.contains("yarn"), "{msg}");
5703 }
5704 other => panic!("olmo2 with a window AND yarn must refuse, got {other:?}"),
5705 }
5706
5707 // A window with no scaling: both of llama.cpp's RoPE branches
5708 // reduce to the same plain rotation, and the difference is
5709 // masking alone, which frink implements.
5710 let mut window_only = base();
5711 window_only.push(("olmo2.attention.sliding_window", Kv::U32(3)));
5712 let file = open_metadata_gguf("olmo2_swa_only", &window_only);
5713 let config = ModelConfig::from_gguf(&file).expect("a window with no scaling must load");
5714 assert_eq!(config.sliding_window, Some(3));
5715 // olmo2.cpp:9-11: the period defaults to 4 and `set_swa_pattern`
5716 // leaves `dense_first` false.
5717 assert_eq!(
5718 config.swa_layers,
5719 crate::swa_layers::SwaLayers::period(4, false)
5720 );
5721
5722 // Scaling with no window: one RoPE for the whole model, which is
5723 // what frink carries.
5724 let mut scaling_only = base();
5725 scaling_only.push(("olmo2.rope.scaling.type", Kv::Str("yarn")));
5726 scaling_only.push(("olmo2.rope.scaling.factor", Kv::F32(4.0)));
5727 let file = open_metadata_gguf("olmo2_yarn_only", &scaling_only);
5728 let config = ModelConfig::from_gguf(&file).expect("scaling with no window must load");
5729 assert_eq!(config.sliding_window, None);
5730 }
5731
5732 /// The hyper-parameters a real Gemma-3 GGUF header carries for one
5733 /// size. `block_count` is the field llama.cpp's `LLM_TYPE_27B`
5734 /// switch reads (`gemma3.cpp:20-28`), so it is never a free
5735 /// parameter here.
5736 ///
5737 /// `linear_factor` adds the pair `conversion/base.py:1222-1230`
5738 /// writes from `rope_parameters["full_attention"]` -- and only from
5739 /// there: its own comment is "TODO: Handle sliding_attention
5740 /// similarly when models start implementing it", so the sliding
5741 /// layers get no scaling key at all.
5742 fn gemma3_config(
5743 tag: &str,
5744 n_layers: u32,
5745 hidden_dim: u32,
5746 n_heads: u32,
5747 head_dim: u32,
5748 linear_factor: Option<f32>,
5749 ) -> ModelConfig {
5750 let mut kvs: Vec<(&str, Kv)> = vec![
5751 ("general.architecture", Kv::Str("gemma3")),
5752 ("gemma3.block_count", Kv::U32(n_layers)),
5753 ("gemma3.embedding_length", Kv::U32(hidden_dim)),
5754 ("gemma3.attention.head_count", Kv::U32(n_heads)),
5755 ("gemma3.attention.head_count_kv", Kv::U32(n_heads)),
5756 ("gemma3.attention.key_length", Kv::U32(head_dim)),
5757 ("gemma3.attention.value_length", Kv::U32(head_dim)),
5758 // Global layers rotate at 1e6; the sliding ones fall back to
5759 // llama.cpp's `rope_freq_base_train_swa` default of 10000,
5760 // because `gemma3.cpp:11` reads only the BASE key.
5761 ("gemma3.rope.freq_base", Kv::F32(1_000_000.0)),
5762 ("gemma3.attention.sliding_window", Kv::U32(1024)),
5763 ("gemma3.attention.sliding_window_pattern", Kv::U32(6)),
5764 ];
5765 if let Some(factor) = linear_factor {
5766 kvs.push(("gemma3.rope.scaling.type", Kv::Str("linear")));
5767 kvs.push(("gemma3.rope.scaling.factor", Kv::F32(factor)));
5768 }
5769 ModelConfig::from_gguf(&open_metadata_gguf(tag, &kvs)).expect("gemma3 fixture must load")
5770 }
5771
5772 /// The 27B attention scale reaches `ModelConfig`, and no other
5773 /// Gemma-3 size acquires one.
5774 ///
5775 /// `capability::attention_scale_override` is where the arithmetic is
5776 /// checked; this pins that the LOADER calls it with this file's own
5777 /// numbers. That step is the one that shipped broken: the function
5778 /// did not exist and `attention_scale` was the literal `None`, under
5779 /// a comment naming the exception. A helper nobody calls looks
5780 /// exactly like a fix.
5781 #[test]
5782 fn a_gemma3_27b_header_sets_the_attention_scale_and_no_smaller_size_does() {
5783 // Gemma-3-27B: 62 layers, n_embd 5376, 32 heads, head_dim 128.
5784 let big = gemma3_config("g3_27b_scale", 62, 5376, 32, 128, Some(8.0));
5785 let want = 1.0f32 / (5376.0f32 / 32.0).sqrt();
5786 let got = big
5787 .attention_scale
5788 .expect("Gemma-3-27B is llama.cpp's LLM_TYPE_27B");
5789 assert!(
5790 (got - want).abs() < 1e-7,
5791 "want 1/sqrt(168) = {want}, got {got}"
5792 );
5793 // The bug's magnitude: scores were sqrt(168/128) = 1.146x too
5794 // large without this.
5795 let kernel = 1.0f32 / 128.0f32.sqrt();
5796 assert!((kernel / got - (168.0f32 / 128.0).sqrt()).abs() < 1e-5);
5797
5798 // Gemma-3-1B and -4B take llama.cpp's other branch, which is the
5799 // scale the attention kernels already apply. A `Some` here would
5800 // double-scale them.
5801 for (tag, n_layers, hidden, heads) in
5802 [("g3_1b_scale", 26, 1152, 4), ("g3_4b_scale", 34, 2560, 8)]
5803 {
5804 let cfg = gemma3_config(tag, n_layers, hidden, heads, 256, None);
5805 assert_eq!(
5806 cfg.attention_scale, None,
5807 "{tag} must keep the kernels' own 1/sqrt(head_dim)"
5808 );
5809 }
5810 }
5811
5812 /// Gemma-3's declared linear scaling reaches the FULL-ATTENTION
5813 /// layers only, and the sliding ones rope unscaled.
5814 ///
5815 /// `gemma3.cpp:11` reads `LLM_KV_ROPE_FREQ_BASE_SWA` and nothing
5816 /// else, so `rope_freq_scale_train_swa` keeps its `1.0f` default
5817 /// (`src/llama-hparams.h:129`) while `get_rope_freq_scale`
5818 /// (`llama-model.cpp:2033-2035`) hands the trained scale to the full
5819 /// layers. The converter agrees: `conversion/base.py:1222-1230`
5820 /// takes the factor from `rope_parameters["full_attention"]` and
5821 /// writes nothing for the sliding half.
5822 ///
5823 /// frink folded the factor into ONE global `rope_freqs` vector, so
5824 /// Gemma-3-4B/12B/27B rotated five layers in six at `p/8`.
5825 #[test]
5826 fn gemma3_linear_scaling_reaches_the_full_layers_and_not_the_sliding_ones() {
5827 // Gemma-3-4B: 34 layers, head_dim 256, `rope_scaling: linear 8`.
5828 let cfg = gemma3_config("g3_4b_rope", 34, 2560, 8, 256, Some(8.0));
5829 let freqs = cfg
5830 .rope_freqs
5831 .as_ref()
5832 .expect("declared linear scaling must produce per-band divisors");
5833 assert!(
5834 freqs.full.iter().all(|f| (*f - 8.0).abs() < 1e-6),
5835 "full-attention layers divide every band by the trained factor: {:?}",
5836 freqs.full
5837 );
5838 let swa = freqs
5839 .swa
5840 .as_ref()
5841 .expect("gemma3 does not assign rope_freq_scale_train_swa, so 1.0 applies");
5842 assert!(
5843 swa.iter().all(|f| (*f - 1.0).abs() < 1e-6),
5844 "sliding layers rope at the raw position: {swa:?}"
5845 );
5846 assert_eq!(swa.len(), freqs.full.len(), "one divisor per rotated pair");
5847
5848 // Period 6, last-dense (`capability::default_swa_layout`), so
5849 // layer 5 is the full-attention one and 0..=4 slide. The phase
5850 // matters: getting it wrong swaps which five-sixths are wrong.
5851 assert!(cfg.layer_sliding_window(0).is_some());
5852 assert!(cfg.layer_sliding_window(5).is_none());
5853 assert_eq!(
5854 cfg.layer_rope(0),
5855 Some(crate::config::LayerRopeParams {
5856 theta: 10_000.0,
5857 freq_factors: Some(&[1.0f32; 128][..]),
5858 rot_dim: None,
5859 })
5860 );
5861 assert_eq!(
5862 cfg.layer_rope(5),
5863 Some(crate::config::LayerRopeParams {
5864 theta: 1_000_000.0,
5865 freq_factors: Some(&[8.0f32; 128][..]),
5866 rot_dim: None,
5867 })
5868 );
5869 assert!(
5870 cfg.rope_freqs_vary_by_layer(),
5871 "the fused Metal stacks take one divisor slice for a whole run \
5872 and so must refuse this model"
5873 );
5874
5875 // Gemma-3-1B declares no scaling at all -- the audited fixture,
5876 // and the reason this was invisible. Nothing to split, so no
5877 // per-layer set and no Metal refusal.
5878 let plain = gemma3_config("g3_1b_rope", 26, 1152, 4, 256, None);
5879 assert!(plain.rope_freqs.is_none());
5880 assert!(!plain.rope_freqs_vary_by_layer());
5881 }
5882
5883 /// Gemma-2 is the counter-case, and it is why the SWA scale needs
5884 /// its own table rather than reusing `swa_rope_base_follows_model`.
5885 ///
5886 /// `gemma2.cpp:10-11` assigns BOTH `rope_freq_base_train_swa` and
5887 /// `rope_freq_scale_train_swa` from the model's trained values, so
5888 /// its sliding layers keep the declared scaling. Splitting them here
5889 /// would be the same bug pointed the other way.
5890 #[test]
5891 fn gemma2_sliding_layers_inherit_the_trained_rope_scale() {
5892 let cfg = ModelConfig::from_gguf(&open_metadata_gguf(
5893 "g2_rope",
5894 &[
5895 ("general.architecture", Kv::Str("gemma2")),
5896 ("gemma2.block_count", Kv::U32(26)),
5897 ("gemma2.embedding_length", Kv::U32(2304)),
5898 ("gemma2.attention.head_count", Kv::U32(8)),
5899 ("gemma2.attention.head_count_kv", Kv::U32(4)),
5900 ("gemma2.attention.key_length", Kv::U32(256)),
5901 ("gemma2.attention.value_length", Kv::U32(256)),
5902 ("gemma2.rope.freq_base", Kv::F32(10_000.0)),
5903 ("gemma2.attention.sliding_window", Kv::U32(4096)),
5904 ("gemma2.rope.scaling.type", Kv::Str("linear")),
5905 ("gemma2.rope.scaling.factor", Kv::F32(8.0)),
5906 ],
5907 ))
5908 .expect("gemma2 fixture must load");
5909
5910 let freqs = cfg.rope_freqs.as_ref().expect("linear scaling declared");
5911 assert_eq!(
5912 freqs.swa, None,
5913 "gemma2.cpp:11 assigns rope_freq_scale_train_swa from the trained scale"
5914 );
5915 assert!(!cfg.rope_freqs_vary_by_layer());
5916 // Period 2, last-dense: layer 0 slides, layer 1 does not, and
5917 // both get the same divisors.
5918 assert!(cfg.layer_sliding_window(0).is_some());
5919 assert!(cfg.layer_sliding_window(1).is_none());
5920 assert_eq!(cfg.layer_rope(0), cfg.layer_rope(1));
5921
5922 // The two tables really are different: this is the pair that
5923 // must not be collapsed into one.
5924 assert!(crate::capability::swa_rope_scale_follows_model("gemma2"));
5925 assert!(!crate::capability::swa_rope_scale_follows_model("gemma3"));
5926 for arch in ["olmo2", "laguna"] {
5927 assert!(
5928 crate::capability::swa_rope_base_follows_model(arch),
5929 "{arch} seeds the SWA base from the model"
5930 );
5931 assert!(
5932 !crate::capability::swa_rope_scale_follows_model(arch),
5933 "{arch} pins the SWA scale to 1.0 (olmo2.cpp:14, laguna.cpp:48)"
5934 );
5935 }
5936 }
5937}