Skip to main content

ferrox_models/
capability.rs

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