From 74920383a61d3234f19a0f9cd6e632e4fba0f745 Mon Sep 17 00:00:00 2001 From: Alberto-Codes Date: Wed, 12 Aug 2026 00:11:18 -0700 Subject: [PATCH 1/2] feat(pack): map routed-expert stacks and any decoder-layer naming The GGUF backend matched `^model\.layers\.(\d+)$` and refused every other group. That refusal caught two names the scan now produces: a stack group from `--group-by stack` (#161), and the Nemotron 3.5 Lightning target's `backbone.layers.` (#160). No `--group-by` value packed that model. The backend now derives the layer index from the group name, so `.layers.`, `.h.`, and `.blocks.` all become `blk..`. A routed-expert stack maps to its fused tensor, `blk..ffn_up_exps.` or `blk..ffn_down_exps.` (#159). Stack overrides go before layer overrides, and both after the protection overrides. llama-quantize applies the first matching pattern, and `blk\.1\.` also matches `blk.1.ffn_up_exps.weight`. The refusal narrows but stands, and it names the group. A shared expert is a separate GGUF tensor, so it still refuses. Issue #183 carries the remaining Nemotron-H classes. Closes #180 --- docs/adr/0012-gguf-type-mapping.md | 25 ++++ docs/adr/0022-within-layer-protections.md | 21 +++ docs/reference/sensitivity-map.md | 25 ++-- src/vramfit/adapters/outbound/gguf/types.py | 120 ++++++++++++---- tests/contract/test_recipe_packer_contract.py | 110 +++++++++++++++ tests/unit/adapters/test_gguf_types.py | 129 ++++++++++++++++-- 6 files changed, 388 insertions(+), 42 deletions(-) diff --git a/docs/adr/0012-gguf-type-mapping.md b/docs/adr/0012-gguf-type-mapping.md index 13fd56b8..18855de6 100644 --- a/docs/adr/0012-gguf-type-mapping.md +++ b/docs/adr/0012-gguf-type-mapping.md @@ -23,6 +23,31 @@ overrides, placed before the group overrides — the quantizer applies the first matching pattern. The backend still rejects tensor-level *groups*: the boundary moved for protections only. +- **Amendment (2026-08-12, issue #180):** decision 2 gains a second + group shape and drops a fixed prefix. + + The backend derives the layer index from the group name. Any + decoder-layer group ending in `.layers.`, `.h.`, or + `.blocks.` becomes `blk\.\.`. GGUF numbers every decoder + layer `blk..` whatever the checkpoint calls it, so matching + `model.layers.` alone refused the Nemotron 3.5 Lightning + target at `backbone.layers.` (#160). + + A routed-expert stack group becomes its fused tensor: + `blk\.\.ffn_up_exps\.`, `ffn_down_exps`, or `ffn_gate_exps`. + llama.cpp fuses one layer's routed experts into a single 3D + tensor carrying one quantization type, so the stack is the unit + a pack addresses (#159, ADR-0001 as amended by #161). + + Stack overrides go before layer overrides, and both go after the + protection overrides. The quantizer applies the first matching + pattern, and `blk\.1\.` also matches `blk.1.ffn_up_exps.weight`. + Order is priority: per-tensor, then stack, then layer. + + The refusal stands for every other group, and it names the + group. Tensor-level groups still refuse. So does any tensor + class outside this mapping — the Mamba `in_proj`, the router, + the shared experts. Issue #183 carries those. ## Context diff --git a/docs/adr/0022-within-layer-protections.md b/docs/adr/0022-within-layer-protections.md index 05891d67..52cd5351 100644 --- a/docs/adr/0022-within-layer-protections.md +++ b/docs/adr/0022-within-layer-protections.md @@ -42,6 +42,27 @@ map. `stack` exists because it is *coarser* than per-tensor and matches the pack, not because per-tensor scanning came back. +- **Amendment (2026-08-12, issue #180):** the amendment above is + superseded in part. The GGUF backend now maps a routed-expert + stack group to its fused tensor, and derives the layer index from + the group name instead of matching `model.layers.` + ([ADR-0012](0012-gguf-type-mapping.md) decision 2, amended the + same day). A `layer`-keyed recipe for the Nemotron 3.5 Lightning + target packs. So does a recipe whose only stack groups are + routed-expert stacks. + + Decision 1's refusal narrows but does not lift. A whole-model + `stack`-keyed recipe still carries groups outside any GGUF class + mapping — the Mamba `in_proj` and `out_proj`, the attention + projections, the router, the shared experts. The backend refuses + each by name. Issue #183 carries that table, and it must first + rule what a recipe does with a group `llama-quantize` never + quantizes. + + The protection path is unchanged. Decision 2's class table still + matches `model.layers...weight` only, because its seven + llama-family classes do not exist on this target under any name. + ## Context The twelfth data point (in diff --git a/docs/reference/sensitivity-map.md b/docs/reference/sensitivity-map.md index 88e39362..bd2bd3a1 100644 --- a/docs/reference/sensitivity-map.md +++ b/docs/reference/sensitivity-map.md @@ -146,14 +146,19 @@ below remain, the sub-4-bit pricing claims do not. `tensor`-keyed map of that model prices 5888 distinctions no pack can express. - !!! warning "A `stack` scan does not pack yet" + !!! warning "A `stack` scan packs its expert stacks, not every group" - The GGUF v1 backend maps only `model.layers.` groups to - `blk..` patterns. It refuses every other group name with a - `PackError` (ADR-0022 decision 1, amended 2026-08-11). A - `stack`-keyed recipe therefore reaches `vramfit pack` and - stops there. The same refusal catches any model whose layers - are not named `model.layers.`, including the Nemotron 3.5 - Lightning target at `backbone.layers.`. Issue #180 carries - the backend work. Scan and plan with `stack` today. Do not - spend a multi-day scan expecting to pack the result. + The GGUF backend maps two group shapes (ADR-0012 decision 2, + amended 2026-08-12). A decoder-layer group becomes + `blk..`, under any checkpoint naming family — both + `model.layers.` and the Nemotron 3.5 Lightning target's + `backbone.layers.`. A routed-expert stack becomes its + fused tensor, `blk..ffn_up_exps.` or + `blk..ffn_down_exps.`. + + Every other `stack` group still raises a `PackError` that + names it. On the Nemotron target that covers the Mamba + `in_proj` and `out_proj`, the attention projections, the + router, and the shared experts. So a `layer`-keyed recipe + packs today, and a whole-model `stack`-keyed recipe does + not. Issue #183 carries the remaining classes. diff --git a/src/vramfit/adapters/outbound/gguf/types.py b/src/vramfit/adapters/outbound/gguf/types.py index 391ed54e..de955e4c 100644 --- a/src/vramfit/adapters/outbound/gguf/types.py +++ b/src/vramfit/adapters/outbound/gguf/types.py @@ -3,8 +3,10 @@ The decision core of the GGUF backend, kept free of IO so the mapping is testable and the verified fake can share it. Nominal precisions map to K-quant types (the full llama.cpp capability set since -ADR-0013), layer groups map to escaped `blk..` regex patterns, -protected tensors map through the fixed HF-to-GGUF class table to +ADR-0013), decoder-layer groups map to escaped `blk..` regex +patterns under any checkpoint naming family, routed-expert stack +groups map to their fused `blk..ffn__exps.` tensor (#159, +#161), protected tensors map through the fixed HF-to-GGUF class table to per-tensor patterns (ADR-0022), excluded pairs map to the full GGUF tensor names ``--exclude-weights`` deletes by substring (ADR-0023), and the embedding and `lm_head` groups map to the quantizer's @@ -69,7 +71,31 @@ OUTPUT_GROUP: Final[str] = "lm_head" -_LAYER_GROUP: Final[re.Pattern[str]] = re.compile(r"^model\.layers\.(\d+)$") +# A decoder-layer group, under any naming family the scan produces. +# The prefix is free, so `model.layers.4` and Nemotron 3.5 +# Lightning's `backbone.layers.4` both yield 4 (#160). GGUF numbers +# every decoder layer `blk..`, whatever the checkpoint calls it. +_LAYER_GROUP: Final[re.Pattern[str]] = re.compile(r"^.+\.(?:layers|h|blocks)\.(\d+)$") + +# A routed-expert stack group: a decoder-layer prefix, then +# `.experts.` with the expert index already collapsed by +# `group_key`, then the projection (#161). The trailing projection +# stops this from also matching a plain layer group. The dot before +# `experts` matters — it refuses `shared_experts`, which GGUF names +# `ffn_up_shexp` and this table does not carry (#183). +_EXPERT_STACK: Final[re.Pattern[str]] = re.compile( + r"^.+\.(?:layers|h|blocks)\.(\d+)\.(?:.*\.)?experts\.([a-z_0-9]+)$" +) + +# llama.cpp fuses one layer's routed experts into a single 3D tensor +# that carries one quantization type, so the pack addresses the +# stack and never one expert inside it (#159). HF projection name to +# fused GGUF tensor. +GGUF_EXPERT_STACK_BY_HF: Final[dict[str, str]] = { + "up_proj": "ffn_up_exps", + "down_proj": "ffn_down_exps", + "gate_proj": "ffn_gate_exps", +} # The fixed class table (ADR-0022): HF tensor suffix to GGUF tensor # suffix, for the seven quantized projections of a llama-family layer. @@ -367,24 +393,70 @@ def imatrix_exclusion_names(recipe: Recipe) -> tuple[str, ...]: ) -def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: - r"""Translate layer groups into quantizer tensor-type overrides. +def gguf_stack_prefix(group: str) -> str | None: + """Map one routed-expert stack group to its GGUF tensor prefix. + + Args: + group: Recipe group name, e.g. + ``backbone.layers.3.mixer.experts.down_proj``. + + Returns: + The GGUF tensor prefix, e.g. ``blk.3.ffn_down_exps.``, or + None when the group is not a routed-expert stack. + + Raises: + PackError: If the group is a routed-expert stack whose + projection has no entry in the fused-stack table. - One override per layer group: ``model.layers.`` becomes the - escaped pattern ``blk\.\.``. Escaping matters — an unescaped - ``blk.1.`` would also match ``blk.11.``. The embedding and - ``lm_head`` groups map to dedicated flags and are skipped here. + Examples: + The Nemotron 3.5 Lightning down projection: + + ```python + group = "backbone.layers.3.mixer.experts.down_proj" + assert gguf_stack_prefix(group) == "blk.3.ffn_down_exps." + ``` + """ + match = _EXPERT_STACK.match(group) + if match is None: + return None + suffix = GGUF_EXPERT_STACK_BY_HF.get(match.group(2)) + if suffix is None: + raise PackError( + f'expert stack "{group}" has no GGUF mapping — llama.cpp fuses ' + f"the projections {sorted(GGUF_EXPERT_STACK_BY_HF)} (#159)" + ) + return f"blk.{match.group(1)}.{suffix}." + + +def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: + r"""Translate recipe groups into quantizer tensor-type overrides. + + Two group shapes map. A decoder-layer group under any naming + family — ``model.layers.``, ``backbone.layers.`` — becomes + the escaped pattern ``blk\.\.``. A routed-expert stack group + becomes the escaped pattern for its fused tensor, e.g. + ``blk\.\.ffn_up_exps\.`` (#159, #161). Escaping matters — an + unescaped ``blk.1.`` would also match ``blk.11.``. The embedding + and ``lm_head`` groups map to dedicated flags and are skipped + here. + + Stack overrides come first, ahead of the layer overrides. The + quantizer applies the first matching pattern, and ``blk\.1\.`` + also matches ``blk.1.ffn_up_exps.weight``. Callers place the + protection overrides ahead of both — a per-tensor pattern is the + most specific of the three (ADR-0022). Args: recipe: The recipe to pack. Returns: - Overrides in recipe order. The quantizer applies the first - match, and the patterns are mutually exclusive. + Stack overrides in recipe order, then layer overrides in + recipe order. Raises: - PackError: If a group is not a layer group, the embedding, - or the output head, or its precision has no table entry. + PackError: If a group is not a layer group, a routed-expert + stack, the embedding, or the output head, or its + precision has no table entry. Examples: The group ``model.layers.7`` at 4-bit becomes an escaped @@ -394,21 +466,23 @@ def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: assert TypeOverride(r"blk\.7\.", "q4_k") in tensor_overrides(recipe) ``` """ - overrides: list[TypeOverride] = [] + stacks: list[TypeOverride] = [] + layers: list[TypeOverride] = [] for assignment in recipe.assignments: if assignment.group in (EMBEDDING_GROUP, OUTPUT_GROUP): continue + prefix = gguf_stack_prefix(assignment.group) + if prefix is not None: + bits = ggml_type_for(assignment.bits) + stacks.append(TypeOverride(re.escape(prefix), bits)) + continue match = _LAYER_GROUP.match(assignment.group) if match is None: raise PackError( f'group "{assignment.group}" has no GGUF tensor mapping — the ' - "v1 backend maps layer groups, the embedding, and the output " - "head (ADR-0012)" - ) - overrides.append( - TypeOverride( - pattern=rf"blk\.{match.group(1)}\.", - quant_type=ggml_type_for(assignment.bits), + "backend maps decoder-layer groups, routed-expert stacks, the " + "embedding, and the output head (ADR-0012, ADR-0022)" ) - ) - return tuple(overrides) + bits = ggml_type_for(assignment.bits) + layers.append(TypeOverride(rf"blk\.{match.group(1)}\.", bits)) + return tuple(stacks) + tuple(layers) diff --git a/tests/contract/test_recipe_packer_contract.py b/tests/contract/test_recipe_packer_contract.py index ec34b3d5..0020c73e 100644 --- a/tests/contract/test_recipe_packer_contract.py +++ b/tests/contract/test_recipe_packer_contract.py @@ -103,6 +103,33 @@ def sample_pack_recipe() -> Recipe: ) +def stack_pack_recipe() -> Recipe: + """A `--group-by stack` recipe shaped like the Nemotron target. + + Layers are named `backbone.layers.` and the routed experts of + one layer fuse into two stacks (#159, #160, #161). Nothing here + maps under the pre-#180 backend. + """ + return replace( + sample_pack_recipe(), + assignments=( + Assignment(group="model.embed_tokens", bits=8, bytes=1_000, damage=0.001), + Assignment( + group="backbone.layers.1.mixer.experts.up_proj", + bits=4, + bytes=900, + damage=0.01, + ), + Assignment( + group="backbone.layers.1.mixer.experts.down_proj", + bits=2, + bytes=900, + damage=0.03, + ), + ), + ) + + def excluded_pack_recipe() -> Recipe: base = sample_pack_recipe() return replace( @@ -237,6 +264,49 @@ def test_pack_carries_the_shared_type_mapping(self, build, tmp_path) -> None: TypeOverride(pattern=r"blk\.1\.", quant_type="q4_k"), ) + def test_pack_carries_the_stack_type_mapping(self, build, tmp_path) -> None: + # A `--group-by stack` recipe for the Nemotron target packs: + # the layer index derives from `backbone.layers.`, and + # each routed-expert stack addresses its fused tensor (#180). + packer: RecipePacker = build(tmp_path) + packer.convert() + + result = packer.pack(stack_pack_recipe()) + + assert result.overrides == ( + TypeOverride(pattern=r"blk\.1\.ffn_up_exps\.", quant_type="q4_k"), + TypeOverride(pattern=r"blk\.1\.ffn_down_exps\.", quant_type="q2_k"), + ) + + def test_pack_stack_recipe_reports_the_real_packed_size( + self, build, tmp_path + ) -> None: + packer: RecipePacker = build(tmp_path) + packer.convert() + + assert packer.pack(stack_pack_recipe()).packed_bytes == PACKED_BYTES + + def test_pack_unmappable_group_raises_pack_error_naming_it( + self, build, tmp_path + ) -> None: + # The Mamba mixer projection has no GGUF class mapping. The + # backend refuses by name rather than guessing a tensor. + packer: RecipePacker = build(tmp_path, base_exists=True) + recipe = replace( + sample_pack_recipe(), + assignments=( + Assignment( + group="backbone.layers.0.mixer.in_proj", + bits=4, + bytes=500, + damage=0.01, + ), + ), + ) + + with pytest.raises(PackError, match=r"backbone\.layers\.0\.mixer\.in_proj"): + packer.pack(recipe) + def test_pack_without_imatrix_records_none(self, build, tmp_path) -> None: packer: RecipePacker = build(tmp_path) packer.convert() @@ -374,6 +444,46 @@ def test_quantize_argv_carries_the_full_type_mapping(self, tmp_path) -> None: "1", ] + def test_quantize_argv_carries_the_stack_patterns(self, tmp_path) -> None: + packer = _real_packer(tmp_path) + packer.convert() + + packer.pack(stack_pack_recipe()) + + argv = json.loads((tmp_path / "quantize-argv.json").read_text()) + pairs = [argv[i + 1] for i, flag in enumerate(argv) if flag == "--tensor-type"] + assert pairs == [ + r"blk\.1\.ffn_up_exps\.=q4_k", + r"blk\.1\.ffn_down_exps\.=q2_k", + ] + + def test_quantize_argv_orders_stack_patterns_before_layer_patterns( + self, tmp_path + ) -> None: + # llama-quantize applies the first matching pattern, and + # `blk\.1\.` also matches `blk.1.ffn_up_exps.weight`. A stack + # pattern placed after the layer pattern would never apply. + packer = _real_packer(tmp_path) + packer.convert() + recipe = replace( + sample_pack_recipe(), + assignments=( + Assignment(group="model.layers.1", bits=8, bytes=1_000, damage=0.001), + Assignment( + group="model.layers.1.mlp.experts.up_proj", + bits=2, + bytes=900, + damage=0.03, + ), + ), + ) + + packer.pack(recipe) + + argv = json.loads((tmp_path / "quantize-argv.json").read_text()) + pairs = [argv[i + 1] for i, flag in enumerate(argv) if flag == "--tensor-type"] + assert pairs == [r"blk\.1\.ffn_up_exps\.=q2_k", r"blk\.1\.=q8_0"] + def test_quantize_argv_with_imatrix_carries_the_flag(self, tmp_path) -> None: packer = _real_packer(tmp_path, with_imatrix=True) packer.convert() diff --git a/tests/unit/adapters/test_gguf_types.py b/tests/unit/adapters/test_gguf_types.py index 9dbc11cd..87c8c41a 100644 --- a/tests/unit/adapters/test_gguf_types.py +++ b/tests/unit/adapters/test_gguf_types.py @@ -12,6 +12,7 @@ base_type, check_runtime, ggml_type_for, + gguf_stack_prefix, gguf_tensor_name, imatrix_exclusion_names, output_tensor_type, @@ -204,25 +205,135 @@ def test_tensor_overrides_reject_tensor_level_groups() -> None: tensor_overrides(recipe) +def test_tensor_overrides_name_the_group_they_cannot_map() -> None: + # The refusal is the backend's only guard against mispacking a + # group it does not understand, so the message must name the + # group and what it does map (#180). + recipe = make_recipe(("backbone.layers.0.mixer.in_proj", 4)) + + with pytest.raises(PackError) as caught: + tensor_overrides(recipe) + + message = str(caught.value) + assert '"backbone.layers.0.mixer.in_proj"' in message + assert "decoder-layer groups, routed-expert stacks" in message + + +@pytest.mark.parametrize( + ("group", "pattern"), + [ + ("model.layers.0", r"blk\.0\."), + ("backbone.layers.7", r"blk\.7\."), + ("transformer.h.3", r"blk\.3\."), + ("gpt_neox.blocks.11", r"blk\.11\."), + ], + ids=["llama", "nemotron", "gpt2", "blocks"], +) +def test_tensor_overrides_derive_the_layer_index_from_any_naming_family( + group: str, pattern: str +) -> None: + # GGUF numbers every decoder layer `blk..` whatever the + # checkpoint calls it, so the index is derived, not matched + # against one fixed prefix (#160, #180). + recipe = make_recipe((group, 4)) + + assert tensor_overrides(recipe) == (TypeOverride(pattern, "q4_k"),) + + @pytest.mark.parametrize( - "group", + ("group", "tensor"), [ - "model.layers.0.mlp.experts.up_proj", - "backbone.layers.0.mixer.experts.down_proj", + ("model.layers.0.mlp.experts.up_proj", "blk.0.ffn_up_exps.weight"), + ("backbone.layers.9.mixer.experts.down_proj", "blk.9.ffn_down_exps.weight"), + ( + "model.layers.2.block_sparse_moe.experts.gate_proj", + "blk.2.ffn_gate_exps.weight", + ), ], - ids=["stack-keyed", "nemotron-naming"], + ids=["up", "down", "gate"], ) -def test_tensor_overrides_reject_stack_keyed_groups(group: str) -> None: - # The v1 backend maps only `model.layers.` groups, so a - # stack-keyed recipe stops at pack rather than mispacking - # (ADR-0022 decision 1, amended 2026-08-11). Issue #180 lifts - # this. Locking the refusal keeps it loud until then. +def test_tensor_overrides_map_a_routed_expert_stack_to_its_fused_tensor( + group: str, tensor: str +) -> None: + # llama.cpp fuses one layer's experts into a single tensor with + # one type, so the stack is what a pack addresses (#159, #161). recipe = make_recipe((group, 4)) + overrides = tensor_overrides(recipe) + + assert len(overrides) == 1 + assert re.search(overrides[0].pattern, tensor) + assert overrides[0].quant_type == "q4_k" + + +def test_tensor_overrides_escape_the_stack_pattern_so_layer_1_never_matches_11() -> ( + None +): + recipe = make_recipe(("model.layers.1.mlp.experts.up_proj", 8)) + + pattern = tensor_overrides(recipe)[0].pattern + + assert re.search(pattern, "blk.1.ffn_up_exps.weight") + assert not re.search(pattern, "blk.11.ffn_up_exps.weight") + assert not re.search(pattern, "blk.1.ffn_up_exps_scale.weight") + + +def test_tensor_overrides_put_stacks_before_layers_so_the_stack_wins() -> None: + # The quantizer applies the first matching pattern, and the + # layer pattern `blk\.1\.` also matches `blk.1.ffn_up_exps. + # weight`. A stack override placed second would never apply. + recipe = make_recipe( + ("model.layers.1", 8), + ("model.layers.1.mlp.experts.up_proj", 2), + ) + + overrides = tensor_overrides(recipe) + + assert [o.quant_type for o in overrides] == ["q2_k", "q8_0"] + assert re.search(overrides[0].pattern, "blk.1.ffn_up_exps.weight") + + +def test_tensor_overrides_keep_recipe_order_within_the_stack_bucket() -> None: + recipe = make_recipe( + ("backbone.layers.5.mixer.experts.down_proj", 2), + ("backbone.layers.1.mixer.experts.up_proj", 8), + ) + + assert [o.quant_type for o in tensor_overrides(recipe)] == ["q2_k", "q8_0"] + + +def test_tensor_overrides_reject_an_expert_projection_outside_the_stack_table() -> None: + # Mixtral spells its projections w1/w2/w3. Guessing which fused + # tensor those become would mispack silently (#159). + recipe = make_recipe(("model.layers.0.block_sparse_moe.experts.w1", 4)) + + with pytest.raises(PackError) as caught: + tensor_overrides(recipe) + + message = str(caught.value) + assert '"model.layers.0.block_sparse_moe.experts.w1"' in message + assert "down_proj" in message + + +def test_tensor_overrides_reject_a_shared_expert_stack() -> None: + # A shared expert is a separate GGUF tensor, `ffn_up_shexp`. + # Folding it into the routed stack would pack the wrong weights + # at the wrong precision (#183). + recipe = make_recipe(("backbone.layers.1.mixer.shared_experts.up_proj", 4)) + with pytest.raises(PackError, match="no GGUF tensor mapping"): tensor_overrides(recipe) +def test_gguf_stack_prefix_maps_experts_attached_straight_to_the_layer() -> None: + assert gguf_stack_prefix("model.layers.4.experts.up_proj") == "blk.4.ffn_up_exps." + + +def test_gguf_stack_prefix_returns_none_for_a_plain_layer_group() -> None: + assert gguf_stack_prefix("model.layers.4") is None + assert gguf_stack_prefix("model.embed_tokens") is None + + def make_protected_recipe( pairs: tuple[tuple[str, int], ...], *assignments: tuple[str, int], From 5e07141d386ad57e229c69b995acf3724d26d078 Mon Sep 17 00:00:00 2001 From: Alberto-Codes Date: Wed, 12 Aug 2026 07:14:22 -0700 Subject: [PATCH 2/2] fix(pack): map the target's embedding and refuse two layer stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review cycle on PR #184 found two defects in the first commit. The embedding group stayed the fixed literal `model.embed_tokens` while the layer prefix generalized. The target names it `backbone.embeddings`, verified against the checkpoint's tensor index. So a `layer`-keyed recipe still raised `PackError` on its 53rd group, and the claim that the target packs was false. The backend now carries both names. A free layer prefix cannot tell one layer stack from another. The target carries `mtp.layers.` beside `backbone.layers.`, and a multimodal checkpoint carries a vision tower GGUF names `v.blk..`. Both mapped onto `blk..`, and the first-match rule dropped one assignment without a word. Before this branch they raised. The backend now refuses a recipe naming two roots, and names both. Also from the review: state the override order on `PackResult`, where the type is defined; make the three prose locations agree on the fused tensors and the refused classes; correct the reason the protection table misses this target; write "expert stack" in full per the glossary; and drop the coined "decoder-layer group". The contract fixture used `model.embed_tokens` beside `backbone.layers.` — a recipe no scan of that checkpoint produces, which hid the first defect. It now uses real names. Surfaced and tracked: #189 (llama-quantize substitutes a reachable type for an unreachable k-quant, silently, on 93% of the target's parameters) and #190 (the naming rule is duplicated across domain and this adapter). --- docs/adr/0012-gguf-type-mapping.md | 57 +++++--- docs/adr/0022-within-layer-protections.md | 34 +++-- docs/reference/sensitivity-map.md | 24 ++-- src/vramfit/adapters/outbound/gguf/types.py | 126 ++++++++++++------ src/vramfit/domain/pack.py | 7 +- tests/contract/test_recipe_packer_contract.py | 77 ++++++++++- tests/integration/test_torch_scan_adapter.py | 4 +- tests/unit/adapters/test_gguf_types.py | 54 +++++++- 8 files changed, 290 insertions(+), 93 deletions(-) diff --git a/docs/adr/0012-gguf-type-mapping.md b/docs/adr/0012-gguf-type-mapping.md index 18855de6..1cee6958 100644 --- a/docs/adr/0012-gguf-type-mapping.md +++ b/docs/adr/0012-gguf-type-mapping.md @@ -24,30 +24,47 @@ applies the first matching pattern. The backend still rejects tensor-level *groups*: the boundary moved for protections only. - **Amendment (2026-08-12, issue #180):** decision 2 gains a second - group shape and drops a fixed prefix. + group shape, drops a fixed prefix, and gains one refusal. The backend derives the layer index from the group name. Any - decoder-layer group ending in `.layers.`, `.h.`, or - `.blocks.` becomes `blk\.\.`. GGUF numbers every decoder - layer `blk..` whatever the checkpoint calls it, so matching - `model.layers.` alone refused the Nemotron 3.5 Lightning - target at `backbone.layers.` (#160). + layer group ending in `.layers.`, `.h.`, or `.blocks.` + becomes `blk\.\.`. GGUF numbers every layer `blk..` + whatever the checkpoint calls it. Matching `model.layers.` + alone refused the Nemotron 3.5 Lightning target at + `backbone.layers.` (#160). + + The embedding group gains the same treatment. Decision 2 fixed + it at `model.embed_tokens`, and the target names it + `backbone.embeddings`. The backend now carries both names. The + output head stays the literal `lm_head`, which the target + carries verbatim. A routed-expert stack group becomes its fused tensor: - `blk\.\.ffn_up_exps\.`, `ffn_down_exps`, or `ffn_gate_exps`. - llama.cpp fuses one layer's routed experts into a single 3D - tensor carrying one quantization type, so the stack is the unit - a pack addresses (#159, ADR-0001 as amended by #161). - - Stack overrides go before layer overrides, and both go after the - protection overrides. The quantizer applies the first matching - pattern, and `blk\.1\.` also matches `blk.1.ffn_up_exps.weight`. - Order is priority: per-tensor, then stack, then layer. - - The refusal stands for every other group, and it names the - group. Tensor-level groups still refuse. So does any tensor - class outside this mapping — the Mamba `in_proj`, the router, - the shared experts. Issue #183 carries those. + `blk\.\.ffn_up_exps\.`, `blk\.\.ffn_down_exps\.`, or + `blk\.\.ffn_gate_exps\.`. llama.cpp fuses one layer's routed + experts into a single 3D tensor. That tensor carries one + quantization type, so the expert stack is the unit a pack + addresses (#159, ADR-0001 as amended by #161). + + Expert-stack overrides go before layer overrides, and both go + after the protection overrides. The quantizer applies the first + matching pattern, and `blk\.1\.` also matches + `blk.1.ffn_up_exps.weight`. Order is priority: per-tensor, then + expert stack, then layer. + + **Every mapped group must hang from one parameter-tree root.** + A free prefix cannot tell a decoder layer from any other layer + stack. The target carries `mtp.layers.` beside + `backbone.layers.`, and a multimodal checkpoint carries a + vision tower that GGUF names `v.blk..`. Both would map onto + `blk..` and lose one assignment to the first-match rule. The + backend refuses a recipe naming two roots, and names both. + + The backend still refuses every other group, and it names the + group. It refuses tensor-level groups. It refuses any tensor + class outside this mapping — the Mamba `in_proj`, `out_proj`, + and `conv1d`, the attention projections, the router, the shared + experts. Issue #183 carries those. ## Context diff --git a/docs/adr/0022-within-layer-protections.md b/docs/adr/0022-within-layer-protections.md index 52cd5351..545bd668 100644 --- a/docs/adr/0022-within-layer-protections.md +++ b/docs/adr/0022-within-layer-protections.md @@ -42,26 +42,32 @@ map. `stack` exists because it is *coarser* than per-tensor and matches the pack, not because per-tensor scanning came back. -- **Amendment (2026-08-12, issue #180):** the amendment above is - superseded in part. The GGUF backend now maps a routed-expert - stack group to its fused tensor, and derives the layer index from - the group name instead of matching `model.layers.` +- **Amendment (2026-08-12, issue #180):** this amendment supersedes + the amendment above in part. The GGUF backend now maps a + routed-expert stack group to its fused tensor. It derives the + layer index from the group name instead of matching + `model.layers.`, and it carries the target's + `backbone.embeddings` name ([ADR-0012](0012-gguf-type-mapping.md) decision 2, amended the same day). A `layer`-keyed recipe for the Nemotron 3.5 Lightning target packs. So does a recipe whose only stack groups are routed-expert stacks. - Decision 1's refusal narrows but does not lift. A whole-model + Decision 1's refusal narrows. It does not lift. A whole-model `stack`-keyed recipe still carries groups outside any GGUF class - mapping — the Mamba `in_proj` and `out_proj`, the attention - projections, the router, the shared experts. The backend refuses - each by name. Issue #183 carries that table, and it must first - rule what a recipe does with a group `llama-quantize` never - quantizes. - - The protection path is unchanged. Decision 2's class table still - matches `model.layers...weight` only, because its seven - llama-family classes do not exist on this target under any name. + mapping — the Mamba `in_proj`, `out_proj`, and `conv1d`, the + attention projections, the router, the shared experts. The + backend refuses each by name. Issue #183 carries that table, and + it must first rule what a recipe does with a group + `llama-quantize` never quantizes. + + The protection path is unchanged. + [ADR-0012](0012-gguf-type-mapping.md) decision 2's class table + still matches `model.layers...weight` only. The target + does carry four of the seven classes, at + `backbone.layers..mixer.{q,k,v,o}_proj`. Generalizing the + prefix alone would still miss them, because the table keys read + `self_attn.q_proj` and this checkpoint says `mixer.q_proj`. ## Context diff --git a/docs/reference/sensitivity-map.md b/docs/reference/sensitivity-map.md index bd2bd3a1..8627ba80 100644 --- a/docs/reference/sensitivity-map.md +++ b/docs/reference/sensitivity-map.md @@ -149,16 +149,22 @@ below remain, the sub-4-bit pricing claims do not. !!! warning "A `stack` scan packs its expert stacks, not every group" The GGUF backend maps two group shapes (ADR-0012 decision 2, - amended 2026-08-12). A decoder-layer group becomes - `blk..`, under any checkpoint naming family — both - `model.layers.` and the Nemotron 3.5 Lightning target's + amended 2026-08-12). A layer group becomes `blk..` across + the three naming families above — both `model.layers.` + and the Nemotron 3.5 Lightning target's `backbone.layers.`. A routed-expert stack becomes its - fused tensor, `blk..ffn_up_exps.` or - `blk..ffn_down_exps.`. + fused tensor: `blk..ffn_up_exps.`, + `blk..ffn_down_exps.`, or `blk..ffn_gate_exps.`. Every other `stack` group still raises a `PackError` that names it. On the Nemotron target that covers the Mamba - `in_proj` and `out_proj`, the attention projections, the - router, and the shared experts. So a `layer`-keyed recipe - packs today, and a whole-model `stack`-keyed recipe does - not. Issue #183 carries the remaining classes. + `in_proj`, `out_proj`, and `conv1d`, the attention + projections, the router, and the shared experts. So a + `layer`-keyed recipe packs today and a whole-model + `stack`-keyed recipe does not. Issue #183 carries the + remaining classes. + + The backend also refuses a recipe naming two layer stacks. + GGUF numbers one stack `blk..`, so the target's + `mtp.layers.` and a multimodal checkpoint's vision tower + each collide with the backbone. Scan one stack at a time. diff --git a/src/vramfit/adapters/outbound/gguf/types.py b/src/vramfit/adapters/outbound/gguf/types.py index de955e4c..6edeef25 100644 --- a/src/vramfit/adapters/outbound/gguf/types.py +++ b/src/vramfit/adapters/outbound/gguf/types.py @@ -3,10 +3,10 @@ The decision core of the GGUF backend, kept free of IO so the mapping is testable and the verified fake can share it. Nominal precisions map to K-quant types (the full llama.cpp capability set since -ADR-0013), decoder-layer groups map to escaped `blk..` regex -patterns under any checkpoint naming family, routed-expert stack -groups map to their fused `blk..ffn__exps.` tensor (#159, -#161), protected tensors map through the fixed HF-to-GGUF class table to +ADR-0013), layer groups map to escaped `blk..` regex patterns +across the three naming families the scan produces, routed-expert +stack groups map to their fused `blk..ffn__exps.` tensor +(#159, #161), protected tensors map through the fixed HF-to-GGUF class table to per-tensor patterns (ADR-0022), excluded pairs map to the full GGUF tensor names ``--exclude-weights`` deletes by substring (ADR-0023), and the embedding and `lm_head` groups map to the quantizer's @@ -67,24 +67,40 @@ # another runtime must not silently become a GGUF (ADR-0013). GGUF_RUNTIME: Final[str] = LLAMA_CPP -EMBEDDING_GROUP: Final[str] = "model.embed_tokens" +# The embedding group names the scan produces, across naming +# families. llama-family checkpoints say `model.embed_tokens`. +# Nemotron-H says `backbone.embeddings`. Both drive the one +# `--token-embedding-type` flag, so the backend needs the names, not +# a pattern. +EMBEDDING_GROUPS: Final[frozenset[str]] = frozenset( + {"model.embed_tokens", "backbone.embeddings"} +) OUTPUT_GROUP: Final[str] = "lm_head" -# A decoder-layer group, under any naming family the scan produces. -# The prefix is free, so `model.layers.4` and Nemotron 3.5 -# Lightning's `backbone.layers.4` both yield 4 (#160). GGUF numbers -# every decoder layer `blk..`, whatever the checkpoint calls it. +# A layer group, under the three naming families the scan produces +# (`domain.scan` names them the same way). The prefix is free, so +# `model.layers.4` and Nemotron-H's `backbone.layers.4` both yield 4 +# (#160). GGUF numbers every layer `blk..`, whatever the +# checkpoint calls it. _LAYER_GROUP: Final[re.Pattern[str]] = re.compile(r"^.+\.(?:layers|h|blocks)\.(\d+)$") -# A routed-expert stack group: a decoder-layer prefix, then -# `.experts.` with the expert index already collapsed by -# `group_key`, then the projection (#161). The trailing projection -# stops this from also matching a plain layer group. The dot before -# `experts` matters — it refuses `shared_experts`, which GGUF names -# `ffn_up_shexp` and this table does not carry (#183). +# A routed-expert stack group: a layer prefix, then `.experts.` with +# the expert index already collapsed by `group_key`, then the +# projection (#161). The dot before `experts` matters — it refuses +# `shared_experts`, which GGUF names `ffn_up_shexp` and this table +# does not carry (#183). _EXPERT_STACK: Final[re.Pattern[str]] = re.compile( - r"^.+\.(?:layers|h|blocks)\.(\d+)\.(?:.*\.)?experts\.([a-z_0-9]+)$" + r"^.+\.(?:layers|h|blocks)\.(\d+)\.(?:.*\.)?experts\.([A-Za-z0-9_]+)$" +) + +# The parameter-tree root a layer or expert-stack group hangs from. +# `blk..` addresses exactly one layer stack, so a recipe that +# names two of them cannot pack. The target carries `backbone` and +# `mtp`, and a multimodal checkpoint carries a vision tower that +# GGUF names `v.blk..` instead. +_STACK_ROOT: Final[re.Pattern[str]] = re.compile( + r"^(.+?)\.(?:layers|h|blocks)\.\d+(?:\.|$)" ) # llama.cpp fuses one layer's routed experts into a single 3D tensor @@ -228,7 +244,8 @@ def token_embedding_type(recipe: Recipe) -> str | None: ``--token-embedding-type`` binds the embedding tensor before any pattern override, so the embedding group never becomes a pattern. When the model ties embeddings, this assignment also governs the - output head (ADR-0012). + output head (ADR-0012). The group carries one of the names in + `EMBEDDING_GROUPS`, which differ by naming family. Args: recipe: The recipe to pack. @@ -249,7 +266,7 @@ def token_embedding_type(recipe: Recipe) -> str | None: ``` """ for assignment in recipe.assignments: - if assignment.group == EMBEDDING_GROUP: + if assignment.group in EMBEDDING_GROUPS: return ggml_type_for(assignment.bits) return None @@ -428,35 +445,66 @@ def gguf_stack_prefix(group: str) -> str | None: return f"blk.{match.group(1)}.{suffix}." +def _claim_root(group: str, roots: dict[str, str]) -> None: + """Hold every mapped group to one parameter-tree root. + + Args: + group: Recipe group name. + roots: Roots claimed so far, mapped to the group that + claimed each. Updated in place. + + Raises: + PackError: If ``group`` hangs from a second root. + """ + match = _STACK_ROOT.match(group) + if match is None: + return + root = match.group(1) + roots.setdefault(root, group) + if len(roots) > 1: + first = next(iter(roots)) + raise PackError( + f'groups "{roots[first]}" and "{group}" name two layer stacks — ' + f'a GGUF pack numbers one stack "blk.." and would silently ' + f"drop the other (#183)" + ) + + def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: r"""Translate recipe groups into quantizer tensor-type overrides. - Two group shapes map. A decoder-layer group under any naming - family — ``model.layers.``, ``backbone.layers.`` — becomes - the escaped pattern ``blk\.\.``. A routed-expert stack group - becomes the escaped pattern for its fused tensor, e.g. - ``blk\.\.ffn_up_exps\.`` (#159, #161). Escaping matters — an - unescaped ``blk.1.`` would also match ``blk.11.``. The embedding - and ``lm_head`` groups map to dedicated flags and are skipped - here. - - Stack overrides come first, ahead of the layer overrides. The - quantizer applies the first matching pattern, and ``blk\.1\.`` - also matches ``blk.1.ffn_up_exps.weight``. Callers place the - protection overrides ahead of both — a per-tensor pattern is the - most specific of the three (ADR-0022). + Two group shapes map. A layer group under any of the three + naming families — ``model.layers.``, ``backbone.layers.`` + — becomes the escaped pattern ``blk\.\.``. A routed-expert + stack group becomes the escaped pattern for its fused tensor, + e.g. ``blk\.\.ffn_up_exps\.`` (#159, #161). Escaping matters + — an unescaped ``blk.1.`` would also match ``blk.11.``. The + embedding and ``lm_head`` groups map to dedicated flags and are + skipped here. + + Expert-stack overrides come first, ahead of the layer overrides. + The quantizer applies the first matching pattern, and + ``blk\.1\.`` also matches ``blk.1.ffn_up_exps.weight``. Callers + place the protection overrides ahead of both — a per-tensor + pattern is the most specific of the three (ADR-0022). + + Every mapped group must hang from one parameter-tree root. + ``blk..`` addresses a single layer stack, so a recipe naming + two of them would map both onto it and silently drop one. Args: recipe: The recipe to pack. Returns: - Stack overrides in recipe order, then layer overrides in - recipe order. + Expert-stack overrides in recipe order, then layer overrides + in recipe order. Raises: PackError: If a group is not a layer group, a routed-expert - stack, the embedding, or the output head, or its - precision has no table entry. + stack, the embedding, or the output head. Also if a + routed-expert stack names a projection outside the + fused-stack table, if the groups hang from two roots, or + if a precision has no table entry. Examples: The group ``model.layers.7`` at 4-bit becomes an escaped @@ -468,9 +516,11 @@ def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: """ stacks: list[TypeOverride] = [] layers: list[TypeOverride] = [] + roots: dict[str, str] = {} for assignment in recipe.assignments: - if assignment.group in (EMBEDDING_GROUP, OUTPUT_GROUP): + if assignment.group in EMBEDDING_GROUPS or assignment.group == OUTPUT_GROUP: continue + _claim_root(assignment.group, roots) prefix = gguf_stack_prefix(assignment.group) if prefix is not None: bits = ggml_type_for(assignment.bits) @@ -480,7 +530,7 @@ def tensor_overrides(recipe: Recipe) -> tuple[TypeOverride, ...]: if match is None: raise PackError( f'group "{assignment.group}" has no GGUF tensor mapping — the ' - "backend maps decoder-layer groups, routed-expert stacks, the " + "backend maps layer groups, routed-expert stacks, the " "embedding, and the output head (ADR-0012, ADR-0022)" ) bits = ggml_type_for(assignment.bits) diff --git a/src/vramfit/domain/pack.py b/src/vramfit/domain/pack.py index 3a5e62a5..937680f1 100644 --- a/src/vramfit/domain/pack.py +++ b/src/vramfit/domain/pack.py @@ -86,9 +86,10 @@ class PackResult: scan measured one, the embedding assignment otherwise (ADR-0012). None when the recipe has neither group. overrides (tuple[TypeOverride, ...]): Ordered per-tensor - overrides, in recipe order. Patterns are unique — the - quantizer applies the first match, so a duplicate would - silently shadow its successor. + overrides: protections, then expert stacks, then layer + groups, each in recipe order. Order carries priority. + The quantizer applies the first match, so a broader + pattern placed first would shadow a narrower one. imatrix_path (str | None): Importance matrix file driven into the quantizer (ADR-0016). None when the pack ran without one. diff --git a/tests/contract/test_recipe_packer_contract.py b/tests/contract/test_recipe_packer_contract.py index 0020c73e..1f90f539 100644 --- a/tests/contract/test_recipe_packer_contract.py +++ b/tests/contract/test_recipe_packer_contract.py @@ -106,14 +106,16 @@ def sample_pack_recipe() -> Recipe: def stack_pack_recipe() -> Recipe: """A `--group-by stack` recipe shaped like the Nemotron target. - Layers are named `backbone.layers.` and the routed experts of - one layer fuse into two stacks (#159, #160, #161). Nothing here - maps under the pre-#180 backend. + Every name is one the target's checkpoint really carries: the + embedding at `backbone.embeddings`, layers at + `backbone.layers.`, and one layer's routed experts fused into + two stacks (#159, #160, #161). No name here mapped under the + pre-#180 backend, the embedding included. """ return replace( sample_pack_recipe(), assignments=( - Assignment(group="model.embed_tokens", bits=8, bytes=1_000, damage=0.001), + Assignment(group="backbone.embeddings", bits=8, bytes=1_000, damage=0.001), Assignment( group="backbone.layers.1.mixer.experts.up_proj", bits=4, @@ -278,13 +280,38 @@ def test_pack_carries_the_stack_type_mapping(self, build, tmp_path) -> None: TypeOverride(pattern=r"blk\.1\.ffn_down_exps\.", quant_type="q2_k"), ) - def test_pack_stack_recipe_reports_the_real_packed_size( + def test_pack_stack_recipe_binds_the_nemotron_embedding_group( self, build, tmp_path ) -> None: + # The target names its embedding `backbone.embeddings`, not + # `model.embed_tokens`. Missing that name refuses the whole + # recipe, because `--pure` would drop the embedding to the + # floor otherwise (#180). packer: RecipePacker = build(tmp_path) packer.convert() - assert packer.pack(stack_pack_recipe()).packed_bytes == PACKED_BYTES + result = packer.pack(stack_pack_recipe()) + + assert result.token_embedding_type == "q8_0" # noqa: S105 - a ggml type name, not a secret + + def test_pack_two_layer_stacks_raises_pack_error_naming_both( + self, build, tmp_path + ) -> None: + # The target carries `mtp.layers.` beside + # `backbone.layers.`. Both would map to `blk..`, and + # the quantizer applies the first match, so the second + # assignment would vanish. Refuse instead (#183). + packer: RecipePacker = build(tmp_path, base_exists=True) + recipe = replace( + sample_pack_recipe(), + assignments=( + Assignment(group="backbone.layers.0", bits=8, bytes=1_000, damage=0.01), + Assignment(group="mtp.layers.0", bits=4, bytes=500, damage=0.02), + ), + ) + + with pytest.raises(PackError, match="two layer stacks"): + packer.pack(recipe) def test_pack_unmappable_group_raises_pack_error_naming_it( self, build, tmp_path @@ -484,6 +511,44 @@ def test_quantize_argv_orders_stack_patterns_before_layer_patterns( pairs = [argv[i + 1] for i, flag in enumerate(argv) if flag == "--tensor-type"] assert pairs == [r"blk\.1\.ffn_up_exps\.=q2_k", r"blk\.1\.=q8_0"] + def test_quantize_argv_orders_protection_then_stack_then_layer( + self, tmp_path + ) -> None: + # ADR-0012 decision 2 rules the three-way priority. The + # quantizer applies the first matching pattern, and each + # pattern here matches a superset of the next. + packer = _real_packer(tmp_path) + packer.convert() + recipe = replace( + sample_pack_recipe(), + plan=replace( + sample_pack_recipe().plan, + protections={"*.self_attn.v_proj.weight": 5}, + ), + assignments=( + Assignment(group="model.layers.1", bits=8, bytes=1_000, damage=0.001), + Assignment( + group="model.layers.1.mlp.experts.up_proj", + bits=2, + bytes=900, + damage=0.03, + ), + ), + protected_tensors=( + ProtectedTensor("model.layers.1.self_attn.v_proj.weight", 5), + ), + ) + + packer.pack(recipe) + + argv = json.loads((tmp_path / "quantize-argv.json").read_text()) + pairs = [argv[i + 1] for i, flag in enumerate(argv) if flag == "--tensor-type"] + assert pairs == [ + r"blk\.1\.attn_v\.=q5_k", + r"blk\.1\.ffn_up_exps\.=q2_k", + r"blk\.1\.=q8_0", + ] + def test_quantize_argv_with_imatrix_carries_the_flag(self, tmp_path) -> None: packer = _real_packer(tmp_path, with_imatrix=True) packer.convert() diff --git a/tests/integration/test_torch_scan_adapter.py b/tests/integration/test_torch_scan_adapter.py index 9e7689b6..b5291080 100644 --- a/tests/integration/test_torch_scan_adapter.py +++ b/tests/integration/test_torch_scan_adapter.py @@ -237,13 +237,13 @@ def test_discovered_groups_match_the_pack_flag_literals(self, tiny_meter) -> Non # flags disengage and the renamed group surfaces only later, # as a PackError for an unmapped group. This pins the drift. from vramfit.adapters.outbound.gguf.types import ( - EMBEDDING_GROUP, + EMBEDDING_GROUPS, OUTPUT_GROUP, ) names = {spec.name for spec in tiny_meter.groups()} - assert EMBEDDING_GROUP in names + assert names & EMBEDDING_GROUPS assert OUTPUT_GROUP in names def test_gpt2_style_names_group_by_layer(self) -> None: diff --git a/tests/unit/adapters/test_gguf_types.py b/tests/unit/adapters/test_gguf_types.py index 87c8c41a..0700cfad 100644 --- a/tests/unit/adapters/test_gguf_types.py +++ b/tests/unit/adapters/test_gguf_types.py @@ -216,7 +216,7 @@ def test_tensor_overrides_name_the_group_they_cannot_map() -> None: message = str(caught.value) assert '"backbone.layers.0.mixer.in_proj"' in message - assert "decoder-layer groups, routed-expert stacks" in message + assert "layer groups, routed-expert stacks" in message @pytest.mark.parametrize( @@ -315,6 +315,58 @@ def test_tensor_overrides_reject_an_expert_projection_outside_the_stack_table() assert "down_proj" in message +@pytest.mark.parametrize( + "group", + ["model.embed_tokens", "backbone.embeddings"], + ids=["llama", "nemotron"], +) +def test_token_embedding_type_maps_every_embedding_naming_family(group: str) -> None: + # `--token-embedding-type` binds one tensor whatever the + # checkpoint calls the group. Missing a name refuses the whole + # recipe, because the group then reaches the pattern branch. + recipe = make_recipe((group, 8), ("backbone.layers.0", 4)) + + assert token_embedding_type(recipe) == "q8_0" + assert [o.pattern for o in tensor_overrides(recipe)] == [r"blk\.0\."] + + +def test_tensor_overrides_reject_two_layer_stacks_naming_both() -> None: + # GGUF numbers one layer stack `blk..`. The target carries + # `mtp.layers.` beside `backbone.layers.`, and a + # multimodal checkpoint carries a vision tower. Mapping both + # onto `blk.0.` would drop one silently (#183). + recipe = make_recipe(("backbone.layers.0", 8), ("mtp.layers.0", 4)) + + with pytest.raises(PackError) as caught: + tensor_overrides(recipe) + + message = str(caught.value) + assert "two layer stacks" in message + assert '"backbone.layers.0"' in message + assert '"mtp.layers.0"' in message + + +def test_tensor_overrides_reject_a_vision_tower_beside_the_language_model() -> None: + # GGUF names vision blocks `v.blk..`, not `blk..`. + recipe = make_recipe( + ("model.layers.0", 4), + ("vision_tower.transformer.layers.0", 4), + ) + + with pytest.raises(PackError, match="two layer stacks"): + tensor_overrides(recipe) + + +def test_tensor_overrides_accept_one_root_across_layers_and_stacks() -> None: + recipe = make_recipe( + ("backbone.layers.0", 8), + ("backbone.layers.1.mixer.experts.up_proj", 4), + ("backbone.layers.2", 2), + ) + + assert len(tensor_overrides(recipe)) == 3 + + def test_tensor_overrides_reject_a_shared_expert_stack() -> None: # A shared expert is a separate GGUF tensor, `ffn_up_shexp`. # Folding it into the routed stack would pack the wrong weights