Skip to content

feat(MODEL-MM-QWEN4-EXP): W1 — resolve and validate the qwen4_exp config, register the arch, and refuse by name - #1986

Open
localai-bot wants to merge 6 commits into
mainfrom
row/MODEL-MM-QWEN4-EXP-W1
Open

feat(MODEL-MM-QWEN4-EXP): W1 — resolve and validate the qwen4_exp config, register the arch, and refuse by name#1986
localai-bot wants to merge 6 commits into
mainfrom
row/MODEL-MM-QWEN4-EXP-W1

Conversation

@localai-bot

@localai-bot localai-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Qwen/Qwen3.8-Flash-Next declares Qwen4ExpForConditionalGeneration /
model_type: qwen4_exp, an architecture vLLM implements at no revision. This makes
it RESOLVE, and makes its config parse and VALIDATE. It does not make it load and it
does not make it forward: both refuse by name, and so does the KV-cache spec.

This body was rewritten after a fresh review failed the change and a different
implementer repaired it. The original body argued for a defect.
It said
partial_rotary_factor had to be read locally with "upstream's inherited default of
0.25", because Qwen4ExpTextConfig subclasses Qwen3_5MoeTextConfig, and that using
the shared reader's value "would make us REFUSE a config upstream ACCEPTS". Every
clause was false at the lane pin. The generated class is
class Qwen4ExpTextConfig(PreTrainedConfig) — no Qwen3.5 base;
partial_rotary_factor occurs exactly twice in the file, both inside the guard, read
from rope_parameters with default 1.0; and 0.25 does not appear at all. The
error came from reading the modular file and assuming its inheritance survives into
the executed, flattened class. It does not.

The consequence was the opposite of what the original body claimed: the local read
produced false ACCEPTs, not a false refusal. A config with no partial_rotary_factor,
or with it only inside rope_parameters at 1.0, is refused upstream at
rotary_dim = 256 > indexer_head_dim = 128; we accepted it and handed W4 a 64-of-256
rotary slice, on a row that has no reachable token gate to catch it. The repair uses
config.rotary_dim from the shared reader, and the "REGRESSION GUARD" case that
pinned the wrong answer was deleted and inverted.

The config layer is the last place any of this is checkable, so the acceptance
criterion is a refusal-set diff rather than a fixture round-trip. ## The refusal boundary in the spec now maps each of the 15 upstream validate_architecture
rejections to the local line implementing it, with two-direction probe evidence: 39
configs written once and fed to both a running transformers 5.16.0 and to our
production hook from the same bytes. The four divergences that remain are ours refusing
what upstream accepts, each tabulated, plus one recorded false ACCEPT reachable only
through a rope_parameters.rope_dim key that no transformers path writes or reads.

The review found more than F1, and the pattern is worth stating: the tests named
guarantees they did not measure.
Gutting the production parse_config hook to
(void)config; left all 151 assertions green, because the test called the hook and
then returned the direct call. Deleting the forward's refusal, or the entire GGUF
arm, left the gate green. Thirteen of twenty-two refusals could be deleted at once
with nothing red. All of that is now load-bearing: 12 cases, 41 subcases, 294
assertions, and 13 mutations that were green and are red.

Four upstream refusals were missing and are added: eos_token_id required when PLE is
enabled — which the published GGUF stores as a first-class key at 248044, and which
feeds the n-gram segment boundary; output_gate_type falling back to hidden_act;
ple_embed_dim <= 0, absent so -2560 % 16 == 0 was accepted; and the four PLE
fields that defaulted to 0 rather than upstream's 3 / 8 / 20000000 / 128, which made
us refuse a config upstream accepts and would have sized a hashed table from zero.
block_topk() and head_dim_per_ngram() divided by zero on a legally-parsed config
with QSA or PLE absent; both now refuse by name, so W2 and W4 get a message instead of
SIGFPE.

