… converted pack is not served causally (Fixes #796) (#1310)
* npu: a d3 pack's own attention_mode declaration is what decides, so a converted pack is not served causally
#1293 refused a noncausal pack whose plan was absent or stale, at the route. That stopped the wrong
answer but left the conversion recipe incomplete: `pack` + `decision` produce a container that says
`noncausal_full_attention` in decision_config.json, and the forward read the mask from plan.json only,
so the documented two steps gave a causally-answered pack until someone ran tools/npu_plan.py.
The declaration now decides. q4nx_semantics.c gains q4nx_attention_mode(declared, plan_loaded,
plan_noncausal, why): the pack's word wins when there is no plan, a plan that AGREES is unchanged, and
a plan that DISAGREES is refused as stale in either direction (that is the only silent-wrong-mask left,
and its fix is in the message). The forward reads decision_config.json's attention_mode next to the
container and prints where the mode came from; the route-level guard from #1293 and its four test
assertions are gone, so there is one place that decides and it is unit-tested.
Measured on the real vllm-sr/d3-lite pack, host GEMM, against the pack's own d3_runtime.py on the same
q4_1 weights (12 questions incl. the 3-question alias): the same directory that answered causally at
worst |dp| 0.3702 with a choice flip now answers at 0.0246 with none -- identical to the arm that has a
plan, with no third conversion step.
Tests: npu_rmsnorm_offset_test gains the eight-case rule table (declaration alone, agreement, no
declaration with and without a plan, empty declaration, disagreement both ways), and the causal path is
unchanged for every model without a decision_config.json (checked on Qwen3-0.6B: loads, no mode line).
Fixes #796
* npu: read the sidecar key as a JSON key, not a bare substring, so a prompt mentioning it cannot be mistaken for the field
---------
Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Refs #796, the pack side. A d3 pack declares the mask it was trained with in its own
decision_config.json(
attention_mode: noncausal_full_attention). The forward readsattention.modefromplan.jsonandnowhere else (
npu/forward/src/q4nx_forward.cpp:534), andplan.jsonis written bytools/npu_plan.py—which the d3 conversion recipe in
docs/npu.mddid not include. So the two documented steps (pack,decision)produce a container that is answered causally, with nothing in the log, the health body or the route saying
so.
Measured (same container, same binary, host GEMM, no device, no ticket)
~/lb/i796-fresh: the real 1.7 GBvllm-sr/d3-litepack throughsafetensors_to_q4nx.py pack --arch qwen3_5decision— 320 tensors, 820,971,656 B, weight bytes byte-identical to the shippedd3-lite-conv(
sha256 313ac4f9…),verify320/320. Reference = the pack's ownd3_runtime.pyon the same q4_1 weights(
~/lb/796-rung1/ref_q41c_fresh.json). 10 contract cases / 12 questions incl. the 3-question alias:plan.json[plan] attention.modelinescore_three_levels)choice_unicode,novsok)npu_plan.py— and the 2026-11-06 rung-1 armnoncausal_full_attention)score_json_levels)Every case moves, worst-first:
score_three_levels0.3702 vs 0.0013,choice_instructions0.2404 vs 0.0003,multi_three/rate0.1673 vs 0.0151,multi_three/yes0.1497 vs 0.0103,choice_unicode0.1336 (flip) vs0.0075,
noul_instructions0.1076 vs 0.0091. The pack is not missing the information —tools/npu_plan.py --printon that directory derivesattention.mode: noncausal_full_attention,gate_up: fusedand the M=16decode dir from the container. The recipe was missing the step, and skipping it silently changed the model.
Change
docs/npu.md(d3 conversion paragraph): the third step is named, with why it is not optional —attention.modeis read from the plan only, and a device row also needs the plan forgate/upfusion and theM=16 decode twins.
app/npu_systemone.hgainssystemone_attention_disagreement(declared, forward_noncausal, plan_present, dir), which returns the refusalwhen
decision_config.jsonsaysnoncausal_full_attentionwhile the forward is causal, naming whetherplan.jsonis absent or stale and printing thetools/npu_plan.pycommand that fixes it. Called once afterthe decision head loads in
app/forward_serve.cpp;npu/forward/include/q4nx_forward.hexposesnoncausal()so the route can ask (it already keeps the flag forstep()'s own refusal).tests/systemone_route_test.cpppins all four combinations — noncausal pack + causal forward refused andnames the command; a plan that disagrees is "stale", not "absent"; a causal pack and a pack that declares no
mask are untouched — so the rule is covered in
ctest systemone_routewithout a model or a device.Verification
systemone_route_test→systemone_route: ok, the four new assertions included. Tested the test: withthe guard's condition replaced by
if (true) return "";, exactly the three refusal assertions fail; restored,they pass.
forward-serveexits non-zero namingdecision_config.json,plan.json is absentandtools/npu_plan.py …,while the same directory with the plan serves and prints
[plan] attention.mode noncausal_full_attention: ….docsand the tool agree with the field the forward actually reads (one read site,q4nx_forward.cpp:534);no new probe and no second source of truth — the plan stays the one place
attention.modeis decided.tools/copyright.py --checkrc 0; device never opened in any arm (0/dev/accel*fds,NPU_NO_DEVICE_LOCK=1,so the 16:1x fleet blocker is not re-created).
Not in this PR
A labelled accuracy row (rung 3 of #796) — the issue's own "deliberately separate" exercise — and any change to
the forward's mask itself, which is #847's and is already in
main.