ferrox_models/capability.rs
1//! Explicit architecture capability registry for the generic GGUF path.
2//!
3//! Mirrors the pinned llama.cpp `llm_arch` / `LLM_ARCH_NAMES` inventory
4//! (`.scratch/llama.cpp/src/llama-arch.{h,cpp}`) with Ferrox-side
5//! classification into decoder families, memory kinds, and scope.
6//! Unknown strings and detected-but-unimplemented features fail closed
7//! (`LoadError`) instead of silently defaulting into fluent-but-wrong
8//! logits.
9//!
10//! Architecture names are registry keys only. Hot-path kernels never
11//! branch on them; load-time resolution produces an [`ArchProfile`]
12//! whose fields the decoder reads as plain data.
13
14use crate::config::RopeLayout;
15
16/// How far this architecture is in Ferrox's delivery scope (plan:
17/// text-generation parity; encoder/multimodal/diffusion/audio deferred).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ArchScope {
20 /// Autoregressive / encoder-decoder text generation -- in scope.
21 TextGeneration,
22 /// Encoder / embedding / pooling models -- deferred.
23 DeferredEncoderEmbedding,
24 /// Vision / multimodal projector paths -- deferred.
25 DeferredMultimodal,
26 /// Diffusion / masked-LM samplers -- deferred.
27 DeferredDiffusion,
28 /// Audio tokenizers / codecs -- deferred.
29 DeferredAudio,
30 /// Enum present in llama.cpp but not a real serve target here.
31 EnumOnly,
32}
33
34/// Shared execution family (maps many GGUF strings onto one engine path).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DecoderFamily {
37 /// Standard GQA (+ optional MoE) with whole-vector optional QK-norm.
38 StandardGqa,
39 /// Qwen3-style: explicit head_dim + per-head Q/K RMSNorm before RoPE.
40 Qwen3Family,
41 /// Gemma-family: embedding scale, post-norms, softcap, SWA pattern, GeGLU.
42 GemmaFamily,
43 /// Phi-family: fused QKV and/or fused gate+up SwiGLU.
44 PhiFamily,
45 /// DeepSeek-2 / Mistral4 MLA (not generic GQA).
46 Mla,
47 /// Attn + SSM / delta-net hybrids.
48 Hybrid,
49 /// Pure recurrent (Mamba / RWKV) -- no KV cache.
50 Recurrent,
51 /// T5-style encoder-decoder.
52 EncoderDecoder,
53 /// Dedicated Ferrox stacks (GLM DSA, DeepSeek V4, Kimi).
54 Dedicated,
55 /// In-repo synthetic fixtures.
56 TestFixture,
57}
58
59/// Memory / KV backend selected once at load (llama.cpp `create_memory`).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum MemoryKind {
62 KvGqa,
63 KvIswa,
64 KvMla,
65 KvDsa,
66 KvDsv4,
67 Recurrent,
68 Hybrid,
69 None,
70}
71
72/// How `attn_q_norm` / `attn_k_norm` weights are applied (when present).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum QkNormStyle {
75 /// OLMoE: one RMSNorm over the full Q/K projection width.
76 #[default]
77 WholeVector,
78 /// Qwen3 / Gemma3: RMSNorm per head with weight length `head_dim`.
79 PerHead,
80}
81
82/// How much work admitting one UNAUDITED architecture to the generic
83/// path would actually be.
84///
85/// Every architecture on the generic path that is not in
86/// [`AUDITED_GENERIC_GQA`] refuses with
87/// `LoadError::UnauditedArchitecture`, and that message used to say the
88/// same thing for all 47 of them. It hid a real difference:
89/// `bailingmoe2` needs a test fixture and nothing else, `deepseek` needs
90/// one name added to one list, and `olmo2` needs a decoder that can skip
91/// the two pre-norms it does not have. A user reading "nobody has
92/// checked this" cannot tell a one-line fix from a new attention
93/// implementation.
94///
95/// **A verdict here is a reading of BOTH trees, never a guess.** Every
96/// non-[`TriageClass::Unknown`] verdict names the `src/models/*.cpp`
97/// line that decides it and the ferrox file that would change.
98/// `Unknown` is a legitimate answer and says what would settle it. The
99/// precedent this rule exists for: four architectures in this very file
100/// once refused while naming a blocker that was not the real one --
101/// `glm4moe` was told it lacked an MLA hyper-parameter it must not have,
102/// and `minimax-m2` was blamed on MTP weights no converter can emit.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum TriageClass {
105 /// Ferrox already implements everything this architecture needs.
106 /// What is missing is EVIDENCE: a fixture, or a parity run against
107 /// llama.cpp on a real checkpoint.
108 FixtureAway,
109 /// One small, nameable piece is missing: an activation, a norm slot,
110 /// a routing flag, an ordering. Nameable is the bar -- if the blocker
111 /// cannot be written as a sentence naming the thing, it is not this
112 /// class.
113 OneMatchArm,
114 /// A different attention or residual structure: a norm the decoder
115 /// unconditionally applies and this model does not have, a scaled
116 /// residual, ALiBi, MLA, block-sparse, recurrent, hybrid.
117 NewCode,
118 /// Not decidable from reading the two trees. The blocker says what
119 /// would settle it.
120 Unknown,
121}
122
123impl TriageClass {
124 /// Short slug used in the refusal message.
125 pub fn label(self) -> &'static str {
126 match self {
127 TriageClass::FixtureAway => "FIXTURE-AWAY",
128 TriageClass::OneMatchArm => "ONE MATCH ARM",
129 TriageClass::NewCode => "NEW CODE",
130 TriageClass::Unknown => "UNKNOWN",
131 }
132 }
133
134 /// One sentence saying what the class means, so the message stands
135 /// alone without this doc comment.
136 pub fn headline(self) -> &'static str {
137 match self {
138 TriageClass::FixtureAway => {
139 "ferrox already implements everything this architecture needs; what is \
140 missing is EVIDENCE, not capability"
141 }
142 TriageClass::OneMatchArm => {
143 "one small, named piece is missing -- an activation, a norm slot, a \
144 routing flag or an ordering"
145 }
146 TriageClass::NewCode => {
147 "a different attention or residual structure than the generic decoder \
148 computes; this is not a fixture away"
149 }
150 TriageClass::Unknown => {
151 "reading both trees did not settle this one; the note below says what \
152 would"
153 }
154 }
155 }
156}
157
158/// One architecture's triage verdict, carried on its own catalog row.
159///
160/// Deliberately NOT a second table keyed by architecture name. This repo
161/// has fixed three separate bugs caused by two structures disagreeing
162/// about the same architecture, so the verdict lives on the
163/// [`ArchProfile`] the loader already resolves, and
164/// `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
165/// pins that no generic row can exist without one or the other.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct UnauditedTriage {
168 pub class: TriageClass,
169 /// What is missing, with the llama.cpp `src/models/*.cpp` line that
170 /// decides it and the ferrox file that would change.
171 pub blocker: &'static str,
172}
173
174/// Unaudited generic-path architectures nobody has read against
175/// llama.cpp's graph yet.
176///
177/// This is a TO-DO, not cover. A name here means the refusal honestly
178/// says "not triaged" rather than inventing a class; a name leaves this
179/// list only by gaining an [`UnauditedTriage`] on its catalog row, and
180/// the two tests below make it impossible for a name to be on both or on
181/// neither.
182pub const TRIAGE_PENDING: &[&str] = &[
183 // Norm-RoPE group.
184 // NEOX-RoPE group.
185];
186
187/// This architecture's triage verdict, or `None` when it has not been
188/// triaged (see [`TRIAGE_PENDING`]) or does not need one.
189pub fn unaudited_triage(arch: &str) -> Option<UnauditedTriage> {
190 resolve_profile(arch).and_then(|p| p.triage)
191}
192
193/// The triage half of the `UnauditedArchitecture` refusal, rendered for
194/// the user.
195///
196/// Appended to the generic "nobody has verified this" sentence so the
197/// message says which of the three classes the architecture is in and
198/// what specifically is missing, rather than the same paragraph for all
199/// 47.
200pub fn unaudited_refusal_detail(arch: &str) -> String {
201 match unaudited_triage(arch) {
202 Some(t) => format!(
203 "TRIAGE ({}): {}. {}.",
204 t.class.label(),
205 t.class.headline(),
206 t.blocker
207 ),
208 None => format!(
209 "TRIAGE: not done for `{arch}` yet -- nobody has read llama.cpp's \
210 src/models/*.cpp for it against the generic decoder, so this refusal names \
211 no blocker and you should not read it as one. Triaging the remaining \
212 architectures is docs/plans/llama-cpp-gap-inventory.md section 8, item 6."
213 ),
214 }
215}
216
217/// Architectures on the shared generic-GQA path that somebody has
218/// actually PROVEN, and the evidence for each.
219///
220/// The generic path is a guess: it assumes an architecture is plain GQA
221/// because nothing said otherwise. That guess has already been wrong
222/// five times. `gpt2`, `mpt`, `refact`, `bloom` and `jais` all sat here
223/// computing ALiBi or learned absolute position embeddings as though
224/// they were NEOX RoPE, and every downstream guard missed them: two
225/// hardcode their ALiBi slope with no GGUF key, one leaves no unread
226/// tensor, and the RoPE pin excluded their group by construction.
227///
228/// So membership here is not "we think this works", it is "there is a
229/// benchmark row, a pinned logit comparison against llama.cpp, or a
230/// fixture". Everything else on the generic path is UNAUDITED and says
231/// so at load time rather than running and hoping.
232///
233/// Adding a name here without evidence defeats the entire point.
234pub const AUDITED_GENERIC_GQA: &[&str] = &[
235 // Bench rows in benchmarks/suite.json, measured against llama.cpp
236 // on the same host and file.
237 "llama", // TinyLlama, Mistral, Mixtral, SmolLM2, Llama-3.x all tag llama
238 "qwen2", // Qwen2.5-0.5B
239 "qwen2moe", // Qwen1.5-MoE-A2.7B
240 "qwen3", // Qwen3-0.6B
241 "olmoe", // OLMoE-1B-7B
242 "gemma2", // Gemma-2-2B
243 "gemma3", // Gemma-3-1B
244 "phi3", // Phi-4-mini tags phi3
245 // Pinned against real libllama logits in tests/.
246 "gpt-oss",
247 "dots1",
248 // tests/qwen3moe_graph.rs: a synthetic 2-layer fixture
249 // (scripts/make_qwen3moe_fixture.py) compared against llama.cpp's
250 // own qwen3moe graph via libllama, on all three forward paths.
251 // Carries per-head QK norm before RoPE, head_dim * n_head != n_embd,
252 // GQA, NEOX RoPE, softmax gating with renormalised top-k, and
253 // n_ff != n_ff_exp.
254 "qwen3moe",
255 // tests/one_match_arm_graphs.rs: five architectures that were
256 // triaged ONE MATCH ARM, each admitted with the same evidence
257 // qwen3moe has -- a synthetic fixture whose golden logits come from
258 // llama.cpp's own graph via libllama, checked on all three forward
259 // paths. The arm each one needed is named beside it; every fixture
260 // is built so that getting that arm wrong moves the logits by orders
261 // of magnitude more than the comparison tolerance.
262 //
263 // `deepseek` (V1, not the MLA deepseek2): top-k weights are NOT
264 // renormalised (deepseek.cpp:145-155 passes norm_w=false and no
265 // converter writes expert_weights_norm), so the fixture carries no
266 // such key and the answer has to come from
267 // NO_TOPK_RENORMALIZE_ARCHITECTURES.
268 "deepseek",
269 // `bailingmoe`: llama.cpp reads leading_dense_block_count and never
270 // branches on it (bailingmoe.cpp:5 vs :39-54). The fixture sets the
271 // key to 1 and ships NO dense FFN on layer 0.
272 "bailingmoe",
273 // `seed_oss`: the pre-FFN norm is stored as post_attention_norm and
274 // there is no ffn_norm (seed-oss.cpp:36-37,113-115) -- gpt-oss's
275 // slot, now a named list rather than an `arch == "gpt-oss"` flag.
276 "seed_oss",
277 // `maincoder` and `hunyuan-moe`: per-head QK norm applied AFTER RoPE
278 // (maincoder.cpp:78-95, hunyuan-moe.cpp:93-118). Both fixtures use
279 // QK-norm weights centred near 1.5 so the ordering is visible.
280 "maincoder",
281 "hunyuan-moe",
282 // tests/fixture_away_graphs.rs: architectures that were triaged
283 // FIXTURE-AWAY -- ferrox already built their graph, and only the
284 // evidence was missing. Same standard as the rows above: a synthetic
285 // fixture from `scripts/make_<arch>_fixture.py` whose golden values
286 // come from llama.cpp's own graph via libllama, compared on prefill,
287 // decode and continuous batching, with a sabotage test per row
288 // proving the fixture can SEE the fact its architecture turns on.
289 //
290 // Each was checked, against the C, on the six things this repo has
291 // lost at least once: RoPE variant, SWA pattern and phase,
292 // `attention_scale`, the two post-norm slots, and QK-norm ordering.
293 //
294 // `internlm2` (internlm2.cpp:3-11,25-33,59-122): plain llama, NORM
295 // RoPE, `1/sqrt(head_dim)` scale, no post-norms, no QK-norm, no SWA.
296 // Its fixture carries the OPTIONAL q/k/v projection biases real
297 // InternLM2 exports ship.
298 "internlm2",
299 // `xverse` (xverse.cpp:3-12,14-35,59-121): the same, with no biases.
300 "xverse",
301 // `ernie4_5` DENSE (ernie4-5.cpp:36-69,95-149): NORM RoPE, head_dim
302 // decoupled from n_embd/n_head. `ernie4_5-moe` is a different row
303 // and still refuses -- its layers interleave on a step ferrox does
304 // not read.
305 "ernie4_5",
306 // `baichuan` (baichuan.cpp:5-14,17-40,64-137): the 7B ONLY. The 13B
307 // is a different model under the same string and is refused by name
308 // on `block_count == 40` in loader.rs before this list is consulted,
309 // because llama.cpp picks ALiBi-and-no-RoPE off the layer count with
310 // no GGUF key to declare it. The fixture therefore has 32 layers: a
311 // 2-layer one would be LLM_TYPE_UNKNOWN and get no RoPE at all.
312 "baichuan",
313 // `exaone` (exaone.cpp:3-10,12-40,65-121): EXAONE 3.x, NEOX RoPE,
314 // tied lm_head. NOT `exaone4` (no pre-norms) and NOT `exaone-moe`
315 // (no RoPE on the full-attention layers); both stay refusing.
316 "exaone",
317 // `plamo3` (plamo3.cpp:3-60,91-193): the sandwich-norm row, and the
318 // only one here with a sliding window. Its verdict was FIXTURE-AWAY
319 // and was WRONG by one tensor name: plamo3 is the sole architecture
320 // upstream that creates ATTN_POST_NORM / FFN_POST_NORM through the
321 // two-argument `tn` overload (:52,55), so it asks for
322 // `blk.N.post_attention_norm` and `blk.N.post_ffw_norm` with NO
323 // `.weight`, and gguf-py emits exactly those names for it. ferrox
324 // read only the suffixed spelling; `load_norm_vec_either_spelling`
325 // in loader.rs now reads both, and says why.
326 //
327 // Its SWA is a real pattern with a real phase -- period from
328 // `attention.sliding_window_pattern`, `dense_first = false` from
329 // `set_swa_pattern`'s default -- and the fixture sets a window
330 // narrower than the prompt so the mask actually bites.
331 "plamo3",
332 // `bailingmoe2` (bailingmoe2.cpp:23-87,111-198): Ling-2.0. The one
333 // MoE row in this batch, so the two MoE facts do arise and both are
334 // asserted: SIGMOID gating, read from the file's REQUIRED
335 // `expert_gating_func` (:11) against ferrox's softmax default, and
336 // `expert_weights_norm` (:10), also read from the file. Its shared
337 // expert is `n_ff_shexp * n_expert_shared` wide (:58), not
338 // `n_ff_shexp`. Per-head QK norm BEFORE RoPE (:123-135), fused
339 // attn_qkv, leading dense layers that llama.cpp really does branch
340 // on (:57) -- unlike `bailingmoe`, which reads the same key and
341 // ignores it.
342 "bailingmoe2",
343];
344
345/// Is this architecture's use of the shared generic path backed by
346/// evidence?
347pub fn is_audited_generic(arch: &str) -> bool {
348 AUDITED_GENERIC_GQA.contains(&arch)
349}
350
351/// How the generic `Decoder` / `ModelConfig::from_gguf` path treats a
352/// GGUF architecture string.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum ArchPath {
355 /// Standard GQA (+ optional MoE) decoder; RoPE layout is known.
356 GenericGqa { rope: RopeLayout },
357 /// In-repo test fixtures (`ferroxtest*`) -- not a real model family.
358 TestFixture { rope: RopeLayout },
359 /// Real architecture, but must not be loaded through the generic
360 /// GQA decoder (wrong attention / residual math).
361 DedicatedOnly { reason: &'static str },
362 /// In the llama.cpp inventory but out of Ferrox scope for now.
363 Deferred { reason: &'static str },
364}
365
366/// Load-time resolved profile for one GGUF `general.architecture` string.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub struct ArchProfile {
369 pub gguf_name: &'static str,
370 pub scope: ArchScope,
371 pub family: DecoderFamily,
372 pub memory: MemoryKind,
373 pub rope: RopeLayout,
374 pub path: ArchPath,
375 /// Default QK-norm style when norm tensors are present; loader may
376 /// refine from tensor length.
377 pub qk_norm: QkNormStyle,
378 /// For an UNAUDITED [`ArchPath::GenericGqa`] row: how far it is from
379 /// running, read against llama.cpp's own graph. `None` on audited
380 /// rows (which run) and on rows still in [`TRIAGE_PENDING`].
381 pub triage: Option<UnauditedTriage>,
382}
383
384impl ArchProfile {
385 /// Attach a triage verdict to a catalog row. Private on purpose:
386 /// verdicts are data of the catalog, not something a caller supplies.
387 fn triaged(mut self, class: TriageClass, blocker: &'static str) -> Self {
388 self.triage = Some(UnauditedTriage { class, blocker });
389 self
390 }
391}
392
393fn prof(
394 name: &'static str,
395 scope: ArchScope,
396 fam: DecoderFamily,
397 mem: MemoryKind,
398 rope: RopeLayout,
399 path: ArchPath,
400 qk: QkNormStyle,
401) -> ArchProfile {
402 ArchProfile {
403 gguf_name: name,
404 scope,
405 family: fam,
406 memory: mem,
407 rope,
408 path,
409 qk_norm: qk,
410 triage: None,
411 }
412}
413
414fn gqa_norm(name: &'static str) -> ArchProfile {
415 prof(
416 name,
417 ArchScope::TextGeneration,
418 DecoderFamily::StandardGqa,
419 MemoryKind::KvGqa,
420 RopeLayout::Norm,
421 ArchPath::GenericGqa {
422 rope: RopeLayout::Norm,
423 },
424 QkNormStyle::WholeVector,
425 )
426}
427
428fn gqa_neox(name: &'static str) -> ArchProfile {
429 prof(
430 name,
431 ArchScope::TextGeneration,
432 DecoderFamily::StandardGqa,
433 MemoryKind::KvGqa,
434 RopeLayout::Neox,
435 ArchPath::GenericGqa {
436 rope: RopeLayout::Neox,
437 },
438 QkNormStyle::WholeVector,
439 )
440}
441
442fn dedicated(name: &'static str, reason: &'static str) -> ArchProfile {
443 prof(
444 name,
445 ArchScope::TextGeneration,
446 DecoderFamily::Dedicated,
447 MemoryKind::KvGqa,
448 RopeLayout::Norm,
449 ArchPath::DedicatedOnly { reason },
450 QkNormStyle::WholeVector,
451 )
452}
453
454fn deferred_scope(name: &'static str, scope: ArchScope, reason: &'static str) -> ArchProfile {
455 prof(
456 name,
457 scope,
458 DecoderFamily::StandardGqa,
459 MemoryKind::None,
460 RopeLayout::Neox,
461 ArchPath::Deferred { reason },
462 QkNormStyle::WholeVector,
463 )
464}
465
466/// Triaged rows of the generic **Norm**-RoPE group, with the llama.cpp
467/// line that decides each verdict. Consumed by
468/// [`architecture_catalog`]; a name here must not also appear in the
469/// untriaged list above it or in [`TRIAGE_PENDING`], which
470/// `catalog_has_unique_names` and
471/// `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
472/// between them enforce.
473const NORM_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
474 (
475 "ernie4_5-moe",
476 TriageClass::OneMatchArm,
477 "interleaved MoE layers. src/models/ernie4-5-moe.cpp:64 makes a layer MoE only when \
478 `il >= n_layer_dense_lead && (il + 1) % n_moe_layer_step == 0`, but \
479 ModelConfig::layer_is_dense (config.rs:353-355) implements only the leading-dense \
480 prefix and nothing in ferrox reads {arch}.interleave_moe_layer_step \
481 (LLM_KV_INTERLEAVE_MOE_LAYER_STEP, read at ernie4-5.cpp:11). A real checkpoint \
482 therefore looks for blk.N.ffn_gate_exps.weight on a layer that stores \
483 blk.N.ffn_gate.weight and fails on the missing tensor. Routing is SOFTMAX with \
484 norm_w=true (:88-90) plus an optional exp_probs_b (ernie4-5.cpp:53), and \
485 ferrox_moe::route_top_k_biased already applies a selection bias under softmax -- \
486 this architecture is NOT sigmoid-routed",
487 ),
488 ("granite", TriageClass::NewCode, GRANITE_MULTIPLIERS),
489 ("granitemoe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
490 // ferrox-only alias row; no llama.cpp GGUF spells it this way, but
491 // it must not carry a different verdict from `granitemoe`.
492 ("granite-moe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
493 (
494 "chatglm",
495 TriageClass::OneMatchArm,
496 "the FUSED `attn_qkv.bias`, which is the same arm `qwen` is refused by name for. \
497 This row said FIXTURE-AWAY until an attempt to build the fixture read the \
498 converter: `src/models/chatglm.cpp:42` calls create_tensor_qkv, which prefers a fused \
499 `wqkv` and then creates `wqkv_b` beside it (llama-model.cpp:2890-2892), and \
500 `build_qkv` adds that bias to the fused projection before splitting \
501 (llama-graph.cpp:1607-1610). Every real chatglm checkpoint carries it: ChatGLM2/3 \
502 set `add_qkv_bias: true`, and gguf-py maps \
503 `encoder.layers.{bid}.self_attention.query_key_value` \
504 (tensor_mapping.py:246) to `blk.N.attn_qkv`, so the file holds \
505 `blk.N.attn_qkv.weight` AND `blk.N.attn_qkv.bias`. \
506 `load_qkv_projections` (loader.rs) splits the fused WEIGHT but reads bias only \
507 under the split `attn_q.bias` / `attn_k.bias` / `attn_v.bias` names, so the bias \
508 is dropped and all three projections run unbiased -- the identical sentence this \
509 file already writes for `qwen` and `starcoder`, on an architecture that was not \
510 on that list. Splitting `attn_qkv.bias` by the same row ranges the weight split \
511 already computes closes chatglm and qwen together, and it is one function. \
512 Everything else really is generic and really was read: the FUSED gate+up SwiGLU \
513 (:48, :128-133, `LLM_FFN_SWIGLU, LLM_FFN_SEQ`) is the audited phi3 call shape \
514 (phi3.cpp:52, :144-149) and `load_dense_expert` already takes it; the graph \
515 (:75-145) is a sequential residual with `1/sqrt(n_embd_head)` (:108), NORM RoPE \
516 (llama-model.cpp:2593), no QK-norm, no post-norms and no window; and chatglm is \
517 PARTIAL-rope -- :59-61 asserts only that the K and V head dims agree, NOT that \
518 n_embd_head == n_rot, and `conversion/chatglm.py:151` writes \
519 rope_dimension_count as `head_dim * partial_rotary_factor` (0.5) -- which \
520 `ModelConfig::rope_dim` already implements",
521 ),
522 (
523 "deci",
524 TriageClass::NewCode,
525 "DeciLM / Llama-3.1-Nemotron layers are not all the same shape. \
526 src/models/deci.cpp:30-34 reads n_head(i), n_head_kv(i) and n_ff(i) PER LAYER, and \
527 the graph branches on them three ways: `n_head == 0` is an attention-free layer \
528 that passes the residual straight through (:107-109), `n_head_kv == 0` is a \
529 \"linear attention\" layer that applies only `wo` with no Q/K/V and no RoPE \
530 (:115-118), and `n_ff == 0` skips the FFN and the residual add entirely with a \
531 `continue` (:147-149). ferrox's ModelConfig carries n_heads, n_kv_heads and \
532 expert_ffn_dim as SCALARS and its decoder runs the same block on every layer, so \
533 there is nowhere to put any of the three. Same class as `openelm`, one step worse",
534 ),
535 (
536 "olmo",
537 TriageClass::NewCode,
538 "OLMo-1 has NO norm weights at all. src/models/olmo.cpp:27-35 creates Q/K/V, \
539 attn_output and gate/up/down and not one norm tensor, and the graph calls \
540 `build_norm(x, NULL, NULL, LLM_NORM, il)` at all three sites (:65-67, :104-106, \
541 :128-130) -- non-parametric LayerNorm: subtract the mean, divide by the standard \
542 deviation, no learned weight and no bias. ferrox has only `rms_norm(x, w, eps)` \
543 and requires `blk.N.attn_norm.weight`, so it is both a different function and a \
544 missing tensor. It also reads an optional {arch}.attention.clamp_kqv (:5) that \
545 nothing here applies. Note this is OLMo-1; `olmo2` is a separate row and a \
546 separate blocker",
547 ),
548 (
549 "arctic",
550 TriageClass::NewCode,
551 "a PARALLEL dense+MoE layer whose MoE branch reads the pre-attention residual. \
552 src/models/arctic.cpp:124-132 runs a dense SiLU FFN on `ffn_norm(ffn_inp)` and adds \
553 it back to ffn_inp, then :136-141 norms `inpSA` -- the layer INPUT, before \
554 attention -- through a second per-layer norm `ffn_norm_exps` (:45) and runs the MoE \
555 on that, and :154 sums the two. The generic decoder computes one FFN on the \
556 post-attention residual, so this is a different graph, not a wider one. The dense \
557 half is also sized `{n_embd, n_embd}` (:40-42) rather than n_ff. Same shape as \
558 `smallthinker`'s router: a branch fed from the raw layer input",
559 ),
560 (
561 "mistral3",
562 TriageClass::NewCode,
563 "per-position attention temperature tuning. src/models/mistral3.cpp:5,14-17 reads \
564 {arch}.attention.temperature_scale and seeds n_attn_temp_floor_scale from \
565 n_ctx_orig_yarn, and :109-111 builds a per-position Q scale that llama-graph.cpp \
566 computes as `log(floor(pos / floor_scale) + 1) * temp_scale + 1` (:159-167). ferrox \
567 has no per-position attention scale at all and no gate on that key, so a checkpoint \
568 carrying it would load and silently drop it -- the class of defect \
569 `unsupported_scaling_keys` exists for, on a key that list does not have. :9 also \
570 reads rope.scaling.yarn_log_multiplier, and loader.rs:588's own comment records \
571 that ferrox implements only YaRN's magnitude term. The rest (:46-83, :120-210) is \
572 leading-dense + MoE + shared expert on a sequential residual, which ferrox has",
573 ),
574 (
575 "nanbeige",
576 TriageClass::NewCode,
577 "nanbeige RUNS THE SAME PHYSICAL LAYERS MORE THAN ONCE. \
578 src/models/nanbeige.cpp:13-31 sets `n_layer_all = n_layer_phys * n_loops` and \
579 rewrites the per-layer head/ff/swa arrays so the graph walks n_layer_all steps over \
580 n_layer_phys sets of weights, and :167 applies `output_norm` to the running \
581 residual inside the loop at the end of each pass. ferrox's decoder walks its layer \
582 vector exactly once and has no concept of a loop count. Everything inside one pass \
583 (:52-63, :106-155) is plain llama, which is what makes this deceptive: the tensor \
584 set alone looks generic",
585 ),
586 ("arcee", TriageClass::NewCode, UNGATED_RELU_SQR),
587 ("plm", TriageClass::NewCode, UNGATED_RELU_SQR),
588];
589
590/// Shared by the three ferrox-only alias rows `mistral`, `mixtral` and
591/// `yi`, and the reason they are UNKNOWN rather than fixture-away.
592///
593/// The temptation is to call them "llama with a different name" and mark
594/// them fixture-away. That would be a guess about a file nobody has
595/// seen, and the RoPE hazard below is exactly why it would be an
596/// expensive one.
597const NO_UPSTREAM_ARCH: &str =
598 "there is no llama.cpp graph to diff against: none of `mistral`, `mixtral` or `yi` \
599 appears in LLM_ARCH_NAMES (src/llama-arch.cpp) or in gguf-py's MODEL_ARCH_NAMES, and \
600 every real Mistral, Mixtral and Yi checkpoint converts to `llama` (llama.cpp's own \
601 conversion scripts emit MODEL_ARCH.LLAMA for all three; only `mistral3` and `mistral4` \
602 exist as their own strings). So these are ferrox-only rows that no llama.cpp-produced \
603 file can carry. THE HAZARD, and why this is not marked fixture-away: the catalog gives \
604 all three NEOX RoPE, while `llama` -- the string these models really ship under, and \
605 the graph they really are -- is in `llama_model_rope_type`'s NORM group \
606 (llama-model.cpp, the `case LLM_ARCH_LLAMA:` arm). A file spelling `mistral` would \
607 therefore be rotated on the wrong pairs of every Q/K head, which is the exact defect \
608 that caused the Llama-3.1-8B wrong-logits bug. It is latent only because the row \
609 refuses. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is literally one \
610 of these three. Absent one, the honest options are to delete the rows or to move them \
611 to NORM to match the graph they claim to be";
612
613/// Shared by `arcee` and `plm`: an ungated ReLU-squared MLP.
614///
615/// Found by the activation audit the `deepseek` renormalisation bug
616/// prompted, not by reading these two files on purpose. Both were on the
617/// generic path with nothing recording that their FFN is neither SwiGLU
618/// nor GeGLU.
619const UNGATED_RELU_SQR: &str =
620 "an UNGATED ReLU-squared MLP, which is a different FFN shape and not only a different \
621 activation. src/models/arcee.cpp:39-40 and plm.cpp:39-40 create only `ffn_up` and \
622 `ffn_down` and no `ffn_gate` at all, and arcee.cpp:123-128 calls build_ffn with a NULL \
623 gate, `LLM_FFN_RELU_SQR` and `LLM_FFN_SEQ` -- i.e. `down(relu(up(x))^2)`, two matrices \
624 in sequence. ferrox's `ExpertWeights` has three required matrices and \
625 `FfnActivation` has only the gated Swiglu / SwigluFused / Gelu variants \
626 (config.rs:302-312), so there is no shape for this and no activation for it either. It \
627 fails closed rather than computing SwiGLU: `load_dense_expert` (loader.rs:1112-1136) \
628 finds no `ffn_gate`, falls to the Phi-3 fused path, and rejects an `ffn_up` that is \
629 `n_ff` rows rather than `2 * n_ff`";
630
631/// Shared by `granite`, `granitemoe` and the `granite-moe` alias: one
632/// blocker, one string, so the three rows cannot drift apart.
633const GRANITE_MULTIPLIERS: &str =
634 "Granite's four multipliers. src/models/granite.cpp:7 reads {arch}.logit_scale as \
635 REQUIRED (granite-moe.cpp:5 too) and :8-10 reads residual_scale / embedding_scale / \
636 attention.scale; the graph divides the final logits by f_logit_scale (:188) and scales \
637 BOTH branch outputs by f_residual_scale before every residual add (:241-242, :301-302). \
638 The generic decoder applies none of them, and residual_scale in particular touches \
639 every CPU and Metal residual path. In practice a real Granite checkpoint never reaches \
640 THIS message: capability::unsupported_scaling_keys already refuses it by name at \
641 loader.rs:191, which runs before the unaudited gate. Separately, granite.cpp:206 gates \
642 RoPE on `hparams.rope_finetuned`, so a Granite export with rope.finetuned=false gets NO \
643 rotation at all -- the ALiBi class of divergence, with no ferrox expression";
644
645/// Triaged rows of the generic **NEOX**-RoPE group. Same rules as
646/// [`NORM_ROPE_TRIAGED`].
647const NEOX_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
648 (
649 "olmo2",
650 TriageClass::NewCode,
651 "olmo2 has NO pre-attention norm and NO pre-FFN norm. load_arch_tensors creates \
652 attn_post_norm and ffn_post_norm (src/models/olmo2.cpp:47,52) and no attn_norm or \
653 ffn_norm at all; the graph projects Q/K/V straight off the residual (:92, \
654 `cur = inpL`) and runs build_ffn on the raw ffn_inp (:169). The generic decoder \
655 REQUIRES blk.N.attn_norm.weight and a pre-FFN norm and applies both on every layer, \
656 so this is a different residual topology, not a missing tensor. Its post-norms are \
657 NOT the blocker: ferrox applies post_attn_norm and post_ffn_norm in exactly \
658 llama.cpp's places already (:160-163,:178-180 vs decoder.rs:4274-4281,:4333-4341). \
659 olmo2 additionally runs its SWA layers' RoPE with YaRN disabled (freq_scale=1, \
660 ext_factor=0, attn_factor=1, :118-133), a second per-layer RoPE variant ferrox \
661 cannot express",
662 ),
663 (
664 "exaone4",
665 TriageClass::NewCode,
666 "same shape as olmo2: src/models/exaone4.cpp:60-67 creates attn_post_norm, per-head \
667 attn_q_norm/attn_k_norm and ffn_post_norm and NO attn_norm and NO ffn_norm, and the \
668 graph projects Q/K/V off the raw residual (:118) and runs build_ffn on the raw \
669 ffn_inp (:159). The generic decoder requires and applies both pre-norms, which is a \
670 different residual topology. Its optional NEXTN/MTP tensors (:69-73) are a separate \
671 matter and are refused by name by the unread-tensor gate",
672 ),
673 (
674 "mellum",
675 TriageClass::NewCode,
676 "two per-layer RoPE variants in one model. src/models/mellum.cpp:128-142 runs the \
677 SWA layers' RoPE with YaRN switched off -- freq_scale = 1.0, ext_factor = 0.0, \
678 attn_factor = 1.0 -- while the full-attention layers use the model's own YaRN \
679 (:143-154). ferrox carries one YaRN configuration for the whole model (it has \
680 `rope_theta_swa` for the BASE only) and cannot express a per-layer ext_factor. \
681 Second, smaller hazard on the same architecture: :12-17 accepts the sliding-window \
682 pattern as a scalar OR as a per-layer ARRAY, and ferrox reads it only as a scalar \
683 (`GgufValue::as_u64` returns None for an array), so an array-valued file falls back \
684 to `default_swa_layout`'s period of 4 with nothing saying it substituted its own \
685 layout for the file's. The tensor set and residual (:45-68, :169-197) are generic",
686 ),
687 (
688 "talkie",
689 TriageClass::NewCode,
690 "talkie has NO norm weights and a learned per-layer skip connection. In \
691 src/models/talkie.cpp every \
692 normalisation is `build_norm(x, nullptr, nullptr, LLM_NORM_RMS, ...)` -- \
693 non-parametric RMSNorm, no weight tensor -- at :50 (on the embeddings, before layer \
694 0), :68, :90, :110 and :137; the only norm weight in the file is `attn_q_norm`, and \
695 it is shaped {1, n_head} (:26), one SCALAR PER HEAD rather than a head_dim vector, \
696 which is neither of ferrox's two QkNormStyle variants. Each layer then adds \
697 `inp_skip * out_scale` (:123-126) with a per-layer learned scalar `out_scale` \
698 (:32), a second residual stream the generic decoder has no slot for, and :5 reads \
699 {arch}.logit_scale as REQUIRED",
700 ),
701 (
702 "mimo2",
703 TriageClass::NewCode,
704 "attention sinks on a non-gpt-oss architecture, plus per-layer shapes. \
705 src/models/mimo2.cpp:58 creates `attn_sinks` per layer; ferrox implements sinks \
706 only inside the gpt-oss path and, per docs/MODELS.md, on CPU only. :47-49 reads \
707 n_head and the KV widths PER LAYER, :16 and :181 scale the attention output by \
708 {arch}.attention.value_scale (a key ferrox neither reads nor gates), :6-12 makes \
709 SWA unconditional with a per-layer is_swa ARRAY rather than a period, and :19,:76-82 \
710 add NEXTN/MTP layers with a `layer_out_norm`. Any one of the first three would \
711 disqualify it; the dense-or-MoE-per-layer choice at :63-72 is the only part ferrox \
712 already has",
713 ),
714 (
715 "afmoe",
716 TriageClass::NewCode,
717 "gated attention plus NoPE layers. src/models/afmoe.cpp:73 creates `wqkv_gate` \
718 (LLM_TENSOR_ATTN_GATE), a learned gate applied to the attention output that the \
719 generic decoder has no slot for, and :137-138 skips RoPE where \
720 `(il + 1) % n_no_rope_layer_step == 0`, the smollm3 class with no GGUF key. It also \
721 scales the embeddings by sqrt(n_embd) at :120, which ferrox does only for the Gemma \
722 family. THIRD, and the quiet one: :8 reads expert_gating_func as OPTIONAL and \
723 :29-30 defaults it to SIGMOID when absent, while ferrox's fallback \
724 (loader.rs:375, SIGMOID_GATING_ARCHITECTURES) defaults to softmax for any \
725 architecture not on its list -- so a checkpoint omitting the key would be routed \
726 through the wrong scoring function. That last one is the `deepseek` shape and would \
727 need fixing even if the rest were free",
728 ),
729 (
730 "apertus",
731 TriageClass::NewCode,
732 "xIELU, with four PER-LAYER parameter arrays. src/models/apertus.cpp:6-9 reads \
733 xielu_alpha_n, xielu_alpha_p, xielu_beta and xielu_eps as n_layer-long arrays and \
734 :132-135 indexes them per layer; `FfnActivation` (config.rs:302-312) has three \
735 variants and no way to carry a per-layer parameter at all. The FFN is also UNGATED \
736 -- :45-46 creates only ffn_down and ffn_up, no ffn_gate -- so it is the same \
737 two-matrix shape as `arcee` and `plm` on top of the activation. It further requires \
738 optional attn_q_norm/attn_k_norm BIASES (:50,:52), and ferrox's norms take a weight \
739 only",
740 ),
741 (
742 "exaone-moe",
743 TriageClass::NewCode,
744 "the GLOBAL layers get no RoPE. src/models/exaone-moe.cpp:155-161 wraps both \
745 ggml_rope_ext calls in `if (is_local_layer)`, where is_local_layer is \
746 `hparams.is_swa(il)` (:136) -- so on the full-attention layers of every period Q \
747 and K are never rotated. ferrox rotates every layer, and there is no GGUF key that \
748 says otherwise: the SWA pattern implies it. Checked and CLEAN on the other axis: \
749 :5 seeds n_swa = 128 but :13 reads {arch}.attention.sliding_window as REQUIRED, so \
750 the window is always the file's own value and ferrox reads the same number, and \
751 `default_swa_layout` already carries exaone-moe as period 4. The MoE half (:72-93) \
752 -- leading dense, exp_probs_b, shared expert, gating from metadata -- ferrox has",
753 ),
754 (
755 "grovemoe",
756 TriageClass::NewCode,
757 "a SECOND bank of experts, not just a scale. src/models/grovemoe.cpp:57-59 creates \
758 `ffn_gate_chexps` / `ffn_down_chexps` / `ffn_up_chexps` -- `n_expert / \
759 n_group_experts` \"chunk\" experts with their own width n_ff_chexp -- and the graph \
760 runs build_moe_ffn TWICE (:137 over the ordinary experts, :153 over the chunk \
761 experts) before :167 adds `scale(moe_out, expert_group_scale)` to the residual. The \
762 inventory recorded only the post-sum group scale and called this small; the second \
763 expert bank with its own routing is the larger half and ferrox's MoE layer holds \
764 one bank. Both n_group_experts and expert_group_scale are REQUIRED keys (:6-7). \
765 QK-norm is before RoPE (:100-109), which is the one thing that would otherwise have \
766 been a blocker",
767 ),
768 (
769 "hunyuan-dense",
770 TriageClass::OneMatchArm,
771 "the NTK-alpha RoPE base rescale. hunyuan-dense has no graph of its own -- \
772 models.h:1830 derives it from llama_model_hunyuan_vl -- so the file to read is \
773 src/models/hunyuan-vl.cpp. It had TWO blockers and one is now gone: it applies \
774 attn_k_norm and attn_q_norm AFTER ggml_rope_ext (:105-123 rotate, then :132 and \
775 :137 norm), and that ordering is implemented -- `Decoder::qk_norm_after_rope`, \
776 admitted for `hunyuan-moe` and `maincoder` with libllama-golden fixtures. What is \
777 left is :8-12, which rescales rope_freq_base_train by \
778 `alpha^(head_dim / (head_dim - 2))` when {arch}.rope.scaling.alpha is positive (a \
779 REQUIRED-if-present key `conversion/hunyuan.py:356` really writes) -- an NTK-alpha \
780 base rescale ferrox neither applies nor gates, so a checkpoint carrying the key \
781 would load and rotate at the unscaled base. Second, smaller: :98-113 switches to \
782 ggml_rope_multi when {arch}.rope.dimension_sections is present, and ferrox has no \
783 M-RoPE. Everything else (:39-51, :86-167) is attn_norm, per-head QK norm, ffn_norm, \
784 dense SiLU SwiGLU and a sequential residual, so this is now a one-arm-plus-a-gate \
785 away rather than two arms",
786 ),
787 (
788 "laguna",
789 TriageClass::NewCode,
790 "per-layer head counts AND a second rotary width. conversion/laguna.py:79 calls \
791 `add_head_count(per_layer_heads)` with a LIST, so the array is really in the file, \
792 and src/models/laguna.cpp:87-88 and :176-177 read n_head(i) / n_head_kv(i) per \
793 layer while ferrox carries both as scalars. :50 then reads \
794 LLM_KV_ROPE_DIMENSION_COUNT_SWA into `n_rot_swa`, so the sliding-window layers \
795 rotate a DIFFERENT number of dimensions than the full-attention layers (its own \
796 comment at :43-45: full layers YaRN over 64 dims, SWA layers plain RoPE over 128); \
797 ferrox has one rotary_dim. It also creates `wqkv_gate` (:124), the gated-attention \
798 tensor afmoe has, and :55-56 defaults expert_gating_func to SIGMOID when the key is \
799 absent where ferrox would default to softmax. `default_swa_layout` already has \
800 laguna as dense_first period 4, which is correct and is not the blocker",
801 ),
802 (
803 "step35",
804 TriageClass::NewCode,
805 "a per-LAYER rotary width. src/models/step35.cpp:65-70 takes `n_rot_max` as the max \
806 of `hparams.n_rot(i)` over all layers -- because n_rot varies by layer -- and :9 \
807 first halves n_rot_full; ferrox has one rotary_dim for the model. On top of that: \
808 per-layer SwiGLU clamp arrays for the routed and shared experts (:28-29, \
809 LLM_KV_SWIGLU_CLAMP_EXP / _SHEXP), where ferrox's only clamp is the gpt-oss scalar; \
810 a `wqkv_gate` (:96); a per-layer is_swa ARRAY rather than a period (:26), which \
811 ferrox reads only as a scalar; NEXTN/MTP layers with trunk-only and MTP-only load \
812 modes (:32-49); and expert_gating_func defaulting to SIGMOID when absent (:19-20) \
813 where ferrox defaults to softmax. The inventory guessed this was \"probably \
814 parameterisable from the gpt-oss clamp\" -- the clamp is, the per-layer n_rot is \
815 not",
816 ),
817 ("mistral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
818 ("mixtral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
819 ("yi", TriageClass::Unknown, NO_UPSTREAM_ARCH),
820 (
821 "grok",
822 TriageClass::NewCode,
823 "grok-1 hardcodes five constants BEFORE letting an optional key override them \
824 (src/models/grok.cpp:5-21): logit_scale = 0.5773502691896257 (1/sqrt(3)), \
825 embedding_scale = 78.38367176906169, attn_out_scale = 0.08838834764831845 \
826 (1/sqrt(128)), and attn / router logit softcapping both 30.0. A GGUF omitting every \
827 key is still scaled by all five, so a key-presence gate such as \
828 `unsupported_scaling_keys` cannot see them -- the same blind spot `minicpm` is \
829 refused for. On top of that the graph is not the generic one: attention runs with \
830 kq_scale = 1.0f (:137) and folds the real scale into a tanh softcap instead \
831 (llama-graph.cpp:2579-2581), every layer computes BOTH a dense GELU FFN and a GELU \
832 MoE and sums them scaled by sqrt(2)/2 (:171-184), and `blk.N.attn_output_norm` \
833 (:62, LLM_TENSOR_ATTN_OUT_NORM = \"blk.%d.attn_output_norm\", llama-arch.cpp:423) is \
834 a tensor name ferrox never reads. Router logit softcapping has no ferrox concept at \
835 all. `uses_geglu` already covers grok's GELU, which is necessary and nowhere near \
836 sufficient",
837 ),
838 (
839 "dbrx",
840 TriageClass::NewCode,
841 "LayerNorm, not RMSNorm. src/models/dbrx.cpp:4 reads LLM_KV_ATTENTION_LAYERNORM_EPS \
842 (not the RMS one) and the graph normalises with `LLM_NORM` at all three sites -- \
843 :69-71 pre-attention, :110-112 pre-FFN, :140-142 final -- which subtracts the mean; \
844 ferrox has only `rms_norm(x, w, eps)`, a different function of the same tensors on \
845 every layer. Note this is NOT caught by the required-bias refusal group: dbrx \
846 creates no norm bias tensors at all, so the marker that group keys on is absent \
847 while the normalisation is still LayerNorm. It also requires \
848 {arch}.attention.clamp_kqv (:5, REQUIRED) and carries no `ffn_norm` -- \
849 `attn_out_norm` (:34) IS the pre-FFN norm (:110-113), the gpt-oss slot again but \
850 under the unread name `blk.%d.attn_output_norm`",
851 ),
852 (
853 "smallthinker",
854 TriageClass::NewCode,
855 "the MoE router reads a DIFFERENT tensor. src/models/smallthinker.cpp:111 computes \
856 the router logits from the raw layer input `inpL`, before the attention block, and \
857 passes them into build_moe_ffn as a precomputed `probs` with a NULL ffn_gate_inp \
858 (:151-161); every other MoE architecture routes on the normed FFN input, which is \
859 what ferrox computes. Two more, either of which alone would disqualify it: (1) NoPE \
860 layers with no GGUF key -- llama-hparams.h:203 defaults n_no_rope_layer_step to 4 \
861 and the SWA branch (:6-15) never overwrites it, so :108-109's \
862 `use_rope = n_no_rope_layer_step == n_layer || il % n_no_rope_layer_step != 0` \
863 leaves layers 0, 4, 8 ... unrotated, the `smollm3` class exactly, which ferrox \
864 refuses outright; (2) `LLM_FFN_RELU` experts (:158), and FfnActivation has no ReLU \
865 variant. :8 also pins n_swa to 4096 over whatever the file declares. \
866 `default_swa_layout` and `swa_rope_base_follows_model` already carry smallthinker \
867 correctly; they are not the blocker",
868 ),
869 (
870 "bitnet",
871 TriageClass::NewCode,
872 "two norms INSIDE the blocks, in slots ferrox does not have. \
873 src/models/bitnet.cpp:24,36 require `attn_sub_norm` and `ffn_sub_norm`, and the \
874 graph applies attn_sub_norm to the attention output BEFORE the output projection \
875 (:101-106 -- not after it, where ferrox's post_attn_norm sits) and ffn_sub_norm \
876 between the gate*up product and `ffn_down` (:135-140), inside the FFN. It also \
877 carries a per-tensor `scale` for every projection (:27-43, applied via \
878 build_lora_mm) and creates no `output` tensor at all, taking the LM head from \
879 `tok_embd` unconditionally (:164). ferrox refuses it by name today via the \
880 unread-tensor gate (`blk.N.attn_sub_norm`, llama-arch.cpp:510-511), which is the \
881 right outcome and not a small fix",
882 ),
883 (
884 "openelm",
885 TriageClass::NewCode,
886 "per-LAYER head counts and FFN width. src/models/openelm.cpp:26-28 reads \
887 `hparams.n_head(i)`, `n_head_kv(i)` and `n_ff(i)` per layer and sizes the fused \
888 `wqkv` as `n_embd x (2*n_head_kv(i) + n_head(i)) * n_embd_head_k` (:34), and the \
889 graph re-derives those widths for every layer (:67-69). ferrox's ModelConfig \
890 carries n_heads, n_kv_heads and expert_ffn_dim as SCALARS, and \
891 `load_qkv_projections` splits a fused QKV at offsets computed from those scalars, \
892 so there is nowhere to put this. It fails closed, but NOT with this message: \
893 conversion/openelm.py:57-59 writes head_count, head_count_kv and \
894 feed_forward_length as ARRAYS, and `GgufValue::as_u64` returns None for an array \
895 (ferrox-gguf/src/lib.rs:83-93), so the load dies on a missing-hparam error for keys \
896 the file does carry, before the unaudited gate is reached. That misleading message \
897 is the `glm4moe` shape and should be fixed alongside",
898 ),
899];
900
901/// Full inventory keyed by GGUF `general.architecture` string.
902/// Kept in sync with `.scratch/llama.cpp/src/llama-arch.cpp` `LLM_ARCH_NAMES`.
903pub fn architecture_catalog() -> &'static [ArchProfile] {
904 use std::sync::OnceLock;
905 use ArchScope::*;
906 use DecoderFamily::*;
907 use MemoryKind::*;
908 use QkNormStyle::*;
909 use RopeLayout::*;
910
911 static CAT: OnceLock<Vec<ArchProfile>> = OnceLock::new();
912 CAT.get_or_init(|| {
913 let mut v = Vec::with_capacity(160);
914 // --- Verified / standard GQA (Norm RoPE) ---
915 //
916 // `llama` is the only untriaged name left in this group: it is
917 // audited, so it runs and needs no verdict. Every other
918 // Norm-RoPE row moved into `NORM_ROPE_TRIAGED` below when it was
919 // read against llama.cpp's graph.
920 v.push(gqa_norm("llama"));
921 // Audited too, each by a libllama-golden fixture -- see
922 // `AUDITED_GENERIC_GQA` for the arm each one needed and
923 // `tests/one_match_arm_graphs.rs` for the evidence.
924 for n in ["bailingmoe", "deepseek", "maincoder"] {
925 v.push(gqa_norm(n));
926 }
927 // Were FIXTURE-AWAY in this group and now have the fixture:
928 // `tests/fixture_away_graphs.rs`, same evidence standard.
929 for n in ["baichuan", "ernie4_5", "internlm2", "xverse"] {
930 v.push(gqa_norm(n));
931 }
932 // Same generic Norm-RoPE path, but READ against llama.cpp's own
933 // graph -- see [`TriageClass`]. Each row below refuses with its
934 // class and its blocker instead of the generic
935 // "nobody has checked this" paragraph.
936 for (n, class, blocker) in NORM_ROPE_TRIAGED {
937 v.push(gqa_norm(n).triaged(*class, blocker));
938 }
939 for n in [
940 "olmoe", "qwen2", "qwen2moe",
941 // llama-model.cpp `llama_model_rope_type`: LLM_ARCH_OPENAI_MOE
942 // falls in the `return LLAMA_ROPE_TYPE_NEOX` group, and a live
943 // load of a gpt-oss GGUF prints `rope type = 2` (= NEOX).
944 // ferrox had it on the interleaved (NORM) list, which rotates
945 // the wrong pairs of every Q/K head.
946 "gpt-oss",
947 // Same audit, run over every arch at once against
948 // `llama_model_rope_type`'s NEOX group
949 // (llama-model.cpp:2613-2683). These 24 were on ferrox's
950 // interleaved (NORM) list and reach the generic GQA decoder,
951 // so every one of them rotated the wrong pairs of every Q/K
952 // head and answered fluently and wrongly. Pinned by
953 // `rope_layout_matches_llama_cpp` below; dots1 additionally
954 // checked end-to-end against llama.cpp's own logits in
955 // `tests/moe_routing_bias.rs`.
956 "dots1",
957 // Audited by libllama-golden fixtures in
958 // `tests/one_match_arm_graphs.rs`: `hunyuan-moe` needed the
959 // post-RoPE QK-norm order, `seed_oss` the gpt-oss pre-FFN
960 // norm slot.
961 "hunyuan-moe",
962 "seed_oss",
963 // Were FIXTURE-AWAY and now have the fixture
964 // (`tests/fixture_away_graphs.rs`). EXAONE 3.x only:
965 // `exaone4` and `exaone-moe` are different graphs and stay
966 // in `NEOX_ROPE_TRIAGED` below. `bailingmoe2` is Ling-2.0
967 // and is unrelated to the NORM-RoPE `bailingmoe` row above.
968 "exaone",
969 "bailingmoe2",
970 "plamo3",
971 ] {
972 v.push(gqa_neox(n));
973 }
974 // Triaged NEOX-RoPE rows; see `NORM_ROPE_TRIAGED` above.
975 for (n, class, blocker) in NEOX_ROPE_TRIAGED {
976 v.push(gqa_neox(n).triaged(*class, blocker));
977 }
978 // --- No RoPE at all: refused, not rotated ------------------
979 //
980 // `llama_model_rope_type` opens with a `LLAMA_ROPE_TYPE_NONE`
981 // group, and these five sat on ferrox's NEOX list instead. The
982 // generic decoder rotates every Q/K head of every layer, so each
983 // of them loaded, ran at full speed, and answered fluently from
984 // positions the checkpoint never encodes that way, the same
985 // silent failure the 24-arch RoPE audit found, one level worse,
986 // because here the right answer is *no rotation*.
987 //
988 // Worse still for a metadata gate: `bloom` and `refact` hardcode
989 // `f_max_alibi_bias = 8.0f` in `load_arch_hparams` and carry no
990 // key at all, so `unsupported_feature_keys` could never have seen
991 // them. Only the registry can. `tests/rope_layout.rs`'s
992 // `LLAMA_NO_ROPE` pins the group so a later edit cannot quietly
993 // put one back on a rotating path.
994 for (n, reason) in [
995 (
996 "smollm3",
997 "a NoPE layer pattern: llama.cpp hardcodes \
998 `hparams.n_no_rope_layer_step = 4` (src/models/smollm3.cpp:5) and \
999 skips RoPE where `(il + 1) % 4 == 0` (:69), so 9 of a 36-layer \
1000 SmolLM3-3B's layers get NO rotation at all. There is NO GGUF key \
1001 for it, so no metadata gate could see it: the tensor set matches \
1002 the generic llama set exactly and the file loads clean. The \
1003 generic decoder rotates every layer, which is a different model. \
1004 Same shape as the ALiBi group below, and found the same way",
1005 ),
1006 (
1007 "gpt2",
1008 "learned absolute position embeddings (`position_embd.weight`, \
1009 src/models/gpt2.cpp:19,74) and no RoPE; the generic decoder has no \
1010 slot for them and rotates instead",
1011 ),
1012 (
1013 "mpt",
1014 "ALiBi attention bias (src/models/mpt.cpp:6), plus an optional \
1015 learned `position_embd` and an optional QKV clamp; the generic \
1016 decoder implements none of the three and applies RoPE instead",
1017 ),
1018 (
1019 "refact",
1020 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1021 GGUF key to detect it (src/models/refact.cpp:12); the generic \
1022 decoder applies RoPE instead",
1023 ),
1024 (
1025 "bloom",
1026 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1027 GGUF key (src/models/bloom.cpp:18), plus a `token_embd_norm` the \
1028 generic decoder never applies; RoPE is applied instead",
1029 ),
1030 (
1031 "jais",
1032 "ALiBi attention bias (src/models/jais.cpp:5); the generic decoder \
1033 applies RoPE instead",
1034 ),
1035 ] {
1036 v.push(prof(
1037 n,
1038 TextGeneration,
1039 StandardGqa,
1040 KvGqa,
1041 // No layout is right here. `Norm` is the struct's least
1042 // surprising filler and nothing reads it: the load
1043 // refuses in `ModelConfig::from_gguf` before any graph
1044 // asks. `rope_layout_matches_llama_cpp` skips
1045 // non-generic paths for exactly this reason.
1046 Norm,
1047 ArchPath::DedicatedOnly { reason },
1048 WholeVector,
1049 ));
1050 }
1051 // --- Required bias tensors the generic decoder has no slot for
1052 //
1053 // Found by transcribing every `create_tensor(tn(..., "bias"), ...)`
1054 // llama.cpp's per-architecture loaders create with flag `0`
1055 // (REQUIRED, as opposed to `TENSOR_NOT_REQUIRED`). Required means
1056 // every real checkpoint of that architecture carries it, so this
1057 // is not a "some files might" gate.
1058 //
1059 // `AttnWeights` carries exactly three of them -- `attn_q.bias`,
1060 // `attn_k.bias`, `attn_v.bias` -- and `GptOssWeights` carries
1061 // gpt-oss's `attn_output.bias` and `ffn_gate_inp.bias`. Nothing
1062 // else has anywhere to go:
1063 //
1064 // - `attn_output.bias`, `ffn_{up,down,gate}.bias` and the
1065 // `output.bias` on the LM head are read by no loader path, so
1066 // they are simply dropped: the projection runs unbiased.
1067 // - `attn_norm.bias` / `ffn_norm.bias` / `output_norm.bias` are
1068 // the marker of a real LayerNorm. The generic decoder only has
1069 // `rms_norm(x, w, eps)` -- no mean subtraction and no bias --
1070 // so it computes a different normalisation at every layer.
1071 // - `attn_qkv.bias` is the *fused* spelling.
1072 // `load_qkv_projections` splits a fused `attn_qkv.weight` but
1073 // looks for the bias only under the split `attn_q.bias` names,
1074 // finds nothing, and runs unbiased.
1075 //
1076 // Every one of these loads clean and answers fluently, which is
1077 // why they are refused here rather than left to a tensor gate.
1078 // Pinned by `tests/attn_bias.rs`.
1079 for (n, rope, reason) in [
1080 (
1081 "codeshell",
1082 Neox,
1083 "required bias tensors with no slot in the generic decoder: \
1084 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1085 (src/models/codeshell.cpp:36,42,45), plus the LayerNorm biases \
1086 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:24,31,39) \
1087 -- the generic decoder is RMSNorm-only and drops all six",
1088 ),
1089 (
1090 "jais2",
1091 Neox,
1092 "required bias tensors with no slot in the generic decoder: \
1093 `attn_output.bias`, `ffn_up.bias`, `ffn_down.bias` \
1094 (src/models/jais2.cpp:41,48,50), plus the LayerNorm biases \
1095 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:20,30,44). \
1096 Only its Q/K/V biases (:38-40) would have been applied",
1097 ),
1098 (
1099 "starcoder",
1100 Norm,
1101 "required bias tensors with no slot in the generic decoder: the \
1102 *fused* `attn_qkv.bias` (src/models/starcoder.cpp:40), which \
1103 `load_qkv_projections` never looks for because it reads bias only \
1104 under the split `attn_q.bias` names; `attn_output.bias`, \
1105 `ffn_down.bias`, `ffn_up.bias` (:43,49,52); and the LayerNorm \
1106 biases `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` \
1107 (:24,37,46). It also adds a learned `position_embd` to the \
1108 embeddings (:75) that the generic decoder has no slot for",
1109 ),
1110 (
1111 "starcoder2",
1112 Neox,
1113 "required bias tensors with no slot in the generic decoder: \
1114 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1115 (src/models/starcoder2.cpp:41,50,51), plus the LayerNorm biases \
1116 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:23,35,44)",
1117 ),
1118 (
1119 "phimoe",
1120 Neox,
1121 "required bias tensors with no slot in the generic decoder: \
1122 `attn_output.bias` and an `output.bias` on the LM head \
1123 (src/models/phimoe.cpp:33,23), plus the LayerNorm biases \
1124 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:21,29,36). \
1125 `phi3` stays generic: it requires none of them",
1126 ),
1127 (
1128 "nemotron",
1129 Neox,
1130 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1131 `ffn_norm.bias` (src/models/nemotron.cpp:19,26,35). llama.cpp \
1132 normalises with `build_norm(..., LLM_NORM, ...)` and a bias; the \
1133 generic decoder applies RMSNorm with weight only, which is a \
1134 different function of the same tensors at every layer",
1135 ),
1136 (
1137 "orion",
1138 Neox,
1139 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1140 `ffn_norm.bias` (src/models/orion.cpp:18,25,31); the generic \
1141 decoder is RMSNorm-only and drops all three",
1142 ),
1143 (
1144 "stablelm",
1145 Neox,
1146 "required LayerNorm biases `output_norm.bias` and `attn_norm.bias` \
1147 (src/models/stablelm.cpp:20,28); the generic decoder is \
1148 RMSNorm-only and drops both",
1149 ),
1150 (
1151 "qwen",
1152 Neox,
1153 "a required *fused* `attn_qkv.bias` (src/models/qwen.cpp:28). \
1154 `load_qkv_projections` splits the fused `attn_qkv.weight` but reads \
1155 bias only under the split `attn_q.bias` / `attn_k.bias` / \
1156 `attn_v.bias` names, so Qwen-1's QKV bias is silently dropped and \
1157 every Q, K and V projection runs unbiased. Qwen-2 and later store \
1158 the split spelling and stay generic",
1159 ),
1160 ] {
1161 v.push(prof(
1162 n,
1163 TextGeneration,
1164 StandardGqa,
1165 KvGqa,
1166 // Unlike the no-RoPE group above, the layout here is
1167 // real and `rope_layout_matches_llama_cpp` still checks
1168 // it: refusing for a bias is not a licence to forget
1169 // what these rotate as.
1170 rope,
1171 ArchPath::DedicatedOnly { reason },
1172 WholeVector,
1173 ));
1174 }
1175 v.push(prof(
1176 "qwen3",
1177 TextGeneration,
1178 Qwen3Family,
1179 KvGqa,
1180 Neox,
1181 ArchPath::GenericGqa { rope: Neox },
1182 PerHead,
1183 ));
1184 v.push(prof(
1185 "qwen3moe",
1186 TextGeneration,
1187 Qwen3Family,
1188 KvGqa,
1189 Neox,
1190 ArchPath::GenericGqa { rope: Neox },
1191 PerHead,
1192 ));
1193 v.push(
1194 prof(
1195 "gemma",
1196 TextGeneration,
1197 GemmaFamily,
1198 KvGqa,
1199 Neox,
1200 ArchPath::GenericGqa { rope: Neox },
1201 PerHead,
1202 )
1203 .triaged(
1204 TriageClass::FixtureAway,
1205 "src/models/gemma.cpp:16-33 creates exactly the tensors the generic decoder \
1206 loads -- attn_norm, split Q/K/V, attn_output, ffn_norm, gate/up/down -- with \
1207 no biases, no QK-norm and no post-norms, and its graph is \
1208 sequential-residual (:97,115). The three Gemma-specific pieces are all \
1209 implemented: the sqrt(n_embd) embedding scale (:49 vs loader.rs:467-474's \
1210 GemmaFamily embedding_scale), GeGLU (:112 vs FfnActivation::Gelu) and a \
1211 1/sqrt(head_dim) attention scale (:86 scales Q, then :91 passes \
1212 kq_scale=1.0f -- which is what loader.rs:476-480 leaving attention_scale as \
1213 None already produces). Gemma-1 declares no softcap and no sliding window, \
1214 so the Gemma-2/3 machinery is inert here. Admitting it needs a fixture or a \
1215 parity run, not new code",
1216 ),
1217 );
1218 v.push(prof(
1219 "gemma2",
1220 TextGeneration,
1221 GemmaFamily,
1222 KvIswa,
1223 Neox,
1224 ArchPath::GenericGqa { rope: Neox },
1225 PerHead,
1226 ));
1227 v.push(prof(
1228 "gemma3",
1229 TextGeneration,
1230 GemmaFamily,
1231 KvIswa,
1232 Neox,
1233 ArchPath::GenericGqa { rope: Neox },
1234 PerHead,
1235 ));
1236 // Gemma-4 text GGUFs (E2B): per-layer embeddings, shared-KV
1237 // layers, and split SWA/full head dims -- dedicated
1238 // [`crate::gemma4_engine::Gemma4Engine`] (not GenericGqa).
1239 for n in ["gemma4", "gemma4-assistant"] {
1240 v.push(prof(
1241 n,
1242 TextGeneration,
1243 GemmaFamily,
1244 KvIswa,
1245 Neox,
1246 ArchPath::DedicatedOnly {
1247 reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
1248 },
1249 PerHead,
1250 ));
1251 }
1252 // Refused, not implemented: the generic decoder computes
1253 // `x + attn(norm(x))` then `y + ffn(norm(y))`, and every arch
1254 // here computes something else that no tensor and (for MiniCPM)
1255 // no metadata key makes visible. See
1256 // `unsupported_scaling_keys` for the metadata-visible half of
1257 // the same class.
1258 const PARALLEL_RESIDUAL: &str =
1259 "parallel attention+FFN residual -- llama.cpp feeds both branches the *same* \
1260 normed input and sums `inpL + attn_out + ffn_out` once; the generic decoder \
1261 computes the sequential form, which is a different graph";
1262 for (n, rope, fam) in [
1263 // src/models/cohere2.cpp:120-134, cohere2moe.cpp:222-266,
1264 // command-r.cpp:106-119. All three also carry a
1265 // `logit_scale` the generic decoder does not apply.
1266 ("command-r", Norm, StandardGqa),
1267 ("cohere2", Norm, StandardGqa),
1268 ("cohere2moe", Norm, StandardGqa),
1269 // src/models/falcon.cpp:121-135 (and an `attn_norm_2` the
1270 // generic decoder has no slot for).
1271 ("falcon", Neox, StandardGqa),
1272 // src/models/gptneox.cpp:147-195 -- parallel or sequential
1273 // per `use_par_res`, and the generic decoder implements
1274 // neither branch of that choice.
1275 ("gptneox", Neox, StandardGqa),
1276 // src/models/phi2.cpp:116-117, plamo.cpp:97-112.
1277 ("phi2", Neox, PhiFamily),
1278 ("plamo", Neox, StandardGqa),
1279 ] {
1280 v.push(prof(
1281 n,
1282 TextGeneration,
1283 fam,
1284 KvGqa,
1285 rope,
1286 ArchPath::DedicatedOnly {
1287 reason: PARALLEL_RESIDUAL,
1288 },
1289 WholeVector,
1290 ));
1291 }
1292 // MiniCPM is the case `unsupported_scaling_keys` cannot catch:
1293 // `src/models/minicpm.cpp:4-14` *hardcodes* an embedding
1294 // multiplier of 12.0, a residual multiplier of
1295 // `1.4/sqrt(n_layer)` and a logit multiplier of `256/n_embd`,
1296 // and only then lets the GGUF override them. An older MiniCPM
1297 // export carrying none of the three keys is still scaled by all
1298 // three, so a key-presence gate sees nothing and the generic
1299 // decoder computes an unscaled graph.
1300 v.push(prof(
1301 "minicpm",
1302 TextGeneration,
1303 StandardGqa,
1304 KvGqa,
1305 Norm,
1306 ArchPath::DedicatedOnly {
1307 reason: "unconditional embedding/residual/logit multipliers that llama.cpp \
1308 applies even when the GGUF omits every key; not applied by the \
1309 generic decoder",
1310 },
1311 WholeVector,
1312 ));
1313 v.push(prof(
1314 "phi3",
1315 TextGeneration,
1316 PhiFamily,
1317 KvGqa,
1318 Neox,
1319 ArchPath::GenericGqa { rope: Neox },
1320 WholeVector,
1321 ));
1322 // Phi-4 GGUFs share the phi3 fused-QKV / fused gate+up graph
1323 // (PhiFamily). Many community checkpoints still tag `phi3`; admit
1324 // `phi4` the same way so either string can load. Receipts / head-dim
1325 // FA-vec coverage remain P6 evidence work -- not a speed claim.
1326 v.push(
1327 prof(
1328 "phi4",
1329 TextGeneration,
1330 PhiFamily,
1331 KvGqa,
1332 Neox,
1333 ArchPath::GenericGqa { rope: Neox },
1334 WholeVector,
1335 )
1336 .triaged(
1337 TriageClass::Unknown,
1338 "there is no llama.cpp graph to diff against. `phi4` is NOT in LLM_ARCH_NAMES \
1339 -- src/llama-arch.cpp:44 lists \"phi3\" and there is no phi4 entry -- so this \
1340 row is a ferrox-only alias and no llama.cpp-produced GGUF can carry the \
1341 string. ferrox admits it as PhiFamily/NEOX, i.e. phi3's fused-QKV and fused \
1342 gate+up graph, on the assumption that a file spelling it means the same \
1343 thing. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is \
1344 literally `phi4`. If its blk.0 carries attn_qkv.weight it is phi3's graph \
1345 and this row is fixture-away behind an already-audited phi3; if it carries \
1346 split attn_q/attn_k/attn_v it is a Llama-shaped graph and belongs on a \
1347 different row",
1348 ),
1349 );
1350 // Llama 4: MoE + interleaved / non-generic attention graph -- not
1351 // safe to admit as GenericGqa (was wrongly listed with plain llama).
1352 v.push(prof(
1353 "llama4",
1354 TextGeneration,
1355 Dedicated,
1356 KvGqa,
1357 Norm,
1358 ArchPath::DedicatedOnly {
1359 reason: "llama4 MoE + non-GQA attn -- see llama4_engine.rs tensor list",
1360 },
1361 WholeVector,
1362 ));
1363 // MiniMax M2 and M3 are two DIFFERENT architectures and were
1364 // wrong to share one reason. Both used to refuse with "256-expert
1365 // sigmoid MoE + MTP"; neither clause is true.
1366 //
1367 // MTP: `minimax-m2.cpp` and `minimax-m3.cpp` create no `nextn.*`
1368 // tensor at all, and `gguf-py/gguf/constants.py`'s
1369 // `MODEL_ARCH.MINIMAXM2` / `.MINIMAXM3` tensor lists contain no
1370 // `NEXTN_*` entry -- so no converter can even emit MTP weights for
1371 // these files. `minimax-m3.cpp:9` says it outright: "MTP is not
1372 // in released model weights."
1373 //
1374 // Sigmoid MoE: ferrox HAS it. `loader.rs` reads
1375 // `{arch}.expert_gating_func` into `GatingFunction::Sigmoid`,
1376 // loads `blk.N.exp_probs_b.bias`, and reads
1377 // `expert_weights_scale` / `expert_weights_norm`. Expert count is
1378 // an hparam, not a ceiling.
1379 //
1380 // llama-arch.cpp puts both in the NEOX RoPE group.
1381 v.push(prof(
1382 "minimax-m2",
1383 TextGeneration,
1384 Dedicated,
1385 KvGqa,
1386 Neox,
1387 ArchPath::DedicatedOnly {
1388 // `minimax-m2.cpp` is plain GQA: `create_tensor_qkv` at
1389 // :26, whole-vector Q/K norm at :30-31 (`attn_q_norm` is
1390 // `n_embd_head_k * n_head` wide, NOT per-head), partial
1391 // NEOX RoPE at :96-106 (:51 notes head_dim=128 but
1392 // n_rot=64), and one SiLU MoE with `exp_probs_b`,
1393 // `expert_weights_scale` and norm_w=true at :131-141.
1394 // ferrox implements every one of those on the generic
1395 // path. What is missing is EVIDENCE, not capability.
1396 reason: "minimax-m2 is UNAUDITED, not unimplemented: llama.cpp's minimax-m2.cpp \
1397 builds plain GQA + whole-vector QK-norm + partial NEOX RoPE (n_rot=64 < \
1398 head_dim=128) + a SiLU sigmoid MoE with exp_probs_b, all of which the \
1399 generic path already has. Admitting it needs a fixture or a parity run \
1400 against llama.cpp, not new code",
1401 },
1402 // `attn_q_norm` is `{n_embd_head_k * n_head}` wide
1403 // (minimax-m2.cpp:30) -- one RMSNorm over the whole Q
1404 // projection, OLMoE's style, not Qwen3's per-head.
1405 WholeVector,
1406 ));
1407 v.push(prof(
1408 "minimax-m3",
1409 TextGeneration,
1410 Dedicated,
1411 KvGqa,
1412 Neox,
1413 ArchPath::DedicatedOnly {
1414 reason: "minimax-m3 needs MiniMax Sparse Attention: a per-layer indexer \
1415 (index_q_proj/index_k_proj/index_q_norm/index_k_norm, minimax-m3.cpp:76-82) \
1416 driving its own MSA KV cache (llama-kv-cache-msa.h) with position<->cell \
1417 maps, plus SWIGLU_OAI experts and shared experts. ferrox has only the \
1418 block-selection rule (ferrox_core::block_sparse), none of the rest",
1419 },
1420 // minimax-m3.cpp:53-55 -- `{n_embd_head_k}`, with llama.cpp's
1421 // own comment "per-head QK-norm: a single head_dim vector
1422 // applied to every head". M2 and M3 DIFFER here, which is why
1423 // the shared entry was wrong for M3.
1424 PerHead,
1425 ));
1426 // MiniCPM3 is MLA, not generic GQA, and the catalog said
1427 // otherwise: it claimed `StandardGqa`/`KvGqa`, which is false
1428 // about the model rather than merely unaudited.
1429 // `src/models/minicpm3.cpp:5-6` requires `q_lora_rank` and
1430 // `kv_lora_rank`, and `:41-46` creates
1431 // `attn_q_a`/`attn_q_b`/`attn_kv_a_mqa`/`attn_kv_b` -- the
1432 // DeepSeek-2 tensor set. There is no `attn_q.weight` in any
1433 // MiniCPM3 checkpoint, so the generic path could never have
1434 // loaded one whatever the audit said.
1435 //
1436 // Reclassified 2026-09-01 by the unaudited-refusal triage. This
1437 // is a MESSAGE-QUALITY fix, not a correctness one: the old
1438 // failure was already a clean missing-tensor error. It stops the
1439 // user being told "unaudited" for something that is not merely
1440 // unaudited.
1441 v.push(prof(
1442 "minicpm3",
1443 TextGeneration,
1444 Mla,
1445 KvMla,
1446 Neox,
1447 ArchPath::DedicatedOnly {
1448 reason: "MiniCPM3 is an MLA model (src/models/minicpm3.cpp:5-6,41-46 -- \
1449 q_lora_rank/kv_lora_rank and the attn_q_a/attn_q_b/attn_kv_a_mqa/\
1450 attn_kv_b tensor set), so it needs the MLA engine and not the \
1451 generic GQA decoder. It ALSO hardcodes MiniCPM's multipliers with \
1452 no GGUF key to read them from -- scale_embd = 12.0, \
1453 scale_depth = 1.4, n_embd_base = 256 at :65-67, applied at :81 -- \
1454 which is the same blind spot `minicpm` is refused for",
1455 },
1456 WholeVector,
1457 ));
1458 v.push(prof(
1459 "deepseek2",
1460 TextGeneration,
1461 Mla,
1462 KvMla,
1463 Norm,
1464 ArchPath::DedicatedOnly {
1465 reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
1466 },
1467 WholeVector,
1468 ));
1469 v.push(prof(
1470 "deepseek32",
1471 TextGeneration,
1472 Mla,
1473 KvDsa,
1474 Norm,
1475 ArchPath::DedicatedOnly {
1476 reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
1477 },
1478 WholeVector,
1479 ));
1480 v.push(prof(
1481 "mistral4",
1482 TextGeneration,
1483 Mla,
1484 KvMla,
1485 Norm,
1486 ArchPath::DedicatedOnly {
1487 reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
1488 },
1489 WholeVector,
1490 ));
1491 v.push(dedicated(
1492 "glm-dsa",
1493 "use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
1494 ));
1495 v.push(dedicated(
1496 "glm4",
1497 "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
1498 ));
1499 // GLM-4.5 / GLM-4.5-Air / GLM-4.6 tag `glm4moe`, and the reason
1500 // here used to point at `glm52_gguf_loader` the way `glm-dsa`
1501 // does. It cannot load one: `read_glm52_hparams` requires
1502 // `{arch}.attention.q_lora_rank`, `.kv_lora_rank`,
1503 // `.qk_nope_head_dim` and `.qk_rope_head_dim`, and glm4moe is
1504 // NOT an MLA model -- `src/models/glm4-moe.cpp`'s
1505 // `load_arch_hparams` never reads any of the four and its
1506 // `load_arch_tensors` calls `create_tensor_qkv` (plain Q/K/V)
1507 // with no `attn_kv_a_mqa` / `attn_kv_b` / `attn_q_a` /
1508 // `attn_q_b` anywhere. So `ferrox run` on a real GLM-4.5-Air
1509 // answered "missing hparam glm4moe.attention.q_lora_rank" for a
1510 // model that has no MLA at all.
1511 //
1512 // What it actually is: plain GQA + DeepSeek-V3-shaped sigmoid
1513 // MoE (`exp_probs_b`, shared expert, leading dense,
1514 // `expert_weights_scale`), all of which the generic decoder
1515 // already computes and `dots1` already pins. The one thing that
1516 // does not fit is the norm slot, and it is a real divergence
1517 // rather than a missing key -- see the reason string. Pinned by
1518 // `tests/glm4moe_refusal.rs` against a synthetic checkpoint
1519 // llama.cpp itself loads and decodes.
1520 v.push(dedicated(
1521 "glm4moe",
1522 "GLM-4.5-MoE stores its pre-FFN norm as `blk.N.post_attention_norm.weight` and \
1523 carries NO `blk.N.ffn_norm.weight` (src/models/glm4-moe.cpp:75, applied to \
1524 `ffn_inp` at :215 -- i.e. AFTER the attention residual). The generic decoder \
1525 requires `ffn_norm` and puts `post_attention_norm` in Gemma's other slot, on the \
1526 attention branch BEFORE the residual add, so it would both fail to find its \
1527 tensors and compute a different graph. This is gpt-oss's norm slot exactly, and \
1528 `loader.rs` already implements it behind an `is_gpt_oss` flag; widening that flag \
1529 is what admits glm4moe. It is NOT MLA -- do not send it to glm52_gguf_loader, \
1530 which asks for a `q_lora_rank` no glm4moe checkpoint carries",
1531 ));
1532 v.push(dedicated(
1533 "deepseek4",
1534 "DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
1535 ));
1536 v.push(dedicated(
1537 "kimi-linear",
1538 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1539 ));
1540 v.push(dedicated(
1541 "kimi_k3",
1542 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1543 ));
1544 for (n, rope) in [
1545 ("jamba", Neox),
1546 ("falcon-h1", Neox),
1547 ("plamo2", Neox),
1548 ("granitehybrid", Norm),
1549 ("granite-hybrid", Norm),
1550 ("lfm2", Neox),
1551 ("lfm2moe", Neox),
1552 ("nemotron_h", Neox),
1553 ("nemotron_h_moe", Neox),
1554 ("qwen3next", Neox),
1555 ("qwen35", Neox),
1556 ("qwen35moe", Neox),
1557 ] {
1558 let qk = if n.starts_with("qwen3") {
1559 PerHead
1560 } else {
1561 WholeVector
1562 };
1563 v.push(prof(
1564 n,
1565 TextGeneration,
1566 DecoderFamily::Hybrid,
1567 MemoryKind::Hybrid,
1568 rope,
1569 ArchPath::DedicatedOnly {
1570 reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
1571 },
1572 qk,
1573 ));
1574 }
1575 for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
1576 v.push(prof(
1577 n,
1578 TextGeneration,
1579 DecoderFamily::Recurrent,
1580 MemoryKind::Recurrent,
1581 Neox,
1582 ArchPath::DedicatedOnly {
1583 reason: "recurrent engine not yet on the serve path",
1584 },
1585 WholeVector,
1586 ));
1587 }
1588 v.push(prof(
1589 "t5",
1590 TextGeneration,
1591 EncoderDecoder,
1592 None,
1593 Neox,
1594 ArchPath::DedicatedOnly {
1595 reason: "T5 encoder-decoder engine not yet on the serve path",
1596 },
1597 WholeVector,
1598 ));
1599 for (n, scope, reason) in [
1600 (
1601 "t5encoder",
1602 DeferredEncoderEmbedding,
1603 "encoder-only; deferred from text-generation parity",
1604 ),
1605 // Deferred from the *decoder* path, and that is still
1606 // right: a `bert` GGUF has no output head, so
1607 // `ensure_generic_decoder` must keep refusing it. It is no
1608 // longer deferred outright -- it loads and embeds through
1609 // `bert_gguf_loader` / `bert_encoder`, checked against
1610 // llama.cpp by `tests/bert_llama_cpp_parity.rs`.
1611 (
1612 "bert",
1613 DeferredEncoderEmbedding,
1614 "encoder; no output head, so never a decoder -- served by \
1615 ferrox_models::EmbeddingModel on /v1/embeddings",
1616 ),
1617 (
1618 "modern-bert",
1619 DeferredEncoderEmbedding,
1620 "encoder/embedding; deferred",
1621 ),
1622 (
1623 "nomic-bert",
1624 DeferredEncoderEmbedding,
1625 "encoder/embedding; deferred",
1626 ),
1627 (
1628 "nomic-bert-moe",
1629 DeferredEncoderEmbedding,
1630 "encoder/embedding; deferred",
1631 ),
1632 (
1633 "neo-bert",
1634 DeferredEncoderEmbedding,
1635 "encoder/embedding; deferred",
1636 ),
1637 (
1638 "jina-bert-v2",
1639 DeferredEncoderEmbedding,
1640 "encoder/embedding; deferred",
1641 ),
1642 (
1643 "jina-bert-v3",
1644 DeferredEncoderEmbedding,
1645 "encoder/embedding; deferred",
1646 ),
1647 (
1648 "eurobert",
1649 DeferredEncoderEmbedding,
1650 "encoder/embedding; deferred",
1651 ),
1652 (
1653 "llama-embed",
1654 DeferredEncoderEmbedding,
1655 "embedding variant; deferred",
1656 ),
1657 (
1658 "gemma-embedding",
1659 DeferredEncoderEmbedding,
1660 "embedding variant; deferred",
1661 ),
1662 (
1663 "pangu-embedded",
1664 DeferredEncoderEmbedding,
1665 "embedding variant; deferred",
1666 ),
1667 ("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
1668 ("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
1669 ("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
1670 ("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
1671 ("cogvlm", DeferredMultimodal, "vision-language; deferred"),
1672 ("chameleon", DeferredMultimodal, "multimodal; deferred"),
1673 ("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
1674 ("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
1675 ("hy_v3", DeferredMultimodal, "multimodal; deferred"),
1676 ("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
1677 ("dream", DeferredDiffusion, "diffusion LM; deferred"),
1678 ("llada", DeferredDiffusion, "diffusion LM; deferred"),
1679 ("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
1680 ("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
1681 (
1682 "wavtokenizer-dec",
1683 DeferredAudio,
1684 "audio tokenizer; deferred",
1685 ),
1686 (
1687 "eagle3",
1688 EnumOnly,
1689 "speculative draft head; not a standalone decoder target",
1690 ),
1691 (
1692 "dflash",
1693 EnumOnly,
1694 "speculative draft head; not a standalone decoder target",
1695 ),
1696 ("clip", EnumOnly, "quantize dummy only"),
1697 ("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
1698 ("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
1699 ] {
1700 v.push(deferred_scope(n, scope, reason));
1701 }
1702 v.push(prof(
1703 "gemma3n",
1704 TextGeneration,
1705 GemmaFamily,
1706 KvIswa,
1707 Neox,
1708 ArchPath::DedicatedOnly {
1709 reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
1710 },
1711 PerHead,
1712 ));
1713 for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
1714 v.push(prof(
1715 n,
1716 TextGeneration,
1717 TestFixture,
1718 KvGqa,
1719 Neox,
1720 ArchPath::TestFixture { rope: Neox },
1721 WholeVector,
1722 ));
1723 }
1724 v
1725 })
1726 .as_slice()
1727}
1728
1729/// Resolve a GGUF `general.architecture` value to its profile.
1730pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
1731 architecture_catalog().iter().find(|p| p.gguf_name == arch)
1732}
1733
1734/// Resolve a GGUF `general.architecture` value. `None` means the string
1735/// is not in the registry -- callers must fail closed rather than guess.
1736pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
1737 resolve_profile(arch).map(|p| p.path)
1738}
1739
1740/// llama.cpp's hardcoded alternating sliding-window layout for one
1741/// architecture: the period, *and* which end of each period is the
1742/// full-attention layer.
1743///
1744/// `llama_hparams::set_swa_pattern` (`src/llama-hparams.cpp:8-22`) has
1745/// two phases, and they are not interchangeable:
1746///
1747/// - `dense_first = false`: `is_swa[il] = il % p < (p - 1)` -- the
1748/// **last** layer of every period is full attention.
1749/// - `dense_first = true`: `is_swa[il] = il % p != 0` -- the **first**
1750/// layer of every period is full attention.
1751///
1752/// For a 32-layer period-4 model the two disagree on 16 of the 32
1753/// layers. Storing only the period would therefore not be a partial
1754/// transcription, it would be a wrong one for the four architectures
1755/// llama.cpp passes `dense_first = true`.
1756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1757pub struct SwaPattern {
1758 /// llama.cpp's `swa_period` seed literal.
1759 pub period: usize,
1760 /// llama.cpp's `dense_first` argument to `set_swa_pattern`.
1761 pub dense_first: bool,
1762}
1763
1764/// Every architecture for which llama.cpp seeds a sliding-window period
1765/// *before* letting `{arch}.attention.sliding_window_pattern` override
1766/// it, transcribed from `src/models/*.cpp`.
1767///
1768/// The period is not in the file for these families -- llama.cpp
1769/// hardcodes it per architecture and only lets the metadata key override
1770/// it (`ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN,
1771/// swa_period, false)` after seeding `swa_period` with the literal
1772/// below). A missing key therefore does **not** mean "every layer is
1773/// windowed", which is what ferrox assumed: `layer_sliding_window`
1774/// returns the window for all layers when `swa_pattern` is `None`, so a
1775/// gpt-oss or cohere2 checkpoint ran its full-attention layers through a
1776/// 128-token window and answered from a truncated history.
1777///
1778/// Two llama.cpp spellings are deliberately absent, because neither is
1779/// a per-arch *period*:
1780///
1781/// - `set_swa_pattern(0)` (`deepseek4.cpp:68`, `dflash.cpp:54`) makes
1782/// **every** layer sliding, which is what ferrox already does for a
1783/// declared window with no pattern.
1784/// - `set_swa_pattern(1)` (`phi3.cpp:23`) makes **no** layer sliding,
1785/// and phi3 zeroes `n_swa` and sets `swa_type = NONE` on the same
1786/// branch, so there is no window left to place.
1787///
1788/// Architectures that only ever read a per-layer *array*
1789/// (`get_key_or_arr(..., hparams.is_swa_impl, n_layer)`: `gemma4`,
1790/// `gemma4-assistant`, `step35`, `mimo2`, `dflash`) seed no scalar and
1791/// so have no default to pin.
1792///
1793/// Pinned by `tests/swa_pattern.rs`.
1794/// Architectures where llama.cpp DISABLES sliding-window attention even
1795/// though the checkpoint declares a window.
1796///
1797/// `src/models/phi3.cpp:12-24`: if `attention.sliding_window` is present
1798/// and non-zero, llama.cpp warns, then sets `n_swa = 0`,
1799/// `swa_type = LLAMA_SWA_TYPE_NONE` and `set_swa_pattern(1)` -- i.e. NO
1800/// layer slides. Its own comment says the conversion scripts populate
1801/// the key wrongly and links the PR that turned it off.
1802///
1803/// ferrox read the key and, having no per-architecture period for
1804/// `phi3`, windowed EVERY layer. So a Phi-3 or Phi-4 model attended over
1805/// a truncated history on every layer where llama.cpp attends over the
1806/// whole context. `phi3` is in [`AUDITED_GENERIC_GQA`], and
1807/// `models/Phi-4-mini-instruct-Q4_K_M.gguf` really does declare
1808/// `phi3.attention.sliding_window = 262144` -- so this was live on a
1809/// model in the benchmark suite, not hypothetical.
1810///
1811/// This is deliberately a REFUSAL TO HONOUR the key rather than a
1812/// transcribed period: llama.cpp is not choosing a different window
1813/// here, it is declining to use the one in the file.
1814pub fn swa_disabled_by_arch(arch: &str) -> bool {
1815 matches!(arch, "phi3")
1816}
1817
1818/// Architectures whose FFN gate uses GELU rather than SiLU, i.e. GeGLU
1819/// rather than SwiGLU.
1820///
1821/// llama.cpp picks this PER ARCHITECTURE -- it is the `LLM_FFN_GELU` vs
1822/// `LLM_FFN_SILU` argument each `src/models/*.cpp` passes to `build_ffn`
1823/// / `build_moe_ffn` -- and ferrox picked it per FAMILY, which is not
1824/// the same partition. `grok` is the case that proves it:
1825/// `src/models/grok.cpp:165` passes `LLM_FFN_GELU` to `build_moe_ffn`,
1826/// but `grok` is `DecoderFamily::StandardGqa`, so ferrox handed it
1827/// SwiGLU and would have computed a different FFN on every layer.
1828///
1829/// Latent only because `grok` is not in [`AUDITED_GENERIC_GQA`] and so
1830/// refuses today. It would have become wrong the moment somebody
1831/// audited it, which is the worst possible time to find out.
1832///
1833/// The other `LLM_FFN_GELU` users upstream -- `bert`, `bloom`,
1834/// `codeshell`, `falcon`, `gpt2`, `gptneox`, `mpt`, `phi2`, `starcoder`,
1835/// `starcoder2`, `t5`, `wavtokenizer-dec` -- are all `Deferred` or
1836/// `DedicatedOnly` here, so none reaches the generic path and none is
1837/// listed. The Gemma lineage is GELU too and stays on the family rule,
1838/// because every Gemma row IS `GemmaFamily`.
1839pub fn uses_geglu(arch: &str) -> bool {
1840 matches!(arch, "grok")
1841}
1842
1843pub fn default_swa_layout(arch: &str) -> Option<SwaPattern> {
1844 let last_dense = |period| {
1845 Some(SwaPattern {
1846 period,
1847 dense_first: false,
1848 })
1849 };
1850 let dense_first = |period| {
1851 Some(SwaPattern {
1852 period,
1853 dense_first: true,
1854 })
1855 };
1856 match arch {
1857 // src/models/openai-moe.cpp:9
1858 "gpt-oss" => last_dense(2),
1859 // src/models/gemma2.cpp:6
1860 "gemma2" => last_dense(2),
1861 // src/models/gemma3.cpp:7
1862 "gemma3" => last_dense(6),
1863 // src/models/gemma3n.cpp:4 says 5, NOT 6. This was transcribed
1864 // as 6 alongside gemma3 and is simply wrong. Inert only because
1865 // `gemma3n` refuses for other reasons today.
1866 "gemma3n" => last_dense(5),
1867 // src/models/gemma-embedding.cpp:5. Deferred (embedding scope),
1868 // so latent rather than live.
1869 "gemma-embedding" => last_dense(6),
1870 // src/models/cohere2.cpp:5, exaone4.cpp:7, olmo2.cpp:9
1871 "cohere2" | "exaone4" | "olmo2" => last_dense(4),
1872 // Added after an audit found this table covered 6 architectures
1873 // where llama.cpp hardcodes a period for 17. A MISSING entry is
1874 // not neutral: with no period, every layer gets windowed, so a
1875 // model whose full-attention layers should see the whole context
1876 // sees only a window instead. That is a different model, and it
1877 // fails silently.
1878 //
1879 // src/models/mellum.cpp:11
1880 "mellum" => last_dense(4),
1881 // src/models/exaone-moe.cpp:6. SWA is unconditional there
1882 // with n_swa = 128, so without this every layer ran with a
1883 // 128-token history.
1884 "exaone-moe" => last_dense(4),
1885 // src/models/afmoe.cpp:17. `afmoe` refuses for other reasons
1886 // today, so this one is latent rather than live, and pinned
1887 // here so it stays right if that changes.
1888 "afmoe" => last_dense(4),
1889 // src/models/plamo3.cpp:9. LIVE: `plamo3` is audited, and its
1890 // fixture drives a period of 2 from the file with a window
1891 // narrower than the prompt, so both the period override and
1892 // this phase are exercised end to end against libllama.
1893 "plamo3" => last_dense(8),
1894 // src/models/llama4.cpp:19 ("pattern: 3 chunked - 1 full").
1895 // `llama4` is `DedicatedOnly` today, so latent.
1896 "llama4" => last_dense(4),
1897 // --- dense_first = true -----------------------------------
1898 //
1899 // These four put the full-attention layer at `il % p == 0`, not
1900 // at `il % p == p - 1`. `ModelConfig::layer_sliding_window`
1901 // implements BOTH phases and carries this flag as
1902 // `swa_dense_first`; it used to implement only the first, which
1903 // is why `smallthinker` and `laguna` windowed every layer.
1904 //
1905 // src/models/smallthinker.cpp:9-11. LIVE: `smallthinker` is on
1906 // the generic GQA path.
1907 "smallthinker" => dense_first(4),
1908 // src/models/laguna.cpp:39-41 (its own comment: "XS.2: FULL at
1909 // il%4==0"). LIVE: `laguna` is on the generic GQA path.
1910 "laguna" => dense_first(4),
1911 // src/models/cohere2moe.cpp:31-33. `DedicatedOnly` today
1912 // (parallel attention+FFN residual), so latent.
1913 "cohere2moe" => dense_first(4),
1914 // src/models/modern-bert.cpp:8-10. Deferred (encoder scope), so
1915 // latent.
1916 "modern-bert" => dense_first(3),
1917 _ => None,
1918 }
1919}
1920
1921/// True when this architecture's SWA layers use the model's own RoPE
1922/// base rather than llama.cpp's `rope_freq_base_train_swa` default of
1923/// `10000`.
1924///
1925/// `llama_hparams` defaults that field to `10000.0f`
1926/// (`src/llama-hparams.h:127`) and the Gemma-3 lineage relies on the
1927/// default; the architectures listed here instead open with
1928/// `hparams.rope_freq_base_train_swa = hparams.rope_freq_base_train;`
1929/// before letting `rope.freq_base_swa` override it. ferrox applied the
1930/// Gemma default to everything, which rotates a gpt-oss SWA layer at
1931/// theta 10000 instead of its real 150000.
1932pub fn swa_rope_base_follows_model(arch: &str) -> bool {
1933 matches!(
1934 arch,
1935 "afmoe"
1936 | "cohere2"
1937 | "cohere2moe"
1938 | "dflash"
1939 | "exaone-moe"
1940 | "exaone4"
1941 | "gemma2"
1942 | "laguna"
1943 | "llama4"
1944 | "mellum"
1945 | "olmo2"
1946 | "gpt-oss"
1947 | "smallthinker"
1948 )
1949}
1950
1951/// True when this architecture's SWA layers inherit the model's TRAINED
1952/// RoPE position scale rather than llama.cpp's
1953/// `rope_freq_scale_train_swa` default of `1.0`.
1954///
1955/// The sibling of [`swa_rope_base_follows_model`], and deliberately NOT
1956/// derived from it: llama.cpp defaults both fields
1957/// (`src/llama-hparams.h:127,129`) and each architecture assigns them
1958/// independently, so the two lists differ. `olmo2.cpp:13-14` and
1959/// `laguna.cpp:47-48` seed the BASE from the model and then pin the
1960/// SCALE to `1.0` -- laguna's own comment is "SWA uses plain RoPE (no
1961/// YaRN scaling); do NOT inherit full layers 1/factor". Collapsing the
1962/// two tables into one would rope those two architectures wrong in
1963/// exactly the way this function exists to stop.
1964///
1965/// The default matters more than the list. `gemma3.cpp:11` reads only
1966/// `LLM_KV_ROPE_FREQ_BASE_SWA` and never touches
1967/// `rope_freq_scale_train_swa`, so Gemma-3's sliding layers rope at
1968/// scale `1.0` while its full-attention layers use the trained scale --
1969/// and the converter agrees, writing `rope.scaling.factor` from
1970/// `rope_parameters["full_attention"]` alone (`conversion/base.py:1222`,
1971/// whose own comment is "TODO: Handle sliding_attention similarly when
1972/// models start implementing it").
1973///
1974/// Every name here is a `hparams.rope_freq_scale_train_swa =
1975/// hparams.rope_freq_scale_train;` in `src/models/`, at the line given.
1976pub fn swa_rope_scale_follows_model(arch: &str) -> bool {
1977 matches!(
1978 arch,
1979 "afmoe" // afmoe.cpp:22
1980 | "cohere2" // cohere2.cpp:10
1981 | "cohere2moe" // cohere2moe.cpp:39
1982 | "dflash" // dflash.cpp:59, :71
1983 | "exaone-moe" // exaone-moe.cpp:10
1984 | "exaone4" // exaone4.cpp:12
1985 | "gemma2" // gemma2.cpp:11
1986 | "llama4" // llama4.cpp:24
1987 | "mellum" // mellum.cpp:20
1988 | "gpt-oss" // openai-moe.cpp:14
1989 | "smallthinker" // smallthinker.cpp:14
1990 )
1991}
1992
1993/// llama.cpp's `hparams.f_attention_scale`, but only when it DIFFERS
1994/// from the `1/sqrt(head_dim)` every ferrox attention kernel already
1995/// applies. `None` means "the kernels' own scale is already right", so
1996/// a caller stores it straight into `ModelConfig::attention_scale`.
1997///
1998/// Only the Gemma-2 and Gemma-3 27B checkpoints answer `Some`:
1999///
2000/// ```cpp
2001/// // src/models/gemma3.cpp:30-33 (src/models/gemma2.cpp:26-29 identical in shape)
2002/// hparams.f_attention_scale = type == LLM_TYPE_27B
2003/// ? 1.0f / std::sqrt(float(hparams.n_embd / hparams.n_head(0)))
2004/// : 1.0f / std::sqrt(float(hparams.n_embd_head_k()));
2005/// ```
2006///
2007/// and llama.cpp applies it as an explicit `ggml_scale` on Q followed by
2008/// `build_attn(..., 1.0f)` (`gemma3.cpp:154`, `gemma2.cpp:110`), which is
2009/// what [`crate::config::ModelConfig::attention_scale`] means here.
2010///
2011/// **The selector is the LAYER COUNT, not a comparison of the two
2012/// widths.** `LLM_TYPE_27B` comes from `switch (hparams.n_layer())`
2013/// (`gemma3.cpp:20-28` `case 62`, `gemma2.cpp:19-23` `case 46`), and
2014/// deriving it instead from `n_embd / n_head != head_dim` would be
2015/// wrong for EVERY other Gemma size -- all of them have
2016/// `n_embd / n_head != head_dim` too, and all of them take llama.cpp's
2017/// `1/sqrt(n_embd_head_k)` branch. See
2018/// `gemma_27b_is_the_only_size_that_overrides_the_kernel_scale`.
2019///
2020/// `hidden_dim / n_heads` is integer division on purpose: llama.cpp
2021/// divides two `uint32_t` and only then converts to float.
2022pub fn attention_scale_override(
2023 arch: &str,
2024 n_layers: usize,
2025 hidden_dim: usize,
2026 n_heads: usize,
2027 head_dim: usize,
2028) -> Option<f32> {
2029 // `case 62` / `case 46` in the `switch (hparams.n_layer())` that
2030 // picks `LLM_TYPE_27B`. Every other Gemma architecture
2031 // (`gemma-embedding`, `gemma3n`, `gemma4`) sets `f_attention_scale`
2032 // unconditionally and has no 27B branch at all.
2033 let is_27b = match arch {
2034 "gemma2" => n_layers == 46,
2035 "gemma3" => n_layers == 62,
2036 _ => false,
2037 };
2038 if !is_27b || n_heads == 0 || head_dim == 0 {
2039 return None;
2040 }
2041 let scale = 1.0 / ((hidden_dim / n_heads) as f32).sqrt();
2042 let kernel_scale = 1.0 / (head_dim as f32).sqrt();
2043 (scale != kernel_scale).then_some(scale)
2044}
2045
2046/// Metadata keys that, when present with a nonzero value, require math
2047/// ferrox's generic decoder does not implement *unless* the architecture
2048/// profile opts into those features (Gemma family).
2049pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
2050 let profile = resolve_profile(arch);
2051 // Gemma family implements softcap + SWA pattern; others still refuse.
2052 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2053 return Vec::new();
2054 }
2055 let key = |suffix: &str| format!("{arch}.{suffix}");
2056 vec![
2057 (
2058 key("attention.logit_softcapping"),
2059 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2060 ),
2061 // The spelling llama.cpp's converters ACTUALLY write
2062 // (`llama-arch.cpp:213` is `%s.attn_logit_softcapping`). The
2063 // line above is a spelling no converter emits, so this gate has
2064 // never fired for any non-Gemma architecture -- while
2065 // `loader.rs` reads BOTH spellings and applies the value.
2066 //
2067 // A checkpoint declaring an attention softcap was therefore not
2068 // refused; it ran with the generic formula. For `grok` that is a
2069 // wrong answer rather than an approximation: `grok.cpp` folds
2070 // the real attention scale INTO the softcap and passes
2071 // `kq_scale = 1.0f`, which the generic path does not do.
2072 //
2073 // A gate that cannot fire is not a gate, and it looked exactly
2074 // like one.
2075 (
2076 key("attn_logit_softcapping"),
2077 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2078 ),
2079 (
2080 key("final_logit_softcapping"),
2081 "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2082 ),
2083 // `{arch}.attention.sliding_window_pattern` WAS refused here,
2084 // with the reason "not implemented in the generic decoder".
2085 // That reason was false, and had been for some time: the
2086 // alternating pattern lives in `ModelConfig::layer_sliding_window`,
2087 // which implements BOTH phases and which `gpt-oss` -- a
2088 // `StandardGqa` row, not a Gemma one -- has relied on since it
2089 // was audited against libllama.
2090 //
2091 // What the gate really did was make the loader's own read of
2092 // that key (`swa_pattern`) unreachable for every non-Gemma
2093 // architecture: llama.cpp lets the file override the
2094 // architecture's hardcoded period, ferrox refused any file that
2095 // tried. `plamo3` is the case that proves it -- its converter
2096 // writes the key verbatim (`conversion/plamo.py:178`) -- and
2097 // `tests/fixture_away_graphs.rs` now drives a period of 2 out of
2098 // a plamo3 fixture and compares against llama.cpp's own graph on
2099 // all three forward paths, with the phase and the window
2100 // sabotaged separately.
2101 //
2102 // The real gap the key can hide is NOT the pattern: it is that
2103 // llama.cpp accepts the value as a scalar OR an n_layer-long
2104 // ARRAY (`ml.get_key_or_arr`), and ferrox carries one scalar
2105 // period. `loader.rs` refuses an array-valued pattern by name,
2106 // where the value can actually be inspected, instead of
2107 // refusing every file that has the key at all.
2108 ]
2109}
2110
2111/// Scalar multipliers a checkpoint can declare in **metadata** that the
2112/// generic decoder does not apply, with the value that means "no-op".
2113///
2114/// These are the blind spot left by
2115/// [`crate::loader::assert_every_tensor_consumed`]: that gate catches a
2116/// missing *tensor*, but Granite / MiniCPM / Command-R style multipliers
2117/// are hparams, not weights, so a checkpoint carrying them loads
2118/// cleanly, runs at full speed, and computes a graph scaled differently
2119/// from the one the checkpoint was trained as. Nothing says so.
2120///
2121/// llama.cpp key names (`llama-arch.cpp`):
2122/// `%s.logit_scale` (`LLM_KV_LOGIT_SCALE`), `%s.residual_scale`,
2123/// `%s.embedding_scale`, `%s.attention.scale`. Granite reads all four
2124/// (`src/models/granite.cpp::load_arch_hparams`); MiniCPM and
2125/// Command-R/Cohere2 read the subset they use.
2126///
2127/// **This is a refusal, not an implementation.** `residual_scale` in
2128/// particular multiplies the attention and FFN branch outputs before
2129/// every residual add, which in ferrox means every CPU decode/prefill/
2130/// multi-seq path *and* the fused Metal kernels that fold the residual
2131/// in -- landing it half-way would be exactly the silent divergence this
2132/// list exists to stop. Until the math is there, a checkpoint that
2133/// declares one of these is refused by name.
2134///
2135/// The no-op value differs by key: the three `*_scale` multipliers are
2136/// `1.0`, while llama.cpp's `f_attention_scale` uses `0.0` as its
2137/// "unset, use 1/sqrt(head_dim)" sentinel.
2138pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
2139 let profile = resolve_profile(arch);
2140 // Gemma implements its own embedding scale (`loader.rs`
2141 // `embedding_scale`) and its own attention scale
2142 // (`attention_scale_override`, including the 27B branch llama.cpp
2143 // takes at `gemma3.cpp:30-33` / `gemma2.cpp:26-29`). That second
2144 // half was an unimplemented claim until the 27B fix; the exemption
2145 // is only honest while `attention_scale_override` covers it, which
2146 // `gemma_27b_is_the_only_size_that_overrides_the_kernel_scale`
2147 // pins.
2148 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2149 return Vec::new();
2150 }
2151 let key = |suffix: &str| format!("{arch}.{suffix}");
2152 vec![
2153 (
2154 key("logit_scale"),
2155 "logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
2156 1.0,
2157 ),
2158 (
2159 key("residual_scale"),
2160 "residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
2161 1.0,
2162 ),
2163 (
2164 key("embedding_scale"),
2165 "embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
2166 1.0,
2167 ),
2168 (
2169 key("attention.scale"),
2170 "explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
2171 0.0,
2172 ),
2173 ]
2174}
2175
2176/// Markdown coverage table for docs / CI drift checks.
2177pub fn coverage_report_markdown() -> String {
2178 let mut lines = vec![
2179 "# Architecture coverage manifest".to_string(),
2180 String::new(),
2181 "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
2182 "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
2183 String::new(),
2184 "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
2185 "|---|---|---|---|---|".to_string(),
2186 ];
2187 for p in architecture_catalog() {
2188 let path = match p.path {
2189 ArchPath::GenericGqa { .. } => "generic-gqa",
2190 ArchPath::TestFixture { .. } => "test-fixture",
2191 ArchPath::DedicatedOnly { .. } => "dedicated",
2192 ArchPath::Deferred { .. } => "deferred",
2193 };
2194 lines.push(format!(
2195 "| `{}` | {:?} | {:?} | {:?} | {} |",
2196 p.gguf_name, p.scope, p.family, p.memory, path
2197 ));
2198 }
2199 lines.push(String::new());
2200 lines.join("\n")
2201}
2202
2203#[cfg(test)]
2204mod audit_tests {
2205 use super::*;
2206
2207 /// Every audited name must actually be on the generic path.
2208 ///
2209 /// A name here that resolves to a dedicated engine, or to nothing,
2210 /// is a stale entry claiming evidence for a path it does not use.
2211 #[test]
2212 fn every_audited_name_is_actually_on_the_generic_path() {
2213 for name in AUDITED_GENERIC_GQA {
2214 let profile = resolve_profile(name)
2215 .unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
2216 assert!(
2217 matches!(profile.path, ArchPath::GenericGqa { .. }),
2218 "`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
2219 profile.path
2220 );
2221 }
2222 }
2223
2224 /// The five architectures that were caught computing the wrong
2225 /// thing must never appear here.
2226 ///
2227 /// They are refused outright now, but this pins the intent: the
2228 /// audited list is evidence of correctness, and these are the
2229 /// counter-examples that motivated it.
2230 #[test]
2231 fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
2232 for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
2233 assert!(
2234 !is_audited_generic(name),
2235 "`{name}` was found computing ALiBi or learned position embeddings as \
2236 though it were RoPE; it cannot be on the audited list"
2237 );
2238 }
2239 }
2240
2241 /// Every unaudited generic-path architecture either carries a
2242 /// triage verdict or is named on [`TRIAGE_PENDING`] -- never both,
2243 /// never neither.
2244 ///
2245 /// This is the anti-drift gate. Adding a new architecture to the
2246 /// generic catalog without either reading it against llama.cpp or
2247 /// admitting on the pending list that nobody has, fails here.
2248 #[test]
2249 fn every_unaudited_generic_architecture_is_triaged_or_listed_as_pending() {
2250 for p in architecture_catalog() {
2251 if !matches!(p.path, ArchPath::GenericGqa { .. }) || is_audited_generic(p.gguf_name) {
2252 continue;
2253 }
2254 let pending = TRIAGE_PENDING.contains(&p.gguf_name);
2255 match (p.triage, pending) {
2256 (Some(_), false) | (None, true) => {}
2257 (Some(t), true) => panic!(
2258 "`{}` carries a {:?} verdict AND is still on TRIAGE_PENDING; remove it \
2259 from the pending list",
2260 p.gguf_name, t.class
2261 ),
2262 (None, false) => panic!(
2263 "`{}` is on the generic path, is not audited, has no triage verdict and \
2264 is not on TRIAGE_PENDING. Read \
2265 .scratch/llama.cpp/src/models/ for it, or say so on the pending list",
2266 p.gguf_name
2267 ),
2268 }
2269 }
2270 }
2271
2272 /// A name on [`TRIAGE_PENDING`] that is not an unaudited generic row
2273 /// is a stale to-do: it would keep claiming work that no longer
2274 /// exists, or point at an architecture the loader never asks about.
2275 #[test]
2276 fn nothing_on_the_pending_list_is_stale() {
2277 for name in TRIAGE_PENDING {
2278 let p = resolve_profile(name)
2279 .unwrap_or_else(|| panic!("TRIAGE_PENDING names `{name}`, not in the catalog"));
2280 assert!(
2281 matches!(p.path, ArchPath::GenericGqa { .. }),
2282 "`{name}` is on TRIAGE_PENDING but resolves to {:?}, which never reaches the \
2283 unaudited refusal",
2284 p.path
2285 );
2286 assert!(
2287 !is_audited_generic(name),
2288 "`{name}` is audited and runs; it does not need a triage verdict"
2289 );
2290 }
2291 // The list is empty because the triage finished, not because it
2292 // was never populated. If a future architecture lands on the
2293 // generic path with no verdict, it belongs here and
2294 // `every_unaudited_generic_architecture_is_triaged_or_listed_as_pending`
2295 // will say so; until then, empty is the completed state.
2296 assert!(
2297 TRIAGE_PENDING.is_empty(),
2298 "TRIAGE_PENDING regrew to {:?}; that is fine, but say so in docs/MODELS.md too",
2299 TRIAGE_PENDING
2300 );
2301 }
2302
2303 /// An audited architecture runs. A triage verdict on one would be a
2304 /// refusal class attached to something that never refuses.
2305 #[test]
2306 fn an_audited_architecture_carries_no_triage_verdict() {
2307 for name in AUDITED_GENERIC_GQA {
2308 assert!(
2309 unaudited_triage(name).is_none(),
2310 "`{name}` is audited and runs, so it must not carry a triage verdict"
2311 );
2312 }
2313 }
2314
2315 /// A verdict has to say something. An empty blocker, or one that
2316 /// cites no llama.cpp source line, is the failure mode this whole
2317 /// item exists to prevent: a refusal that names a blocker nobody
2318 /// checked.
2319 #[test]
2320 fn every_triage_verdict_cites_the_llama_cpp_line_that_decides_it() {
2321 let mut seen = 0;
2322 for p in architecture_catalog() {
2323 let Some(t) = p.triage else { continue };
2324 seen += 1;
2325 assert!(
2326 t.blocker.len() > 80,
2327 "`{}`'s blocker is too short to name anything: {:?}",
2328 p.gguf_name,
2329 t.blocker
2330 );
2331 let cites_llama_cpp =
2332 t.blocker.contains("src/models/") || t.blocker.contains("src/llama-arch.cpp");
2333 assert!(
2334 cites_llama_cpp,
2335 "`{}`'s blocker cites no llama.cpp source: {}",
2336 p.gguf_name, t.blocker
2337 );
2338 if t.class == TriageClass::Unknown {
2339 assert!(
2340 t.blocker.contains("WOULD SETTLE IT"),
2341 "`{}` is UNKNOWN but does not say what would settle it",
2342 p.gguf_name
2343 );
2344 }
2345 }
2346 assert!(
2347 seen == 34,
2348 "every unaudited generic architecture is triaged; found {seen}. \
2349 It was 47 until the triage found `minicpm3` was an MLA model on the \
2350 generic-GQA row and it moved to DedicatedOnly, 46 until five ONE MATCH ARM \
2351 rows -- deepseek, bailingmoe, seed_oss, maincoder, hunyuan-moe -- were admitted \
2352 with libllama-golden fixtures, and 41 until seven FIXTURE-AWAY rows -- \
2353 internlm2, xverse, ernie4_5, baichuan, exaone, bailingmoe2, plamo3 -- got \
2354 theirs (tests/fixture_away_graphs.rs)"
2355 );
2356 }
2357
2358 /// The class reaches the message. Two architectures in different
2359 /// classes must not read the same, which is the defect being fixed.
2360 #[test]
2361 fn the_refusal_detail_distinguishes_the_classes() {
2362 // `gemma` (v1), not `bailingmoe2`: that one was FIXTURE-AWAY
2363 // here until it got its fixture (`tests/fixture_away_graphs.rs`)
2364 // and is audited now, so it renders no detail at all.
2365 let fixture = unaudited_refusal_detail("gemma");
2366 let arm = unaudited_refusal_detail("ernie4_5-moe");
2367 let new_code = unaudited_refusal_detail("olmo2");
2368 // TRIAGE_PENDING is empty now that all 47 are read, so the
2369 // untriaged branch is exercised through a name the catalog does
2370 // not carry. The branch has to keep working: it is what a NEW
2371 // architecture added to the catalog would render until somebody
2372 // reads it.
2373 let untriaged = unaudited_refusal_detail("an-arch-nobody-has-read");
2374 assert!(fixture.contains("FIXTURE-AWAY"), "{fixture}");
2375 assert!(arm.contains("ONE MATCH ARM"), "{arm}");
2376 assert!(new_code.contains("NEW CODE"), "{new_code}");
2377 assert!(
2378 untriaged.contains("not done for `an-arch-nobody-has-read` yet"),
2379 "{untriaged}"
2380 );
2381 for a in [&fixture, &arm, &new_code, &untriaged] {
2382 for b in [&fixture, &arm, &new_code, &untriaged] {
2383 if !std::ptr::eq(a, b) {
2384 assert_ne!(a, b, "two refusal details are identical");
2385 }
2386 }
2387 }
2388 // The blocker itself, not only the class label, has to be in the
2389 // message -- a class with no specifics is the old refusal with a
2390 // new adjective.
2391 assert!(arm.contains("interleave_moe_layer_step"), "{arm}");
2392 assert!(new_code.contains("olmo2.cpp:47,52"), "{new_code}");
2393 }
2394
2395 /// An architecture nobody has checked is not audited, which is the
2396 /// whole point of the inversion.
2397 #[test]
2398 fn an_unchecked_architecture_is_not_audited() {
2399 assert!(!is_audited_generic("smallthinker"));
2400 assert!(!is_audited_generic("mellum"));
2401 assert!(!is_audited_generic("an-arch-that-does-not-exist"));
2402 }
2403}
2404
2405#[cfg(test)]
2406mod tests {
2407 use super::*;
2408
2409 #[test]
2410 fn known_mainstream_families_resolve() {
2411 assert_eq!(
2412 resolve_architecture("llama"),
2413 Some(ArchPath::GenericGqa {
2414 rope: RopeLayout::Norm
2415 })
2416 );
2417 assert_eq!(
2418 resolve_architecture("qwen2moe"),
2419 Some(ArchPath::GenericGqa {
2420 rope: RopeLayout::Neox
2421 })
2422 );
2423 assert_eq!(
2424 resolve_architecture("mistral"),
2425 Some(ArchPath::GenericGqa {
2426 rope: RopeLayout::Neox
2427 })
2428 );
2429 assert_eq!(
2430 resolve_architecture("yi"),
2431 Some(ArchPath::GenericGqa {
2432 rope: RopeLayout::Neox
2433 })
2434 );
2435 assert_eq!(
2436 resolve_architecture("mixtral"),
2437 Some(ArchPath::GenericGqa {
2438 rope: RopeLayout::Neox
2439 })
2440 );
2441 assert_eq!(
2442 resolve_architecture("phi3"),
2443 Some(ArchPath::GenericGqa {
2444 rope: RopeLayout::Neox
2445 })
2446 );
2447 assert_eq!(
2448 resolve_architecture("phi4"),
2449 Some(ArchPath::GenericGqa {
2450 rope: RopeLayout::Neox
2451 })
2452 );
2453 assert_eq!(
2454 resolve_profile("phi4").map(|p| p.family),
2455 Some(DecoderFamily::PhiFamily)
2456 );
2457 assert_eq!(
2458 resolve_architecture("gemma3"),
2459 Some(ArchPath::GenericGqa {
2460 rope: RopeLayout::Neox
2461 })
2462 );
2463 for arch in ["gemma4", "gemma4-assistant"] {
2464 assert!(
2465 matches!(
2466 resolve_architecture(arch),
2467 Some(ArchPath::DedicatedOnly { .. })
2468 ),
2469 "{arch} uses dedicated Gemma4 engine"
2470 );
2471 assert_eq!(
2472 resolve_profile(arch).map(|p| p.family),
2473 Some(DecoderFamily::GemmaFamily)
2474 );
2475 }
2476 assert!(matches!(
2477 resolve_architecture("gemma3n"),
2478 Some(ArchPath::DedicatedOnly { .. })
2479 ));
2480 assert_eq!(
2481 resolve_architecture("deepseek"),
2482 Some(ArchPath::GenericGqa {
2483 rope: RopeLayout::Norm
2484 })
2485 );
2486 assert_eq!(
2487 resolve_profile("qwen3").map(|p| p.qk_norm),
2488 Some(QkNormStyle::PerHead)
2489 );
2490 }
2491
2492 #[test]
2493 fn deepseek2_is_dedicated_mla_not_generic() {
2494 assert!(matches!(
2495 resolve_architecture("deepseek2"),
2496 Some(ArchPath::DedicatedOnly { .. })
2497 ));
2498 }
2499
2500 #[test]
2501 fn unknown_architecture_is_none() {
2502 assert_eq!(resolve_architecture("totally-unknown-arch"), None);
2503 // t5 is registered as dedicated encoder-decoder stub
2504 assert!(matches!(
2505 resolve_architecture("t5"),
2506 Some(ArchPath::DedicatedOnly { .. })
2507 ));
2508 }
2509
2510 #[test]
2511 fn dedicated_paths_are_not_generic() {
2512 assert!(matches!(
2513 resolve_architecture("glm-dsa"),
2514 Some(ArchPath::DedicatedOnly { .. })
2515 ));
2516 assert!(matches!(
2517 resolve_architecture("deepseek4"),
2518 Some(ArchPath::DedicatedOnly { .. })
2519 ));
2520 for arch in ["minimax-m2", "minimax-m3"] {
2521 assert!(
2522 matches!(
2523 resolve_architecture(arch),
2524 Some(ArchPath::DedicatedOnly { .. })
2525 ),
2526 "{arch} must fail closed, not silent generic GQA"
2527 );
2528 }
2529 assert!(
2530 matches!(
2531 resolve_architecture("llama4"),
2532 Some(ArchPath::DedicatedOnly {
2533 reason: "llama4 MoE + non-GQA attn -- see llama4_engine.rs tensor list"
2534 })
2535 ),
2536 "llama4 must fail closed, not silent generic GQA"
2537 );
2538 assert!(matches!(
2539 resolve_architecture("glm4"),
2540 Some(ArchPath::DedicatedOnly { .. })
2541 ));
2542 assert!(matches!(
2543 resolve_architecture("glm4moe"),
2544 Some(ArchPath::DedicatedOnly { .. })
2545 ));
2546 }
2547
2548 #[test]
2549 fn test_fixtures_remain_loadable() {
2550 for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
2551 assert!(matches!(
2552 resolve_architecture(arch),
2553 Some(ArchPath::TestFixture { .. })
2554 ));
2555 }
2556 }
2557
2558 #[test]
2559 fn catalog_has_unique_names() {
2560 let mut seen = std::collections::HashSet::new();
2561 for p in architecture_catalog() {
2562 assert!(
2563 seen.insert(p.gguf_name),
2564 "duplicate arch name {}",
2565 p.gguf_name
2566 );
2567 }
2568 }
2569
2570 #[test]
2571 fn gemma_family_does_not_fail_closed_on_softcap_keys() {
2572 assert!(unsupported_feature_keys("gemma3").is_empty());
2573 assert!(!unsupported_feature_keys("llama").is_empty());
2574 }
2575
2576 /// Parallel attention+FFN residual is not a tensor and, for MiniCPM,
2577 /// not even a metadata key -- llama.cpp hardcodes MiniCPM's three
2578 /// multipliers. Neither the tensor-consumption gate nor
2579 /// `unsupported_scaling_keys` can see the difference, so these
2580 /// architectures must not be admitted to the generic decoder at all.
2581 #[test]
2582 fn architectures_with_a_different_residual_topology_are_refused() {
2583 for arch in [
2584 "command-r",
2585 "cohere2",
2586 "cohere2moe",
2587 "falcon",
2588 "gptneox",
2589 "phi2",
2590 "plamo",
2591 "minicpm",
2592 ] {
2593 match resolve_architecture(arch) {
2594 Some(ArchPath::DedicatedOnly { reason }) => {
2595 assert!(!reason.is_empty(), "{arch} must say why");
2596 }
2597 other => panic!("{arch} must be refused, got {other:?}"),
2598 }
2599 }
2600 // The sequential-residual siblings stay on the generic path --
2601 // this is a named list, not a family-wide ban.
2602 //
2603 // `phimoe`, `starcoder2` and `nemotron` used to be checked here
2604 // too. They left the generic path for an unrelated reason (the
2605 // required bias tensors pinned by `tests/attn_bias.rs`), so
2606 // asserting them generic would now assert the wrong thing; what
2607 // still has to hold is that neither they nor the archs below are
2608 // refused for a *residual* reason they do not have.
2609 for arch in ["phi3", "plamo3", "qwen2", "llama"] {
2610 assert!(
2611 matches!(
2612 resolve_architecture(arch),
2613 Some(ArchPath::GenericGqa { .. })
2614 ),
2615 "{arch} must stay generic"
2616 );
2617 }
2618 for arch in ["phimoe", "starcoder2", "nemotron"] {
2619 match resolve_architecture(arch) {
2620 Some(ArchPath::DedicatedOnly { reason }) => assert!(
2621 reason.contains("bias"),
2622 "{arch} is refused for the wrong reason: {reason}"
2623 ),
2624 other => panic!("{arch} must be refused for its biases, got {other:?}"),
2625 }
2626 }
2627 }
2628
2629 /// Every architecture appears exactly once, so a refusal added next
2630 /// to an existing entry cannot be shadowed by whichever the lookup
2631 /// happens to find first.
2632 #[test]
2633 fn no_architecture_is_listed_twice() {
2634 let mut seen = std::collections::HashSet::new();
2635 for p in architecture_catalog() {
2636 assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
2637 }
2638 }
2639
2640 /// Every key this gate refuses must be a key a converter actually
2641 /// writes, or the gate cannot fire.
2642 ///
2643 /// `unsupported_feature_keys` listed `{arch}.attention.logit_softcapping`.
2644 /// llama.cpp writes `{arch}.attn_logit_softcapping`
2645 /// (`llama-arch.cpp:213`), and no converter emits the first
2646 /// spelling -- so that arm never matched anything, for any non-Gemma
2647 /// architecture, ever. Meanwhile `loader.rs` reads BOTH spellings,
2648 /// so the value was read and applied with the generic formula
2649 /// instead of being refused. For `grok` that is a wrong answer:
2650 /// `grok.cpp` folds the real attention scale into the softcap and
2651 /// passes `kq_scale = 1.0f`.
2652 ///
2653 /// A gate that cannot fire is worse than a missing gate, because it
2654 /// reads as coverage.
2655 #[test]
2656 fn every_refused_key_is_one_a_converter_actually_writes() {
2657 let keys: Vec<String> = unsupported_feature_keys("llama")
2658 .into_iter()
2659 .map(|(k, _)| k)
2660 .collect();
2661
2662 // Transcribed from `llama-arch.cpp`'s LLM_KV_NAMES.
2663 // `llama.attention.sliding_window_pattern` was on this list and
2664 // is deliberately off it: the alternating pattern IS
2665 // implemented (`ModelConfig::layer_sliding_window`, both
2666 // phases), so refusing it was a gate with a false reason that
2667 // also made the loader's own read of the key unreachable. See
2668 // the comment where it used to be. The array-valued case, which
2669 // ferrox genuinely cannot express, is refused in `loader.rs`
2670 // where the value can be inspected.
2671 for real in [
2672 "llama.attn_logit_softcapping",
2673 "llama.final_logit_softcapping",
2674 ] {
2675 assert!(
2676 keys.iter().any(|k| k == real),
2677 "{real} is a key llama.cpp writes and this gate must refuse it; \
2678 gate currently holds {keys:?}"
2679 );
2680 }
2681
2682 // Gemma implements all three, so it must still be exempt --
2683 // otherwise "fix the spelling" would have turned into "refuse
2684 // every Gemma checkpoint".
2685 assert!(
2686 unsupported_feature_keys("gemma2").is_empty(),
2687 "the Gemma family implements softcap and the SWA pattern"
2688 );
2689 // And the pattern key must not come back: a file carrying it
2690 // gets its period READ, which is what llama.cpp does.
2691 assert!(
2692 !keys.iter().any(|k| k.ends_with("sliding_window_pattern")),
2693 "the SWA pattern is implemented; refusing it makes the loader's read of the \
2694 key dead code: {keys:?}"
2695 );
2696 }
2697
2698 /// llama.cpp picks Gemma's `f_attention_scale` on the LAYER COUNT
2699 /// (`gemma3.cpp:20-33`, `gemma2.cpp:19-29`), and every published
2700 /// Gemma size -- not just 27B -- has `n_embd / n_head != head_dim`.
2701 /// An override derived from "the two widths disagree" would fire on
2702 /// all eight rows below and mis-scale six of them, which is why this
2703 /// walks the real sizes rather than asserting the 27B number alone.
2704 ///
2705 /// Shipped broken: `loader.rs` hardcoded `attention_scale = None`
2706 /// beside a comment naming the 27B exception, so Gemma-2-27B scored
2707 /// `sqrt(144/128)` and Gemma-3-27B `sqrt(168/128)` too large on
2708 /// every layer -- a sharper softmax than the trained one, with no
2709 /// error.
2710 #[test]
2711 fn gemma_27b_is_the_only_size_that_overrides_the_kernel_scale() {
2712 /// One published Gemma size, as its GGUF header declares it.
2713 struct Size {
2714 arch: &'static str,
2715 n_layers: usize,
2716 n_embd: usize,
2717 n_head: usize,
2718 /// `attention.key_length`, llama.cpp's `n_embd_head_k()`.
2719 head_dim: usize,
2720 /// The denominator llama.cpp's 27B branch produces, or
2721 /// `None` where it takes the `1/sqrt(n_embd_head_k)` branch.
2722 want_denom: Option<f32>,
2723 }
2724 let size = |arch, n_layers, n_embd, n_head, head_dim, want_denom| Size {
2725 arch,
2726 n_layers,
2727 n_embd,
2728 n_head,
2729 head_dim,
2730 want_denom,
2731 };
2732 let sizes = [
2733 size("gemma2", 26, 2304, 8, 256, None), // Gemma-2-2B
2734 size("gemma2", 42, 3584, 16, 256, None), // Gemma-2-9B
2735 size("gemma2", 46, 4608, 32, 128, Some(144.0)), // Gemma-2-27B
2736 size("gemma3", 18, 640, 4, 256, None), // Gemma-3-270M
2737 size("gemma3", 26, 1152, 4, 256, None), // Gemma-3-1B
2738 size("gemma3", 34, 2560, 8, 256, None), // Gemma-3-4B
2739 size("gemma3", 48, 3840, 16, 256, None), // Gemma-3-12B
2740 size("gemma3", 62, 5376, 32, 128, Some(168.0)), // Gemma-3-27B
2741 ];
2742 for &Size {
2743 arch,
2744 n_layers,
2745 n_embd,
2746 n_head,
2747 head_dim,
2748 want_denom,
2749 } in &sizes
2750 {
2751 // The premise of the whole test: no Gemma size has
2752 // `n_embd / n_head == head_dim`, so "the widths disagree"
2753 // cannot be the selector.
2754 assert_ne!(
2755 n_embd / n_head,
2756 head_dim,
2757 "{arch}/{n_layers}L: if this ever holds, re-read the derivation"
2758 );
2759 let got = attention_scale_override(arch, n_layers, n_embd, n_head, head_dim);
2760 match want_denom {
2761 None => assert_eq!(
2762 got, None,
2763 "{arch}/{n_layers}L takes llama.cpp's 1/sqrt(n_embd_head_k) branch, \
2764 which the attention kernels already apply"
2765 ),
2766 Some(denom) => {
2767 let want = 1.0 / denom.sqrt();
2768 let got = got.unwrap_or_else(|| {
2769 panic!("{arch}/{n_layers}L is llama.cpp's LLM_TYPE_27B; scale must be set")
2770 });
2771 assert!(
2772 (got - want).abs() < 1e-7,
2773 "{arch}/{n_layers}L: want 1/sqrt({denom}) = {want}, got {got}"
2774 );
2775 // The direction of the correction: the kernels' own
2776 // scale is the LARGER one, so the override shrinks
2777 // the scores rather than growing them.
2778 let kernel = 1.0f32 / (head_dim as f32).sqrt();
2779 assert!(
2780 kernel > got,
2781 "{arch}/{n_layers}L: kernel scale {kernel} must exceed {got}"
2782 );
2783 }
2784 }
2785 }
2786 // `gemma-embedding`, `gemma3n` and `gemma4` set
2787 // `f_attention_scale` unconditionally in llama.cpp and have no
2788 // `LLM_TYPE_27B` branch; nothing outside gemma2/gemma3 reaches
2789 // this at all.
2790 for arch in ["gemma-embedding", "gemma3n", "gemma4", "llama", "qwen3"] {
2791 assert_eq!(
2792 attention_scale_override(arch, 62, 5376, 32, 128),
2793 None,
2794 "{arch} has no LLM_TYPE_27B branch in llama.cpp"
2795 );
2796 }
2797 }
2798}