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 swa_rope_scale_follows_model(arch: &str) -> bool {
1969 matches!(
1970 arch,
1971 "afmoe" | "cohere2" | "cohere2moe" | "dflash" | "exaone-moe" | "exaone4" | "gemma2" | "llama4" | "mellum" | "gpt-oss" | "smallthinker" )
1983}
1984
1985pub fn attention_scale_override(
2015 arch: &str,
2016 n_layers: usize,
2017 hidden_dim: usize,
2018 n_heads: usize,
2019 head_dim: usize,
2020) -> Option<f32> {
2021 let is_27b = match arch {
2026 "gemma2" => n_layers == 46,
2027 "gemma3" => n_layers == 62,
2028 _ => false,
2029 };
2030 if !is_27b || n_heads == 0 || head_dim == 0 {
2031 return None;
2032 }
2033 let scale = 1.0 / ((hidden_dim / n_heads) as f32).sqrt();
2034 let kernel_scale = 1.0 / (head_dim as f32).sqrt();
2035 (scale != kernel_scale).then_some(scale)
2036}
2037
2038pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
2042 let profile = resolve_profile(arch);
2043 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2045 return Vec::new();
2046 }
2047 let key = |suffix: &str| format!("{arch}.{suffix}");
2048 vec![
2049 (
2050 key("attention.logit_softcapping"),
2051 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2052 ),
2053 (
2068 key("attn_logit_softcapping"),
2069 "attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2070 ),
2071 (
2072 key("final_logit_softcapping"),
2073 "final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
2074 ),
2075 (
2076 key("attention.sliding_window_pattern"),
2077 "alternating sliding-window pattern (Gemma 2+); not implemented in the generic decoder",
2078 ),
2079 ]
2080}
2081
2082pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
2110 let profile = resolve_profile(arch);
2111 if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
2120 return Vec::new();
2121 }
2122 let key = |suffix: &str| format!("{arch}.{suffix}");
2123 vec![
2124 (
2125 key("logit_scale"),
2126 "logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
2127 1.0,
2128 ),
2129 (
2130 key("residual_scale"),
2131 "residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
2132 1.0,
2133 ),
2134 (
2135 key("embedding_scale"),
2136 "embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
2137 1.0,
2138 ),
2139 (
2140 key("attention.scale"),
2141 "explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
2142 0.0,
2143 ),
2144 ]
2145}
2146
2147pub fn coverage_report_markdown() -> String {
2149 let mut lines = vec![
2150 "# Architecture coverage manifest".to_string(),
2151 String::new(),
2152 "Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
2153 "Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
2154 String::new(),
2155 "| GGUF arch | Scope | Family | Memory | Path |".to_string(),
2156 "|---|---|---|---|---|".to_string(),
2157 ];
2158 for p in architecture_catalog() {
2159 let path = match p.path {
2160 ArchPath::GenericGqa { .. } => "generic-gqa",
2161 ArchPath::TestFixture { .. } => "test-fixture",
2162 ArchPath::DedicatedOnly { .. } => "dedicated",
2163 ArchPath::Deferred { .. } => "deferred",
2164 };
2165 lines.push(format!(
2166 "| `{}` | {:?} | {:?} | {:?} | {} |",
2167 p.gguf_name, p.scope, p.family, p.memory, path
2168 ));
2169 }
2170 lines.push(String::new());
2171 lines.join("\n")
2172}
2173
2174#[cfg(test)]
2175mod audit_tests {
2176 use super::*;
2177
2178 #[test]
2183 fn every_audited_name_is_actually_on_the_generic_path() {
2184 for name in AUDITED_GENERIC_GQA {
2185 let profile = resolve_profile(name)
2186 .unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
2187 assert!(
2188 matches!(profile.path, ArchPath::GenericGqa { .. }),
2189 "`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
2190 profile.path
2191 );
2192 }
2193 }
2194
2195 #[test]
2202 fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
2203 for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
2204 assert!(
2205 !is_audited_generic(name),
2206 "`{name}` was found computing ALiBi or learned position embeddings as \
2207 though it were RoPE; it cannot be on the audited list"
2208 );
2209 }
2210 }
2211
2212 #[test]
2220 fn every_unaudited_generic_architecture_is_triaged_or_listed_as_pending() {
2221 for p in architecture_catalog() {
2222 if !matches!(p.path, ArchPath::GenericGqa { .. }) || is_audited_generic(p.gguf_name) {
2223 continue;
2224 }
2225 let pending = TRIAGE_PENDING.contains(&p.gguf_name);
2226 match (p.triage, pending) {
2227 (Some(_), false) | (None, true) => {}
2228 (Some(t), true) => panic!(
2229 "`{}` carries a {:?} verdict AND is still on TRIAGE_PENDING; remove it \
2230 from the pending list",
2231 p.gguf_name, t.class
2232 ),
2233 (None, false) => panic!(
2234 "`{}` is on the generic path, is not audited, has no triage verdict and \
2235 is not on TRIAGE_PENDING. Read \
2236 .scratch/llama.cpp/src/models/ for it, or say so on the pending list",
2237 p.gguf_name
2238 ),
2239 }
2240 }
2241 }
2242
2243 #[test]
2247 fn nothing_on_the_pending_list_is_stale() {
2248 for name in TRIAGE_PENDING {
2249 let p = resolve_profile(name)
2250 .unwrap_or_else(|| panic!("TRIAGE_PENDING names `{name}`, not in the catalog"));
2251 assert!(
2252 matches!(p.path, ArchPath::GenericGqa { .. }),
2253 "`{name}` is on TRIAGE_PENDING but resolves to {:?}, which never reaches the \
2254 unaudited refusal",
2255 p.path
2256 );
2257 assert!(
2258 !is_audited_generic(name),
2259 "`{name}` is audited and runs; it does not need a triage verdict"
2260 );
2261 }
2262 assert!(
2268 TRIAGE_PENDING.is_empty(),
2269 "TRIAGE_PENDING regrew to {:?}; that is fine, but say so in docs/MODELS.md too",
2270 TRIAGE_PENDING
2271 );
2272 }
2273
2274 #[test]
2277 fn an_audited_architecture_carries_no_triage_verdict() {
2278 for name in AUDITED_GENERIC_GQA {
2279 assert!(
2280 unaudited_triage(name).is_none(),
2281 "`{name}` is audited and runs, so it must not carry a triage verdict"
2282 );
2283 }
2284 }
2285
2286 #[test]
2291 fn every_triage_verdict_cites_the_llama_cpp_line_that_decides_it() {
2292 let mut seen = 0;
2293 for p in architecture_catalog() {
2294 let Some(t) = p.triage else { continue };
2295 seen += 1;
2296 assert!(
2297 t.blocker.len() > 80,
2298 "`{}`'s blocker is too short to name anything: {:?}",
2299 p.gguf_name,
2300 t.blocker
2301 );
2302 let cites_llama_cpp =
2303 t.blocker.contains("src/models/") || t.blocker.contains("src/llama-arch.cpp");
2304 assert!(
2305 cites_llama_cpp,
2306 "`{}`'s blocker cites no llama.cpp source: {}",
2307 p.gguf_name, t.blocker
2308 );
2309 if t.class == TriageClass::Unknown {
2310 assert!(
2311 t.blocker.contains("WOULD SETTLE IT"),
2312 "`{}` is UNKNOWN but does not say what would settle it",
2313 p.gguf_name
2314 );
2315 }
2316 }
2317 assert!(
2318 seen == 41,
2319 "every unaudited generic architecture is triaged; found {seen}. \
2320 It was 47 until the triage found `minicpm3` was an MLA model on the \
2321 generic-GQA row and it moved to DedicatedOnly, and 46 until five ONE MATCH ARM \
2322 rows -- deepseek, bailingmoe, seed_oss, maincoder, hunyuan-moe -- were admitted \
2323 with libllama-golden fixtures"
2324 );
2325 }
2326
2327 #[test]
2330 fn the_refusal_detail_distinguishes_the_classes() {
2331 let fixture = unaudited_refusal_detail("bailingmoe2");
2332 let arm = unaudited_refusal_detail("ernie4_5-moe");
2333 let new_code = unaudited_refusal_detail("olmo2");
2334 let untriaged = unaudited_refusal_detail("an-arch-nobody-has-read");
2340 assert!(fixture.contains("FIXTURE-AWAY"), "{fixture}");
2341 assert!(arm.contains("ONE MATCH ARM"), "{arm}");
2342 assert!(new_code.contains("NEW CODE"), "{new_code}");
2343 assert!(
2344 untriaged.contains("not done for `an-arch-nobody-has-read` yet"),
2345 "{untriaged}"
2346 );
2347 for a in [&fixture, &arm, &new_code, &untriaged] {
2348 for b in [&fixture, &arm, &new_code, &untriaged] {
2349 if !std::ptr::eq(a, b) {
2350 assert_ne!(a, b, "two refusal details are identical");
2351 }
2352 }
2353 }
2354 assert!(arm.contains("interleave_moe_layer_step"), "{arm}");
2358 assert!(new_code.contains("olmo2.cpp:47,52"), "{new_code}");
2359 }
2360
2361 #[test]
2364 fn an_unchecked_architecture_is_not_audited() {
2365 assert!(!is_audited_generic("smallthinker"));
2366 assert!(!is_audited_generic("mellum"));
2367 assert!(!is_audited_generic("an-arch-that-does-not-exist"));
2368 }
2369}
2370
2371#[cfg(test)]
2372mod tests {
2373 use super::*;
2374
2375 #[test]
2376 fn known_mainstream_families_resolve() {
2377 assert_eq!(
2378 resolve_architecture("llama"),
2379 Some(ArchPath::GenericGqa {
2380 rope: RopeLayout::Norm
2381 })
2382 );
2383 assert_eq!(
2384 resolve_architecture("qwen2moe"),
2385 Some(ArchPath::GenericGqa {
2386 rope: RopeLayout::Neox
2387 })
2388 );
2389 assert_eq!(
2390 resolve_architecture("mistral"),
2391 Some(ArchPath::GenericGqa {
2392 rope: RopeLayout::Neox
2393 })
2394 );
2395 assert_eq!(
2396 resolve_architecture("yi"),
2397 Some(ArchPath::GenericGqa {
2398 rope: RopeLayout::Neox
2399 })
2400 );
2401 assert_eq!(
2402 resolve_architecture("mixtral"),
2403 Some(ArchPath::GenericGqa {
2404 rope: RopeLayout::Neox
2405 })
2406 );
2407 assert_eq!(
2408 resolve_architecture("phi3"),
2409 Some(ArchPath::GenericGqa {
2410 rope: RopeLayout::Neox
2411 })
2412 );
2413 assert_eq!(
2414 resolve_architecture("phi4"),
2415 Some(ArchPath::GenericGqa {
2416 rope: RopeLayout::Neox
2417 })
2418 );
2419 assert_eq!(
2420 resolve_profile("phi4").map(|p| p.family),
2421 Some(DecoderFamily::PhiFamily)
2422 );
2423 assert_eq!(
2424 resolve_architecture("gemma3"),
2425 Some(ArchPath::GenericGqa {
2426 rope: RopeLayout::Neox
2427 })
2428 );
2429 for arch in ["gemma4", "gemma4-assistant"] {
2430 assert!(
2431 matches!(
2432 resolve_architecture(arch),
2433 Some(ArchPath::DedicatedOnly { .. })
2434 ),
2435 "{arch} uses dedicated Gemma4 engine"
2436 );
2437 assert_eq!(
2438 resolve_profile(arch).map(|p| p.family),
2439 Some(DecoderFamily::GemmaFamily)
2440 );
2441 }
2442 assert!(matches!(
2443 resolve_architecture("gemma3n"),
2444 Some(ArchPath::DedicatedOnly { .. })
2445 ));
2446 assert_eq!(
2447 resolve_architecture("deepseek"),
2448 Some(ArchPath::GenericGqa {
2449 rope: RopeLayout::Norm
2450 })
2451 );
2452 assert_eq!(
2453 resolve_profile("qwen3").map(|p| p.qk_norm),
2454 Some(QkNormStyle::PerHead)
2455 );
2456 }
2457
2458 #[test]
2459 fn deepseek2_is_dedicated_mla_not_generic() {
2460 assert!(matches!(
2461 resolve_architecture("deepseek2"),
2462 Some(ArchPath::DedicatedOnly { .. })
2463 ));
2464 }
2465
2466 #[test]
2467 fn unknown_architecture_is_none() {
2468 assert_eq!(resolve_architecture("totally-unknown-arch"), None);
2469 assert!(matches!(
2471 resolve_architecture("t5"),
2472 Some(ArchPath::DedicatedOnly { .. })
2473 ));
2474 }
2475
2476 #[test]
2477 fn dedicated_paths_are_not_generic() {
2478 assert!(matches!(
2479 resolve_architecture("glm-dsa"),
2480 Some(ArchPath::DedicatedOnly { .. })
2481 ));
2482 assert!(matches!(
2483 resolve_architecture("deepseek4"),
2484 Some(ArchPath::DedicatedOnly { .. })
2485 ));
2486 for arch in ["minimax-m2", "minimax-m3"] {
2487 assert!(
2488 matches!(
2489 resolve_architecture(arch),
2490 Some(ArchPath::DedicatedOnly { .. })
2491 ),
2492 "{arch} must fail closed, not silent generic GQA"
2493 );
2494 }
2495 assert!(
2496 matches!(
2497 resolve_architecture("llama4"),
2498 Some(ArchPath::DedicatedOnly {
2499 reason: "llama4 MoE + non-GQA attn -- see llama4_engine.rs tensor list"
2500 })
2501 ),
2502 "llama4 must fail closed, not silent generic GQA"
2503 );
2504 assert!(matches!(
2505 resolve_architecture("glm4"),
2506 Some(ArchPath::DedicatedOnly { .. })
2507 ));
2508 assert!(matches!(
2509 resolve_architecture("glm4moe"),
2510 Some(ArchPath::DedicatedOnly { .. })
2511 ));
2512 }
2513
2514 #[test]
2515 fn test_fixtures_remain_loadable() {
2516 for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
2517 assert!(matches!(
2518 resolve_architecture(arch),
2519 Some(ArchPath::TestFixture { .. })
2520 ));
2521 }
2522 }
2523
2524 #[test]
2525 fn catalog_has_unique_names() {
2526 let mut seen = std::collections::HashSet::new();
2527 for p in architecture_catalog() {
2528 assert!(
2529 seen.insert(p.gguf_name),
2530 "duplicate arch name {}",
2531 p.gguf_name
2532 );
2533 }
2534 }
2535
2536 #[test]
2537 fn gemma_family_does_not_fail_closed_on_softcap_keys() {
2538 assert!(unsupported_feature_keys("gemma3").is_empty());
2539 assert!(!unsupported_feature_keys("llama").is_empty());
2540 }
2541
2542 #[test]
2548 fn architectures_with_a_different_residual_topology_are_refused() {
2549 for arch in [
2550 "command-r",
2551 "cohere2",
2552 "cohere2moe",
2553 "falcon",
2554 "gptneox",
2555 "phi2",
2556 "plamo",
2557 "minicpm",
2558 ] {
2559 match resolve_architecture(arch) {
2560 Some(ArchPath::DedicatedOnly { reason }) => {
2561 assert!(!reason.is_empty(), "{arch} must say why");
2562 }
2563 other => panic!("{arch} must be refused, got {other:?}"),
2564 }
2565 }
2566 for arch in ["phi3", "plamo3", "qwen2", "llama"] {
2576 assert!(
2577 matches!(
2578 resolve_architecture(arch),
2579 Some(ArchPath::GenericGqa { .. })
2580 ),
2581 "{arch} must stay generic"
2582 );
2583 }
2584 for arch in ["phimoe", "starcoder2", "nemotron"] {
2585 match resolve_architecture(arch) {
2586 Some(ArchPath::DedicatedOnly { reason }) => assert!(
2587 reason.contains("bias"),
2588 "{arch} is refused for the wrong reason: {reason}"
2589 ),
2590 other => panic!("{arch} must be refused for its biases, got {other:?}"),
2591 }
2592 }
2593 }
2594
2595 #[test]
2599 fn no_architecture_is_listed_twice() {
2600 let mut seen = std::collections::HashSet::new();
2601 for p in architecture_catalog() {
2602 assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
2603 }
2604 }
2605
2606 #[test]
2622 fn every_refused_key_is_one_a_converter_actually_writes() {
2623 let keys: Vec<String> = unsupported_feature_keys("llama")
2624 .into_iter()
2625 .map(|(k, _)| k)
2626 .collect();
2627
2628 for real in [
2630 "llama.attn_logit_softcapping",
2631 "llama.final_logit_softcapping",
2632 "llama.attention.sliding_window_pattern",
2633 ] {
2634 assert!(
2635 keys.iter().any(|k| k == real),
2636 "{real} is a key llama.cpp writes and this gate must refuse it; \
2637 gate currently holds {keys:?}"
2638 );
2639 }
2640
2641 assert!(
2645 unsupported_feature_keys("gemma2").is_empty(),
2646 "the Gemma family implements softcap and the SWA pattern"
2647 );
2648 }
2649
2650 #[test]
2663 fn gemma_27b_is_the_only_size_that_overrides_the_kernel_scale() {
2664 struct Size {
2666 arch: &'static str,
2667 n_layers: usize,
2668 n_embd: usize,
2669 n_head: usize,
2670 head_dim: usize,
2672 want_denom: Option<f32>,
2675 }
2676 let size = |arch, n_layers, n_embd, n_head, head_dim, want_denom| Size {
2677 arch,
2678 n_layers,
2679 n_embd,
2680 n_head,
2681 head_dim,
2682 want_denom,
2683 };
2684 let sizes = [
2685 size("gemma2", 26, 2304, 8, 256, None), size("gemma2", 42, 3584, 16, 256, None), size("gemma2", 46, 4608, 32, 128, Some(144.0)), size("gemma3", 18, 640, 4, 256, None), size("gemma3", 26, 1152, 4, 256, None), size("gemma3", 34, 2560, 8, 256, None), size("gemma3", 48, 3840, 16, 256, None), size("gemma3", 62, 5376, 32, 128, Some(168.0)), ];
2694 for &Size {
2695 arch,
2696 n_layers,
2697 n_embd,
2698 n_head,
2699 head_dim,
2700 want_denom,
2701 } in &sizes
2702 {
2703 assert_ne!(
2707 n_embd / n_head,
2708 head_dim,
2709 "{arch}/{n_layers}L: if this ever holds, re-read the derivation"
2710 );
2711 let got = attention_scale_override(arch, n_layers, n_embd, n_head, head_dim);
2712 match want_denom {
2713 None => assert_eq!(
2714 got, None,
2715 "{arch}/{n_layers}L takes llama.cpp's 1/sqrt(n_embd_head_k) branch, \
2716 which the attention kernels already apply"
2717 ),
2718 Some(denom) => {
2719 let want = 1.0 / denom.sqrt();
2720 let got = got.unwrap_or_else(|| {
2721 panic!("{arch}/{n_layers}L is llama.cpp's LLM_TYPE_27B; scale must be set")
2722 });
2723 assert!(
2724 (got - want).abs() < 1e-7,
2725 "{arch}/{n_layers}L: want 1/sqrt({denom}) = {want}, got {got}"
2726 );
2727 let kernel = 1.0f32 / (head_dim as f32).sqrt();
2731 assert!(
2732 kernel > got,
2733 "{arch}/{n_layers}L: kernel scale {kernel} must exceed {got}"
2734 );
2735 }
2736 }
2737 }
2738 for arch in ["gemma-embedding", "gemma3n", "gemma4", "llama", "qwen3"] {
2743 assert_eq!(
2744 attention_scale_override(arch, 62, 5376, 32, 128),
2745 None,
2746 "{arch} has no LLM_TYPE_27B branch in llama.cpp"
2747 );
2748 }
2749 }
2750}