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