Skip to main content

ferrox_models/
capability.rs

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