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