Two design points the review examined and cleared. Qwen4ExpLayerKind has no
kFullAttention enumerator at all, so the checkpoint's spelling for the twelve layers
that actually run the QSA indexer is unrepresentable rather than merely unused; the
test asserts the stronger property that the rewritten published list and the list
synthesized from full_attention_interval are equal, so if either path is wrong the
other says so. And the forward refuses before opening the type-erased handle: while
load_weights refuses unconditionally nothing can produce a loaded Qwen4-Exp, so a
downcast placed first would turn every reach into a type-mismatch report and leave the
advertised refusal dead. The test pins both directions.

ple_layer_ids is stored already converted to 0-based. It is one-indexed in the
checkpoint and upstream says so in terms, its validator resolves layer_types[id - 1],
and every PLE tensor in the released weights sits under ...layers.1.ple. — as does
qwen4exp.ple.layers = [1] in the published GGUF, which is a third independent
confirmation.

Nothing here is reachable. No forward, no loader, no token and no speed number, and
the speed axis does not open until G4. Row MODEL-MM-QWEN4-EXP owns the wiring and
#1978 tracks it.

Tracked by #1981, under #1978.

Gates: test_qwen4_exp_scaffold 12 cases / 294 assertions; test_model_registry
24/958; test_model_loader_gguf 6/18; check-supported-models ok at 42
architectures; check-runner-routing-consistency classifies qwen4_exp REFUSE;
check-windows-portability ok. agent-preflight.sh reports one FAIL,
test_cpu_x86_llamacpp_floor, at load average 90 with concurrent builds on the box —
the known #618 load flake, which touches no path this change reaches.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]

mudler added 5 commits August 26, 2026 15:03
…fig, register the arch, and refuse by name

First product code on the row whose spec landed in #1980. The architecture now
RESOLVES, and its config parses and validates; it does not load and it does not
forward, and both refuse by name rather than dying a layer down.

The resolve IS the validation. Every refusal mirrors one in upstream
`Qwen4ExpTextConfig.validate_architecture` / `__post_init__` at the accepted lane
pin, transformers 5.16.0: unsupported layer types, `hc_count <= 1`, the MoE bounds,
the all-or-nothing QSA field group, `indexer_kv_heads != 1`, a budget that does not
divide by the compress ratio, a rotary dim wider than the indexer head, and a PLE id
outside the one-indexed range or landing on a sparse-attention layer.

Two things the config layer gets right that a reader taking the checkpoint at face
value would not.

The published `layer_types` says `full_attention` for the twelve layers that actually
run the QSA indexer, and upstream rewrites them in `__post_init__`. `Qwen4ExpLayerKind`
therefore has no `kFullAttention` enumerator at all, so the wrong state is
unrepresentable rather than merely unused. The test asserts something stronger than the
rewrite: the rewritten published list and the list synthesized from
`full_attention_interval` are equal, so if either path is wrong the other says so. On
the real checkpoint both give sparse at 3, 7, ..., 47.

`partial_rotary_factor` is read here with upstream's inherited default of 0.25 rather
than taken from `config.rotary_dim`, because `IsQwen35Family` in the shared reader does
not list `qwen4_exp`. An absent key defaults there to 1.0 where upstream, subclassing
`Qwen3_5MoeTextConfig`, inherits 0.25 — and since upstream's own guard is
`rotary_dim > indexer_head_dim`, the shared reader's value would make us REFUSE a
config upstream ACCEPTS. On the published checkpoint the key is present and both agree
at 64; the divergence is latent, and the regression guard is a named case.

`ple_layer_ids` is stored already converted to 0-based. It is one-indexed in the
checkpoint and upstream says so in terms, its validator resolves `layer_types[id - 1]`,
and every PLE tensor in the released weights sits under `...layers.1.ple.`. Carrying a
one-indexed value through the port is how that gets rediscovered as a bug.

