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