From eb2cc2b8be5ada2823947533e35d26f7f78cbec3 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 10:18:05 -0400 Subject: [PATCH 1/3] npu-pack: role -> engine slot for the roles the layer type does not change (engine#1788, 3c placement) An import has two sides. The classifier says WHAT a tensor is; the engine reads weights by SLOT name, so the writer needs role -> slot. This lands the seventeen roles whose destination does not depend on the layer type, each one a string literal that exists in npu/forward/src (grepped, not inferred from the HF name), and names the nine it does NOT place with the reason: qkv_proj's prefix is the layer type's, q_norm/k_norm have no verified weight slot, gate_up_proj needs a measured split, and the shared-expert family differs per architecture. The arm with teeth is the coverage one: every role the classifier can emit must be either placed or explained, so a new recogniser without a destination decision fails the test instead of landing a tensor in a slot the engine reads with another shape. Removing qkv_proj's explanation fails it: 'no role falls through the table (qkv_proj)'. --- npu/pack_import.cpp | 51 ++++++++++++++++++++++++++++++++++ npu/pack_import.h | 13 +++++++++ tests/npu_pack_import_test.cpp | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/npu/pack_import.cpp b/npu/pack_import.cpp index 80f31d3cd..2fabc3014 100644 --- a/npu/pack_import.cpp +++ b/npu/pack_import.cpp @@ -3487,6 +3487,57 @@ RoleLayerReport container_role_layer_types(const std::vector& tensor return out; } +// engine#1788, slice 3c: the roles a layer type does not change, and the engine slot each becomes. Every right-hand +// side below is a literal that exists in npu/forward/src (grep the strings, do not infer them from HF names). +static const struct { const char* role; const char* slot; } kRoleSlots[] = { + {"embed", "model.embed_tokens.weight"}, + {"final_norm", "model.norm.weight"}, + {"input_norm", "input_layernorm.weight"}, + {"post_norm", "post_attention_layernorm.weight"}, + {"lm_head", "lm_head.weight"}, + {"q_proj", "self_attn.q_proj.weight"}, + {"k_proj", "self_attn.k_proj.weight"}, + {"v_proj", "self_attn.v_proj.weight"}, + {"o_proj", "self_attn.o_proj.weight"}, + {"self_attn_gate_proj","self_attn.gate_proj.weight"}, + {"gate_proj", "mlp.gate_proj.weight"}, + {"up_proj", "mlp.up_proj.weight"}, + {"down_proj", "mlp.down_proj.weight"}, + {"gate_exps", "mlp.experts.gate_proj.weight"}, + {"up_exps", "mlp.experts.up_proj.weight"}, + {"down_exps", "mlp.experts.down_proj.weight"}, + {"shortconv_in_proj", "shortconv.in_proj.weight"}, +}; + +// The roles this slice does NOT place, each with the reason. A number here is honest; a guessed slot is not. +static const struct { const char* role; const char* why; } kRoleNoSlot[] = { + {"qkv_proj", "the slot prefix is the LAYER TYPE's (`linear_attn.` or `self_attn.`), so it needs the second key"}, + {"q_norm", "the engine's normaliser slots are per-architecture; none is verified for this role yet"}, + {"k_norm", "same: the engine's `k_norm` string is a config field, not a verified weight slot"}, + {"gate_up_proj", "fused in some containers, two slots in the engine; needs a measured split"}, + {"moe_router", "the engine reads `mlp.router.bias`; the weight slot is not verified"}, + {"share_gate_exps", "the shared expert's slots differ per architecture and none is verified"}, + {"share_up_exps", "same"}, + {"share_down_exps", "same"}, + {"shared_expert_gate", "same"}, +}; + +RoleSlotReport container_role_slots(const ContainerRoles& roles) { + RoleSlotReport r; + for (const auto& kv : kRoleSlots) r.slot_of[kv.role] = kv.slot; + for (const auto& kv : kRoleNoSlot) r.why[kv.role] = kv.why; + // Every role the caller actually met must be either placed or explained: a role that is in neither is a hole in + // this table, and saying so is the point of the report. + for (const auto& c : roles.counts) { + if (r.slot_of.count(c.first)) r.placed.push_back(c.first); + else if (r.why.count(c.first)) r.no_slot.push_back(c.first); + else { r.no_slot.push_back(c.first); r.why[c.first] = "a role this table does not know: it needs a decision"; } + } + std::sort(r.placed.begin(), r.placed.end()); + std::sort(r.no_slot.begin(), r.no_slot.end()); + return r; +} + ContainerRoles container_roles(const std::vector& tensors) { struct Rule { const char* role; const char* needle; }; // Longest/most specific spellings first: `qkv_proj` before `q_proj`, `gate_up_proj` before `gate_proj`. diff --git a/npu/pack_import.h b/npu/pack_import.h index eaee101bc..127bee8e2 100644 --- a/npu/pack_import.h +++ b/npu/pack_import.h @@ -386,6 +386,19 @@ struct ContainerRoles { }; ContainerRoles container_roles(const std::vector& tensors); +// engine#1788, slice 3c, the writer's destination side. A role says WHAT a tensor is; the engine reads weights by SLOT +// name (the string literals in npu/forward/src), so an import needs role -> slot. Only the roles whose destination does +// not depend on the LAYER TYPE are placed here. The rest are named in `no_slot` with a reason instead of guessed: +// a wrong slot does not fail loudly, it loads a tensor into a field the engine reads with another shape, and the +// symptom is a wrong number rather than an error. +struct RoleSlotReport { + std::map slot_of; // role -> engine slot; absent when this slice does not place it + std::vector placed; // placed roles, sorted + std::vector no_slot; // deliberately unplaced roles, sorted + std::map why; // role -> why it is not placed here +}; +RoleSlotReport container_role_slots(const ContainerRoles& roles); + // engine#1788, slice 3c: a container's roles need a SECOND key, and the second key is the layer type. // Measured: gemma4-e2b's `q_proj` is N=384 on all 28 `sliding_attention` layers and N=768 on all 7 `full_attention` ones; // bolt-e4b's is (640,5120) on all 35 sliding and (1280,5120) on all 7 full. A perfect partition, structural in origin diff --git a/tests/npu_pack_import_test.cpp b/tests/npu_pack_import_test.cpp index 182dbe612..9caf8b968 100644 --- a/tests/npu_pack_import_test.cpp +++ b/tests/npu_pack_import_test.cpp @@ -6232,6 +6232,54 @@ static void selfcheck_head_rule_arms() { std::printf(" head fit rule: 15 arms (two measured heads, scaled/rms/noisy/no-corr controls, fit_head at blocks 32 and 128, gemma2 by name)\n"); } +// engine#1788, slice 3c: the writer's destination side. The arm with teeth is the last one: a role the classifier can +// emit must be EITHER placed or explained, so adding a recogniser without deciding its destination fails here rather +// than in a pack that loads a tensor into the wrong slot. +static void test_container_role_slots() { + using namespace onebit::npu::pack_import; + { + ContainerRoles r; + r.counts["q_proj"] = 1; r.counts["gate_exps"] = 1; r.counts["input_norm"] = 1; + const RoleSlotReport s = container_role_slots(r); + check(s.slot_of.at("q_proj") == "self_attn.q_proj.weight", "slots: the attention query projection"); + check(s.slot_of.at("gate_exps") == "mlp.experts.gate_proj.weight", "slots: the routed gate is the expert gate"); + check(s.slot_of.at("input_norm") == "input_layernorm.weight", "slots: the pre-attention norm"); + check(s.no_slot.empty(), "slots: three placed roles leave nothing unexplained"); + } + { + // no two roles may claim the same slot: a collision silently drops one of them + std::map seen; + for (const auto& kv : container_role_slots(ContainerRoles{}).slot_of) { + check(!seen.count(kv.second), "slots: no two roles share a destination (" + kv.second + ")"); + seen[kv.second] = kv.first; + } + } + { + // qkv_proj is the one role everybody gets wrong: its PREFIX is the layer type's, so this slice must refuse it + ContainerRoles r; r.counts["qkv_proj"] = 1; + const RoleSlotReport s = container_role_slots(r); + check(!s.slot_of.count("qkv_proj"), "slots: the fused qkv is not placed without the layer type"); + check(s.why.count("qkv_proj") && s.why.at("qkv_proj").size() > 20, "slots: and the refusal says why"); + } + { + // the classifier's whole vocabulary: every role either placed or explained, none unknown + static const char* kRoles[] = {"embed", "final_norm", "input_norm", "post_norm", "lm_head", "q_proj", "k_proj", + "v_proj", "o_proj", "q_norm", "k_norm", "self_attn_gate_proj", "gate_proj", "up_proj", "down_proj", + "gate_up_proj", "qkv_proj", "moe_router", "gate_exps", "up_exps", "down_exps", "share_gate_exps", + "share_up_exps", "share_down_exps", "shared_expert_gate", "shortconv_in_proj"}; + ContainerRoles r; + for (const char* k : kRoles) r.counts[k] = 1; + const RoleSlotReport s = container_role_slots(r); + check(int64_t(s.placed.size() + s.no_slot.size()) == int64_t(sizeof(kRoles) / sizeof(kRoles[0])), + "slots: every role the classifier can emit is accounted for"); + for (const auto& kv : s.why) { + check(kv.second.find("does not know") == std::string::npos, + "slots: no role falls through the table (" + kv.first + ")"); + } + check(s.placed.size() >= 17, "slots: at least the layer-type-independent ones are placed"); + } +} + int main(int argc, char** argv) { selfcheck_head_rule_arms(); // engine#1907: every crafted 1.9 import writes a canonical copy into the scratch cache. Without this the suite left 0-byte @@ -6294,6 +6342,7 @@ int main(int argc, char** argv) { test_container_roles(); test_container_roles_gemma_local(); test_container_roles_moe_and_singular_layer(); + test_container_role_slots(); test_container_role_layer_types(); test_container_plan(golden_dir.empty() ? "." : golden_dir); test_hybrid_pack_import(golden_dir.empty() ? "." : golden_dir); From 9a20094d8f345a91fea20825acad54d79d766020 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 10:52:50 -0400 Subject: [PATCH 2/3] npu-pack: the layer type decides exactly one destination, and it decides it the way the engine does (3c, engine#1788) qkv_proj was the role this slice refused first, because its prefix belongs to the layer type. Now it is placed where the ENGINE has a slot for it: grep of npu/forward/src finds 'linear_attn.qkv_proj.weight' and no 'self_attn.qkv_proj' at all, so a fused qkv on a linear layer has a destination and on a full or sliding attention layer it has none. The tempting mapping -- always emit self_attn.qkv_proj.weight -- is the mutant this pins: it fails two arms. container_role_slot(role, layer_type) returns when there is no destination, and the caller cannot read that as 'unchanged', which is why the report carries the reasons and this returns a string. --- npu/pack_import.cpp | 9 +++++++++ npu/pack_import.h | 6 ++++++ tests/npu_pack_import_test.cpp | 12 ++++++++++++ 3 files changed, 27 insertions(+) diff --git a/npu/pack_import.cpp b/npu/pack_import.cpp index 2fabc3014..bc6b2688c 100644 --- a/npu/pack_import.cpp +++ b/npu/pack_import.cpp @@ -3522,6 +3522,15 @@ static const struct { const char* role; const char* why; } kRoleNoSlot[] = { {"shared_expert_gate", "same"}, }; +std::string container_role_slot(const std::string& role, const std::string& layer_type) { + if (role == "qkv_proj") { + // The engine's only fused-qkv slot is the linear-attention one; `self_attn.qkv_proj` is not a name it reads. + return layer_type.find("linear") != std::string::npos ? "linear_attn.qkv_proj.weight" : std::string(); + } + for (const auto& kv : kRoleSlots) if (role == kv.role) return kv.slot; + return std::string(); +} + RoleSlotReport container_role_slots(const ContainerRoles& roles) { RoleSlotReport r; for (const auto& kv : kRoleSlots) r.slot_of[kv.role] = kv.slot; diff --git a/npu/pack_import.h b/npu/pack_import.h index 127bee8e2..b5f8633f4 100644 --- a/npu/pack_import.h +++ b/npu/pack_import.h @@ -399,6 +399,12 @@ struct RoleSlotReport { }; RoleSlotReport container_role_slots(const ContainerRoles& roles); +// engine#1788, slice 3c: the one role whose destination the LAYER TYPE decides, and the reason it decides it. The engine +// carries `linear_attn.qkv_proj.weight` and has NO `self_attn.qkv_proj` slot at all (grep the literals in npu/forward/src), +// so the fused qkv has a destination on a linear layer and nowhere else. Empty string = no destination; the caller must +// not treat that as "unchanged", which is why this returns a string and the report above returns the reasons. +std::string container_role_slot(const std::string& role, const std::string& layer_type); + // engine#1788, slice 3c: a container's roles need a SECOND key, and the second key is the layer type. // Measured: gemma4-e2b's `q_proj` is N=384 on all 28 `sliding_attention` layers and N=768 on all 7 `full_attention` ones; // bolt-e4b's is (640,5120) on all 35 sliding and (1280,5120) on all 7 full. A perfect partition, structural in origin diff --git a/tests/npu_pack_import_test.cpp b/tests/npu_pack_import_test.cpp index 9caf8b968..a35d7211a 100644 --- a/tests/npu_pack_import_test.cpp +++ b/tests/npu_pack_import_test.cpp @@ -6246,6 +6246,18 @@ static void test_container_role_slots() { check(s.slot_of.at("input_norm") == "input_layernorm.weight", "slots: the pre-attention norm"); check(s.no_slot.empty(), "slots: three placed roles leave nothing unexplained"); } + { + // the layer type decides exactly one destination, and it decides it the way the ENGINE does: the only fused-qkv + // slot npu/forward/src reads is the linear-attention one, so a full-attention qkv has no destination at all. + check(container_role_slot("qkv_proj", "linear_attention") == "linear_attn.qkv_proj.weight", + "slots: the fused qkv on a linear layer"); + check(container_role_slot("qkv_proj", "full_attention").empty(), + "slots: a full-attention qkv has no engine slot, so it is not placed"); + check(container_role_slot("q_proj", "full_attention") == "self_attn.q_proj.weight", + "slots: the layer type does not move a role it does not decide"); + check(container_role_slot("qkv_proj", "sliding_attention").empty(), + "slots: sliding attention is not linear, so the fused qkv stays unplaced"); + } { // no two roles may claim the same slot: a collision silently drops one of them std::map seen; From e926c3b96dd5c98cc69581c21735fe79b715d11e Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 11:33:34 -0400 Subject: [PATCH 3/3] npu-pack: linear attention is an exact layer-type set, not a substring (review of #2285) find("linear") was a substring test where an identity was meant -- the defect family this stack exists to fix (#1840, #1841, #2277's needle order). Now kLinearAttentionLayerTypes is an exact set, so a declared value that merely CONTAINS 'linear' does not map a fused qkv into linear_attn.qkv_proj.weight, and a new family (gdn, mamba-style) has to be added deliberately, with a test failure if the vocabulary moves without it. Two arms pin the difference: 'nonlinear_attention' and 'linear_sliding_attention' must both stay unplaced. With the substring restored (the mutant) both fail; a missed match still degrades to 'unplaced', never to a wrong slot. --- npu/pack_import.cpp | 12 +++++++++++- tests/npu_pack_import_test.cpp | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/npu/pack_import.cpp b/npu/pack_import.cpp index bc6b2688c..e7ab438f3 100644 --- a/npu/pack_import.cpp +++ b/npu/pack_import.cpp @@ -3522,10 +3522,20 @@ static const struct { const char* role; const char* why; } kRoleNoSlot[] = { {"shared_expert_gate", "same"}, }; +// The layer types that are linear attention, as an EXACT set rather than a substring: the defect family this stack +// exists to fix is substring matching where an identity was meant (#1840, #1841, #2277's needle order). A value that +// merely contains "linear" is not linear attention, and a new family (gdn, mamba-style) must be added here on purpose. +static const char* const kLinearAttentionLayerTypes[] = {"linear_attention"}; + +static bool is_linear_attention(const std::string& layer_type) { + for (const char* t : kLinearAttentionLayerTypes) if (layer_type == t) return true; + return false; +} + std::string container_role_slot(const std::string& role, const std::string& layer_type) { if (role == "qkv_proj") { // The engine's only fused-qkv slot is the linear-attention one; `self_attn.qkv_proj` is not a name it reads. - return layer_type.find("linear") != std::string::npos ? "linear_attn.qkv_proj.weight" : std::string(); + return is_linear_attention(layer_type) ? "linear_attn.qkv_proj.weight" : std::string(); } for (const auto& kv : kRoleSlots) if (role == kv.role) return kv.slot; return std::string(); diff --git a/tests/npu_pack_import_test.cpp b/tests/npu_pack_import_test.cpp index a35d7211a..823340e17 100644 --- a/tests/npu_pack_import_test.cpp +++ b/tests/npu_pack_import_test.cpp @@ -6257,6 +6257,11 @@ static void test_container_role_slots() { "slots: the layer type does not move a role it does not decide"); check(container_role_slot("qkv_proj", "sliding_attention").empty(), "slots: sliding attention is not linear, so the fused qkv stays unplaced"); + // the arm for the substring defect: a value that CONTAINS "linear" and is not linear attention must not match + check(container_role_slot("qkv_proj", "nonlinear_attention").empty(), + "slots: a layer type that merely contains 'linear' is not linear attention"); + check(container_role_slot("qkv_proj", "linear_sliding_attention").empty(), + "slots: neither is a compound name containing it"); } { // no two roles may claim the same slot: a collision silently drops one of them