Two gates caught real defects in the first draft, and both fixes are recorded at the
site. `[[noreturn]]` on a non-void return type is MSVC C4646, promoted to C2220 under
/W4 /WX. And the forward refusal has to be `VT_CHECK(false, ...)` in the hook body:
`check-runner-routing-consistency.py` recognises a refuse-by-name stub by that token
and classifies the hook body itself, so a bare throw put this model in the silently
exempt NONE bucket — the exact hole that checker exists to close. dots3-note's
delegate hop does not help here, because it resolves `Class::ForwardDevice` across
translation units or through a file-local helper, and a class in this TU's own
anonymous namespace is neither. No `Qwen4ExpModel::ForwardDevice` is invented to
refuse from, because there is no device forward yet and asserting a routing shape this
row has not earned would be a claim, not a stub.

The spec also gains two corrections the llama.cpp studies settled. Its ragged-K
question is RESOLVED rather than owed: `tensor_type_fallback` maps `Q4_K -> Q5_0` and
`IQ4_XS -> IQ4_NL`, so the answer depends on the recipe, and our GGUF reader supports
neither type. And "no GGUF exists and no tool can produce one" is superseded —
`unsloth/Qwen3.8-Flash-Next-GGUF` UD-IQ1_S is 67.56 GiB of weights that FIT GB10 with
~52 GiB of headroom, and its metadata confirms this spec's n-gram derivation to the
digit, including `ple.layer_multipliers = [23703573157769, 20109073645365,
8052911324071]`.

Reachability: every case drives `LoadHfConfig -> ModelRegistry::Resolve ->
factory->parse_config` and the refusals through the factory's own hooks. A case that
built `Qwen4ExpParams` by hand would prove the struct parses and not that anything
reaches it.

Tracked by #1981, under #1978.

Gates: `test_qwen4_exp_scaffold` 7 cases / 151 assertions green, and load-bearing
under mutation — deleting the `full_attention` rewrite reds 6 of 7 cases, and
defaulting `partial_rotary_factor` to the shared reader's 1.0 reds the guard case (via
a throw, so the assertion line still read 148/148, which is the documented doctest
shape). Tree restored byte-for-byte after each. `test_model_registry` 24/958,
`test_model_loader_gguf` 6/18, `check-supported-models` ok at 42 architectures,
`check-runner-routing-consistency` classifies qwen4_exp REFUSE,
`check-windows-portability` ok, `agent-preflight.sh --staged` ok.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
`origin/main` moved to d9a5285 while W1 was in gate, so the trailer and style
gates SKIPPED as behind-base. Re-gated on the merged tree.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
…four other config defaults were wrong

A fresh review failed this row, and the deciding finding retracts a claim
a4c144f made three times: in a code comment, in its commit body, and in the
pull request body. This commit corrects all three and repairs the code they
described.

