use crate::config::RopeLayout;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchScope {
TextGeneration,
DeferredEncoderEmbedding,
DeferredMultimodal,
DeferredDiffusion,
DeferredAudio,
EnumOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecoderFamily {
StandardGqa,
Qwen3Family,
GemmaFamily,
PhiFamily,
Mla,
Hybrid,
Recurrent,
EncoderDecoder,
Dedicated,
TestFixture,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryKind {
KvGqa,
KvIswa,
KvMla,
KvDsa,
KvDsv4,
Recurrent,
Hybrid,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QkNormStyle {
#[default]
WholeVector,
PerHead,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriageClass {
FixtureAway,
OneMatchArm,
NewCode,
Unknown,
}
impl TriageClass {
pub fn label(self) -> &'static str {
match self {
TriageClass::FixtureAway => "FIXTURE-AWAY",
TriageClass::OneMatchArm => "ONE MATCH ARM",
TriageClass::NewCode => "NEW CODE",
TriageClass::Unknown => "UNKNOWN",
}
}
pub fn headline(self) -> &'static str {
match self {
TriageClass::FixtureAway => {
"ferrox already implements everything this architecture needs; what is \
missing is EVIDENCE, not capability"
}
TriageClass::OneMatchArm => {
"one small, named piece is missing -- an activation, a norm slot, a \
routing flag or an ordering"
}
TriageClass::NewCode => {
"a different attention or residual structure than the generic decoder \
computes; this is not a fixture away"
}
TriageClass::Unknown => {
"reading both trees did not settle this one; the note below says what \
would"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnauditedTriage {
pub class: TriageClass,
pub blocker: &'static str,
}
pub const TRIAGE_PENDING: &[&str] = &[
];
pub fn unaudited_triage(arch: &str) -> Option<UnauditedTriage> {
resolve_profile(arch).and_then(|p| p.triage)
}
pub fn unaudited_refusal_detail(arch: &str) -> String {
match unaudited_triage(arch) {
Some(t) => format!(
"TRIAGE ({}): {}. {}.",
t.class.label(),
t.class.headline(),
t.blocker
),
None => format!(
"TRIAGE: not done for `{arch}` yet -- nobody has read llama.cpp's \
src/models/*.cpp for it against the generic decoder, so this refusal names \
no blocker and you should not read it as one. Triaging the remaining \
architectures is docs/plans/llama-cpp-gap-inventory.md section 8, item 6."
),
}
}
pub const AUDITED_GENERIC_GQA: &[&str] = &[
"llama", "qwen2", "qwen2moe", "qwen3", "olmoe", "gemma2", "gemma3", "phi3", "gpt-oss", "dots1",
"qwen3moe",
];
pub fn is_audited_generic(arch: &str) -> bool {
AUDITED_GENERIC_GQA.contains(&arch)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchPath {
GenericGqa { rope: RopeLayout },
TestFixture { rope: RopeLayout },
DedicatedOnly { reason: &'static str },
Deferred { reason: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArchProfile {
pub gguf_name: &'static str,
pub scope: ArchScope,
pub family: DecoderFamily,
pub memory: MemoryKind,
pub rope: RopeLayout,
pub path: ArchPath,
pub qk_norm: QkNormStyle,
pub triage: Option<UnauditedTriage>,
}
impl ArchProfile {
fn triaged(mut self, class: TriageClass, blocker: &'static str) -> Self {
self.triage = Some(UnauditedTriage { class, blocker });
self
}
}
fn prof(
name: &'static str,
scope: ArchScope,
fam: DecoderFamily,
mem: MemoryKind,
rope: RopeLayout,
path: ArchPath,
qk: QkNormStyle,
) -> ArchProfile {
ArchProfile {
gguf_name: name,
scope,
family: fam,
memory: mem,
rope,
path,
qk_norm: qk,
triage: None,
}
}
fn gqa_norm(name: &'static str) -> ArchProfile {
prof(
name,
ArchScope::TextGeneration,
DecoderFamily::StandardGqa,
MemoryKind::KvGqa,
RopeLayout::Norm,
ArchPath::GenericGqa {
rope: RopeLayout::Norm,
},
QkNormStyle::WholeVector,
)
}
fn gqa_neox(name: &'static str) -> ArchProfile {
prof(
name,
ArchScope::TextGeneration,
DecoderFamily::StandardGqa,
MemoryKind::KvGqa,
RopeLayout::Neox,
ArchPath::GenericGqa {
rope: RopeLayout::Neox,
},
QkNormStyle::WholeVector,
)
}
fn dedicated(name: &'static str, reason: &'static str) -> ArchProfile {
prof(
name,
ArchScope::TextGeneration,
DecoderFamily::Dedicated,
MemoryKind::KvGqa,
RopeLayout::Norm,
ArchPath::DedicatedOnly { reason },
QkNormStyle::WholeVector,
)
}
fn deferred_scope(name: &'static str, scope: ArchScope, reason: &'static str) -> ArchProfile {
prof(
name,
scope,
DecoderFamily::StandardGqa,
MemoryKind::None,
RopeLayout::Neox,
ArchPath::Deferred { reason },
QkNormStyle::WholeVector,
)
}
const NORM_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
(
"internlm2",
TriageClass::FixtureAway,
"src/models/internlm2.cpp:25-33 creates attn_norm, split Q/K/V via create_tensor_qkv \
(whose only biases are TENSOR_NOT_REQUIRED q/k/v biases, which loader.rs already \
loads), attn_output, ffn_norm and gate/up/down -- no QK-norm, no post-norms, no \
bias the generic decoder has no slot for. The graph is sequential-residual SiLU \
SwiGLU (:98,107-115) and load_arch_hparams (:4) reads nothing but the RMS epsilon. \
Admitting it needs a fixture or a parity run, not new code",
),
(
"deepseek",
TriageClass::OneMatchArm,
"top-k renormalisation. src/models/deepseek.cpp:145-155 passes norm_w=false to \
build_moe_ffn, and conversion/deepseek.py's DeepseekModel (:124-217) never writes \
{arch}.expert_weights_norm -- only DeepseekV2Model does (:354) -- so no real \
`deepseek` GGUF carries the key. ferrox therefore falls back to \
`!NO_TOPK_RENORMALIZE_ARCHITECTURES.contains(arch)` (loader.rs:119, currently \
[\"olmoe\", \"qwen2moe\"]) and renormalises the selected experts' softmax weights \
where llama.cpp does not; adding \"deepseek\" to that list is the change. This is \
the same class of bug that made OLMoE answer wrongly. Everything else -- leading \
dense (:43), shared experts (:63-65), expert_weights_scale (:153), softmax gating \
(:154), sequential residual (:130,172) -- ferrox already has",
),
(
"ernie4_5",
TriageClass::FixtureAway,
"src/models/ernie4-5.cpp's dense branch (:39-47,65-67) is the plain Llama tensor set \
and its graph (:100-142) is sequential-residual SiLU SwiGLU with \
kq_scale=1/sqrt(head_dim) (:120). The only thing ferrox has no slot for is the \
OPTIONAL attn_output.bias at :45 (TENSOR_NOT_REQUIRED), and a checkpoint carrying \
one is refused BY NAME by assert_every_tensor_consumed rather than run unbiased. \
Admitting it needs a fixture, not new code",
),
(
"ernie4_5-moe",
TriageClass::OneMatchArm,
"interleaved MoE layers. src/models/ernie4-5-moe.cpp:64 makes a layer MoE only when \
`il >= n_layer_dense_lead && (il + 1) % n_moe_layer_step == 0`, but \
ModelConfig::layer_is_dense (config.rs:353-355) implements only the leading-dense \
prefix and nothing in ferrox reads {arch}.interleave_moe_layer_step \
(LLM_KV_INTERLEAVE_MOE_LAYER_STEP, read at ernie4-5.cpp:11). A real checkpoint \
therefore looks for blk.N.ffn_gate_exps.weight on a layer that stores \
blk.N.ffn_gate.weight and fails on the missing tensor. Routing is SOFTMAX with \
norm_w=true (:88-90) plus an optional exp_probs_b (ernie4-5.cpp:53), and \
ferrox_moe::route_top_k_biased already applies a selection bias under softmax -- \
this architecture is NOT sigmoid-routed",
),
("granite", TriageClass::NewCode, GRANITE_MULTIPLIERS),
("granitemoe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
("granite-moe", TriageClass::NewCode, GRANITE_MULTIPLIERS),
(
"xverse",
TriageClass::FixtureAway,
"xverse is llama under a different name. src/models/xverse.cpp:25-33 creates \
attn_norm, split Q/K/V via create_tensor_qkv (optional biases only), attn_output, \
ffn_norm and gate/up/down and nothing else; the graph (:60-114) is the sequential \
`x + attn(norm(x))` then `y + ffn(norm(y))` residual with SiLU SwiGLU, and \
load_arch_hparams (:4) reads nothing but the RMS epsilon. llama-model.cpp's \
`llama_model_rope_type` puts it in the NORM group, which is where the catalog has \
it. Admitting it needs a fixture, not new code",
),
(
"baichuan",
TriageClass::FixtureAway,
"for the 7B. src/models/baichuan.cpp:29-38 is the plain llama tensor set and the \
graph (:65-130) is sequential-residual SiLU SwiGLU, NORM RoPE. The 13B is a \
DIFFERENT model under the same string -- baichuan.cpp:5-13 switches on layer count \
and sets `f_max_alibi_bias = 8.0f` for the 40-layer case, with no GGUF key to \
detect it -- and ferrox already refuses that one by name at loader.rs:231, pinned \
by `baichuan_13b_is_refused_because_it_uses_alibi_and_the_7b_is_not`. So the \
unaudited refusal only ever reaches a 32-layer file, and for that file this is a \
fixture away",
),
(
"chatglm",
TriageClass::FixtureAway,
"the one non-llama thing chatglm does is the FUSED gate+up SwiGLU, and that is the \
audited phi3 path exactly. src/models/chatglm.cpp:48 sizes `ffn_up` as \
`{n_embd, n_ff * 2}` and :128-133 calls build_ffn with a NULL gate and \
`LLM_FFN_SWIGLU, LLM_FFN_SEQ` -- the same call shape as phi3.cpp:52 and :144-149, \
and `phi3` is in AUDITED_GENERIC_GQA. ferrox handles it without an activation \
flag: `load_dense_expert` (loader.rs:1127-1161) finds no `ffn_gate`, takes the \
fused branch and splits the tensor into gate and up itself. Everything else \
(:41-50, :76-138) is attn_norm, create_tensor_qkv with optional biases, \
attn_output, ffn_norm and a sequential residual. Admitting it needs a fixture",
),
(
"deci",
TriageClass::NewCode,
"DeciLM / Llama-3.1-Nemotron layers are not all the same shape. \
src/models/deci.cpp:30-34 reads n_head(i), n_head_kv(i) and n_ff(i) PER LAYER, and \
the graph branches on them three ways: `n_head == 0` is an attention-free layer \
that passes the residual straight through (:107-109), `n_head_kv == 0` is a \
\"linear attention\" layer that applies only `wo` with no Q/K/V and no RoPE \
(:115-118), and `n_ff == 0` skips the FFN and the residual add entirely with a \
`continue` (:147-149). ferrox's ModelConfig carries n_heads, n_kv_heads and \
expert_ffn_dim as SCALARS and its decoder runs the same block on every layer, so \
there is nowhere to put any of the three. Same class as `openelm`, one step worse",
),
(
"olmo",
TriageClass::NewCode,
"OLMo-1 has NO norm weights at all. src/models/olmo.cpp:27-35 creates Q/K/V, \
attn_output and gate/up/down and not one norm tensor, and the graph calls \
`build_norm(x, NULL, NULL, LLM_NORM, il)` at all three sites (:65-67, :104-106, \
:128-130) -- non-parametric LayerNorm: subtract the mean, divide by the standard \
deviation, no learned weight and no bias. ferrox has only `rms_norm(x, w, eps)` \
and requires `blk.N.attn_norm.weight`, so it is both a different function and a \
missing tensor. It also reads an optional {arch}.attention.clamp_kqv (:5) that \
nothing here applies. Note this is OLMo-1; `olmo2` is a separate row and a \
separate blocker",
),
(
"maincoder",
TriageClass::OneMatchArm,
"QK-norm ordering, the same arm `hunyuan-moe` needs. src/models/maincoder.cpp \
norms Q and K AFTER rotating them, not before -- ggml_rope_ext at :78-90, then \
`build_norm(Qcur, attn_q_norm, ...)` at :92 and the K norm at :95 -- while ferrox's \
decoder norms Q and K and then rotates. Everything else is plain: attn_norm (:28), \
create_tensor_qkv, attn_output, per-head attn_q_norm/attn_k_norm at \
{n_embd_head_k} (:33-34), ffn_norm (:36), gate/up/down, sequential residual with \
SiLU SwiGLU (:110,119-127), kq_scale = 1/sqrt(head_dim) (:104), and \
load_arch_hparams (:4) reads nothing but the RMS epsilon. Two architectures now \
need this one flag, which is the argument for adding it rather than special-casing",
),
(
"bailingmoe",
TriageClass::OneMatchArm,
"llama.cpp READS `leading_dense_block_count` and then never branches on it. \
src/models/bailingmoe.cpp:5 reads LLM_KV_LEADING_DENSE_BLOCK_COUNT, but \
load_arch_tensors creates ffn_gate_inp and the expert and shared-expert tensors \
UNCONDITIONALLY for every layer (:39-54, no `if (i < n_layer_dense_lead)` branch \
anywhere) and the graph has no dense path either (:119-152). ferrox's \
`ModelConfig::layer_is_dense` does branch on the key, so on a checkpoint whose \
`first_k_dense_replace` is nonzero (conversion/bailingmoe.py:27 writes it \
verbatim) ferrox looks for `blk.0.ffn_gate.weight` on a layer that only has \
experts, and fails on the missing tensor. Making bailingmoe ignore that key is the \
change. The rest agrees: gating is hardcoded SOFTMAX (:137) and no converter writes \
expert_gating_func, so ferrox's softmax default matches; expert_weights_norm and \
expert_weights_scale come from metadata (:9,:8) and ferrox reads both",
),
(
"arctic",
TriageClass::NewCode,
"a PARALLEL dense+MoE layer whose MoE branch reads the pre-attention residual. \
src/models/arctic.cpp:124-132 runs a dense SiLU FFN on `ffn_norm(ffn_inp)` and adds \
it back to ffn_inp, then :136-141 norms `inpSA` -- the layer INPUT, before \
attention -- through a second per-layer norm `ffn_norm_exps` (:45) and runs the MoE \
on that, and :154 sums the two. The generic decoder computes one FFN on the \
post-attention residual, so this is a different graph, not a wider one. The dense \
half is also sized `{n_embd, n_embd}` (:40-42) rather than n_ff. Same shape as \
`smallthinker`'s router: a branch fed from the raw layer input",
),
(
"mistral3",
TriageClass::NewCode,
"per-position attention temperature tuning. src/models/mistral3.cpp:5,14-17 reads \
{arch}.attention.temperature_scale and seeds n_attn_temp_floor_scale from \
n_ctx_orig_yarn, and :109-111 builds a per-position Q scale that llama-graph.cpp \
computes as `log(floor(pos / floor_scale) + 1) * temp_scale + 1` (:159-167). ferrox \
has no per-position attention scale at all and no gate on that key, so a checkpoint \
carrying it would load and silently drop it -- the class of defect \
`unsupported_scaling_keys` exists for, on a key that list does not have. :9 also \
reads rope.scaling.yarn_log_multiplier, and loader.rs:588's own comment records \
that ferrox implements only YaRN's magnitude term. The rest (:46-83, :120-210) is \
leading-dense + MoE + shared expert on a sequential residual, which ferrox has",
),
(
"nanbeige",
TriageClass::NewCode,
"nanbeige RUNS THE SAME PHYSICAL LAYERS MORE THAN ONCE. \
src/models/nanbeige.cpp:13-31 sets `n_layer_all = n_layer_phys * n_loops` and \
rewrites the per-layer head/ff/swa arrays so the graph walks n_layer_all steps over \
n_layer_phys sets of weights, and :167 applies `output_norm` to the running \
residual inside the loop at the end of each pass. ferrox's decoder walks its layer \
vector exactly once and has no concept of a loop count. Everything inside one pass \
(:52-63, :106-155) is plain llama, which is what makes this deceptive: the tensor \
set alone looks generic",
),
("arcee", TriageClass::NewCode, UNGATED_RELU_SQR),
("plm", TriageClass::NewCode, UNGATED_RELU_SQR),
];
const NO_UPSTREAM_ARCH: &str =
"there is no llama.cpp graph to diff against: none of `mistral`, `mixtral` or `yi` \
appears in LLM_ARCH_NAMES (src/llama-arch.cpp) or in gguf-py's MODEL_ARCH_NAMES, and \
every real Mistral, Mixtral and Yi checkpoint converts to `llama` (llama.cpp's own \
conversion scripts emit MODEL_ARCH.LLAMA for all three; only `mistral3` and `mistral4` \
exist as their own strings). So these are ferrox-only rows that no llama.cpp-produced \
file can carry. THE HAZARD, and why this is not marked fixture-away: the catalog gives \
all three NEOX RoPE, while `llama` -- the string these models really ship under, and \
the graph they really are -- is in `llama_model_rope_type`'s NORM group \
(llama-model.cpp, the `case LLM_ARCH_LLAMA:` arm). A file spelling `mistral` would \
therefore be rotated on the wrong pairs of every Q/K head, which is the exact defect \
that caused the Llama-3.1-8B wrong-logits bug. It is latent only because the row \
refuses. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is literally one \
of these three. Absent one, the honest options are to delete the rows or to move them \
to NORM to match the graph they claim to be";
const UNGATED_RELU_SQR: &str =
"an UNGATED ReLU-squared MLP, which is a different FFN shape and not only a different \
activation. src/models/arcee.cpp:39-40 and plm.cpp:39-40 create only `ffn_up` and \
`ffn_down` and no `ffn_gate` at all, and arcee.cpp:123-128 calls build_ffn with a NULL \
gate, `LLM_FFN_RELU_SQR` and `LLM_FFN_SEQ` -- i.e. `down(relu(up(x))^2)`, two matrices \
in sequence. ferrox's `ExpertWeights` has three required matrices and \
`FfnActivation` has only the gated Swiglu / SwigluFused / Gelu variants \
(config.rs:302-312), so there is no shape for this and no activation for it either. It \
fails closed rather than computing SwiGLU: `load_dense_expert` (loader.rs:1112-1136) \
finds no `ffn_gate`, falls to the Phi-3 fused path, and rejects an `ffn_up` that is \
`n_ff` rows rather than `2 * n_ff`";
const GRANITE_MULTIPLIERS: &str =
"Granite's four multipliers. src/models/granite.cpp:7 reads {arch}.logit_scale as \
REQUIRED (granite-moe.cpp:5 too) and :8-10 reads residual_scale / embedding_scale / \
attention.scale; the graph divides the final logits by f_logit_scale (:188) and scales \
BOTH branch outputs by f_residual_scale before every residual add (:241-242, :301-302). \
The generic decoder applies none of them, and residual_scale in particular touches \
every CPU and Metal residual path. In practice a real Granite checkpoint never reaches \
THIS message: capability::unsupported_scaling_keys already refuses it by name at \
loader.rs:191, which runs before the unaudited gate. Separately, granite.cpp:206 gates \
RoPE on `hparams.rope_finetuned`, so a Granite export with rope.finetuned=false gets NO \
rotation at all -- the ALiBi class of divergence, with no ferrox expression";
const NEOX_ROPE_TRIAGED: &[(&str, TriageClass, &str)] = &[
(
"olmo2",
TriageClass::NewCode,
"olmo2 has NO pre-attention norm and NO pre-FFN norm. load_arch_tensors creates \
attn_post_norm and ffn_post_norm (src/models/olmo2.cpp:47,52) and no attn_norm or \
ffn_norm at all; the graph projects Q/K/V straight off the residual (:92, \
`cur = inpL`) and runs build_ffn on the raw ffn_inp (:169). The generic decoder \
REQUIRES blk.N.attn_norm.weight and a pre-FFN norm and applies both on every layer, \
so this is a different residual topology, not a missing tensor. Its post-norms are \
NOT the blocker: ferrox applies post_attn_norm and post_ffn_norm in exactly \
llama.cpp's places already (:160-163,:178-180 vs decoder.rs:4274-4281,:4333-4341). \
olmo2 additionally runs its SWA layers' RoPE with YaRN disabled (freq_scale=1, \
ext_factor=0, attn_factor=1, :118-133), a second per-layer RoPE variant ferrox \
cannot express",
),
(
"exaone4",
TriageClass::NewCode,
"same shape as olmo2: src/models/exaone4.cpp:60-67 creates attn_post_norm, per-head \
attn_q_norm/attn_k_norm and ffn_post_norm and NO attn_norm and NO ffn_norm, and the \
graph projects Q/K/V off the raw residual (:118) and runs build_ffn on the raw \
ffn_inp (:159). The generic decoder requires and applies both pre-norms, which is a \
different residual topology. Its optional NEXTN/MTP tensors (:69-73) are a separate \
matter and are refused by name by the unread-tensor gate",
),
(
"seed_oss",
TriageClass::OneMatchArm,
"the gpt-oss norm slot. seed_oss stores its PRE-FFN norm as \
blk.N.post_attention_norm.weight and carries NO blk.N.ffn_norm.weight: \
src/models/seed-oss.cpp:36-37 creates attn_norm and attn_post_norm only, and \
:113-115 applies attn_post_norm to ffn_inp, i.e. AFTER the attention residual. That \
is gpt-oss's slot exactly, and glm4moe's. loader.rs already implements it behind \
`let is_gpt_oss = arch == \"gpt-oss\"` (loader.rs:1736, used at :1796 and :2004); \
widening that one flag is the change. Everything else (:63-126) is plain GQA with a \
sequential residual and SiLU SwiGLU",
),
(
"exaone",
TriageClass::FixtureAway,
"src/models/exaone.cpp:29-38 is the plain Llama tensor set plus an optional global \
rope_freqs.weight, which loader.rs:521 already loads; the graph is \
sequential-residual SiLU SwiGLU (:99,106-113) and load_arch_hparams (:4) reads \
nothing but the RMS epsilon. Note this is EXAONE 3.x, not exaone4, which is a \
different graph. Admitting it needs a fixture, not new code",
),
(
"bailingmoe2",
TriageClass::FixtureAway,
"src/models/bailingmoe2.cpp is plain GQA on the generic path: attn_norm (:47), a \
FUSED attn_qkv (:49) that load_qkv_projections already splits, per-head \
attn_q_norm/attn_k_norm ({n_embd_head_k}, :52-53) applied BEFORE RoPE (:123-135) the \
way ferrox applies them, ffn_norm (:55), leading dense layers (:57), exp_probs_b \
(:61), shared experts (:67-69), and expert_weights_norm / expert_weights_scale / \
expert_gating_func all read from METADATA (:9-11) rather than hardcoded, which \
loader.rs reads too. Sequential residual (:149,191). What is missing is EVIDENCE. \
Caveat, and it fails closed: a checkpoint that ships the last n_layer_nextn layers' \
NEXTN and layer_out_norm tensors (:78-84) is refused by name by the unread-tensor \
gate",
),
(
"hunyuan-moe",
TriageClass::OneMatchArm,
"QK-norm ordering. src/models/hunyuan-moe.cpp applies attn_k_norm and attn_q_norm \
AFTER ggml_rope_ext -- RoPE at :93 and :104, then build_norm at :110 and :115 -- \
while ferrox's decoder norms Q and K and then rotates. That is one named \
per-architecture ordering flag, but it changes every layer's attention scores, so it \
cannot be admitted without it. Everything else -- attn_norm (:31), ffn_norm (:39), a \
shared expert on every layer (:137-143), softmax with norm_w=true and \
expert_weights_scale (:147-150), sequential residual (:129,163) -- ferrox has",
),
(
"mellum",
TriageClass::NewCode,
"two per-layer RoPE variants in one model. src/models/mellum.cpp:128-142 runs the \
SWA layers' RoPE with YaRN switched off -- freq_scale = 1.0, ext_factor = 0.0, \
attn_factor = 1.0 -- while the full-attention layers use the model's own YaRN \
(:143-154). ferrox carries one YaRN configuration for the whole model (it has \
`rope_theta_swa` for the BASE only) and cannot express a per-layer ext_factor. \
Second, smaller hazard on the same architecture: :12-17 accepts the sliding-window \
pattern as a scalar OR as a per-layer ARRAY, and ferrox reads it only as a scalar \
(`GgufValue::as_u64` returns None for an array), so an array-valued file falls back \
to `default_swa_layout`'s period of 4 with nothing saying it substituted its own \
layout for the file's. The tensor set and residual (:45-68, :169-197) are generic",
),
(
"talkie",
TriageClass::NewCode,
"talkie has NO norm weights and a learned per-layer skip connection. In \
src/models/talkie.cpp every \
normalisation is `build_norm(x, nullptr, nullptr, LLM_NORM_RMS, ...)` -- \
non-parametric RMSNorm, no weight tensor -- at :50 (on the embeddings, before layer \
0), :68, :90, :110 and :137; the only norm weight in the file is `attn_q_norm`, and \
it is shaped {1, n_head} (:26), one SCALAR PER HEAD rather than a head_dim vector, \
which is neither of ferrox's two QkNormStyle variants. Each layer then adds \
`inp_skip * out_scale` (:123-126) with a per-layer learned scalar `out_scale` \
(:32), a second residual stream the generic decoder has no slot for, and :5 reads \
{arch}.logit_scale as REQUIRED",
),
(
"mimo2",
TriageClass::NewCode,
"attention sinks on a non-gpt-oss architecture, plus per-layer shapes. \
src/models/mimo2.cpp:58 creates `attn_sinks` per layer; ferrox implements sinks \
only inside the gpt-oss path and, per docs/MODELS.md, on CPU only. :47-49 reads \
n_head and the KV widths PER LAYER, :16 and :181 scale the attention output by \
{arch}.attention.value_scale (a key ferrox neither reads nor gates), :6-12 makes \
SWA unconditional with a per-layer is_swa ARRAY rather than a period, and :19,:76-82 \
add NEXTN/MTP layers with a `layer_out_norm`. Any one of the first three would \
disqualify it; the dense-or-MoE-per-layer choice at :63-72 is the only part ferrox \
already has",
),
(
"plamo3",
TriageClass::FixtureAway,
"PLaMo-3 is the sandwich-norm shape ferrox already implements, and this one really \
does line up slot for slot. src/models/plamo3.cpp:46-58 creates attn_norm, a FUSED \
attn_qkv (:47) that load_qkv_projections splits, per-head attn_q_norm/attn_k_norm \
(:49-50) applied BEFORE RoPE (:128-137), attn_post_norm (:52), ffn_norm (:54), \
ffn_post_norm (:55) and a fused SwiGLU ffn_up of `n_ff * 2` (:57) driven by \
`LLM_FFN_SWIGLU, LLM_FFN_SEQ` (:168) -- the audited phi3 path. The graph applies \
the post-norms exactly where ferrox does: attn_post_norm to the attention branch \
before the residual add (:152-155) and ffn_post_norm to the FFN output before its \
add (:171-174). Its SWA uses a scalar sliding_window_pattern that \
conversion/plamo.py's Plamo3Model writes verbatim (:176-177), and \
`default_swa_layout` already carries plamo3 as period 8. Plamo3Model also inherits \
TextModel's scalar head_count / key_length / value_length, so the per-layer \
`hparams.n_head(i)` accessors in the graph are uniform -- unlike deci, laguna and \
openelm, whose converters really do write arrays. CONFIRM ON THE FIXTURE: that \
attention.key_length equals attention.value_length, since llama.cpp carries \
head_dim_q and head_dim_v separately (:25-26) and ferrox has one head_dim",
),
(
"afmoe",
TriageClass::NewCode,
"gated attention plus NoPE layers. src/models/afmoe.cpp:73 creates `wqkv_gate` \
(LLM_TENSOR_ATTN_GATE), a learned gate applied to the attention output that the \
generic decoder has no slot for, and :137-138 skips RoPE where \
`(il + 1) % n_no_rope_layer_step == 0`, the smollm3 class with no GGUF key. It also \
scales the embeddings by sqrt(n_embd) at :120, which ferrox does only for the Gemma \
family. THIRD, and the quiet one: :8 reads expert_gating_func as OPTIONAL and \
:29-30 defaults it to SIGMOID when absent, while ferrox's fallback \
(loader.rs:375, SIGMOID_GATING_ARCHITECTURES) defaults to softmax for any \
architecture not on its list -- so a checkpoint omitting the key would be routed \
through the wrong scoring function. That last one is the `deepseek` shape and would \
need fixing even if the rest were free",
),
(
"apertus",
TriageClass::NewCode,
"xIELU, with four PER-LAYER parameter arrays. src/models/apertus.cpp:6-9 reads \
xielu_alpha_n, xielu_alpha_p, xielu_beta and xielu_eps as n_layer-long arrays and \
:132-135 indexes them per layer; `FfnActivation` (config.rs:302-312) has three \
variants and no way to carry a per-layer parameter at all. The FFN is also UNGATED \
-- :45-46 creates only ffn_down and ffn_up, no ffn_gate -- so it is the same \
two-matrix shape as `arcee` and `plm` on top of the activation. It further requires \
optional attn_q_norm/attn_k_norm BIASES (:50,:52), and ferrox's norms take a weight \
only",
),
(
"exaone-moe",
TriageClass::NewCode,
"the GLOBAL layers get no RoPE. src/models/exaone-moe.cpp:155-161 wraps both \
ggml_rope_ext calls in `if (is_local_layer)`, where is_local_layer is \
`hparams.is_swa(il)` (:136) -- so on the full-attention layers of every period Q \
and K are never rotated. ferrox rotates every layer, and there is no GGUF key that \
says otherwise: the SWA pattern implies it. Checked and CLEAN on the other axis: \
:5 seeds n_swa = 128 but :13 reads {arch}.attention.sliding_window as REQUIRED, so \
the window is always the file's own value and ferrox reads the same number, and \
`default_swa_layout` already carries exaone-moe as period 4. The MoE half (:72-93) \
-- leading dense, exp_probs_b, shared expert, gating from metadata -- ferrox has",
),
(
"grovemoe",
TriageClass::NewCode,
"a SECOND bank of experts, not just a scale. src/models/grovemoe.cpp:57-59 creates \
`ffn_gate_chexps` / `ffn_down_chexps` / `ffn_up_chexps` -- `n_expert / \
n_group_experts` \"chunk\" experts with their own width n_ff_chexp -- and the graph \
runs build_moe_ffn TWICE (:137 over the ordinary experts, :153 over the chunk \
experts) before :167 adds `scale(moe_out, expert_group_scale)` to the residual. The \
inventory recorded only the post-sum group scale and called this small; the second \
expert bank with its own routing is the larger half and ferrox's MoE layer holds \
one bank. Both n_group_experts and expert_group_scale are REQUIRED keys (:6-7). \
QK-norm is before RoPE (:100-109), which is the one thing that would otherwise have \
been a blocker",
),
(
"hunyuan-dense",
TriageClass::OneMatchArm,
"two named pieces, both small. hunyuan-dense has no graph of its own -- \
models.h:1830 derives it from llama_model_hunyuan_vl -- so the file to read is \
src/models/hunyuan-vl.cpp. (1) It applies attn_k_norm and attn_q_norm AFTER \
ggml_rope_ext (:105-123 rotate, then :132 and :137 norm), the same ordering flag \
`hunyuan-moe` and `maincoder` need; three architectures now want it. (2) :8-12 \
rescales rope_freq_base_train by `alpha^(head_dim / (head_dim - 2))` when \
{arch}.rope.scaling.alpha is positive -- an NTK-alpha base rescale ferrox neither \
applies nor gates, so a checkpoint carrying the key would load and rotate at the \
unscaled base. Everything else (:39-51, :86-167) is attn_norm, per-head QK norm, \
ffn_norm, dense SiLU SwiGLU and a sequential residual",
),
(
"laguna",
TriageClass::NewCode,
"per-layer head counts AND a second rotary width. conversion/laguna.py:79 calls \
`add_head_count(per_layer_heads)` with a LIST, so the array is really in the file, \
and src/models/laguna.cpp:87-88 and :176-177 read n_head(i) / n_head_kv(i) per \
layer while ferrox carries both as scalars. :50 then reads \
LLM_KV_ROPE_DIMENSION_COUNT_SWA into `n_rot_swa`, so the sliding-window layers \
rotate a DIFFERENT number of dimensions than the full-attention layers (its own \
comment at :43-45: full layers YaRN over 64 dims, SWA layers plain RoPE over 128); \
ferrox has one rotary_dim. It also creates `wqkv_gate` (:124), the gated-attention \
tensor afmoe has, and :55-56 defaults expert_gating_func to SIGMOID when the key is \
absent where ferrox would default to softmax. `default_swa_layout` already has \
laguna as dense_first period 4, which is correct and is not the blocker",
),
(
"step35",
TriageClass::NewCode,
"a per-LAYER rotary width. src/models/step35.cpp:65-70 takes `n_rot_max` as the max \
of `hparams.n_rot(i)` over all layers -- because n_rot varies by layer -- and :9 \
first halves n_rot_full; ferrox has one rotary_dim for the model. On top of that: \
per-layer SwiGLU clamp arrays for the routed and shared experts (:28-29, \
LLM_KV_SWIGLU_CLAMP_EXP / _SHEXP), where ferrox's only clamp is the gpt-oss scalar; \
a `wqkv_gate` (:96); a per-layer is_swa ARRAY rather than a period (:26), which \
ferrox reads only as a scalar; NEXTN/MTP layers with trunk-only and MTP-only load \
modes (:32-49); and expert_gating_func defaulting to SIGMOID when absent (:19-20) \
where ferrox defaults to softmax. The inventory guessed this was \"probably \
parameterisable from the gpt-oss clamp\" -- the clamp is, the per-layer n_rot is \
not",
),
("mistral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
("mixtral", TriageClass::Unknown, NO_UPSTREAM_ARCH),
("yi", TriageClass::Unknown, NO_UPSTREAM_ARCH),
(
"grok",
TriageClass::NewCode,
"grok-1 hardcodes five constants BEFORE letting an optional key override them \
(src/models/grok.cpp:5-21): logit_scale = 0.5773502691896257 (1/sqrt(3)), \
embedding_scale = 78.38367176906169, attn_out_scale = 0.08838834764831845 \
(1/sqrt(128)), and attn / router logit softcapping both 30.0. A GGUF omitting every \
key is still scaled by all five, so a key-presence gate such as \
`unsupported_scaling_keys` cannot see them -- the same blind spot `minicpm` is \
refused for. On top of that the graph is not the generic one: attention runs with \
kq_scale = 1.0f (:137) and folds the real scale into a tanh softcap instead \
(llama-graph.cpp:2579-2581), every layer computes BOTH a dense GELU FFN and a GELU \
MoE and sums them scaled by sqrt(2)/2 (:171-184), and `blk.N.attn_output_norm` \
(:62, LLM_TENSOR_ATTN_OUT_NORM = \"blk.%d.attn_output_norm\", llama-arch.cpp:423) is \
a tensor name ferrox never reads. Router logit softcapping has no ferrox concept at \
all. `uses_geglu` already covers grok's GELU, which is necessary and nowhere near \
sufficient",
),
(
"dbrx",
TriageClass::NewCode,
"LayerNorm, not RMSNorm. src/models/dbrx.cpp:4 reads LLM_KV_ATTENTION_LAYERNORM_EPS \
(not the RMS one) and the graph normalises with `LLM_NORM` at all three sites -- \
:69-71 pre-attention, :110-112 pre-FFN, :140-142 final -- which subtracts the mean; \
ferrox has only `rms_norm(x, w, eps)`, a different function of the same tensors on \
every layer. Note this is NOT caught by the required-bias refusal group: dbrx \
creates no norm bias tensors at all, so the marker that group keys on is absent \
while the normalisation is still LayerNorm. It also requires \
{arch}.attention.clamp_kqv (:5, REQUIRED) and carries no `ffn_norm` -- \
`attn_out_norm` (:34) IS the pre-FFN norm (:110-113), the gpt-oss slot again but \
under the unread name `blk.%d.attn_output_norm`",
),
(
"smallthinker",
TriageClass::NewCode,
"the MoE router reads a DIFFERENT tensor. src/models/smallthinker.cpp:111 computes \
the router logits from the raw layer input `inpL`, before the attention block, and \
passes them into build_moe_ffn as a precomputed `probs` with a NULL ffn_gate_inp \
(:151-161); every other MoE architecture routes on the normed FFN input, which is \
what ferrox computes. Two more, either of which alone would disqualify it: (1) NoPE \
layers with no GGUF key -- llama-hparams.h:203 defaults n_no_rope_layer_step to 4 \
and the SWA branch (:6-15) never overwrites it, so :108-109's \
`use_rope = n_no_rope_layer_step == n_layer || il % n_no_rope_layer_step != 0` \
leaves layers 0, 4, 8 ... unrotated, the `smollm3` class exactly, which ferrox \
refuses outright; (2) `LLM_FFN_RELU` experts (:158), and FfnActivation has no ReLU \
variant. :8 also pins n_swa to 4096 over whatever the file declares. \
`default_swa_layout` and `swa_rope_base_follows_model` already carry smallthinker \
correctly; they are not the blocker",
),
(
"bitnet",
TriageClass::NewCode,
"two norms INSIDE the blocks, in slots ferrox does not have. \
src/models/bitnet.cpp:24,36 require `attn_sub_norm` and `ffn_sub_norm`, and the \
graph applies attn_sub_norm to the attention output BEFORE the output projection \
(:101-106 -- not after it, where ferrox's post_attn_norm sits) and ffn_sub_norm \
between the gate*up product and `ffn_down` (:135-140), inside the FFN. It also \
carries a per-tensor `scale` for every projection (:27-43, applied via \
build_lora_mm) and creates no `output` tensor at all, taking the LM head from \
`tok_embd` unconditionally (:164). ferrox refuses it by name today via the \
unread-tensor gate (`blk.N.attn_sub_norm`, llama-arch.cpp:510-511), which is the \
right outcome and not a small fix",
),
(
"openelm",
TriageClass::NewCode,
"per-LAYER head counts and FFN width. src/models/openelm.cpp:26-28 reads \
`hparams.n_head(i)`, `n_head_kv(i)` and `n_ff(i)` per layer and sizes the fused \
`wqkv` as `n_embd x (2*n_head_kv(i) + n_head(i)) * n_embd_head_k` (:34), and the \
graph re-derives those widths for every layer (:67-69). ferrox's ModelConfig \
carries n_heads, n_kv_heads and expert_ffn_dim as SCALARS, and \
`load_qkv_projections` splits a fused QKV at offsets computed from those scalars, \
so there is nowhere to put this. It fails closed, but NOT with this message: \
conversion/openelm.py:57-59 writes head_count, head_count_kv and \
feed_forward_length as ARRAYS, and `GgufValue::as_u64` returns None for an array \
(ferrox-gguf/src/lib.rs:83-93), so the load dies on a missing-hparam error for keys \
the file does carry, before the unaudited gate is reached. That misleading message \
is the `glm4moe` shape and should be fixed alongside",
),
];
pub fn architecture_catalog() -> &'static [ArchProfile] {
use std::sync::OnceLock;
use ArchScope::*;
use DecoderFamily::*;
use MemoryKind::*;
use QkNormStyle::*;
use RopeLayout::*;
static CAT: OnceLock<Vec<ArchProfile>> = OnceLock::new();
CAT.get_or_init(|| {
let mut v = Vec::with_capacity(160);
v.push(gqa_norm("llama"));
for (n, class, blocker) in NORM_ROPE_TRIAGED {
v.push(gqa_norm(n).triaged(*class, blocker));
}
for n in [
"olmoe", "qwen2", "qwen2moe",
"gpt-oss",
"dots1",
] {
v.push(gqa_neox(n));
}
for (n, class, blocker) in NEOX_ROPE_TRIAGED {
v.push(gqa_neox(n).triaged(*class, blocker));
}
for (n, reason) in [
(
"smollm3",
"a NoPE layer pattern: llama.cpp hardcodes \
`hparams.n_no_rope_layer_step = 4` (src/models/smollm3.cpp:5) and \
skips RoPE where `(il + 1) % 4 == 0` (:69), so 9 of a 36-layer \
SmolLM3-3B's layers get NO rotation at all. There is NO GGUF key \
for it, so no metadata gate could see it: the tensor set matches \
the generic llama set exactly and the file loads clean. The \
generic decoder rotates every layer, which is a different model. \
Same shape as the ALiBi group below, and found the same way",
),
(
"gpt2",
"learned absolute position embeddings (`position_embd.weight`, \
src/models/gpt2.cpp:19,74) and no RoPE; the generic decoder has no \
slot for them and rotates instead",
),
(
"mpt",
"ALiBi attention bias (src/models/mpt.cpp:6), plus an optional \
learned `position_embd` and an optional QKV clamp; the generic \
decoder implements none of the three and applies RoPE instead",
),
(
"refact",
"ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
GGUF key to detect it (src/models/refact.cpp:12); the generic \
decoder applies RoPE instead",
),
(
"bloom",
"ALiBi attention bias, hardcoded `f_max_alibi_bias = 8.0f` with no \
GGUF key (src/models/bloom.cpp:18), plus a `token_embd_norm` the \
generic decoder never applies; RoPE is applied instead",
),
(
"jais",
"ALiBi attention bias (src/models/jais.cpp:5); the generic decoder \
applies RoPE instead",
),
] {
v.push(prof(
n,
TextGeneration,
StandardGqa,
KvGqa,
Norm,
ArchPath::DedicatedOnly { reason },
WholeVector,
));
}
for (n, rope, reason) in [
(
"codeshell",
Neox,
"required bias tensors with no slot in the generic decoder: \
`attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
(src/models/codeshell.cpp:36,42,45), plus the LayerNorm biases \
`output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:24,31,39) \
-- the generic decoder is RMSNorm-only and drops all six",
),
(
"jais2",
Neox,
"required bias tensors with no slot in the generic decoder: \
`attn_output.bias`, `ffn_up.bias`, `ffn_down.bias` \
(src/models/jais2.cpp:41,48,50), plus the LayerNorm biases \
`output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:20,30,44). \
Only its Q/K/V biases (:38-40) would have been applied",
),
(
"starcoder",
Norm,
"required bias tensors with no slot in the generic decoder: the \
*fused* `attn_qkv.bias` (src/models/starcoder.cpp:40), which \
`load_qkv_projections` never looks for because it reads bias only \
under the split `attn_q.bias` names; `attn_output.bias`, \
`ffn_down.bias`, `ffn_up.bias` (:43,49,52); and the LayerNorm \
biases `output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` \
(:24,37,46). It also adds a learned `position_embd` to the \
embeddings (:75) that the generic decoder has no slot for",
),
(
"starcoder2",
Neox,
"required bias tensors with no slot in the generic decoder: \
`attn_output.bias`, `ffn_down.bias`, `ffn_up.bias` \
(src/models/starcoder2.cpp:41,50,51), plus the LayerNorm biases \
`output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:23,35,44)",
),
(
"phimoe",
Neox,
"required bias tensors with no slot in the generic decoder: \
`attn_output.bias` and an `output.bias` on the LM head \
(src/models/phimoe.cpp:33,23), plus the LayerNorm biases \
`output_norm.bias`, `attn_norm.bias`, `ffn_norm.bias` (:21,29,36). \
`phi3` stays generic: it requires none of them",
),
(
"nemotron",
Neox,
"required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
`ffn_norm.bias` (src/models/nemotron.cpp:19,26,35). llama.cpp \
normalises with `build_norm(..., LLM_NORM, ...)` and a bias; the \
generic decoder applies RMSNorm with weight only, which is a \
different function of the same tensors at every layer",
),
(
"orion",
Neox,
"required LayerNorm biases `output_norm.bias`, `attn_norm.bias`, \
`ffn_norm.bias` (src/models/orion.cpp:18,25,31); the generic \
decoder is RMSNorm-only and drops all three",
),
(
"stablelm",
Neox,
"required LayerNorm biases `output_norm.bias` and `attn_norm.bias` \
(src/models/stablelm.cpp:20,28); the generic decoder is \
RMSNorm-only and drops both",
),
(
"qwen",
Neox,
"a required *fused* `attn_qkv.bias` (src/models/qwen.cpp:28). \
`load_qkv_projections` splits the fused `attn_qkv.weight` but reads \
bias only under the split `attn_q.bias` / `attn_k.bias` / \
`attn_v.bias` names, so Qwen-1's QKV bias is silently dropped and \
every Q, K and V projection runs unbiased. Qwen-2 and later store \
the split spelling and stay generic",
),
] {
v.push(prof(
n,
TextGeneration,
StandardGqa,
KvGqa,
rope,
ArchPath::DedicatedOnly { reason },
WholeVector,
));
}
v.push(prof(
"qwen3",
TextGeneration,
Qwen3Family,
KvGqa,
Neox,
ArchPath::GenericGqa { rope: Neox },
PerHead,
));
v.push(prof(
"qwen3moe",
TextGeneration,
Qwen3Family,
KvGqa,
Neox,
ArchPath::GenericGqa { rope: Neox },
PerHead,
));
v.push(
prof(
"gemma",
TextGeneration,
GemmaFamily,
KvGqa,
Neox,
ArchPath::GenericGqa { rope: Neox },
PerHead,
)
.triaged(
TriageClass::FixtureAway,
"src/models/gemma.cpp:16-33 creates exactly the tensors the generic decoder \
loads -- attn_norm, split Q/K/V, attn_output, ffn_norm, gate/up/down -- with \
no biases, no QK-norm and no post-norms, and its graph is \
sequential-residual (:97,115). The three Gemma-specific pieces are all \
implemented: the sqrt(n_embd) embedding scale (:49 vs loader.rs:467-474's \
GemmaFamily embedding_scale), GeGLU (:112 vs FfnActivation::Gelu) and a \
1/sqrt(head_dim) attention scale (:86 scales Q, then :91 passes \
kq_scale=1.0f -- which is what loader.rs:476-480 leaving attention_scale as \
None already produces). Gemma-1 declares no softcap and no sliding window, \
so the Gemma-2/3 machinery is inert here. Admitting it needs a fixture or a \
parity run, not new code",
),
);
v.push(prof(
"gemma2",
TextGeneration,
GemmaFamily,
KvIswa,
Neox,
ArchPath::GenericGqa { rope: Neox },
PerHead,
));
v.push(prof(
"gemma3",
TextGeneration,
GemmaFamily,
KvIswa,
Neox,
ArchPath::GenericGqa { rope: Neox },
PerHead,
));
for n in ["gemma4", "gemma4-assistant"] {
v.push(prof(
n,
TextGeneration,
GemmaFamily,
KvIswa,
Neox,
ArchPath::DedicatedOnly {
reason: "use load_gemma4_engine_from_path / ServedEngine::Gemma4",
},
PerHead,
));
}
const PARALLEL_RESIDUAL: &str =
"parallel attention+FFN residual -- llama.cpp feeds both branches the *same* \
normed input and sums `inpL + attn_out + ffn_out` once; the generic decoder \
computes the sequential form, which is a different graph";
for (n, rope, fam) in [
("command-r", Norm, StandardGqa),
("cohere2", Norm, StandardGqa),
("cohere2moe", Norm, StandardGqa),
("falcon", Neox, StandardGqa),
("gptneox", Neox, StandardGqa),
("phi2", Neox, PhiFamily),
("plamo", Neox, StandardGqa),
] {
v.push(prof(
n,
TextGeneration,
fam,
KvGqa,
rope,
ArchPath::DedicatedOnly {
reason: PARALLEL_RESIDUAL,
},
WholeVector,
));
}
v.push(prof(
"minicpm",
TextGeneration,
StandardGqa,
KvGqa,
Norm,
ArchPath::DedicatedOnly {
reason: "unconditional embedding/residual/logit multipliers that llama.cpp \
applies even when the GGUF omits every key; not applied by the \
generic decoder",
},
WholeVector,
));
v.push(prof(
"phi3",
TextGeneration,
PhiFamily,
KvGqa,
Neox,
ArchPath::GenericGqa { rope: Neox },
WholeVector,
));
v.push(
prof(
"phi4",
TextGeneration,
PhiFamily,
KvGqa,
Neox,
ArchPath::GenericGqa { rope: Neox },
WholeVector,
)
.triaged(
TriageClass::Unknown,
"there is no llama.cpp graph to diff against. `phi4` is NOT in LLM_ARCH_NAMES \
-- src/llama-arch.cpp:44 lists \"phi3\" and there is no phi4 entry -- so this \
row is a ferrox-only alias and no llama.cpp-produced GGUF can carry the \
string. ferrox admits it as PhiFamily/NEOX, i.e. phi3's fused-QKV and fused \
gate+up graph, on the assumption that a file spelling it means the same \
thing. WHAT WOULD SETTLE IT: a real GGUF whose general.architecture is \
literally `phi4`. If its blk.0 carries attn_qkv.weight it is phi3's graph \
and this row is fixture-away behind an already-audited phi3; if it carries \
split attn_q/attn_k/attn_v it is a Llama-shaped graph and belongs on a \
different row",
),
);
v.push(prof(
"llama4",
TextGeneration,
Dedicated,
KvGqa,
Norm,
ArchPath::DedicatedOnly {
reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list",
},
WholeVector,
));
v.push(prof(
"minimax-m2",
TextGeneration,
Dedicated,
KvGqa,
Neox,
ArchPath::DedicatedOnly {
reason: "minimax-m2 is UNAUDITED, not unimplemented: llama.cpp's minimax-m2.cpp \
builds plain GQA + whole-vector QK-norm + partial NEOX RoPE (n_rot=64 < \
head_dim=128) + a SiLU sigmoid MoE with exp_probs_b, all of which the \
generic path already has. Admitting it needs a fixture or a parity run \
against llama.cpp, not new code",
},
WholeVector,
));
v.push(prof(
"minimax-m3",
TextGeneration,
Dedicated,
KvGqa,
Neox,
ArchPath::DedicatedOnly {
reason: "minimax-m3 needs MiniMax Sparse Attention: a per-layer indexer \
(index_q_proj/index_k_proj/index_q_norm/index_k_norm, minimax-m3.cpp:76-82) \
driving its own MSA KV cache (llama-kv-cache-msa.h) with position<->cell \
maps, plus SWIGLU_OAI experts and shared experts. ferrox has only the \
block-selection rule (ferrox_core::block_sparse), none of the rest",
},
PerHead,
));
v.push(prof(
"minicpm3",
TextGeneration,
Mla,
KvMla,
Neox,
ArchPath::DedicatedOnly {
reason: "MiniCPM3 is an MLA model (src/models/minicpm3.cpp:5-6,41-46 -- \
q_lora_rank/kv_lora_rank and the attn_q_a/attn_q_b/attn_kv_a_mqa/\
attn_kv_b tensor set), so it needs the MLA engine and not the \
generic GQA decoder. It ALSO hardcodes MiniCPM's multipliers with \
no GGUF key to read them from -- scale_embd = 12.0, \
scale_depth = 1.4, n_embd_base = 256 at :65-67, applied at :81 -- \
which is the same blind spot `minicpm` is refused for",
},
WholeVector,
));
v.push(prof(
"deepseek2",
TextGeneration,
Mla,
KvMla,
Norm,
ArchPath::DedicatedOnly {
reason: "DeepSeek-2 MLA needs the MLA engine, not generic GQA",
},
WholeVector,
));
v.push(prof(
"deepseek32",
TextGeneration,
Mla,
KvDsa,
Norm,
ArchPath::DedicatedOnly {
reason: "DeepSeek-3.2 DSA/MLA needs the dedicated sparse/MLA stack",
},
WholeVector,
));
v.push(prof(
"mistral4",
TextGeneration,
Mla,
KvMla,
Norm,
ArchPath::DedicatedOnly {
reason: "mistral4 reuses DeepSeek-2 MLA loader/graph in llama.cpp",
},
WholeVector,
));
v.push(dedicated(
"glm-dsa",
"use ferrox_models::glm52_decoder / glm52_gguf_loader (DSA), not the generic GQA Decoder",
));
v.push(dedicated(
"glm4",
"use ferrox_models::glm52_decoder / glm52_gguf_loader, not the generic GQA Decoder",
));
v.push(dedicated(
"glm4moe",
"GLM-4.5-MoE stores its pre-FFN norm as `blk.N.post_attention_norm.weight` and \
carries NO `blk.N.ffn_norm.weight` (src/models/glm4-moe.cpp:75, applied to \
`ffn_inp` at :215 -- i.e. AFTER the attention residual). The generic decoder \
requires `ffn_norm` and puts `post_attention_norm` in Gemma's other slot, on the \
attention branch BEFORE the residual add, so it would both fail to find its \
tensors and compute a different graph. This is gpt-oss's norm slot exactly, and \
`loader.rs` already implements it behind an `is_gpt_oss` flag; widening that flag \
is what admits glm4moe. It is NOT MLA -- do not send it to glm52_gguf_loader, \
which asks for a `q_lora_rank` no glm4moe checkpoint carries",
));
v.push(dedicated(
"deepseek4",
"DeepSeek V4 needs CSA/HCA + mHC assembly; generic GQA Decoder is not valid",
));
v.push(dedicated(
"kimi-linear",
"use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
));
v.push(dedicated(
"kimi_k3",
"use ferrox_models::kimi_decoder / kimi_loader, not the generic GQA Decoder",
));
for (n, rope) in [
("jamba", Neox),
("falcon-h1", Neox),
("plamo2", Neox),
("granitehybrid", Norm),
("granite-hybrid", Norm),
("lfm2", Neox),
("lfm2moe", Neox),
("nemotron_h", Neox),
("nemotron_h_moe", Neox),
("qwen3next", Neox),
("qwen35", Neox),
("qwen35moe", Neox),
] {
let qk = if n.starts_with("qwen3") {
PerHead
} else {
WholeVector
};
v.push(prof(
n,
TextGeneration,
DecoderFamily::Hybrid,
MemoryKind::Hybrid,
rope,
ArchPath::DedicatedOnly {
reason: "hybrid attn+SSM/delta-net engine not yet on the serve path",
},
qk,
));
}
for n in ["mamba", "mamba2", "rwkv6", "rwkv6qwen2", "rwkv7", "arwkv7"] {
v.push(prof(
n,
TextGeneration,
DecoderFamily::Recurrent,
MemoryKind::Recurrent,
Neox,
ArchPath::DedicatedOnly {
reason: "recurrent engine not yet on the serve path",
},
WholeVector,
));
}
v.push(prof(
"t5",
TextGeneration,
EncoderDecoder,
None,
Neox,
ArchPath::DedicatedOnly {
reason: "T5 encoder-decoder engine not yet on the serve path",
},
WholeVector,
));
for (n, scope, reason) in [
(
"t5encoder",
DeferredEncoderEmbedding,
"encoder-only; deferred from text-generation parity",
),
("bert", DeferredEncoderEmbedding, "encoder/embedding; deferred"),
(
"modern-bert",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"nomic-bert",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"nomic-bert-moe",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"neo-bert",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"jina-bert-v2",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"jina-bert-v3",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"eurobert",
DeferredEncoderEmbedding,
"encoder/embedding; deferred",
),
(
"llama-embed",
DeferredEncoderEmbedding,
"embedding variant; deferred",
),
(
"gemma-embedding",
DeferredEncoderEmbedding,
"embedding variant; deferred",
),
(
"pangu-embedded",
DeferredEncoderEmbedding,
"embedding variant; deferred",
),
("yi-vl", DeferredMultimodal, "Yi vision-language; deferred"),
("qwen2vl", DeferredMultimodal, "vision-language; deferred"),
("qwen3vl", DeferredMultimodal, "vision-language; deferred"),
("qwen3vlmoe", DeferredMultimodal, "vision-language; deferred"),
("cogvlm", DeferredMultimodal, "vision-language; deferred"),
("chameleon", DeferredMultimodal, "multimodal; deferred"),
("hunyuan_vl", DeferredMultimodal, "vision-language; deferred"),
("paddleocr", DeferredMultimodal, "OCR multimodal; deferred"),
("hy_v3", DeferredMultimodal, "multimodal; deferred"),
("deepseek2-ocr", DeferredMultimodal, "OCR multimodal; deferred"),
("dream", DeferredDiffusion, "diffusion LM; deferred"),
("llada", DeferredDiffusion, "diffusion LM; deferred"),
("llada-moe", DeferredDiffusion, "diffusion LM; deferred"),
("rnd1", DeferredDiffusion, "diffusion LM; deferred"),
(
"wavtokenizer-dec",
DeferredAudio,
"audio tokenizer; deferred",
),
(
"eagle3",
EnumOnly,
"speculative draft head; not a standalone decoder target",
),
(
"dflash",
EnumOnly,
"speculative draft head; not a standalone decoder target",
),
("clip", EnumOnly, "quantize dummy only"),
("gptj", EnumOnly, "enum-only in llama.cpp factory gap"),
("(unknown)", EnumOnly, "llama.cpp unknown sentinel"),
] {
v.push(deferred_scope(n, scope, reason));
}
v.push(prof(
"gemma3n",
TextGeneration,
GemmaFamily,
KvIswa,
Neox,
ArchPath::DedicatedOnly {
reason: "gemma3n AltUp/Laurel tensors not implemented in the generic decoder",
},
PerHead,
));
for n in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
v.push(prof(
n,
TextGeneration,
TestFixture,
KvGqa,
Neox,
ArchPath::TestFixture { rope: Neox },
WholeVector,
));
}
v
})
.as_slice()
}
pub fn resolve_profile(arch: &str) -> Option<&'static ArchProfile> {
architecture_catalog().iter().find(|p| p.gguf_name == arch)
}
pub fn resolve_architecture(arch: &str) -> Option<ArchPath> {
resolve_profile(arch).map(|p| p.path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SwaPattern {
pub period: usize,
pub dense_first: bool,
}
pub fn swa_disabled_by_arch(arch: &str) -> bool {
matches!(arch, "phi3")
}
pub fn uses_geglu(arch: &str) -> bool {
matches!(arch, "grok")
}
pub fn default_swa_layout(arch: &str) -> Option<SwaPattern> {
let last_dense = |period| {
Some(SwaPattern {
period,
dense_first: false,
})
};
let dense_first = |period| {
Some(SwaPattern {
period,
dense_first: true,
})
};
match arch {
"gpt-oss" => last_dense(2),
"gemma2" => last_dense(2),
"gemma3" => last_dense(6),
"gemma3n" => last_dense(5),
"gemma-embedding" => last_dense(6),
"cohere2" | "exaone4" | "olmo2" => last_dense(4),
"mellum" => last_dense(4),
"exaone-moe" => last_dense(4),
"afmoe" => last_dense(4),
"plamo3" => last_dense(8),
"llama4" => last_dense(4),
"smallthinker" => dense_first(4),
"laguna" => dense_first(4),
"cohere2moe" => dense_first(4),
"modern-bert" => dense_first(3),
_ => None,
}
}
pub fn swa_rope_base_follows_model(arch: &str) -> bool {
matches!(
arch,
"afmoe"
| "cohere2"
| "cohere2moe"
| "dflash"
| "exaone-moe"
| "exaone4"
| "gemma2"
| "laguna"
| "llama4"
| "mellum"
| "olmo2"
| "gpt-oss"
| "smallthinker"
)
}
pub fn unsupported_feature_keys(arch: &str) -> Vec<(String, &'static str)> {
let profile = resolve_profile(arch);
if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
return Vec::new();
}
let key = |suffix: &str| format!("{arch}.{suffix}");
vec![
(
key("attention.logit_softcapping"),
"attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
),
(
key("attn_logit_softcapping"),
"attention logit soft-capping (Gemma 2+); not implemented in the generic decoder",
),
(
key("final_logit_softcapping"),
"final logit soft-capping (Gemma 2+); not implemented in the generic decoder",
),
(
key("attention.sliding_window_pattern"),
"alternating sliding-window pattern (Gemma 2+); not implemented in the generic decoder",
),
]
}
pub fn unsupported_scaling_keys(arch: &str) -> Vec<(String, &'static str, f32)> {
let profile = resolve_profile(arch);
if matches!(profile.map(|p| p.family), Some(DecoderFamily::GemmaFamily)) {
return Vec::new();
}
let key = |suffix: &str| format!("{arch}.{suffix}");
vec![
(
key("logit_scale"),
"logit multiplier (Granite / Command-R `logits_scaling`); not applied by the generic decoder",
1.0,
),
(
key("residual_scale"),
"residual multiplier (Granite `residual_multiplier`); not applied by the generic decoder",
1.0,
),
(
key("embedding_scale"),
"embedding multiplier (Granite / MiniCPM `embedding_multiplier`); the generic decoder only scales embeddings for the Gemma family",
1.0,
),
(
key("attention.scale"),
"explicit attention score scale (Granite `attention_multiplier`); the generic decoder always uses 1/sqrt(head_dim)",
0.0,
),
]
}
pub fn coverage_report_markdown() -> String {
let mut lines = vec![
"# Architecture coverage manifest".to_string(),
String::new(),
"Generated from `ferrox_models::capability::architecture_catalog`.".to_string(),
"Source of truth for names: pinned llama.cpp `LLM_ARCH_NAMES`.".to_string(),
String::new(),
"| GGUF arch | Scope | Family | Memory | Path |".to_string(),
"|---|---|---|---|---|".to_string(),
];
for p in architecture_catalog() {
let path = match p.path {
ArchPath::GenericGqa { .. } => "generic-gqa",
ArchPath::TestFixture { .. } => "test-fixture",
ArchPath::DedicatedOnly { .. } => "dedicated",
ArchPath::Deferred { .. } => "deferred",
};
lines.push(format!(
"| `{}` | {:?} | {:?} | {:?} | {} |",
p.gguf_name, p.scope, p.family, p.memory, path
));
}
lines.push(String::new());
lines.join("\n")
}
#[cfg(test)]
mod audit_tests {
use super::*;
#[test]
fn every_audited_name_is_actually_on_the_generic_path() {
for name in AUDITED_GENERIC_GQA {
let profile = resolve_profile(name)
.unwrap_or_else(|| panic!("audited arch `{name}` is not in the catalog"));
assert!(
matches!(profile.path, ArchPath::GenericGqa { .. }),
"`{name}` is listed as an audited GENERIC-path arch but resolves to {:?}",
profile.path
);
}
}
#[test]
fn the_architectures_that_were_wrong_are_not_claimed_as_audited() {
for name in ["gpt2", "mpt", "refact", "bloom", "jais"] {
assert!(
!is_audited_generic(name),
"`{name}` was found computing ALiBi or learned position embeddings as \
though it were RoPE; it cannot be on the audited list"
);
}
}
#[test]
fn every_unaudited_generic_architecture_is_triaged_or_listed_as_pending() {
for p in architecture_catalog() {
if !matches!(p.path, ArchPath::GenericGqa { .. }) || is_audited_generic(p.gguf_name) {
continue;
}
let pending = TRIAGE_PENDING.contains(&p.gguf_name);
match (p.triage, pending) {
(Some(_), false) | (None, true) => {}
(Some(t), true) => panic!(
"`{}` carries a {:?} verdict AND is still on TRIAGE_PENDING; remove it \
from the pending list",
p.gguf_name, t.class
),
(None, false) => panic!(
"`{}` is on the generic path, is not audited, has no triage verdict and \
is not on TRIAGE_PENDING. Read \
.scratch/llama.cpp/src/models/ for it, or say so on the pending list",
p.gguf_name
),
}
}
}
#[test]
fn nothing_on_the_pending_list_is_stale() {
for name in TRIAGE_PENDING {
let p = resolve_profile(name)
.unwrap_or_else(|| panic!("TRIAGE_PENDING names `{name}`, not in the catalog"));
assert!(
matches!(p.path, ArchPath::GenericGqa { .. }),
"`{name}` is on TRIAGE_PENDING but resolves to {:?}, which never reaches the \
unaudited refusal",
p.path
);
assert!(
!is_audited_generic(name),
"`{name}` is audited and runs; it does not need a triage verdict"
);
}
assert!(
TRIAGE_PENDING.is_empty(),
"TRIAGE_PENDING regrew to {:?}; that is fine, but say so in docs/MODELS.md too",
TRIAGE_PENDING
);
}
#[test]
fn an_audited_architecture_carries_no_triage_verdict() {
for name in AUDITED_GENERIC_GQA {
assert!(
unaudited_triage(name).is_none(),
"`{name}` is audited and runs, so it must not carry a triage verdict"
);
}
}
#[test]
fn every_triage_verdict_cites_the_llama_cpp_line_that_decides_it() {
let mut seen = 0;
for p in architecture_catalog() {
let Some(t) = p.triage else { continue };
seen += 1;
assert!(
t.blocker.len() > 80,
"`{}`'s blocker is too short to name anything: {:?}",
p.gguf_name,
t.blocker
);
let cites_llama_cpp =
t.blocker.contains("src/models/") || t.blocker.contains("src/llama-arch.cpp");
assert!(
cites_llama_cpp,
"`{}`'s blocker cites no llama.cpp source: {}",
p.gguf_name, t.blocker
);
if t.class == TriageClass::Unknown {
assert!(
t.blocker.contains("WOULD SETTLE IT"),
"`{}` is UNKNOWN but does not say what would settle it",
p.gguf_name
);
}
}
assert!(
seen == 46,
"every unaudited generic architecture is triaged; found {seen}. \
It was 47 until the triage found `minicpm3` was an MLA model on the \
generic-GQA row and it moved to DedicatedOnly"
);
}
#[test]
fn the_refusal_detail_distinguishes_the_classes() {
let fixture = unaudited_refusal_detail("bailingmoe2");
let arm = unaudited_refusal_detail("seed_oss");
let new_code = unaudited_refusal_detail("olmo2");
let untriaged = unaudited_refusal_detail("an-arch-nobody-has-read");
assert!(fixture.contains("FIXTURE-AWAY"), "{fixture}");
assert!(arm.contains("ONE MATCH ARM"), "{arm}");
assert!(new_code.contains("NEW CODE"), "{new_code}");
assert!(
untriaged.contains("not done for `an-arch-nobody-has-read` yet"),
"{untriaged}"
);
for a in [&fixture, &arm, &new_code, &untriaged] {
for b in [&fixture, &arm, &new_code, &untriaged] {
if !std::ptr::eq(a, b) {
assert_ne!(a, b, "two refusal details are identical");
}
}
}
assert!(arm.contains("post_attention_norm"), "{arm}");
assert!(new_code.contains("olmo2.cpp:47,52"), "{new_code}");
}
#[test]
fn an_unchecked_architecture_is_not_audited() {
assert!(!is_audited_generic("smallthinker"));
assert!(!is_audited_generic("mellum"));
assert!(!is_audited_generic("an-arch-that-does-not-exist"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_mainstream_families_resolve() {
assert_eq!(
resolve_architecture("llama"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Norm
})
);
assert_eq!(
resolve_architecture("qwen2moe"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_architecture("mistral"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_architecture("yi"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_architecture("mixtral"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_architecture("phi3"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_architecture("phi4"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
assert_eq!(
resolve_profile("phi4").map(|p| p.family),
Some(DecoderFamily::PhiFamily)
);
assert_eq!(
resolve_architecture("gemma3"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Neox
})
);
for arch in ["gemma4", "gemma4-assistant"] {
assert!(
matches!(
resolve_architecture(arch),
Some(ArchPath::DedicatedOnly { .. })
),
"{arch} uses dedicated Gemma4 engine"
);
assert_eq!(
resolve_profile(arch).map(|p| p.family),
Some(DecoderFamily::GemmaFamily)
);
}
assert!(matches!(
resolve_architecture("gemma3n"),
Some(ArchPath::DedicatedOnly { .. })
));
assert_eq!(
resolve_architecture("deepseek"),
Some(ArchPath::GenericGqa {
rope: RopeLayout::Norm
})
);
assert_eq!(
resolve_profile("qwen3").map(|p| p.qk_norm),
Some(QkNormStyle::PerHead)
);
}
#[test]
fn deepseek2_is_dedicated_mla_not_generic() {
assert!(matches!(
resolve_architecture("deepseek2"),
Some(ArchPath::DedicatedOnly { .. })
));
}
#[test]
fn unknown_architecture_is_none() {
assert_eq!(resolve_architecture("totally-unknown-arch"), None);
assert!(matches!(
resolve_architecture("t5"),
Some(ArchPath::DedicatedOnly { .. })
));
}
#[test]
fn dedicated_paths_are_not_generic() {
assert!(matches!(
resolve_architecture("glm-dsa"),
Some(ArchPath::DedicatedOnly { .. })
));
assert!(matches!(
resolve_architecture("deepseek4"),
Some(ArchPath::DedicatedOnly { .. })
));
for arch in ["minimax-m2", "minimax-m3"] {
assert!(
matches!(
resolve_architecture(arch),
Some(ArchPath::DedicatedOnly { .. })
),
"{arch} must fail closed, not silent generic GQA"
);
}
assert!(
matches!(
resolve_architecture("llama4"),
Some(ArchPath::DedicatedOnly {
reason: "llama4 MoE + non-GQA attn — see llama4_engine.rs tensor list"
})
),
"llama4 must fail closed, not silent generic GQA"
);
assert!(matches!(
resolve_architecture("glm4"),
Some(ArchPath::DedicatedOnly { .. })
));
assert!(matches!(
resolve_architecture("glm4moe"),
Some(ArchPath::DedicatedOnly { .. })
));
}
#[test]
fn test_fixtures_remain_loadable() {
for arch in ["ferroxtest", "ferroxtestmoe", "ferroxtestmixed"] {
assert!(matches!(
resolve_architecture(arch),
Some(ArchPath::TestFixture { .. })
));
}
}
#[test]
fn catalog_has_unique_names() {
let mut seen = std::collections::HashSet::new();
for p in architecture_catalog() {
assert!(
seen.insert(p.gguf_name),
"duplicate arch name {}",
p.gguf_name
);
}
}
#[test]
fn gemma_family_does_not_fail_closed_on_softcap_keys() {
assert!(unsupported_feature_keys("gemma3").is_empty());
assert!(!unsupported_feature_keys("llama").is_empty());
}
#[test]
fn architectures_with_a_different_residual_topology_are_refused() {
for arch in [
"command-r",
"cohere2",
"cohere2moe",
"falcon",
"gptneox",
"phi2",
"plamo",
"minicpm",
] {
match resolve_architecture(arch) {
Some(ArchPath::DedicatedOnly { reason }) => {
assert!(!reason.is_empty(), "{arch} must say why");
}
other => panic!("{arch} must be refused, got {other:?}"),
}
}
for arch in ["phi3", "plamo3", "qwen2", "llama"] {
assert!(
matches!(
resolve_architecture(arch),
Some(ArchPath::GenericGqa { .. })
),
"{arch} must stay generic"
);
}
for arch in ["phimoe", "starcoder2", "nemotron"] {
match resolve_architecture(arch) {
Some(ArchPath::DedicatedOnly { reason }) => assert!(
reason.contains("bias"),
"{arch} is refused for the wrong reason: {reason}"
),
other => panic!("{arch} must be refused for its biases, got {other:?}"),
}
}
}
#[test]
fn no_architecture_is_listed_twice() {
let mut seen = std::collections::HashSet::new();
for p in architecture_catalog() {
assert!(seen.insert(p.gguf_name), "{} listed twice", p.gguf_name);
}
}
#[test]
fn every_refused_key_is_one_a_converter_actually_writes() {
let keys: Vec<String> = unsupported_feature_keys("llama")
.into_iter()
.map(|(k, _)| k)
.collect();
for real in [
"llama.attn_logit_softcapping",
"llama.final_logit_softcapping",
"llama.attention.sliding_window_pattern",
] {
assert!(
keys.iter().any(|k| k == real),
"{real} is a key llama.cpp writes and this gate must refuse it; \
gate currently holds {keys:?}"
);
}
assert!(
unsupported_feature_keys("gemma2").is_empty(),
"the Gemma family implements softcap and the SWA pattern"
);
}
}