Skip to main content

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