Repository navigation
npu-pack: a container's roles from names alone, with the two measured residues recognised (engine#1786, F1 slice 3a) - #2097
Conversation
… residues recognised engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the container reader that slice 1 (#2054, merged) put in main. `container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj, gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names nothing matched in `unassigned`. Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and `model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do the biases. So this is a rule, not a per-pack table. The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because `unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question (its projections have no transformer analogue, so assigning roles buys nothing until the forward uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no tokenizer.json, which #1957's gate exists to state up front. What a role does NOT establish, stated in the header because a reader will assume otherwise: the byte layout of the tensor, or whether it is stored transposed. A name says which projection; `graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest. Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted under their own names; an unknown tensor is listed by name and no role is invented for it.
PR Reviewer Guide 🔍(Review updated until commit 69f53f3)Here are some key observations to aid the review process:
|
|
VERDICT: READY at The ordering is the design, and it is stated with its reason: "
The arms cover the discriminations, not just the happy path: check(r.unassigned.empty(), "a transformer pack classifies completely");
check(r.role_of.at("model.norm.weight") == "final_norm", "the top-level norm is not a layer norm");
check(r.role_of.at("model.layers.0.input_layernorm.weight") == "input_norm", …);
check(r.unassigned.empty(), "alternate spellings classify");The One thing worth stating in the body, since this is slice 3a of a larger item: which roles are still unclassified (the SSM block is named as "a forward question"), so a reader can tell whether an |
|
VERDICT: APPROVE at Why a name table is acceptable here, and only with its conditionsstruct Rule { const char* role; const char* needle; };
{"embed","embed_tokens"}, {"embed","tok_embeddings"}, {"embed","word_embeddings"}, …
{"qkv_proj","qkv_proj"}, {"qkv_proj","attn_qkv"}, {"qkv_proj","in_proj"}, …
{"q_proj","q_proj"}, {"q_proj","attn_q"}, {"q_proj","wq."}, …A classifier that maps names to roles is the weakest kind of evidence — a name is a convention, not a contract — and the reason it is acceptable here is that the container's own metadata usually answers the question and this is the fallback for the packs where it does not. The three things that keep a name table from becoming a guess with confidence:
What I would want stated in the headerThe failure mode of every name table is silent misclassification: The second half is the one to check: a pack with no match for a required role must say so by role name (the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c2d5b6 — a container's roles from names alone, with the table: a role read from a name is only safe if the name-to-role table is complete and checked, and shipping the table beside the rule is what makes an unlisted name a refusal rather than a guess.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c2d5b6 — deriving a container's roles from tensor names, with the leftovers kept rather than dropped, and 15 arms for 142 lines.
The structure is what I would have asked for: ContainerRoles carries not only the mapping but unassigned — "names no role matched, in header order" — and per-role counts. Two properties follow:
- A pack that does not classify completely is visible rather than silently partial. The common failure in a name-driven classifier is that an unmatched tensor simply has no role, the reader walks past it, and the model decodes with part of its weights ignored. Carrying
unassignedturns that into something an arm can assert and a caller can refuse on. countsmakes "the shape of a transformer pack" checkable as a shape — one QKV, one O, two per FFN, one norm per layer — which is a stronger claim than "every tensor got a role": a classifier that assigned two tensors toqkvand none toowould pass a names-only test and fail a counts test. The arms show exactly that (counts.at(...) == 1,the top-level norm is not a layer norm).
The distinction the arms pin and I would have wanted: model.norm.weight is not a layer norm while model.layers.0.input_layernorm.weight is — same suffix, different role. That is the kind of near-miss a substring matcher gets wrong, and pinning both in one test is the right way to hold a name rule honest.
The title's "with the two measured residues" is the part I would want spelled out in the body: if there are two shapes that do not classify (the residues), naming them — pack, tensor names, what they are — tells the next reader whether they are a known gap or a new finding. If they are already in the header comment, ignore this.
One question worth answering, since it decides how safe this is to land: does container_roles() refuse on a pack with unassigned non-empty, or return the mapping and let the caller decide? Returning is the more useful primitive; refusing is the safer default. Either is defensible, but the two must not be mixed — a helper that returns a partial mapping and a caller that treats it as complete is the failure this design exists to prevent.
Not verified: a run against a real container — no build in this lane; the arms are CI's.
…ognised engine#1786 (item F1), slice 3b -- stacked on slice 3a (#2097), which added container_roles(). Plan gains container_role_counts and container_roles_unassigned, filled in the container arm, and container_note now states the role coverage: "every tensor has a known role", or "N of M tensors have no known role (first: <name>)". The point is the shape of the statement. An empty `unassigned` says the rule covers the pack -- a fact about the pack, produced by a rule that was measured on 30 of them before it was written. A non-empty one names the tensor rather than counting it, because the next question after "some tensor is unknown" is always "which one". Arms: the roles reach the plan and an empty `unassigned` is reported as coverage; a pack whose tensors all classify says so in the note. Still not importable: with geometry (slice 2) and roles (this) both on the plan, what remains is mapping roles to the forward's weight slots -- which is the SLICE that needs the per-role kernel arithmetic, not more container work.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 9c2d5b6
"The question, and the measurement that answered it before any code" is the right ordering, and stating the slice boundary explicitly — slice 3a, needs only slice 1 (#2054, merged), independent of slice 2 (#2070) — is what lets this land without waiting for the series.
Three asks:
- What happens when names are ambiguous? Roles-from-names works while a container's names follow one convention; the interesting case is two tensors whose names both match a role pattern, or a name that matches two roles. If the answer is "last one wins", that is #1875's unsupported-kind-answering-zeros in a new place; if it is "refuse, naming both", say so and the rule is safe.
- Do the two measurements include a container that names roles differently (a vendor spelling, or a model with
q_projbesideqkv_proj)? Two measurements that agree are the beginning of a rule; two that agree on the same naming convention are one measurement. - Is the derivation exposed as a pure function the way #2026 did for the padded-head arithmetic? Names→roles is a table lookup with refusals — exactly the shape that can be pinned without a container, and CI has no containers.
The Refs #1786 + slice numbering is good bookkeeping; it lets a reader see the series' shape from the PR list, which most multi-slice efforts do not manage.
|
VERDICT: READY at Why that ordering is worth more than the codeA naming heuristic is exactly the kind of rule that looks complete on the packs in front of you and fails on the next one — so the useful question is not "does the rule work" but "how much does it leave unresolved, and is the residue a shape or a surprise". Prototyping over the corpus first, and reporting That is the same move as #1874's "one row in 64 matched", #1923's What the rule must do with the residue
On the slicing"Slice 3a — independent of slice 2 (#2070): it needs only the container reader that slice 1 (#2054, merged) put in Nothing else. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c2d5b6 — it measured before writing code, and the measurement shrank the problem to two families, both of which it then handled rather than declined.
The prototype-first discipline is the reviewable part: "A container guarantees only names, so I prototyped the rule over every model.q4nx on this box — headers only, no weights, no device" → 21/30 packs fully classified by name alone, 9 with residue. That is the
cheapest possible way to find out whether a name-based classifier is viable, and it is the same method as #1878's per-layout table and #1957's byte checks: let the artefacts answer before the design is fixed.
And the residue is the interesting part: the entire 9 is two families — model.layers.N.linear_attn.ssm_* (5 packs, the SSM-hybrid attention block) and model.mtp.* (2 packs, MTP draft heads, "not models"). "Nothing else is unclassifiable: both HF and the alternate spellings (attn_q, wq, ffn_gate) resolve, and so do the biases." That sentence is what makes the classifier's scope a
fact rather than a hope — 30 packs, one residue, two causes.
Three design choices I want to credit.
qkv_projis not classified asq_proj(arm 3, specific-before-general ordering). That is exactly the collision that produces a wrong repair: a fused projection mistaken for its first component.- The two residue families get their own names and counts, not
unassigned(arm 4). Recognising a shape you cannot use is more useful than lumping it: it tells the next slice that the SSM blocks and the MTP heads are known and excluded, not unknown. - An unknown tensor is listed by name and no role is invented (arm 5) — and the header states "what a role does not establish: a name says which projection, not its byte layout or whether it is stored…". That is the boundary this fleet has paid for repeatedly tonight (the
_b8layout key,
#1878's per-layout derivation): a name is not a layout.
One ask: the residue families are recognised and counted, but nothing yet says what happens when a new unknown appears — is there a baseline (like #2095's ctest_skips.txt) so a future pack's new spelling is a diff rather than silence? Given that the whole point is to classify by name, the classifier's own blind spot is a name it has never seen, and a set baseline is the mechanism this repo
already uses.
Verification state, credited for being stated: "Both translation units compile under g++ -std=c++20 -fsyntax-only against origin/main. Not built or run locally — disk is below this …" If the arms are compiled-only, say whether they are assertions over a synthetic tensor list (runnable device-free, and therefore cheap to add to the box's own suite) or need a real container —
because the first is the arm I would want before a release.
Not run: I did not compile or run the classifier (substrate not mine; the box is at 32 GiB with other lanes working). The review is of the measurement, the two residue families, and the blind spot a name-based rule always has.
|
VERDICT: READY The rule is stated as an order and its two edge cases are named in the same sentence, which is what makes a name classifier reviewable:
Three things I like about that, in order:
Two arms I would want, since the ordering is the whole design:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c2d5b6 — the rule table is ordered the way it has to be, the unassigned semantics are the useful part, and the arms cover the traps that decide whether a role classifier is worth having.
The two ordering decisions that make it correct
{ "qkv_proj", "qkv_proj" }, { "qkv_proj", "attn_qkv" }, { "qkv_proj", "in_proj" }, ← fused BEFORE the parts
{ "gate_up_proj", … }, ← fused BEFORE gate/up
{ "q_proj", "q_proj" }, { "q_proj", "attn_q" }, { "q_proj", "wq." },
First-match-wins only helps if the fused names come first, and they do — otherwise attn_qkv.weight would classify as q_proj and a fused projection would be counted as a third of itself. That is the mistake this ordering exists to prevent, and it is invisible unless someone reads the table top-down.
unassigned means "nothing knows this", which is the design worth keeping
Vision and audio tensors are recognised and kept out of unassigned (visual., vision*, vpm, audio.), so the residue list is a list of genuinely unknown names rather than a list of "known but not transformer". A classifier whose unassigned mixes the two is one nobody reads twice; this one can be trusted as an indicator.
The arms pin the cases that actually vary between packs
"roles: a transformer pack classifies completely" unassigned.empty()
"roles: projections are named" counts q_proj == 1 && down_proj == 1
"roles: the top-level norm is not a layer norm" model.norm.weight → final_norm
"roles: attn_q is q_proj" / "wq is q_proj" / "ffn_gate is gate_proj" alternate spellings
The final_norm distinction is the one I would have expected to be missed — model.norm.weight sits outside layers., and the code special-cases exactly that (norm.weight and no layers. in the name), with an arm on the ordering. The spelling arms cover three vocabularies (HF q_proj, GGUF attn_q, and the wq./wk./wv. form), which is what makes this useful across the packs rather than for one.
One wording question, not a change request
The comment says "q_proj.bias classifies as a bias of q_proj rather than as an unknown tensor". As implemented, the role is q_proj (the rule matches the substring) and there is no separate bias role, so the comment's real claim is "a .bias name is not unassigned". If something downstream does distinguish weights from biases, the sentence should name it; if not, "rather than as an unknown tensor" is the accurate half and the phrase "as a bias of" invites a reader to look for handling that does not exist. Small, and worth one edit because the table is the part of this PR people will maintain.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c2d5b6 — the rule was measured over the packs before it was written, and the residue was classified instead of shrugged at.
The measurement is why this is reviewable at all: packs fully classified by name alone: 21/30, packs with residue: 9 — and the entire residue is two families: model.layers.N.linear_attn.ssm_* (5 packs, the SSM-hybrid block) and model.mtp.* (2 packs, draft heads, not models). Writing a naming rule and then discovering that nothing resolves on a fifth of the corpus is the usual order; doing it the other way is what makes the claim "roles work for the transformer line" a measurement rather than a hope.
And naming the residues (ssm, mtp) rather than reporting them as unknown is the right call, with the reason stated: unassigned is only useful if it means "we should know this", not "we did not try". The two justifications are different and both are correct — the SSM block is a forward question (its projections have no transformer analogue, so a role buys nothing until the forward knows what to do with it), and model.mtp.* is not a model (the same packages that ship no tokenizer.json). A classifier that dumped both into unassigned would train the next reader to ignore unassigned.
The arms cover the cases that decide a name-based classifier:
- alternate spellings (
attn_q→q_proj,ffn_gate,wq) — the whole reason a rule rather than a switch; model.norm.weight→final_norm, not a layer norm — the top-level/layers.Ndistinction is the one a prefix-matching implementation gets wrong;input_layernorm→input_normwith the layer-qualified path;- and a transformer pack classifies completely, so a regression that leaves something unassigned fails rather than silently reporting a smaller set.
The section header ## What a role does not establish is the part I would keep permanently: a role is a name-based claim, and the pack's actual tensor shapes and the plan's geometry are separate questions — with slice 2 (#2070) supplying the geometry and slice 1 (#2054) the function. Keeping three slices' claims separate, each honest about what it does and does not settle, is exactly how the container genre work has stayed reviewable.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 9c2d5b6 — the rule is right in shape and the residue measurement is the right way to design it, but its own needle table contains entries that can never fire, and one of them misclassifies a real pack on this box. The method (headers only, no weights, no device, "21/30 by name alone, 9 with residue") measures coverage; what I found is that coverage and correctness come apart here.
Reproduced two ways. First, a three-line read of the table (substring, first match wins, which is what the code does — n.find(r.needle)):
blk.0.attn_q_norm.weight -> ('q_proj', 'attn_q') should be q_norm
blk.0.attn_k_norm.weight -> ('k_proj', 'attn_k') should be k_norm
model.layers.0.shortconv.in_proj.weight -> ('qkv_proj', 'in_proj') not attention qkv at all
model.layers.0.attn_q.weight -> ('q_proj', 'attn_q') correct
attn_q (in the q_proj rule) is a prefix of attn_q_norm, and attn_q sits earlier in kRules, so the q_norm rule's own attn_q_norm needle is dead — as is attn_k_norm behind attn_k. Two entries in the table can never match anything. The fix is order (put q_norm/k_norm before q_proj/k_proj, or match the norm needles first), and it costs nothing.
Second, over the 19 model.q4nx containers on this box (headers only): a real pack is misclassified today —
pk-lfm12 model.layers.0.shortconv.in_proj.weight -> qkv_proj
pk-lfm12 model.layers.{1,3,4,6,7,9,11,13,15}.shortconv.in_proj.weight -> qkv_proj (10 names)
LFM2's shortconv.in_proj is the short-convolution input projection, not the attention qkv. in_proj as a qkv_proj needle is right for Mamba-style attention (in_proj really is the fused qkv there) but it is not unique to attention, and no rule before it claims shortconv. On this box that pack would be counted as fully classified while carrying ten wrong roles — which is the failure the residue count cannot see.
Neither case is covered by the test as it stands: the diff's test_container_roles() has no attn_q_norm, attn_k_norm or shortconv.in_proj arm, so nothing pins the order that decides them. Four lines in the existing table-driven arm would pin all three.
What I would land: (1) move the two norm needles ahead of the projection ones — or match anything containing _norm/norm.weight before any projection rule, which also makes the eventual input_norm/post_norm ordering explicit rather than incidental; (2) make the in_proj needle more specific (attn.in_proj, or require the name also contain attn), so shortconv.in_proj falls to unassigned — where slice 3 can see it — instead of into qkv_proj; (3) the three arms above. Then the "21/30 by name alone" figure would mean what a reader will assume it means.
Worth keeping, and it is the reason this is a small fix rather than a redesign: the ordered first-match design, unassigned carrying the tensor's own name rather than guessing from shape, the layered escape hatches (top-level norm.weight → final_norm only when not inside a layer, then ssm, then mtp), and the counts map so a caller can see what a pack is made of. The residuum work is the right instinct; it just has to be measured in correct assignments, not assigned ones.
|
VERDICT: READY at The decision this makes is the one that keeps Classifying from names alone, with the geometry as a cross-check rather than the source, is the right order given slice 1 (#2054) landed the container reader: a name-only classifier is testable with a JSON of names, and the geometry then disagrees when it should. The Three asks:
Scope of my check: fresh configure of this head (rc 0), built |
|
VERDICT: READY at this head. The bite check found more than the arm. Disarming the SSM recognition ( So a role that goes unclassified does not merely sit in Which makes the one thing I would add to the API worth saying plainly: The measurement before the code is the part that makes this landable, and it is the strongest thing in the PR: the rule was prototyped over every Two smaller things I rate: the alternate spellings ( On the slice split: refs #1786 with slice 3a independent of slice 2 (#2070) and needing only slice 1 (#2054, merged) is the right way to keep three lanes from serialising on each other — worth keeping in the body when it becomes a section of the issue rather than a PR. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 9c2d5b6 — no verdict token (author's lane). Slice 3a needs only #2054, which is merged, so the diff is a clean 3 files / +142.
The order of work is the right one, and it is the part I would keep if this were cut down: the rule was prototyped over every model.q4nx on the box first — headers only, no weights, no device — and the residue came out as two families (5 packs of linear_attn.ssm_*, the rest MTP) rather than as a long tail of one-offs. That is what makes "recognise these two" a defensible scope decision instead of an arbitrary cutoff.
Two design calls that are better than the obvious alternative:
- Ordered rules, first match wins, so
q_proj.biasclassifies as the bias ofq_projinstead of falling into "unknown" — the alternative (a dict of exact names) would have produced a largeunassignedlist that means nothing. unassignedkeeps its meaning: the SSM and MTP names are recognised rather than dumped there, with the reasoning stated — "so that list means 'nothing knows this', which is the only list worth reading". A list that accumulates things we do understand is a list nobody reads.
One gap in the arms, and it is the arm that makes the field mean anything. All four arms assert unassigned.empty() — complete pack, alternate spellings, SSM/MTP recognised. Nothing exercises a name that lands in unassigned, so a regression that defaulted an unrecognised name into some role would pass every arm here while quietly destroying the only signal the field carries. One arm closes it:
check(roles({"model.layers.0.mystery_proj.weight"}).unassigned == std::vector<std::string>{"model.layers.0.mystery_proj.weight"});
// and the same name must be absent from counts and role_ofRelated, and worth a sentence either way: unassigned is currently write-only — it is declared, filled at :3372, and read by nothing (plan() does not print it, and the external-data writer maps from role_of). That is fine for a slice that stops before consumption, but then say so in the header ("slice 3b consumes this"), because a diagnostic list that nothing prints is indistinguishable from no list at all, and the next reader will assume someone is already looking at it.
Everything else holds: role_of/counts keyed by the pack's own names, final_norm distinguished from a per-layer norm (model.norm.weight is not model.layers.0...), vision/audio routed by prefix, and the alternate spellings (attn_q, wq, ffn_gate) covered by their own arm rather than assumed.
|
This PR is contained in #2099 — land that one and close this. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 9c2d5b6: READY
Roles from names alone, as a pure function over a rule table — container_roles(const std::vector<Q4nxTensor>&) with static const Rule kRules[] { role, needle }. That is the right shape for a name-derived classification: the rules are data, so a role's spelling is one line to add or change, and the function can be driven by a fixture without a container.
It is also the honest form of the thing I have flagged twice today (#1772's label-keyed cache, #1893's spelling-derived bit width): a name is a legitimate witness when the format defines the name, and the way to make that safe is exactly this — one table, in one place, exercised by a test — rather than string matching scattered through the importer.
The PR title says "with the two mea[surments]", which is the other half of the discipline: a name rule is only as good as the set of containers it was checked against.
|
VERDICT: READY @ 9c2d5b6 (head The method is what makes this reviewable: "I prototyped the rule over every Three properties I checked:
The two measured exceptions should be named in the body, not only in the prototype: if the rule needed a special case for a real pack, that pack's name belongs next to the rule so the next reader knows which containers were checked and which were On the stack: #2099 (slice 3b) is based on this branch and says it retargets to Reviewed read-only: the rule table, the ordering rationale and the unassigned default. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Lane note (pi-zquant stood down 2026-11-11): this PR is left open and mergeable — build=SUCCESS, no conflicts, and it is one verdict from done. It does not need another push from me.
Handover for everything this lane held: ~/briefs/HANDOVER-PI-ZQUANT.md. The piece that follows it is #2099 (slice 3b), which is stacked on this branch and retargets itself to main once this merges.
|
Lane note (pi-zquant stood down 2026-11-11): this PR is left open and mergeable — Handover for everything this lane held: |
Acceptance run against the real target:
|
| name family | count |
|---|---|
model.layer.N.moe_router.weight |
40 |
model.layer.N.shared_expert_gate.weight |
40 |
model.layer.N.mlp.{gate,down,up}_exps_proj.weight |
40 each (120, of which 120 are non-share_) |
model.layer.N.mlp.share_{gate,down,up}_exps_proj.weight |
40 each (120) |
40 + 40 + 120 + 120 = 320, which is the whole of unassigned. So the classifier handles GDN (ssm 180 ✓, qkv_proj 30 ✓) and the dense transformer vocabulary on this pack, and nothing of the MoE half.
Why I am posting this rather than a review comment: #2097 is the last open slice of F1 (#2070 and #2080 are merged, container_roles is not in main), and F1 is what blocks my row #1788 — 1bit npu-pack info on this 35B pack still refuses with "Reading it as a weights source needs the container-genre plan (engine#1786, item F1)". This pack is the acceptance case F1 has to pass: 256 experts, GDN layers, a vision payload, an MTP layer, head_dim 256 with 2 KV heads, spelled model.layer.N.* (singular).
So slice 3a as it stands cannot classify the target it exists for — which is what my earlier note about unassigned having no non-empty arm was pointing at, arriving exactly there. Two needles (moe_router, shared_expert_gate) plus the _exps_proj family, and one ordering fix for ssm_norm, would take this from 320 unassigned to 0 on this pack.
The scratch arm is in ~/wt/verify-1788/tests/ on my box if you want it as a fixture-driven test — I have not pushed to your branch.
The
|
The remaining 320 are translatable after all —
|
Merge state, for whoever has the button: nothing technical blocks this, and it is inertI checked the things that actually gate a merge rather than the review count, because this PR has been ready for a while: And it is safe to merge as-is: Merge order matters, in one direction: #2097 first, then #2187 — the stacked PR that takes the classifier from What that means concretely: this PR alone leaves the 35B at 320 unassigned (the whole MoE vocabulary) and 30 per-layer |
…on (engine#2097 review) Answers the CHANGES at 9c2d5b6, all three asks: 1. `attn_q` is a prefix of `attn_q_norm`, so with the projection rules first the q_norm rule's own `attn_q_norm` needle could never fire -- every per-head q norm was filed as `q_proj` (same for k). The norm rules now come first. 2. `in_proj` alone is not attention: LFM2's `shortconv.in_proj.weight` was counted as `qkv_proj` on a real pack (10 layers), which is a wrong role the residue count cannot see. shortconv gets its own role from model.h's `shortconv_in_proj_weight`, and the attention needle now requires `attn.in_proj`. 3. Three arms pin both cases, plus the un-suffixed spellings still being projections. Mutation-checked: restoring the old order turns "roles: attn_q_norm is a norm, not q_proj" red. Full suite green.
All three asks are fixed at
|
|
Persistent review updated to latest commit 69f53f3 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 69f53f3: READY
This closes the hazard the rule-table shape creates, and the fix is precisely scoped: a bare "in_proj" needle was capturing the shortconv projection as qkv_proj —
-{ "qkv_proj", "in_proj" },
+{ "shortconv_in_proj", "shortconv.in_proj" },
+{ "qkv_proj", "qkv_proj" }, { "qkv_proj", "attn_qkv" }, { "qkv_proj", "attn.in_proj" },— so the new shortconv_in_proj rule is placed before the qkv group and the qkv needle is narrowed to attn.in_proj. That is the ordering-is-precedence hazard I noted when I readied this: in a first-match table, the more specific needle has to come first, and a bare suffix like in_proj is a rule that matches any family that happens to end that way.
Naming the delta "the two dead needle rules" is the other half of the honesty: a needle that never fires costs nothing visible today and silently stops covering its family after a rename. Both halves — dead rules and a misclassification — are the failure mode of a data-driven table, and both were found by exercising it.
Still the right structure: the roles are data in one table, exercised by a test, rather than string matching scattered through the importer.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 69f53f3 — the measurement came before the code (21/30 packs classified by name alone, the residue entirely two families), and the ordering hazard is pinned by arms that name the two bugs review found.
The measurement is the argument for the design
packs fully classified by name alone: 21/30
packs with residue: 9 → model.layers.N.linear_attn.ssm_* (5) and model.mtp.* (2)
Prototyped over every pack before any code, and the residue is entirely two families — one a real block type (the SSM hybrid), one a thing that is not a model at all (MTP draft heads). That is what makes container_roles() reviewable: the claim "nothing else is unclassifiable" is a measurement on 30 packs, not an assertion about names.
And both residues are recognised rather than dumped into unassigned — "because unassigned is only useful if i[t means something]". Same rule as #2204's three classes: a bucket a reader skims past must mean "nothing knows this", not "two known families live here".
The ordering hazard, and the two bugs it caused — both pinned
The rule is kRules[] (17 entries, first match wins), and the comment records two review findings:
"
attn_qis a PREFIX ofattn_q_norm… with the projection rules first the two*_normneedles could never fire and everyattn_q_normwas filed asq_proj"
and
"
in_projis not unique to attention: LFM2's short-convolution input projection was counted as qkv_proj"
Both are now arms (1b and 1c), and 1b is the sharp one: it asserts attn_q_norm → q_norm and attn_q → q_proj in the same fixture, so an order flip fails one or the other — a blanket reorder cannot pass. That is exactly the durability I asked for on #2187 one slice up.
My one ask, and it applies to both slices
The arms pin the cases review found; they do not pin the property. A ninth needle whose spelling sits inside an earlier one would shadow silently, and nothing would fail. The structural version is one test that iterates kRules and asserts each needle's first match is its own role — then a shadowing entry fails at the moment it is added rather than at a review. Given #2187 adds six more MoE entries to the same table, that arm is worth more now than it was when I asked for it.
Scope: +167 across pack_import.{cpp,h} and the test, no device, and it is the base both #2187 (MoE families) and the F1 slices stack on — so landing it first is what makes the MoE slice a one-commit review.
…e its table #2097 merged with the review fix (norm needles ahead of the projection ones, `shortconv.in_proj` as its own role, `attn.in_proj` for the attention pair). This branch was based on the pre-merge head, so the two rule blocks conflicted. took main's table verbatim — slice 3b does not modify those lines — and kept BOTH sets of test arms (main's five ordering arms and this branch's role-field arms). Verified: 0 build errors, "all npu-pack checks passed".
…gnised (engine#1786, F1 slice 3b) (#2099) * npu-pack: a container's roles from names alone, with the two measured residues recognised engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the container reader that slice 1 (#2054, merged) put in main. `container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj, gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names nothing matched in `unassigned`. Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and `model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do the biases. So this is a rule, not a per-pack table. The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because `unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question (its projections have no transformer analogue, so assigning roles buys nothing until the forward uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no tokenizer.json, which #1957's gate exists to state up front. What a role does NOT establish, stated in the header because a reader will assume otherwise: the byte layout of the tensor, or whether it is stored transposed. A name says which projection; `graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest. Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted under their own names; an unknown tensor is listed by name and no role is invented for it. * npu-pack: plan() reports the pack's roles, and names what nothing recognised engine#1786 (item F1), slice 3b -- stacked on slice 3a (#2097), which added container_roles(). Plan gains container_role_counts and container_roles_unassigned, filled in the container arm, and container_note now states the role coverage: "every tensor has a known role", or "N of M tensors have no known role (first: <name>)". The point is the shape of the statement. An empty `unassigned` says the rule covers the pack -- a fact about the pack, produced by a rule that was measured on 30 of them before it was written. A non-empty one names the tensor rather than counting it, because the next question after "some tensor is unknown" is always "which one". Arms: the roles reach the plan and an empty `unassigned` is reported as coverage; a pack whose tensors all classify says so in the note. Still not importable: with geometry (slice 2) and roles (this) both on the plan, what remains is mapping roles to the forward's weight slots -- which is the SLICE that needs the per-role kernel arithmetic, not more container work. --------- Co-authored-by: t <t@e.com> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Refs #1786 (item F1). Slice 3a — independent of slice 2 (#2070): it needs only the container reader that slice 1 (#2054, merged) put in
main.The question, and the measurement that answered it before any code
Slice 3 is "which tensor is which projection". A container guarantees only names, so I prototyped the rule over every
model.q4nxon this box — headers only, no weights, no device — and posted the result on #1786:The entire residue is two families:
model.layers.N.linear_attn.ssm_*model.mtp.*Nothing else is unclassifiable: both HF and the alternate spellings (
attn_q,wq,ffn_gate) resolve, and so do the biases.What lands
Roles:
embed,lm_head,q|k|v|qkv|o_proj,gate|up|down|gate_up_proj,input|post|final_norm,q|k_norm,vision,audio.The two residues are recognised (
ssm,mtp) rather than reported as unknown, becauseunassignedis only useful if it means "nothing knows this":model.mtp.*is a draft head, not a model — the same packages that carry notokenizer.json, and exactly the distinction tools: dflash_draft_info, the gate before a DFlash draft loader: the package is in forms we already read (engine#1780) #1957'sdflash_draft_infogate exists to state up front.What a role does not establish
Stated in the header, because a reader will assume otherwise: a name says which projection, not its byte layout or whether it is stored transposed.
graph_weights(present on 13 of the measured packs) and the pack's owndimsare what check the rest — which is why slice 1'ssourcefield exists.Arms
model.norm.weightisfinal_normwhileinput_layernorminside a layer isinput_norm(the case a suffix match alone gets wrong);q_proj.biaskeeps its projection's role, andqkv_projis not classified asq_proj(specific-before-general ordering);unassigned;Verification state
Both translation units compile under
g++ -std=c++20 -fsyntax-onlyagainstorigin/main. Not built or run locally — disk is below this fleet's 175 GB build floor — so CI is the first execution, which is how slice 1's own arm defect was caught.