THE RETRACTION. a4c144f read `partial_rotary_factor` out of the text config
with a hardcoded default of 0.25, on the stated ground that `Qwen4ExpTextConfig`
subclasses `Qwen3_5MoeTextConfig` and inherits that value, and that taking
`config.rotary_dim` from the shared reader would therefore make us refuse a
config upstream accepts. That is false at the lane pin, and it came from reading
the MODULAR file and assuming its inheritance survives into the flattened class
that actually executes. It does not. At transformers v5.16.0 the generated class
is `class Qwen4ExpTextConfig(PreTrainedConfig)`; `partial_rotary_factor` is not
among its declared fields and the string `0.25` does not occur anywhere in
`configuration_qwen4_exp.py`. Its only two mentions of the name are the
validator's own read, `(self.rope_parameters or {}).get("partial_rotary_factor",
1.0)`, and the `rotary_dim` it feeds. Even in the modular source the bypass is
deliberate: `__post_init__` calls `PreTrainedConfig.__post_init__` DIRECTLY,
skipping the `kwargs.setdefault("partial_rotary_factor", 0.25)  # assign default
for BC` that is the sole source of the value.

So the argument was backwards in both directions. We ACCEPTED a config with no
factor at all, where upstream computes rotary_dim 256, finds it wider than
indexer_head_dim 128, and raises — handing W4 a 64-of-256 slice on a checkpoint
that wants 256. And we REFUSED a config with top-level 1.0 and
`rope_parameters.partial_rotary_factor` 0.25, which upstream accepts: the exact
false refusal the comment claimed to prevent. The shared reader was right all
along. `ParseRopeParameters` takes the top level first and lets the rope dict
override, which is precisely upstream's `setdefault` precedence, and
`IsQwen35Family` correctly does not list `qwen4_exp`. The local read is gone,
`config.rotary_dim` is used, and the test that pinned 0.25 under a "REGRESSION
GUARD" banner is inverted rather than deleted, so the wrong answer cannot come
back quietly.

THE OTHER DEFAULTS, all of the same shape and none visible to a token gate.
The four PLE n-gram fields defaulted to 0 rather than upstream's 3, 8,
20_000_000 and 128, so a config that omits them — legal upstream, every one is a
declared field with a default — was refused, and the two vocab fields, which have
no guard at all, carried a zero-sized n-gram table into W2. They now resolve
unconditionally, as dataclass fields do, which also removes a division by zero.
`output_gate_type` did not fall back to `hidden_act` as `self.output_gate_type or
self.hidden_act` requires, and the local check for it was a constant false: the
shared reader had already refused everything outside the accepted set before that
line could run. `eos_token_id` was not required when PLE is enabled, though it is
a segment boundary in the hashed n-gram construction via
`_shift_right_ignore_eos` and the published GGUF stores it as
`qwen4exp.ple.eos_token_id`. And `ple_embed_dim <= 0` was dropped from upstream's
condition, so -2560 passed, because `-2560 % 16 == 0` in C++.

THE BOUNDARY IS NOW MEASURED, NOT DESCRIBED. This row has no reachable token
gate, so nothing downstream will ever catch one of these by running the model,
and the config layer is the last place they are checkable. The config layer is
gateable even though the model is not: transformers 5.16.0 installs and imports
without torch — it says so itself — and `Qwen4ExpConfig.from_dict` runs
`validate_architecture` in full. W1 is therefore gated by a 39-case two-direction
sweep, each config put through the oracle on one side and through
`LoadHfConfig -> ModelRegistry::Resolve -> factory->parse_config` on the other.
35 agree. 4 differ, and all 4 are ours refusing what upstream accepts, never the
reverse. All 15 upstream rejections are tabulated against their upstream line in
the spec's new `## The refusal boundary`, with the local tighter guards listed
beside them and one of those attributed to the shared reader rather than to this
model.

THREE THINGS NOTHING TESTED, each deletable without a red. The registered
`parse_config` hook was called and then ignored: every assertion observed the
free function instead, so gutting the hook to `(void)config;` left all 151
assertions green. Refusals now enter through the hook alone. The forward refusal
was unreachable rather than merely untested — `ModelAs<Qwen4ExpLoadedModel>` ran
first, and nothing can produce a loaded Qwen4-Exp while the loader refuses, so
every reach became a type-mismatch report. The refusal moved above the downcast,
which is also strictly safer on the #775 axis because no cast happens at all; W5
restores `ModelAs` when there is a real model to open. And the GGUF arm's refusal
had no assertion, so deleting the whole branch would have sent a GGUF load to the
safetensors message and the reader to the wrong wave.

`block_topk()` and `head_dim_per_ngram()` divided by zero on a legally-parsed
config — QSA is optional as a group and an absent PLE left the head count at
zero — which is SIGFPE, a crash rather than a refusal, on the two helpers the
header advertises to W2 and W4. Both refuse by name now. The model's local
`TextOf` resolved only `text_config` where the shared `ResolveTextConfig` also
handles `llm_config` and `thinker_config.text_config`, so one parse answered
"what is the text config" two different ways and produced a silently half-parsed
result; it mirrors the shared resolution now.

