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