1use crate::config::RopeLayout;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ArchScope {
20 TextGeneration,
22 DeferredEncoderEmbedding,
24 DeferredMultimodal,
26 DeferredDiffusion,
28 DeferredAudio,
30 EnumOnly,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DecoderFamily {
37 StandardGqa,
39 Qwen3Family,
41 GemmaFamily,
43 PhiFamily,
45 Mla,
47 Hybrid,
49 Recurrent,
51 EncoderDecoder,
53 Dedicated,
55 TestFixture,
57}
58
59#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum QkNormStyle {
75 #[default]
77 WholeVector,
78 PerHead,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum TriageClass {
105 FixtureAway,
109 OneMatchArm,
114 NewCode,
118 Unknown,
121}
122
123impl TriageClass {
124 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct UnauditedTriage {
168 pub class: TriageClass,
169 pub blocker: &'static str,
172}
173
174pub const TRIAGE_PENDING: &[&str] = &[
183 ];
186
187pub fn unaudited_triage(arch: &str) -> Option<UnauditedTriage> {
190 resolve_profile(arch).and_then(|p| p.triage)
191}
192
193pub 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
217pub const AUDITED_GENERIC_GQA: &[&str] = &[
235 "llama", "qwen2", "qwen2moe", "qwen3", "olmoe", "gemma2", "gemma3", "phi3", "gpt-oss", "dots1",
247 "qwen3moe",
254];
255
256pub fn is_audited_generic(arch: &str) -> bool {
259 AUDITED_GENERIC_GQA.contains(&arch)
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum ArchPath {
266 GenericGqa { rope: RopeLayout },
268 TestFixture { rope: RopeLayout },
270 DedicatedOnly { reason: &'static str },
273 Deferred { reason: &'static str },
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct ArchProfile {
280 pub gguf_name: &'static str,
281 pub scope: ArchScope,
282 pub family: DecoderFamily,
283 pub memory: MemoryKind,
284 pub rope: RopeLayout,
285 pub path: ArchPath,
286 pub qk_norm: QkNormStyle,
289 pub triage: Option<UnauditedTriage>,
293}
294
295impl ArchProfile {
296 fn triaged(mut self, class: TriageClass, blocker: &'static str) -> Self {
299 self.triage = Some(UnauditedTriage { class, blocker });
300 self
301 }
302}
303
304fn prof(
305 name: &'static str,
306 scope: ArchScope,
307 fam: DecoderFamily,
308 mem: MemoryKind,
309 rope: RopeLayout,
310 path: ArchPath,
311 qk: QkNormStyle,
312) -> ArchProfile {
313 ArchProfile {
314 gguf_name: name,
315 scope,
316 family: fam,
317 memory: mem,
318 rope,
319 path,
320 qk_norm: qk,
321 triage: None,
322 }
323}
324
325fn gqa_norm(name: &'static str) -> ArchProfile {
326 prof(
327 name,
328 ArchScope::TextGeneration,
329 DecoderFamily::StandardGqa,
330 MemoryKind::KvGqa,
331 RopeLayout::Norm,
332 ArchPath::GenericGqa {
333 rope: RopeLayout::Norm,
334 },
335 QkNormStyle::WholeVector,
336 )
337}
338
339fn gqa_neox(name: &'static str) -> ArchProfile {
340 prof(
341 name,
342 ArchScope::TextGeneration,
343 DecoderFamily::StandardGqa,
344 MemoryKind::KvGqa,
345 RopeLayout::Neox,
346 ArchPath::GenericGqa {
347 rope: RopeLayout::Neox,
348 },
349 QkNormStyle::WholeVector,
350 )
351}
352
353fn dedicated(name: &'static str, reason: &'static str) -> ArchProfile {
354 prof(
355 name,
356 ArchScope::TextGeneration,
357 DecoderFamily::Dedicated,
358 MemoryKind::KvGqa,
359 RopeLayout::Norm,
360 ArchPath::DedicatedOnly { reason },
361 QkNormStyle::WholeVector,
362 )
363}
364
365fn deferred_scope(name: &'static str, scope: ArchScope, reason: &'static str) -> ArchProfile {
366 prof(
367 name,
368 scope,
369 DecoderFamily::StandardGqa,
370 MemoryKind::None,
371 RopeLayout::Neox,
372 ArchPath::Deferred { reason },
373 QkNormStyle::WholeVector,
374 )
375}
376
377const NORM_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
385 (
386 "internlm2",
387 TriageClass::FixtureAway,
388 "src/models/internlm2.cpp:25-33 creates attn_norm, split Q/K/V via create_tensor_qkv \
389 (whose only biases are TENSOR_NOT_REQUIRED q/k/v biases, which loader.rs already \
390 loads), attn_output, ffn_norm and gate/up/down -- no QK-norm, no post-norms, no \
391 bias the generic decoder has no slot for. The graph is sequential-residual SiLU \
392 SwiGLU (:98,107-115) and load_arch_hparams (:4) reads nothing but the RMS epsilon. \
393 Admitting it needs a fixture or a parity run, not new code",
394 ),
395 (
396 "deepseek",
397 TriageClass::OneMatchArm,
398 "top-k renormalisation. src/models/deepseek.cpp:145-155 passes norm_w=false to \
399 build_moe_ffn, and conversion/deepseek.py's DeepseekModel (:124-217) never writes \
400 {arch}.expert_weights_norm -- only DeepseekV2Model does (:354) -- so no real \
401 `deepseek` GGUF carries the key. ferrox therefore falls back to \
402 `!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(arch)` (loader.rs:119, currently \
403 [\"olmoe\", \"qwen2moe\"]) and renormalises the selected experts' softmax weights \
404 where llama.cpp does not; adding \"deepseek\" to that list is the change. This is \
405 the same class of bug that made OLMoE answer wrongly. Everything else -- leading \
406 dense (:43), shared experts (:63-65), expert_weights_scale (:153), softmax gating \
407 (:154), sequential residual (:130,172) -- ferrox already has",
408 ),
409 (
410 "ernie4_5",
411 TriageClass::FixtureAway,
412 "src/models/ernie4-5.cpp's dense branch (:39-47,65-67) is the plain Llama tensor set \
413 and its graph (:100-142) is sequential-residual SiLU SwiGLU with \
414 kq_scale=1/sqrt(head_dim) (:120). The only thing ferrox has no slot for is the \
415 OPTIONAL attn_output.bias at :45 (TENSOR_NOT_REQUIRED), and a checkpoint carrying \
416 one is refused BY NAME by assert_every_tensor_consumed rather than run unbiased. \
417 Admitting it needs a fixture, not new code",
418 ),
419 (
420 "ernie4_5-moe",
421 TriageClass::OneMatchArm,
422 "interleaved MoE layers. src/models/ernie4-5-moe.cpp:64 makes a layer MoE only when \
423 `il >= n_layer_dense_lead && (il + 1) % n_moe_layer_step == 0`, but \
424 ModelConfig::layer_is_dense (config.rs:353-355) implements only the leading-dense \
425 prefix and nothing in ferrox reads {arch}.interleave_moe_layer_step \
426 (LLM_KV_INTERLEAVE_MOE_LAYER_STEP, read at ernie4-5.cpp:11). A real checkpoint \
427 therefore looks for blk.N.ffn_gate_exps.weight on a layer that stores \
428 blk.N.ffn_gate.weight and fails on the missing tensor. Routing is SOFTMAX with \
429 norm_w=true (:88-90) plus an optional exp_probs_b (ernie4-5.cpp:53), and \
430 ferrox_moe::route_top_k_biased already applies a selection bias under softmax -- \
431 this architecture is NOT sigmoid-routed",
432 ),
433 ("granite", TriageClass::NewCode, GRANITE_MULTIPLIERS),
434 ("granitemoe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
435 ("granite-moe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
438 (
439 "xverse",
440 TriageClass::FixtureAway,
441 "xverse is llama under a different name. src/models/xverse.cpp:25-33 creates \
442 attn_norm, split Q/K/V via create_tensor_qkv (optional biases only), attn_output, \
443 ffn_norm and gate/up/down and nothing else; the graph (:60-114) is the sequential \
444 `x + attn(norm(x))` then `y + ffn(norm(y))` residual with SiLU SwiGLU, and \
445 load_arch_hparams (:4) reads nothing but the RMS epsilon. llama-model.cpp's \
446 `llama_model_rope_type` puts it in the NORM group, which is where the catalog has \
447 it. Admitting it needs a fixture, not new code",
448 ),
449 (
450 "baichuan",
451 TriageClass::FixtureAway,
452 "for the 7B. src/models/baichuan.cpp:29-38 is the plain llama tensor set and the \
453 graph (:65-130) is sequential-residual SiLU SwiGLU, NORM RoPE. The 13B is a \
454 DIFFERENT model under the same string -- baichuan.cpp:5-13 switches on layer count \
455 and sets `f_max_alibi_bias = 8.0f` for the 40-layer case, with no GGUF key to \
456 detect it -- and ferrox already refuses that one by name at loader.rs:231, pinned \
457 by `baichuan_13b_is_refused_because_it_uses_alibi_and_the_7b_is_not`. So the \
458 unaudited refusal only ever reaches a 32-layer file, and for that file this is a \
459 fixture away",
460 ),
461 (
462 "chatglm",
463 TriageClass::FixtureAway,
464 "the one non-llama thing chatglm does is the FUSED gate+up SwiGLU, and that is the \
465 audited phi3 path exactly. src/models/chatglm.cpp:48 sizes `ffn_up` as \
466 `{n_embd, n_ff * 2}` and :128-133 calls build_ffn with a NULL gate and \
467 `LLM_FFN_SWIGLU, LLM_FFN_SEQ` -- the same call shape as phi3.cpp:52 and :144-149, \
468 and `phi3` is in AUDITED_GENERIC_GQA. ferrox handles it without an activation \
469 flag: `load_dense_expert` (loader.rs:1127-1161) finds no `ffn_gate`, takes the \
470 fused branch and splits the tensor into gate and up itself. Everything else \
471 (:41-50, :76-138) is attn_norm, create_tensor_qkv with optional biases, \
472 attn_output, ffn_norm and a sequential residual. Admitting it needs a fixture",
473 ),
474 (
475 "deci",
476 TriageClass::NewCode,
477 "DeciLM / Llama-3.1-Nemotron layers are not all the same shape. \
478 src/models/deci.cpp:30-34 reads n_head(i), n_head_kv(i) and n_ff(i) PER LAYER, and \
479 the graph branches on them three ways: `n_head == 0` is an attention-free layer \
480 that passes the residual straight through (:107-109), `n_head_kv == 0` is a \
481 \"linear attention\" layer that applies only `wo` with no Q/K/V and no RoPE \
482 (:115-118), and `n_ff == 0` skips the FFN and the residual add entirely with a \
483 `continue` (:147-149). ferrox's ModelConfig carries n_heads, n_kv_heads and \
484 expert_ffn_dim as SCALARS and its decoder runs the same block on every layer, so \
485 there is nowhere to put any of the three. Same class as `openelm`, one step worse",
486 ),
487 (
488 "olmo",
489 TriageClass::NewCode,
490 "OLMo-1 has NO norm weights at all. src/models/olmo.cpp:27-35 creates Q/K/V, \
491 attn_output and gate/up/down and not one norm tensor, and the graph calls \
492 `build_norm(x, NULL, NULL, LLM_NORM, il)` at all three sites (:65-67, :104-106, \
493 :128-130) -- non-parametric LayerNorm: subtract the mean, divide by the standard \
494 deviation, no learned weight and no bias. ferrox has only `rms_norm(x, w, eps)` \
495 and requires `blk.N.attn_norm.weight`, so it is both a different function and a \
496 missing tensor. It also reads an optional {arch}.attention.clamp_kqv (:5) that \
497 nothing here applies. Note this is OLMo-1; `olmo2` is a separate row and a \
498 separate blocker",
499 ),
500 (
501 "maincoder",
502 TriageClass::OneMatchArm,
503 "QK-norm ordering, the same arm `hunyuan-moe` needs. src/models/maincoder.cpp \
504 norms Q and K AFTER rotating them, not before -- ggml_rope_ext at :78-90, then \
505 `build_norm(Qcur, attn_q_norm, ...)` at :92 and the K norm at :95 -- while ferrox's \
506 decoder norms Q and K and then rotates. Everything else is plain: attn_norm (:28), \
507 create_tensor_qkv, attn_output, per-head attn_q_norm/attn_k_norm at \
508 {n_embd_head_k} (:33-34), ffn_norm (:36), gate/up/down, sequential residual with \
509 SiLU SwiGLU (:110,119-127), kq_scale = 1/sqrt(head_dim) (:104), and \
510 load_arch_hparams (:4) reads nothing but the RMS epsilon. Two architectures now \
511 need this one flag, which is the argument for adding it rather than special-casing",
512 ),
513 (
514 "bailingmoe",
515 TriageClass::OneMatchArm,
516 "llama.cpp READS `leading_dense_block_count` and then never branches on it. \
517 src/models/bailingmoe.cpp:5 reads LLM_KV_LEADING_DENSE_BLOCK_COUNT, but \
518 load_arch_tensors creates ffn_gate_inp and the expert and shared-expert tensors \
519 UNCONDITIONALLY for every layer (:39-54, no `if (i < n_layer_dense_lead)` branch \
520 anywhere) and the graph has no dense path either (:119-152). ferrox's \
521 `ModelConfig::layer_is_dense` does branch on the key, so on a checkpoint whose \
522 `first_k_dense_replace` is nonzero (conversion/bailingmoe.py:27 writes it \
523 verbatim) ferrox looks for `blk.0.ffn_gate.weight` on a layer that only has \
524 experts, and fails on the missing tensor. Making bailingmoe ignore that key is the \
525 change. The rest agrees: gating is hardcoded SOFTMAX (:137) and no converter writes \
526 expert_gating_func, so ferrox's softmax default matches; expert_weights_norm and \
527 expert_weights_scale come from metadata (:9,:8) and ferrox reads both",
528 ),
529 (
530 "arctic",
531 TriageClass::NewCode,
532 "a PARALLEL dense+MoE layer whose MoE branch reads the pre-attention residual. \
533 src/models/arctic.cpp:124-132 runs a dense SiLU FFN on `ffn_norm(ffn_inp)` and adds \
534 it back to ffn_inp, then :136-141 norms `inpSA` -- the layer INPUT, before \
535 attention -- through a second per-layer norm `ffn_norm_exps` (:45) and runs the MoE \
536 on that, and :154 sums the two. The generic decoder computes one FFN on the \
537 post-attention residual, so this is a different graph, not a wider one. The dense \
538 half is also sized `{n_embd, n_embd}` (:40-42) rather than n_ff. Same shape as \
539 `smallthinker`'s router: a branch fed from the raw layer input",
540 ),
541 (
542 "mistral3",
543 TriageClass::NewCode,
544 "per-position attention temperature tuning. src/models/mistral3.cpp:5,14-17 reads \
545 {arch}.attention.temperature_scale and seeds n_attn_temp_floor_scale from \
546 n_ctx_orig_yarn, and :109-111 builds a per-position Q scale that llama-graph.cpp \
547 computes as `log(floor(pos / floor_scale) + 1) * temp_scale + 1` (:159-167). ferrox \
548 has no per-position attention scale at all and no gate on that key, so a checkpoint \
549 carrying it would load and silently drop it -- the class of defect \
550 `unsupported_scaling_keys` exists for, on a key that list does not have. :9 also \
551 reads rope.scaling.yarn_log_multiplier, and loader.rs:588's own comment records \
552 that ferrox implements only YaRN's magnitude term. The rest (:46-83, :120-210) is \
553 leading-dense + MoE + shared expert on a sequential residual, which ferrox has",
554 ),
555 (
556 "nanbeige",
557 TriageClass::NewCode,
558 "nanbeige RUNS THE SAME PHYSICAL LAYERS MORE THAN ONCE. \
559 src/models/nanbeige.cpp:13-31 sets `n_layer_all = n_layer_phys * n_loops` and \
560 rewrites the per-layer head/ff/swa arrays so the graph walks n_layer_all steps over \
561 n_layer_phys sets of weights, and :167 applies `output_norm` to the running \
562 residual inside the loop at the end of each pass. ferrox's decoder walks its layer \
563 vector exactly once and has no concept of a loop count. Everything inside one pass \
564 (:52-63, :106-155) is plain llama, which is what makes this deceptive: the tensor \
565 set alone looks generic",
566 ),
567 ("arcee", TriageClass::NewCode, UNGATED_RELU_SQR),
568 ("plm", TriageClass::NewCode, UNGATED_RELU_SQR),
569];
570
571const NO_UPSTREAM_ARCH: &str =
579 "there is no llama.cpp graph to diff against: none of `mistral`, `mixtral` or `yi` \
580 appears in LLM_ARCH_NAMES (src/llama-arch.cpp) or in gguf-py's MODEL_ARCH_NAMES, and \
581 every real Mistral, Mixtral and Yi checkpoint converts to `llama` (llama.cpp's own \
582 conversion scripts emit MODEL_ARCH.LLAMA for all three; only `mistral3` and `mistral4` \
583 exist as their own strings). So these are ferrox-only rows that no llama.cpp-produced \
584 file can carry. THE HAZARD, and why this is not marked fixture-away: the catalog gives \
585 all three NEOX RoPE, while `llama` -- the string these models really ship under, and \
586 the graph they really are -- is in `llama_model_rope_type`'s NORM group \
587 (llama-model.cpp, the `case LLM_ARCH_LLAMA:` arm). A file spelling `mistral` would \
588 therefore be rotated on the wrong pairs of every Q/K head, which is the exact defect \
589 that caused the Llama-3.1-8B wrong-logits bug. It is latent only because the row \
590 refuses. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is literally one \
591 of these three. Absent one, the honest options are to delete the rows or to move them \
592 to NORM to match the graph they claim to be";
593
594const UNGATED_RELU_SQR: &str =
601 "an UNGATED ReLU-squared MLP, which is a different FFN shape and not only a different \
602 activation. src/models/arcee.cpp:39-40 and plm.cpp:39-40 create only `ffn_up` and \
603 `ffn_down` and no `ffn_gate` at all, and arcee.cpp:123-128 calls build_ffn with a NULL \
604 gate, `LLM_FFN_RELU_SQR` and `LLM_FFN_SEQ` -- i.e. `down(relu(up(x))^2)`, two matrices \
605 in sequence. ferrox's `ExpertWeights` has three required matrices and \
606 `FfnActivation` has only the gated Swiglu / SwigluFused / Gelu variants \
607 (config.rs:302-312), so there is no shape for this and no activation for it either. It \
608 fails closed rather than computing SwiGLU: `load_dense_expert` (loader.rs:1112-1136) \
609 finds no `ffn_gate`, falls to the Phi-3 fused path, and rejects an `ffn_up` that is \
610 `n_ff` rows rather than `2 * n_ff`";
611
612const GRANITE_MULTIPLIERS: &str =
615 "Granite's four multipliers. src/models/granite.cpp:7 reads {arch}.logit_scale as \
616 REQUIRED (granite-moe.cpp:5 too) and :8-10 reads residual_scale / embedding_scale / \
617 attention.scale; the graph divides the final logits by f_logit_scale (:188) and scales \
618 BOTH branch outputs by f_residual_scale before every residual add (:241-242, :301-302). \
619 The generic decoder applies none of them, and residual_scale in particular touches \
620 every CPU and Metal residual path. In practice a real Granite checkpoint never reaches \
621 THIS message: capability::unsupported_scaling_keys already refuses it by name at \
622 loader.rs:191, which runs before the unaudited gate. Separately, granite.cpp:206 gates \
623 RoPE on `hparams.rope_finetuned`, so a Granite export with rope.finetuned=false gets NO \
624 rotation at all -- the ALiBi class of divergence, with no ferrox expression";
625
626const NEOX_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
629 (
630 "olmo2",
631 TriageClass::NewCode,
632 "olmo2 has NO pre-attention norm and NO pre-FFN norm. load_arch_tensors creates \
633 attn_post_norm and ffn_post_norm (src/models/olmo2.cpp:47,52) and no attn_norm or \
634 ffn_norm at all; the graph projects Q/K/V straight off the residual (:92, \
635 `cur = inpL`) and runs build_ffn on the raw ffn_inp (:169). The generic decoder \
636 REQUIRES blk.N.attn_norm.weight and a pre-FFN norm and applies both on every layer, \
637 so this is a different residual topology, not a missing tensor. Its post-norms are \
638 NOT the blocker: ferrox applies post_attn_norm and post_ffn_norm in exactly \
639 llama.cpp's places already (:160-163,:178-180 vs decoder.rs:4274-4281,:4333-4341). \
640 olmo2 additionally runs its SWA layers' RoPE with YaRN disabled (freq_scale=1, \
641 ext_factor=0, attn_factor=1, :118-133), a second per-layer RoPE variant ferrox \
642 cannot express",
643 ),
644 (
645 "exaone4",
646 TriageClass::NewCode,
647 "same shape as olmo2: src/models/exaone4.cpp:60-67 creates attn_post_norm, per-head \
648 attn_q_norm/attn_k_norm and ffn_post_norm and NO attn_norm and NO ffn_norm, and the \
649 graph projects Q/K/V off the raw residual (:118) and runs build_ffn on the raw \
650 ffn_inp (:159). The generic decoder requires and applies both pre-norms, which is a \
651 different residual topology. Its optional NEXTN/MTP tensors (:69-73) are a separate \
652 matter and are refused by name by the unread-tensor gate",
653 ),
654 (
655 "seed_oss",
656 TriageClass::OneMatchArm,
657 "the gpt-oss norm slot. seed_oss stores its PRE-FFN norm as \
658 blk.N.post_attention_norm.weight and carries NO blk.N.ffn_norm.weight: \
659 src/models/seed-oss.cpp:36-37 creates attn_norm and attn_post_norm only, and \
660 :113-115 applies attn_post_norm to ffn_inp, i.e. AFTER the attention residual. That \
661 is gpt-oss's slot exactly, and glm4moe's. loader.rs already implements it behind \
662 `let is_gpt_oss = arch == \"gpt-oss\"` (loader.rs:1736, used at :1796 and :2004); \
663 widening that one flag is the change. Everything else (:63-126) is plain GQA with a \
664 sequential residual and SiLU SwiGLU",
665 ),
666 (
667 "exaone",
668 TriageClass::FixtureAway,
669 "src/models/exaone.cpp:29-38 is the plain Llama tensor set plus an optional global \
670 rope_freqs.weight, which loader.rs:521 already loads; the graph is \
671 sequential-residual SiLU SwiGLU (:99,106-113) and load_arch_hparams (:4) reads \
672 nothing but the RMS epsilon. Note this is EXAONE 3.x, not exaone4, which is a \
673 different graph. Admitting it needs a fixture, not new code",
674 ),
675 (
676 "bailingmoe2",
677 TriageClass::FixtureAway,
678 "src/models/bailingmoe2.cpp is plain GQA on the generic path: attn_norm (:47), a \
679 FUSED attn_qkv (:49) that load_qkv_projections already splits, per-head \
680 attn_q_norm/attn_k_norm ({n_embd_head_k}, :52-53) applied BEFORE RoPE (:123-135) the \
681 way ferrox applies them, ffn_norm (:55), leading dense layers (:57), exp_probs_b \
682 (:61), shared experts (:67-69), and expert_weights_norm / expert_weights_scale / \
683 expert_gating_func all read from METADATA (:9-11) rather than hardcoded, which \
684 loader.rs reads too. Sequential residual (:149,191). What is missing is EVIDENCE. \
685 Caveat, and it fails closed: a checkpoint that ships the last n_layer_nextn layers' \
686 NEXTN and layer_out_norm tensors (:78-84) is refused by name by the unread-tensor \
687 gate",
688 ),
689 (
690 "hunyuan-moe",
691 TriageClass::OneMatchArm,
692 "QK-norm ordering. src/models/hunyuan-moe.cpp applies attn_k_norm and attn_q_norm \
693 AFTER ggml_rope_ext -- RoPE at :93 and :104, then build_norm at :110 and :115 -- \
694 while ferrox's decoder norms Q and K and then rotates. That is one named \
695 per-architecture ordering flag, but it changes every layer's attention scores, so it \
696 cannot be admitted without it. Everything else -- attn_norm (:31), ffn_norm (:39), a \
697 shared expert on every layer (:137-143), softmax with norm_w=true and \
698 expert_weights_scale (:147-150), sequential residual (:129,163) -- ferrox has",
699 ),
700 (
701 "mellum",
702 TriageClass::NewCode,
703 "two per-layer RoPE variants in one model. src/models/mellum.cpp:128-142 runs the \
704 SWA layers' RoPE with YaRN switched off -- freq_scale = 1.0, ext_factor = 0.0, \
705 attn_factor = 1.0 -- while the full-attention layers use the model's own YaRN \
706 (:143-154). ferrox carries one YaRN configuration for the whole model (it has \
707 `rope_theta_swa` for the BASE only) and cannot express a per-layer ext_factor. \
708 Second, smaller hazard on the same architecture: :12-17 accepts the sliding-window \
709 pattern as a scalar OR as a per-layer ARRAY, and ferrox reads it only as a scalar \
710 (`GgufValue::as_u64` returns None for an array), so an array-valued file falls back \
711 to `default_swa_layout`'s period of 4 with nothing saying it substituted its own \
712 layout for the file's. The tensor set and residual (:45-68, :169-197) are generic",
713 ),
714 (
715 "talkie",
716 TriageClass::NewCode,
717 "talkie has NO norm weights and a learned per-layer skip connection. In \
718 src/models/talkie.cpp every \
719 normalisation is `build_norm(x, nullptr, nullptr, LLM_NORM_RMS, ...)` -- \
720 non-parametric RMSNorm, no weight tensor -- at :50 (on the embeddings, before layer \
721 0), :68, :90, :110 and :137; the only norm weight in the file is `attn_q_norm`, and \
722 it is shaped {1, n_head} (:26), one SCALAR PER HEAD rather than a head_dim vector, \
723 which is neither of ferrox's two QkNormStyle variants. Each layer then adds \
724 `inp_skip * out_scale` (:123-126) with a per-layer learned scalar `out_scale` \
725 (:32), a second residual stream the generic decoder has no slot for, and :5 reads \
726 {arch}.logit_scale as REQUIRED",
727 ),
728 (
729 "mimo2",
730 TriageClass::NewCode,
731 "attention sinks on a non-gpt-oss architecture, plus per-layer shapes. \
732 src/models/mimo2.cpp:58 creates `attn_sinks` per layer; ferrox implements sinks \
733 only inside the gpt-oss path and, per docs/MODELS.md, on CPU only. :47-49 reads \
734 n_head and the KV widths PER LAYER, :16 and :181 scale the attention output by \
735 {arch}.attention.value_scale (a key ferrox neither reads nor gates), :6-12 makes \
736 SWA unconditional with a per-layer is_swa ARRAY rather than a period, and :19,:76-82 \
737 add NEXTN/MTP layers with a `layer_out_norm`. Any one of the first three would \
738 disqualify it; the dense-or-MoE-per-layer choice at :63-72 is the only part ferrox \
739 already has",
740 ),
741 (
742 "plamo3",
743 TriageClass::FixtureAway,
744 "PLaMo-3 is the sandwich-norm shape ferrox already implements, and this one really \
745 does line up slot for slot. src/models/plamo3.cpp:46-58 creates attn_norm, a FUSED \
746 attn_qkv (:47) that load_qkv_projections splits, per-head attn_q_norm/attn_k_norm \
747 (:49-50) applied BEFORE RoPE (:128-137), attn_post_norm (:52), ffn_norm (:54), \
748 ffn_post_norm (:55) and a fused SwiGLU ffn_up of `n_ff * 2` (:57) driven by \
749 `LLM_FFN_SWIGLU, LLM_FFN_SEQ` (:168) -- the audited phi3 path. The graph applies \
750 the post-norms exactly where ferrox does: attn_post_norm to the attention branch \
751 before the residual add (:152-155) and ffn_post_norm to the FFN output before its \
752 add (:171-174). Its SWA uses a scalar sliding_window_pattern that \
753 conversion/plamo.py's Plamo3Model writes verbatim (:176-177), and \
754 `default_swa_layout` already carries plamo3 as period 8. Plamo3Model also inherits \
755 TextModel's scalar head_count / key_length / value_length, so the per-layer \
756 `hparams.n_head(i)` accessors in the graph are uniform -- unlike deci, laguna and \
757 openelm, whose converters really do write arrays. CONFIRM ON THE FIXTURE: that \
758 attention.key_length equals attention.value_length, since llama.cpp carries \
759 head_dim_q and head_dim_v separately (:25-26) and ferrox has one head_dim",
760 ),
761 (
762 "afmoe",
763 TriageClass::NewCode,
764 "gated attention plus NoPE layers. src/models/afmoe.cpp:73 creates `wqkv_gate` \
765 (LLM_TENSOR_ATTN_GATE), a learned gate applied to the attention output that the \
766 generic decoder has no slot for, and :137-138 skips RoPE where \
767 `(il + 1) % n_no_rope_layer_step == 0`, the smollm3 class with no GGUF key. It also \
768 scales the embeddings by sqrt(n_embd) at :120, which ferrox does only for the Gemma \
769 family. THIRD, and the quiet one: :8 reads expert_gating_func as OPTIONAL and \
770 :29-30 defaults it to SIGMOID when absent, while ferrox's fallback \
771 (loader.rs:375, SIGMOID_GATING_ARCHITECTURES) defaults to softmax for any \
772 architecture not on its list -- so a checkpoint omitting the key would be routed \
773 through the wrong scoring function. That last one is the `deepseek` shape and would \
774 need fixing even if the rest were free",
775 ),
776 (
777 "apertus",
778 TriageClass::NewCode,
779 "xIELU, with four PER-LAYER parameter arrays. src/models/apertus.cpp:6-9 reads \
780 xielu_alpha_n, xielu_alpha_p, xielu_beta and xielu_eps as n_layer-long arrays and \
781 :132-135 indexes them per layer; `FfnActivation` (config.rs:302-312) has three \
782 variants and no way to carry a per-layer parameter at all. The FFN is also UNGATED \
783 -- :45-46 creates only ffn_down and ffn_up, no ffn_gate -- so it is the same \
784 two-matrix shape as `arcee` and `plm` on top of the activation. It further requires \
785 optional attn_q_norm/attn_k_norm BIASES (:50,:52), and ferrox's norms take a weight \
786 only",
787 ),
788 (
789 "exaone-moe",
790 TriageClass::NewCode,
791 "the GLOBAL layers get no RoPE. src/models/exaone-moe.cpp:155-161 wraps both \
792 ggml_rope_ext calls in `if (is_local_layer)`, where is_local_layer is \
793 `hparams.is_swa(il)` (:136) -- so on the full-attention layers of every period Q \
794 and K are never rotated. ferrox rotates every layer, and there is no GGUF key that \
795 says otherwise: the SWA pattern implies it. Checked and CLEAN on the other axis: \
796 :5 seeds n_swa = 128 but :13 reads {arch}.attention.sliding_window as REQUIRED, so \
797 the window is always the file's own value and ferrox reads the same number, and \
798 `default_swa_layout` already carries exaone-moe as period 4. The MoE half (:72-93) \
799 -- leading dense, exp_probs_b, shared expert, gating from metadata -- ferrox has",
800 ),
801 (
802 "grovemoe",
803 TriageClass::NewCode,
804 "a SECOND bank of experts, not just a scale. src/models/grovemoe.cpp:57-59 creates \
805 `ffn_gate_chexps` / `ffn_down_chexps` / `ffn_up_chexps` -- `n_expert / \
806 n_group_experts` \"chunk\" experts with their own width n_ff_chexp -- and the graph \
807 runs build_moe_ffn TWICE (:137 over the ordinary experts, :153 over the chunk \
808 experts) before :167 adds `scale(moe_out, expert_group_scale)` to the residual. The \
809 inventory recorded only the post-sum group scale and called this small; the second \
810 expert bank with its own routing is the larger half and ferrox's MoE layer holds \
811 one bank. Both n_group_experts and expert_group_scale are REQUIRED keys (:6-7). \
812 QK-norm is before RoPE (:100-109), which is the one thing that would otherwise have \
813 been a blocker",
814 ),
815 (
816 "hunyuan-dense",
817 TriageClass::OneMatchArm,
818 "two named pieces, both small. hunyuan-dense has no graph of its own -- \
819 models.h:1830 derives it from llama_model_hunyuan_vl -- so the file to read is \
820 src/models/hunyuan-vl.cpp. (1) It applies attn_k_norm and attn_q_norm AFTER \
821 ggml_rope_ext (:105-123 rotate, then :132 and :137 norm), the same ordering flag \
822 `hunyuan-moe` and `maincoder` need; three architectures now want it. (2) :8-12 \
823 rescales rope_freq_base_train by `alpha^(head_dim / (head_dim - 2))` when \
824 {arch}.rope.scaling.alpha is positive -- an NTK-alpha base rescale ferrox neither \
825 applies nor gates, so a checkpoint carrying the key would load and rotate at the \
826 unscaled base. Everything else (:39-51, :86-167) is attn_norm, per-head QK norm, \
827 ffn_norm, dense SiLU SwiGLU and a sequential residual",
828 ),
829 (
830 "laguna",
831 TriageClass::NewCode,
832 "per-layer head counts AND a second rotary width. conversion/laguna.py:79 calls \
833 `add_head_count(per_layer_heads)` with a LIST, so the array is really in the file, \
834 and src/models/laguna.cpp:87-88 and :176-177 read n_head(i) / n_head_kv(i) per \
835 layer while ferrox carries both as scalars. :50 then reads \
836 LLM_KV_ROPE_DIMENSION_COUNT_SWA into `n_rot_swa`, so the sliding-window layers \
837 rotate a DIFFERENT number of dimensions than the full-attention layers (its own \
838 comment at :43-45: full layers YaRN over 64 dims, SWA layers plain RoPE over 128); \
839 ferrox has one rotary_dim. It also creates `wqkv_gate` (:124), the gated-attention \
840 tensor afmoe has, and :55-56 defaults expert_gating_func to SIGMOID when the key is \
841 absent where ferrox would default to softmax. `default_swa_layout` already has \
842 laguna as dense_first period 4, which is correct and is not the blocker",
843 ),
844 (
845 "step35",
846 TriageClass::NewCode,
847 "a per-LAYER rotary width. src/models/step35.cpp:65-70 takes `n_rot_max` as the max \
848 of `hparams.n_rot(i)` over all layers -- because n_rot varies by layer -- and :9 \
849 first halves n_rot_full; ferrox has one rotary_dim for the model. On top of that: \
850 per-layer SwiGLU clamp arrays for the routed and shared experts (:28-29, \
851 LLM_KV_SWIGLU_CLAMP_EXP / _SHEXP), where ferrox's only clamp is the gpt-oss scalar; \
852 a `wqkv_gate` (:96); a per-layer is_swa ARRAY rather than a period (:26), which \
853 ferrox reads only as a scalar; NEXTN/MTP layers with trunk-only and MTP-only load \
854 modes (:32-49); and expert_gating_func defaulting to SIGMOID when absent (:19-20) \
855 where ferrox defaults to softmax. The inventory guessed this was \"probably \
856 parameterisable from the gpt-oss clamp\" -- the clamp is, the per-layer n_rot is \
857 not",
858 ),
859 ("mistral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
860 ("mixtral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
861 ("yi", TriageClass::Unknown, NO_UPSTREAM_ARCH),
862 (
863 "grok",
864 TriageClass::NewCode,
865 "grok-1 hardcodes five constants BEFORE letting an optional key override them \
866 (src/models/grok.cpp:5-21): logit_scale = 0.5773502691896257 (1/sqrt(3)), \
867 embedding_scale = 78.38367176906169, attn_out_scale = 0.08838834764831845 \
868 (1/sqrt(128)), and attn / router logit softcapping both 30.0. A GGUF omitting every \
869 key is still scaled by all five, so a key-presence gate such as \
870 `unsupported_scaling_keys` cannot see them -- the same blind spot `minicpm` is \
871 refused for. On top of that the graph is not the generic one: attention runs with \
872 kq_scale = 1.0f (:137) and folds the real scale into a tanh softcap instead \
873 (llama-graph.cpp:2579-2581), every layer computes BOTH a dense GELU FFN and a GELU \
874 MoE and sums them scaled by sqrt(2)/2 (:171-184), and `blk.N.attn_output_norm` \
875 (:62, LLM_TENSOR_ATTN_OUT_NORM = \"blk.%d.attn_output_norm\", llama-arch.cpp:423) is \
876 a tensor name ferrox never reads. Router logit softcapping has no ferrox concept at \
877 all. `uses_geglu` already covers grok's GELU, which is necessary and nowhere near \
878 sufficient",
879 ),
880 (
881 "dbrx",
882 TriageClass::NewCode,
883 "LayerNorm, not RMSNorm. src/models/dbrx.cpp:4 reads LLM_KV_ATTENTION_LAYERNORM_EPS \
884 (not the RMS one) and the graph normalises with `LLM_NORM` at all three sites -- \
885 :69-71 pre-attention, :110-112 pre-FFN, :140-142 final -- which subtracts the mean; \
886 ferrox has only `rms_norm(x, w, eps)`, a different function of the same tensors on \
887 every layer. Note this is NOT caught by the required-bias refusal group: dbrx \
888 creates no norm bias tensors at all, so the marker that group keys on is absent \
889 while the normalisation is still LayerNorm. It also requires \
890 {arch}.attention.clamp_kqv (:5, REQUIRED) and carries no `ffn_norm` -- \
891 `attn_out_norm` (:34) IS the pre-FFN norm (:110-113), the gpt-oss slot again but \
892 under the unread name `blk.%d.attn_output_norm`",
893 ),
894 (
895 "smallthinker",
896 TriageClass::NewCode,
897 "the MoE router reads a DIFFERENT tensor. src/models/smallthinker.cpp:111 computes \
898 the router logits from the raw layer input `inpL`, before the attention block, and \
899 passes them into build_moe_ffn as a precomputed `probs` with a NULL ffn_gate_inp \
900 (:151-161); every other MoE architecture routes on the normed FFN input, which is \
901 what ferrox computes. Two more, either of which alone would disqualify it: (1) NoPE \
902 layers with no GGUF key -- llama-hparams.h:203 defaults n_no_rope_layer_step to 4 \
903 and the SWA branch (:6-15) never overwrites it, so :108-109's \
904 `use_rope = n_no_rope_layer_step == n_layer || il % n_no_rope_layer_step != 0` \
905 leaves layers 0, 4, 8 ... unrotated, the `smollm3` class exactly, which ferrox \
906 refuses outright; (2) `LLM_FFN_RELU` experts (:158), and FfnActivation has no ReLU \
907 variant. :8 also pins n_swa to 4096 over whatever the file declares. \
908 `default_swa_layout` and `swa_rope_base_follows_model` already carry smallthinker \
909 correctly; they are not the blocker",
910 ),
911 (
912 "bitnet",
913 TriageClass::NewCode,
914 "two norms INSIDE the blocks, in slots ferrox does not have. \
915 src/models/bitnet.cpp:24,36 require `attn_sub_norm` and `ffn_sub_norm`, and the \
916 graph applies attn_sub_norm to the attention output BEFORE the output projection \
917 (:101-106 -- not after it, where ferrox's post_attn_norm sits) and ffn_sub_norm \
918 between the gate*up product and `ffn_down` (:135-140), inside the FFN. It also \
919 carries a per-tensor `scale` for every projection (:27-43, applied via \
920 build_lora_mm) and creates no `output` tensor at all, taking the LM head from \
921 `tok_embd` unconditionally (:164). ferrox refuses it by name today via the \
922 unread-tensor gate (`blk.N.attn_sub_norm`, llama-arch.cpp:510-511), which is the \
923 right outcome and not a small fix",
924 ),
925 (
926 "openelm",
927 TriageClass::NewCode,
928 "per-LAYER head counts and FFN width. src/models/openelm.cpp:26-28 reads \
929 `hparams.n_head(i)`, `n_head_kv(i)` and `n_ff(i)` per layer and sizes the fused \
930 `wqkv` as `n_embd x (2*n_head_kv(i) + n_head(i)) * n_embd_head_k` (:34), and the \
931 graph re-derives those widths for every layer (:67-69). ferrox's ModelConfig \
932 carries n_heads, n_kv_heads and expert_ffn_dim as SCALARS, and \
933 `load_qkv_projections` splits a fused QKV at offsets computed from those scalars, \
934 so there is nowhere to put this. It fails closed, but NOT with this message: \
935 conversion/openelm.py:57-59 writes head_count, head_count_kv and \
936 feed_forward_length as ARRAYS, and `GgufValue::as_u64` returns None for an array \
937 (ferrox-gguf/src/lib.rs:83-93), so the load dies on a missing-hparam error for keys \
938 the file does carry, before the unaudited gate is reached. That misleading message \
939 is the `glm4moe` shape and should be fixed alongside",
940 ),
941];
942
943pub fn architecture_catalog() -> &'static [ArchProfile] {
946 use std::sync::OnceLock;
947 use ArchScope::*;
948 use DecoderFamily::*;
949 use MemoryKind::*;
950 use QkNormStyle::*;
951 use RopeLayout::*;
952
953 static CAT: OnceLock<Vec<ArchProfile>> = OnceLock::new();
954 CAT.get_or_init(|| {
955 let mut v = Vec::with_capacity(160);
956 v.push(gqa_norm("llama"));
963 for (n, class, blocker) in NORM_ROPE_TRIAGED {
968 v.push(gqa_norm(n).triaged(*class, blocker));
969 }
970 for n in [
971 "olmoe", "qwen2", "qwen2moe",
972 "gpt-oss",
978 "dots1",
988 ] {
989 v.push(gqa_neox(n));
990 }
991 for (n, class, blocker) in NEOX_ROPE_TRIAGED {
993 v.push(gqa_neox(n).triaged(*class, blocker));
994 }
995 for (n, reason) in [
1012 (
1013 "smollm3",
1014 "a NoPE layer pattern: llama.cpp hardcodes \
1015 `hparams.n_no_rope_layer_step = 4` (src/models/smollm3.cpp:5) and \
1016 skips RoPE where `(il + 1) % 4 == 0` (:69), so 9 of a 36-layer \
1017 SmolLM3-3B's layers get NO rotation at all. There is NO GGUF key \
1018 for it, so no metadata gate could see it: the tensor set matches \
1019 the generic llama set exactly and the file loads clean. The \
1020 generic decoder rotates every layer, which is a different model. \
1021 Same shape as the ALiBi group below, and found the same way",
1022 ),
1023 (
1024 "gpt2",
1025 "learned absolute position embeddings (`position_embd.weight`, \
1026 src/models/gpt2.cpp:19,74) and no RoPE; the generic decoder has no \
1027 slot for them and rotates instead",
1028 ),
1029 (
1030 "mpt",
1031 "ALiBi attention bias (src/models/mpt.cpp:6), plus an optional \
1032 learned `position_embd` and an optional QKV clamp; the generic \
1033 decoder implements none of the three and applies RoPE instead",
1034 ),
1035 (
1036 "refact",
1037 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1038 GGUF key to detect it (src/models/refact.cpp:12); the generic \
1039 decoder applies RoPE instead",
1040 ),
1041 (
1042 "bloom",
1043 "ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
1044 GGUF key (src/models/bloom.cpp:18), plus a `token_embd_norm` the \
1045 generic decoder never applies; RoPE is applied instead",
1046 ),
1047 (
1048 "jais",
1049 "ALiBi attention bias (src/models/jais.cpp:5); the generic decoder \
1050 applies RoPE instead",
1051 ),
1052 ] {
1053 v.push(prof(
1054 n,
1055 TextGeneration,
1056 StandardGqa,
1057 KvGqa,
1058 Norm,
1064 ArchPath::DedicatedOnly { reason },
1065 WholeVector,
1066 ));
1067 }
1068 for (n, rope, reason) in [
1097 (
1098 "codeshell",
1099 Neox,
1100 "required bias tensors with no slot in the generic decoder: \
1101 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1102 (src/models/codeshell.cpp:36,42,45), plus the LayerNorm biases \
1103 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:24,31,39) \
1104 -- the generic decoder is RMSNorm-only and drops all six",
1105 ),
1106 (
1107 "jais2",
1108 Neox,
1109 "required bias tensors with no slot in the generic decoder: \
1110 `attn_output.bias`, `ffn_up.bias`, `ffn_down.bias` \
1111 (src/models/jais2.cpp:41,48,50), plus the LayerNorm biases \
1112 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:20,30,44). \
1113 Only its Q/K/V biases (:38-40) would have been applied",
1114 ),
1115 (
1116 "starcoder",
1117 Norm,
1118 "required bias tensors with no slot in the generic decoder: the \
1119 *fused* `attn_qkv.bias` (src/models/starcoder.cpp:40), which \
1120 `load_qkv_projections` never looks for because it reads bias only \
1121 under the split `attn_q.bias` names; `attn_output.bias`, \
1122 `ffn_down.bias`, `ffn_up.bias` (:43,49,52); and the LayerNorm \
1123 biases `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` \
1124 (:24,37,46). It also adds a learned `position_embd` to the \
1125 embeddings (:75) that the generic decoder has no slot for",
1126 ),
1127 (
1128 "starcoder2",
1129 Neox,
1130 "required bias tensors with no slot in the generic decoder: \
1131 `attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
1132 (src/models/starcoder2.cpp:41,50,51), plus the LayerNorm biases \
1133 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:23,35,44)",
1134 ),
1135 (
1136 "phimoe",
1137 Neox,
1138 "required bias tensors with no slot in the generic decoder: \
1139 `attn_output.bias` and an `output.bias` on the LM head \
1140 (src/models/phimoe.cpp:33,23), plus the LayerNorm biases \
1141 `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:21,29,36). \
1142 `phi3` stays generic: it requires none of them",
1143 ),
1144 (
1145 "nemotron",
1146 Neox,
1147 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1148 `ffn_norm.bias` (src/models/nemotron.cpp:19,26,35). llama.cpp \
1149 normalises with `build_norm(..., LLM_NORM, ...)` and a bias; the \
1150 generic decoder applies RMSNorm with weight only, which is a \
1151 different function of the same tensors at every layer",
1152 ),
1153 (
1154 "orion",
1155 Neox,
1156 "required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
1157 `ffn_norm.bias` (src/models/orion.cpp:18,25,31); the generic \
1158 decoder is RMSNorm-only and drops all three",
1159 ),
1160 (
1161 "stablelm",
1162 Neox,
1163 "required LayerNorm biases `output_norm.bias` and `attn_norm.bias` \
1164 (src/models/stablelm.cpp:20,28); the generic decoder is \
1165 RMSNorm-only and drops both",
1166 ),
1167 (
1168 "qwen",
1169 Neox,
1170 "a required *fused* `attn_qkv.bias` (src/models/qwen.cpp:28). \
1171 `load_qkv_projections` splits the fused `attn_qkv.weight` but reads \
1172 bias only under the split `attn_q.bias` / `attn_k.bias` / \
1173 `attn_v.bias` names, so Qwen-1's QKV bias is silently dropped and \
1174 every Q, K and V projection runs unbiased. Qwen-2 and later store \
1175 the split spelling and stay generic",
1176 ),
1177 ] {
1178 v.push(prof(
1179 n,
1180 TextGeneration,
1181 StandardGqa,
1182 KvGqa,
1183 rope,
1188 ArchPath::DedicatedOnly { reason },
1189 WholeVector,
1190 ));
1191 }
1192 v.push(prof(
1193 "qwen3",
1194 TextGeneration,
1195 Qwen3Family,
1196 KvGqa,
1197 Neox,
1198 ArchPath::GenericGqa { rope: Neox },
1199 PerHead,
1200 ));
1201 v.push(prof(
1202 "qwen3moe",
1203 TextGeneration,
1204 Qwen3Family,
1205 KvGqa,
1206 Neox,
1207 ArchPath::GenericGqa { rope: Neox },
1208 PerHead,
1209 ));
1210 v.push(
1211 prof(
1212 "gemma",
1213 TextGeneration,
1214 GemmaFamily,
1215 KvGqa,
1216 Neox,
1217 ArchPath::GenericGqa { rope: Neox },
1218 PerHead,
1219 )
1220 .triaged(
1221 TriageClass::FixtureAway,
1222 "src/models/gemma.cpp:16-33 creates exactly the tensors the generic decoder \
1223 loads -- attn_norm, split Q/K/V, attn_output, ffn_norm, gate/up/down -- with \
1224 no biases, no QK-norm and no post-norms, and its graph is \
1225 sequential-residual (:97,115). The three Gemma-specific pieces are all \
1226 implemented: the sqrt(n_embd) embedding scale (:49 vs loader.rs:467-474's \
1227 GemmaFamily embedding_scale), GeGLU (:112 vs FfnActivation::Gelu) and a \
1228 1/sqrt(head_dim) attention scale (:86 scales Q, then :91 passes \
1229 kq_scale=1.0f -- which is what loader.rs:476-480 leaving attention_scale as \
1230 None already produces). Gemma-1 declares no softcap and no sliding window, \
1231 so the Gemma-2/3 machinery is inert here. Admitting it needs a fixture or a \
1232 parity run, not new code",
1233 ),
1234 );
1235 v.push(prof(
1236 "gemma2",
1237 TextGeneration,
1238 GemmaFamily,
1239 KvIswa,
1240 Neox,
1241 ArchPath::GenericGqa { rope: Neox },
1242 PerHead,
1243 ));
1244 v.push(prof(
1245 "gemma3",
1246 TextGeneration,
1247 GemmaFamily,
1248 KvIswa,
1249 Neox,
1250 ArchPath::GenericGqa { rope: Neox },
1251 PerHead,
1252 ));
1253 for n in ["gemma4", "gemma4-assistant"] {
1257 v.push(prof(
1258 n,
1259 TextGeneration,
1260 GemmaFamily,
1261 KvIswa,
1262 Neox,
1263 ArchPath::DedicatedOnly {
1264 reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
1265 },
1266 PerHead,
1267 ));
1268 }
1269 const PARALLEL_RESIDUAL: &str =
1276 "parallel attention+FFN residual -- llama.cpp feeds both branches the *same* \
1277 normed input and sums `inpL + attn_out + ffn_out` once; the generic decoder \
1278 computes the sequential form, which is a different graph";
1279 for (n, rope, fam) in [
1280 ("command-r", Norm, StandardGqa),
1284 ("cohere2", Norm, StandardGqa),
1285 ("cohere2moe", Norm, StandardGqa),
1286 ("falcon", Neox, StandardGqa),
1289 ("gptneox", Neox, StandardGqa),
1293 ("phi2", Neox, PhiFamily),
1295 ("plamo", Neox, StandardGqa),
1296 ] {
1297 v.push(prof(
1298 n,
1299 TextGeneration,
1300 fam,
1301 KvGqa,
1302 rope,
1303 ArchPath::DedicatedOnly {
1304 reason: PARALLEL_RESIDUAL,
1305 },
1306 WholeVector,
1307 ));
1308 }
1309 v.push(prof(
1318 "minicpm",
1319 TextGeneration,
1320 StandardGqa,
1321 KvGqa,
1322 Norm,
1323 ArchPath::DedicatedOnly {
1324 reason: "unconditional embedding/residual/logit multipliers that llama.cpp \
1325 applies even when the GGUF omits every key; not applied by the \
1326 generic decoder",
1327 },
1328 WholeVector,
1329 ));
1330 v.push(prof(
1331 "phi3",
1332 TextGeneration,
1333 PhiFamily,
1334 KvGqa,
1335 Neox,
1336 ArchPath::GenericGqa { rope: Neox },
1337 WholeVector,
1338 ));
1339 v.push(
1344 prof(
1345 "phi4",
1346 TextGeneration,
1347 PhiFamily,
1348 KvGqa,
1349 Neox,
1350 ArchPath::GenericGqa { rope: Neox },
1351 WholeVector,
1352 )
1353 .triaged(
1354 TriageClass::Unknown,
1355 "there is no llama.cpp graph to diff against. `phi4` is NOT in LLM_ARCH_NAMES \
1356 -- src/llama-arch.cpp:44 lists \"phi3\" and there is no phi4 entry -- so this \
1357 row is a ferrox-only alias and no llama.cpp-produced GGUF can carry the \
1358 string. ferrox admits it as PhiFamily/NEOX, i.e. phi3's fused-QKV and fused \
1359 gate+up graph, on the assumption that a file spelling it means the same \
1360 thing. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is \
1361 literally `phi4`. If its blk.0 carries attn_qkv.weight it is phi3's graph \
1362 and this row is fixture-away behind an already-audited phi3; if it carries \
1363 split attn_q/attn_k/attn_v it is a Llama-shaped graph and belongs on a \
1364 different row",
1365 ),
1366 );
1367 v.push(prof(
1370 "llama4",
1371 TextGeneration,
1372 Dedicated,
1373 KvGqa,
1374 Norm,
1375 ArchPath::DedicatedOnly {
1376 reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list",
1377 },
1378 WholeVector,
1379 ));
1380 v.push(prof(
1399 "minimax-m2",
1400 TextGeneration,
1401 Dedicated,
1402 KvGqa,
1403 Neox,
1404 ArchPath::DedicatedOnly {
1405 reason: "minimax-m2 is UNAUDITED, not unimplemented: llama.cpp's minimax-m2.cpp \
1414 builds plain GQA + whole-vector QK-norm + partial NEOX RoPE (n_rot=64 < \
1415 head_dim=128) + a SiLU sigmoid MoE with exp_probs_b, all of which the \
1416 generic path already has. Admitting it needs a fixture or a parity run \
1417 against llama.cpp, not new code",
1418 },
1419 WholeVector,
1423 ));
1424 v.push(prof(
1425 "minimax-m3",
1426 TextGeneration,
1427 Dedicated,
1428 KvGqa,
1429 Neox,
1430 ArchPath::DedicatedOnly {
1431 reason: "minimax-m3 needs MiniMax Sparse Attention: a per-layer indexer \
1432 (index_q_proj/index_k_proj/index_q_norm/index_k_norm, minimax-m3.cpp:76-82) \
1433 driving its own MSA KV cache (llama-kv-cache-msa.h) with position<->cell \
1434 maps, plus SWIGLU_OAI experts and shared experts. ferrox has only the \
1435 block-selection rule (ferrox_core::block_sparse), none of the rest",
1436 },
1437 PerHead,
1442 ));
1443 v.push(prof(
1459 "minicpm3",
1460 TextGeneration,
1461 Mla,
1462 KvMla,
1463 Neox,
1464 ArchPath::DedicatedOnly {
1465 reason: "MiniCPM3 is an MLA model (src/models/minicpm3.cpp:5-6,41-46 -- \
1466 q_lora_rank/kv_lora_rank and the attn_q_a/attn_q_b/attn_kv_a_mqa/\
1467 attn_kv_b tensor set), so it needs the MLA engine and not the \
1468 generic GQA decoder. It ALSO hardcodes MiniCPM's multipliers with \
1469 no GGUF key to read them from -- scale_embd = 12.0, \
1470 scale_depth = 1.4, n_embd_base = 256 at :65-67, applied at :81 -- \
1471 which is the same blind spot `minicpm` is refused for",
1472 },
1473 WholeVector,
1474 ));
1475 v.push(prof(
1476 "deepseek2",
1477 TextGeneration,
1478 Mla,
1479 KvMla,
1480 Norm,
1481 ArchPath::DedicatedOnly {
1482 reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
1483 },
1484 WholeVector,
1485 ));
1486 v.push(prof(
1487 "deepseek32",
1488 TextGeneration,
1489 Mla,
1490 KvDsa,
1491 Norm,
1492 ArchPath::DedicatedOnly {
1493 reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
1494 },
1495 WholeVector,
1496 ));
1497 v.push(prof(
1498 "mistral4",
1499 TextGeneration,
1500 Mla,
1501 KvMla,
1502 Norm,
1503 ArchPath::DedicatedOnly {
1504 reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
1505 },
1506 WholeVector,
1507 ));
1508 v.push(dedicated(
1509 "glm-dsa",
1510 "use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
1511 ));
1512 v.push(dedicated(
1513 "glm4",
1514 "use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
1515 ));
1516 v.push(dedicated(
1538 "glm4moe",
1539 "GLM-4.5-MoE stores its pre-FFN norm as `blk.N.post_attention_norm.weight` and \
1540 carries NO `blk.N.ffn_norm.weight` (src/models/glm4-moe.cpp:75, applied to \
1541 `ffn_inp` at :215 -- i.e. AFTER the attention residual). The generic decoder \
1542 requires `ffn_norm` and puts `post_attention_norm` in Gemma's other slot, on the \
1543 attention branch BEFORE the residual add, so it would both fail to find its \
1544 tensors and compute a different graph. This is gpt-oss's norm slot exactly, and \
1545 `loader.rs` already implements it behind an `is_gpt_oss` flag; widening that flag \
1546 is what admits glm4moe. It is NOT MLA -- do not send it to glm52_gguf_loader, \
1547 which asks for a `q_lora_rank` no glm4moe checkpoint carries",
1548 ));
1549 v.push(dedicated(
1550 "deepseek4",
1551 "DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
1552 ));
1553 v.push(dedicated(
1554 "kimi-linear",
1555 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1556 ));
1557 v.push(dedicated(
1558 "kimi_k3",
1559 "use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
1560 ));
1561 for (n, rope) in [
1562 ("jamba", Neox),
1563 ("falcon-h1", Neox),
1564 ("plamo2", Neox),
1565 ("granitehybrid", Norm),
1566 ("granite-hybrid", Norm),
1567 ("lfm2", Neox),
1568 ("lfm2moe", Neox),
1569 ("nemotron_h", Neox),
1570 ("nemotron_h_moe", Neox),
1571 ("qwen3next", Neox),
1572 ("qwen35", Neox),
1573 ("qwen35moe", Neox),
1574 ] {
1575 let qk = if n.starts_with("qwen3") {
1576 PerHead
1577 } else {
1578 WholeVector
1579 };
1580 v.push(prof(
1581 n,
1582 TextGeneration,
1583 DecoderFamily::Hybrid,
1584 MemoryKind::Hybrid,
1585 rope,
1586 ArchPath::DedicatedOnly {
1587 reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
1588 },
1589 qk,
1590 ));
1591 }
1592 for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
1593 v.push(prof(
1594 n,
1595 TextGeneration,
1596 DecoderFamily::Recurrent,
1597 MemoryKind::Recurrent,
1598 Neox,
1599 ArchPath::DedicatedOnly {
1600 reason: "recurrent engine not yet on the serve path",
1601 },
1602 WholeVector,
1603 ));
1604 }
1605 v.push(prof(
1606 "t5",
1607 TextGeneration,
1608 EncoderDecoder,
1609 None,
1610 Neox,
1611 ArchPath::DedicatedOnly {
1612 reason: "T5 encoder-decoder engine not yet on the serve path",
1613 },
1614 WholeVector,
1615 ));
1616 for (n, scope, reason) in [
1617 (
1618 "t5encoder",
1619 DeferredEncoderEmbedding,
1620 "encoder-only; deferred from text-generation parity",
1621 ),
1622 ("bert", DeferredEncoderEmbedding, "encoder/embedding; deferred"),
1623 (
1624 "modern-bert",
1625 DeferredEncoderEmbedding,
1626 "encoder/embedding; deferred",
1627 ),
1628 (
1629 "nomic-bert",
1630 DeferredEncoderEmbedding,
1631 "encoder/embedding; deferred",
1632 ),
1633 (
1634 "nomic-bert-moe",
1635 DeferredEncoderEmbedding,
1636 "encoder/embedding; deferred",
1637 ),
1638 (
1639 "neo-bert",
1640 DeferredEncoderEmbedding,
1641 "encoder/embedding; deferred",
1642 ),
1643 (
1644 "jina-bert-v2",
1645 DeferredEncoderEmbedding,
1646 "encoder/embedding; deferred",
1647 ),
1648 (
1649 "jina-bert-v3",
1650 DeferredEncoderEmbedding,
1651 "encoder/embedding; deferred",
1652 ),
1653 (
1654 "eurobert",
1655 DeferredEncoderEmbedding,
1656 "encoder/embedding; deferred",
1657 ),
1658 (
1659 "llama-embed",
1660 DeferredEncoderEmbedding,
1661 "embedding variant; deferred",
1662 ),
1663 (
1664 "gemma-embedding",
1665 DeferredEncoderEmbedding,
1666 "embedding variant; deferred",
1667 ),
1668 (
1669 "pangu-embedded",
1670 DeferredEncoderEmbedding,
1671 "embedding variant; deferred",
1672 ),
1673 ("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
1674 ("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
1675 ("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
1676 ("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
1677 ("cogvlm", DeferredMultimodal, "vision-language; deferred"),
1678 ("chameleon", DeferredMultimodal, "multimodal; deferred"),
1679 ("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
1680 ("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
1681 ("hy_v3", DeferredMultimodal, "multimodal; deferred"),
1682 ("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
1683 ("dream", DeferredDiffusion, "diffusion LM; deferred"),
1684 ("llada", DeferredDiffusion, "diffusion LM; deferred"),
1685 ("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
1686 ("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
1687 (
1688 "wavtokenizer-dec",
1689 DeferredAudio,
1690 "audio tokenizer; deferred",
1691 ),
1692 (
1693 "eagle3",
1694 EnumOnly,
1695 "speculative draft head; not a standalone decoder target",
1696 ),
1697 (
1698 "dflash",
1699 EnumOnly,
1700 "speculative draft head; not a standalone decoder target",
1701 ),
1702 ("clip", EnumOnly, "quantize dummy only"),
1703 ("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
1704 ("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
1705 ] {
1706 v.push(deferred_scope(n, scope, reason));
1707 }
1708 v.push(prof(
1709 "gemma3n",
1710 TextGeneration,
1711 GemmaFamily,
1712 KvIswa,
1713 Neox,
1714 ArchPath::DedicatedOnly {
1715 reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
1716 },
1717 PerHead,
1718 ));
1719 for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
1720 v.push(prof(
1721 n,
1722 TextGeneration,
1723 TestFixture,
1724 KvGqa,
1725 Neox,
1726 ArchPath::TestFixture { rope: Neox },
1727 WholeVector,
1728 ));
1729 }
1730 v
1731 })
1732 .as_slice()
1733}
1734
1735pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
1737 architecture_catalog().iter().find(|p| p.gguf_name == arch)
1738}
1739
1740pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
1743 resolve_profile(arch).map(|p| p.path)
1744}
1745
1746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1763pub struct SwaPattern {
1764 pub period: usize,
1766 pub dense_first: bool,
1768}
1769
1770pub fn swa_disabled_by_arch(arch: &str) -> bool {
1821 matches!(arch, "phi3")
1822}
1823
1824pub fn uses_geglu(arch: &str) -> bool {
1846 matches!(arch, "grok")
1847}
1848
1849pub fn default_swa_layout(arch: &str) -> Option<SwaPattern> {
1850 let last_dense = |period| {
1851 Some(SwaPattern {
1852 period,
1853 dense_first: false,
1854 })
1855 };
1856 let dense_first = |period| {
1857 Some(SwaPattern {
1858 period,
1859 dense_first: true,
1860 })
1861 };
1862 match arch {
1863 "gpt-oss" => last_dense(2),
1865 "gemma2" => last_dense(2),
1867 "gemma3" => last_dense(6),
1869 "gemma3n" => last_dense(5),
1873 "gemma-embedding" => last_dense(6),
1876 "cohere2" | "exaone4" | "olmo2" => last_dense(4),
1878 "mellum" => last_dense(4),
1887 "exaone-moe" => last_dense(4),
1891 "afmoe" => last_dense(4),
1895 "plamo3" => last_dense(8),
1896 "llama4" => last_dense(4),
1899 "smallthinker" => dense_first(4),
1910 "laguna" => dense_first(4),
1913 "cohere2moe" => dense_first(4),
1916 "modern-bert" => dense_first(3),
1919 _ => None,
1920 }
1921}
1922
1923pub fn swa_rope_base_follows_model(arch: &str) -> bool {
1935 matches!(
1936 arch,
1937 "afmoe"
1938 | "cohere2"
1939 | "cohere2moe"
1940 | "dflash"
1941 | "exaone-moe"
1942 | "exaone4"
1943 | "gemma2"
1944 | "laguna"
1945 | "llama4"
1946 | "mellum"
1947 | "olmo2"
1948 | "gpt-oss"
1949 | "smallthinker"
1950 )
1951}
1952
1953pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
1957 let profile = resolve_profile(arch);
1958 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
1960 return Vec::new();
1961 }
1962 let key = |suffix: &str| format!("{arch}.{suffix}");
1963 vec![
1964 (
1965 key("attention.logit_softcapping"),
1966 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
1967 ),
1968 (
1983 key("attn_logit_softcapping"),
1984 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
1985 ),
1986 (
1987 key("final_logit_softcapping"),
1988 "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
1989 ),
1990 (
1991 key("attention.sliding_window_pattern"),
1992 "alternating sliding-window pattern (Gemma 2+); not implemented in the generic decoder",
1993 ),
1994 ]
1995}
1996
1997pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
2025 let profile = resolve_profile(arch);
2026 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2028 return Vec::new();
2029 }
2030 let key = |suffix: &str| format!("{arch}.{suffix}");
2031 vec![
2032 (
2033 key("logit_scale"),
2034 "logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
2035 1.0,
2036 ),
2037 (
2038 key("residual_scale"),
2039 "residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
2040 1.0,
2041 ),
2042 (
2043 key("embedding_scale"),
2044 "embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
2045 1.0,
2046 ),
2047 (
2048 key("attention.scale"),
2049 "explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
2050 0.0,
2051 ),
2052 ]
2053}
2054
2055pub fn coverage_report_markdown() -> String {
2057 let mut lines = vec![
2058 "# Architecture coverage manifest".to_string(),
2059 String::new(),
2060 "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
2061 "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
2062 String::new(),
2063 "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
2064 "|---|---|---|---|---|".to_string(),
2065 ];
2066 for p in architecture_catalog() {
2067 let path = match p.path {
2068 ArchPath::GenericGqa { .. } => "generic-gqa",
2069 ArchPath::TestFixture { .. } => "test-fixture",
2070 ArchPath::DedicatedOnly { .. } => "dedicated",
2071 ArchPath::Deferred { .. } => "deferred",
2072 };
2073 lines.push(format!(
2074 "| `{}` | {:?} | {:?} | {:?} | {} |",
2075 p.gguf_name, p.scope, p.family, p.memory, path
2076 ));
2077 }
2078 lines.push(String::new());
2079 lines.join("\n")
2080}
2081
2082#[cfg(test)]
2083mod audit_tests {
2084 use super::*;
2085
2086 #[test]
2091 fn every_audited_name_is_actually_on_the_generic_path() {
2092 for name in AUDITED_GENERIC_GQA {
2093 let profile = resolve_profile(name)
2094 .unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
2095 assert!(
2096 matches!(profile.path, ArchPath::GenericGqa { .. }),
2097 "`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
2098 profile.path
2099 );
2100 }
2101 }
2102
2103 #[test]
2110 fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
2111 for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
2112 assert!(
2113 !is_audited_generic(name),
2114 "`{name}` was found computing ALiBi or learned position embeddings as \
2115 though it were RoPE; it cannot be on the audited list"
2116 );
2117 }
2118 }
2119
2120 #[test]
2128 fn every_unaudited_generic_architecture_is_triaged_or_listed_as_pending() {
2129 for p in architecture_catalog() {
2130 if !matches!(p.path, ArchPath::GenericGqa { .. }) || is_audited_generic(p.gguf_name) {
2131 continue;
2132 }
2133 let pending = TRIAGE_PENDING.contains(&p.gguf_name);
2134 match (p.triage, pending) {
2135 (Some(_), false) | (None, true) => {}
2136 (Some(t), true) => panic!(
2137 "`{}` carries a {:?} verdict AND is still on TRIAGE_PENDING; remove it \
2138 from the pending list",
2139 p.gguf_name, t.class
2140 ),
2141 (None, false) => panic!(
2142 "`{}` is on the generic path, is not audited, has no triage verdict and \
2143 is not on TRIAGE_PENDING. Read \
2144 .scratch/llama.cpp/src/models/ for it, or say so on the pending list",
2145 p.gguf_name
2146 ),
2147 }
2148 }
2149 }
2150
2151 #[test]
2155 fn nothing_on_the_pending_list_is_stale() {
2156 for name in TRIAGE_PENDING {
2157 let p = resolve_profile(name)
2158 .unwrap_or_else(|| panic!("TRIAGE_PENDING names `{name}`, not in the catalog"));
2159 assert!(
2160 matches!(p.path, ArchPath::GenericGqa { .. }),
2161 "`{name}` is on TRIAGE_PENDING but resolves to {:?}, which never reaches the \
2162 unaudited refusal",
2163 p.path
2164 );
2165 assert!(
2166 !is_audited_generic(name),
2167 "`{name}` is audited and runs; it does not need a triage verdict"
2168 );
2169 }
2170 assert!(
2176 TRIAGE_PENDING.is_empty(),
2177 "TRIAGE_PENDING regrew to {:?}; that is fine, but say so in docs/MODELS.md too",
2178 TRIAGE_PENDING
2179 );
2180 }
2181
2182 #[test]
2185 fn an_audited_architecture_carries_no_triage_verdict() {
2186 for name in AUDITED_GENERIC_GQA {
2187 assert!(
2188 unaudited_triage(name).is_none(),
2189 "`{name}` is audited and runs, so it must not carry a triage verdict"
2190 );
2191 }
2192 }
2193
2194 #[test]
2199 fn every_triage_verdict_cites_the_llama_cpp_line_that_decides_it() {
2200 let mut seen = 0;
2201 for p in architecture_catalog() {
2202 let Some(t) = p.triage else { continue };
2203 seen += 1;
2204 assert!(
2205 t.blocker.len() > 80,
2206 "`{}`'s blocker is too short to name anything: {:?}",
2207 p.gguf_name,
2208 t.blocker
2209 );
2210 let cites_llama_cpp =
2211 t.blocker.contains("src/models/") || t.blocker.contains("src/llama-arch.cpp");
2212 assert!(
2213 cites_llama_cpp,
2214 "`{}`'s blocker cites no llama.cpp source: {}",
2215 p.gguf_name, t.blocker
2216 );
2217 if t.class == TriageClass::Unknown {
2218 assert!(
2219 t.blocker.contains("WOULD SETTLE IT"),
2220 "`{}` is UNKNOWN but does not say what would settle it",
2221 p.gguf_name
2222 );
2223 }
2224 }
2225 assert!(
2226 seen == 46,
2227 "every unaudited generic architecture is triaged; found {seen}. \
2228 It was 47 until the triage found `minicpm3` was an MLA model on the \
2229 generic-GQA row and it moved to DedicatedOnly"
2230 );
2231 }
2232
2233 #[test]
2236 fn the_refusal_detail_distinguishes_the_classes() {
2237 let fixture = unaudited_refusal_detail("bailingmoe2");
2238 let arm = unaudited_refusal_detail("seed_oss");
2239 let new_code = unaudited_refusal_detail("olmo2");
2240 let untriaged = unaudited_refusal_detail("an-arch-nobody-has-read");
2246 assert!(fixture.contains("FIXTURE-AWAY"), "{fixture}");
2247 assert!(arm.contains("ONE MATCH ARM"), "{arm}");
2248 assert!(new_code.contains("NEW CODE"), "{new_code}");
2249 assert!(
2250 untriaged.contains("not done for `an-arch-nobody-has-read` yet"),
2251 "{untriaged}"
2252 );
2253 for a in [&fixture, &arm, &new_code, &untriaged] {
2254 for b in [&fixture, &arm, &new_code, &untriaged] {
2255 if !std::ptr::eq(a, b) {
2256 assert_ne!(a, b, "two refusal details are identical");
2257 }
2258 }
2259 }
2260 assert!(arm.contains("post_attention_norm"), "{arm}");
2264 assert!(new_code.contains("olmo2.cpp:47,52"), "{new_code}");
2265 }
2266
2267 #[test]
2270 fn an_unchecked_architecture_is_not_audited() {
2271 assert!(!is_audited_generic("smallthinker"));
2272 assert!(!is_audited_generic("mellum"));
2273 assert!(!is_audited_generic("an-arch-that-does-not-exist"));
2274 }
2275}
2276
2277#[cfg(test)]
2278mod tests {
2279 use super::*;
2280
2281 #[test]
2282 fn known_mainstream_families_resolve() {
2283 assert_eq!(
2284 resolve_architecture("llama"),
2285 Some(ArchPath::GenericGqa {
2286 rope: RopeLayout::Norm
2287 })
2288 );
2289 assert_eq!(
2290 resolve_architecture("qwen2moe"),
2291 Some(ArchPath::GenericGqa {
2292 rope: RopeLayout::Neox
2293 })
2294 );
2295 assert_eq!(
2296 resolve_architecture("mistral"),
2297 Some(ArchPath::GenericGqa {
2298 rope: RopeLayout::Neox
2299 })
2300 );
2301 assert_eq!(
2302 resolve_architecture("yi"),
2303 Some(ArchPath::GenericGqa {
2304 rope: RopeLayout::Neox
2305 })
2306 );
2307 assert_eq!(
2308 resolve_architecture("mixtral"),
2309 Some(ArchPath::GenericGqa {
2310 rope: RopeLayout::Neox
2311 })
2312 );
2313 assert_eq!(
2314 resolve_architecture("phi3"),
2315 Some(ArchPath::GenericGqa {
2316 rope: RopeLayout::Neox
2317 })
2318 );
2319 assert_eq!(
2320 resolve_architecture("phi4"),
2321 Some(ArchPath::GenericGqa {
2322 rope: RopeLayout::Neox
2323 })
2324 );
2325 assert_eq!(
2326 resolve_profile("phi4").map(|p| p.family),
2327 Some(DecoderFamily::PhiFamily)
2328 );
2329 assert_eq!(
2330 resolve_architecture("gemma3"),
2331 Some(ArchPath::GenericGqa {
2332 rope: RopeLayout::Neox
2333 })
2334 );
2335 for arch in ["gemma4", "gemma4-assistant"] {
2336 assert!(
2337 matches!(
2338 resolve_architecture(arch),
2339 Some(ArchPath::DedicatedOnly { .. })
2340 ),
2341 "{arch} uses dedicated Gemma4 engine"
2342 );
2343 assert_eq!(
2344 resolve_profile(arch).map(|p| p.family),
2345 Some(DecoderFamily::GemmaFamily)
2346 );
2347 }
2348 assert!(matches!(
2349 resolve_architecture("gemma3n"),
2350 Some(ArchPath::DedicatedOnly { .. })
2351 ));
2352 assert_eq!(
2353 resolve_architecture("deepseek"),
2354 Some(ArchPath::GenericGqa {
2355 rope: RopeLayout::Norm
2356 })
2357 );
2358 assert_eq!(
2359 resolve_profile("qwen3").map(|p| p.qk_norm),
2360 Some(QkNormStyle::PerHead)
2361 );
2362 }
2363
2364 #[test]
2365 fn deepseek2_is_dedicated_mla_not_generic() {
2366 assert!(matches!(
2367 resolve_architecture("deepseek2"),
2368 Some(ArchPath::DedicatedOnly { .. })
2369 ));
2370 }
2371
2372 #[test]
2373 fn unknown_architecture_is_none() {
2374 assert_eq!(resolve_architecture("totally-unknown-arch"), None);
2375 assert!(matches!(
2377 resolve_architecture("t5"),
2378 Some(ArchPath::DedicatedOnly { .. })
2379 ));
2380 }
2381
2382 #[test]
2383 fn dedicated_paths_are_not_generic() {
2384 assert!(matches!(
2385 resolve_architecture("glm-dsa"),
2386 Some(ArchPath::DedicatedOnly { .. })
2387 ));
2388 assert!(matches!(
2389 resolve_architecture("deepseek4"),
2390 Some(ArchPath::DedicatedOnly { .. })
2391 ));
2392 for arch in ["minimax-m2", "minimax-m3"] {
2393 assert!(
2394 matches!(
2395 resolve_architecture(arch),
2396 Some(ArchPath::DedicatedOnly { .. })
2397 ),
2398 "{arch} must fail closed, not silent generic GQA"
2399 );
2400 }
2401 assert!(
2402 matches!(
2403 resolve_architecture("llama4"),
2404 Some(ArchPath::DedicatedOnly {
2405 reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list"
2406 })
2407 ),
2408 "llama4 must fail closed, not silent generic GQA"
2409 );
2410 assert!(matches!(
2411 resolve_architecture("glm4"),
2412 Some(ArchPath::DedicatedOnly { .. })
2413 ));
2414 assert!(matches!(
2415 resolve_architecture("glm4moe"),
2416 Some(ArchPath::DedicatedOnly { .. })
2417 ));
2418 }
2419
2420 #[test]
2421 fn test_fixtures_remain_loadable() {
2422 for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
2423 assert!(matches!(
2424 resolve_architecture(arch),
2425 Some(ArchPath::TestFixture { .. })
2426 ));
2427 }
2428 }
2429
2430 #[test]
2431 fn catalog_has_unique_names() {
2432 let mut seen = std::collections::HashSet::new();
2433 for p in architecture_catalog() {
2434 assert!(
2435 seen.insert(p.gguf_name),
2436 "duplicate arch name {}",
2437 p.gguf_name
2438 );
2439 }
2440 }
2441
2442 #[test]
2443 fn gemma_family_does_not_fail_closed_on_softcap_keys() {
2444 assert!(unsupported_feature_keys("gemma3").is_empty());
2445 assert!(!unsupported_feature_keys("llama").is_empty());
2446 }
2447
2448 #[test]
2454 fn architectures_with_a_different_residual_topology_are_refused() {
2455 for arch in [
2456 "command-r",
2457 "cohere2",
2458 "cohere2moe",
2459 "falcon",
2460 "gptneox",
2461 "phi2",
2462 "plamo",
2463 "minicpm",
2464 ] {
2465 match resolve_architecture(arch) {
2466 Some(ArchPath::DedicatedOnly { reason }) => {
2467 assert!(!reason.is_empty(), "{arch} must say why");
2468 }
2469 other => panic!("{arch} must be refused, got {other:?}"),
2470 }
2471 }
2472 for arch in ["phi3", "plamo3", "qwen2", "llama"] {
2482 assert!(
2483 matches!(
2484 resolve_architecture(arch),
2485 Some(ArchPath::GenericGqa { .. })
2486 ),
2487 "{arch} must stay generic"
2488 );
2489 }
2490 for arch in ["phimoe", "starcoder2", "nemotron"] {
2491 match resolve_architecture(arch) {
2492 Some(ArchPath::DedicatedOnly { reason }) => assert!(
2493 reason.contains("bias"),
2494 "{arch} is refused for the wrong reason: {reason}"
2495 ),
2496 other => panic!("{arch} must be refused for its biases, got {other:?}"),
2497 }
2498 }
2499 }
2500
2501 #[test]
2505 fn no_architecture_is_listed_twice() {
2506 let mut seen = std::collections::HashSet::new();
2507 for p in architecture_catalog() {
2508 assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
2509 }
2510 }
2511
2512 #[test]
2528 fn every_refused_key_is_one_a_converter_actually_writes() {
2529 let keys: Vec<String> = unsupported_feature_keys("llama")
2530 .into_iter()
2531 .map(|(k, _)| k)
2532 .collect();
2533
2534 for real in [
2536 "llama.attn_logit_softcapping",
2537 "llama.final_logit_softcapping",
2538 "llama.attention.sliding_window_pattern",
2539 ] {
2540 assert!(
2541 keys.iter().any(|k| k == real),
2542 "{real} is a key llama.cpp writes and this gate must refuse it; \
2543 gate currently holds {keys:?}"
2544 );
2545 }
2546
2547 assert!(
2551 unsupported_feature_keys("gemma2").is_empty(),
2552 "the Gemma family implements softcap and the SWA pattern"
2553 );
2554 }
2555}