Records. The model-matrix row
`MODEL-MM-qwen4-exp-qwen4-exp-for-conditional-generation` said SPEC ONLY, NO
PRODUCT CODE and `READY` / unassigned while `docs/FEATURES.md` already said
REGISTERED, NOT LOADABLE; the two would have contradicted each other on main. It
moves to `ACTIVE`, owner `MODEL-MM-QWEN4-EXP-W1`, and the lifecycle rollup moves
with it (ACTIVE 10 to 11, READY 4 to 3) because `check-model-checklist.py`
requires the state row and the rollup to agree in the same commit. #1981 was in the pull request
body and in every runtime refusal message this code emits, and in neither the
issue index nor the spec, so a reader following the pointer a running binary
gives them found nothing at the other end. Two promoted claims got the pin their
promotion requires: `tensor_type_fallback` is now read at the llama-cpp oracle's
recorded revision 10bf611e (b10451) rather than at master, and the published GGUF
is pinned to revision 8bdc6666 with per-shard sizes and sha256s, which mattered —
the repo moved after this pull request was opened.

Test count 7 cases / 151 assertions to 12 / 294. `test_model_registry` 24/958,
`test_model_loader_gguf` 6/18, `test_registry_downcast_refusal` 6/33,
`check-supported-models`, `check-runner-routing-consistency` and
`check-windows-portability` all OK.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
…ring gates that a deleted guard still satisfied

Mutating each of the 23 refusals in `ParseQwen4ExpParams` one at a time, rather
than as the union the review used, found two of my own cases passing with their
subject removed.

`num_hidden_layers = 0` asserted that the message contains `num_hidden_layers`.
Delete that guard and the next refusal down fires instead — "`layer_types` has 48
entries but `num_hidden_layers` is 0" — which contains the same word, so the case
stayed green. `num_experts = 0` had the identical shape against the
`num_experts_per_tok must be in [1, num_experts]` message. Both now assert the
distinguishing text, and both go red when their guard is deleted.

The general rule this is an instance of: a substring assertion is a weak gate
wherever two refusals share a word, and only a per-guard mutation finds it. A
union mutation cannot: the first one attempted here took SIGFPE on `(i + 1) % 0`
at its second member and never reached the other eleven, so eleven guards would
have been reported as covered on the strength of one crash.

All 23 refusals now red individually, restored byte-for-byte after each, and the
spec records the method beside the sweep it complements.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
`origin/main` moved to 21fe11c (dots3-note W4b-2) while the W1 review repair
was in gate. Merged and re-gated on the merged tree; no conflict, and nothing
that commit touches is on this row's paths.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
…d a fortieth case falsifies it

The refusal-boundary sweep said "35 agree; 4 differ, and all 4 are ours refusing what
upstream accepts — never the reverse". The first half is a measurement. The second
half was an absolute, and a fresh re-review broke it with a case outside the 39.

`rope_parameters` carrying `rope_dim = 64` alongside `partial_rotary_factor = 1.0`:
upstream ignores `rope_dim` entirely and computes `int(head_dim * partial_rotary_factor)`
= 256 unconditionally, so it REFUSES at 256 > indexer_head_dim 128. We prefer
`rope_dim`, following vLLM's `get_rope` semantics in the shared reader, and ACCEPT at
rotary_dim 64 — handing W4 a 64-of-256 slice. Same failure mode and same direction as
the finding that failed this wave's first review, reached through a different key.

Scoped rather than repaired, and the distinction matters. The divergence is narrow:
`rope_dim` has zero occurrences in `modeling_rope_utils.py` at v5.16.0, so no
transformers path writes or reads it and no published checkpoint carries it. It also
does not live in this model — it is the shared reader deliberately mirroring vLLM
instead of transformers on that point, which is the polarity AGENTS.md sets. Repairing
it here would mean changing a shared rope resolution from inside a model row.

What is not acceptable is leaving the absolute standing. In the row whose entire
product is a refusal boundary, a claim about that boundary has to be either true or
bounded, and this one is now bounded to the 39 cases actually measured, with the
fortieth stated in full and owed to whoever reconciles the shared reader.

Tracked by #1981, under #1978.

Gates: `check-agent-record` ok, `check-model-checklist` ok. Records-only; no code
changed, so the focused gate is unmoved at 12 cases / 294 assertions.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants