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
Open
feat(MODEL-MM-QWEN4-EXP): W1 — resolve and validate the qwen4_exp config, register the arch, and refuse by name#1986localai-bot wants to merge 6 commits into
localai-bot wants to merge 6 commits into
Conversation
…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]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Qwen/Qwen3.8-Flash-NextdeclaresQwen4ExpForConditionalGeneration/model_type: qwen4_exp, an architecture vLLM implements at no revision. This makesit 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_factorhad to be read locally with "upstream's inherited default of0.25", because
Qwen4ExpTextConfigsubclassesQwen3_5MoeTextConfig, and that usingthe 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_factoroccurs exactly twice in the file, both inside the guard, readfrom
rope_parameterswith default 1.0; and0.25does not appear at all. Theerror 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_parametersat 1.0, is refused upstream atrotary_dim = 256 > indexer_head_dim = 128; we accepted it and handed W4 a 64-of-256rotary slice, on a row that has no reachable token gate to catch it. The repair uses
config.rotary_dimfrom the shared reader, and the "REGRESSION GUARD" case thatpinned 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 boundaryin the spec now maps each of the 15 upstreamvalidate_architecturerejections to the local line implementing it, with two-direction probe evidence: 39
configs written once and fed to both a running
transformers5.16.0 and to ourproduction 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_dimkey 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_confighook to(void)config;left all 151 assertions green, because the test called the hook andthen 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_idrequired when PLE isenabled — which the published GGUF stores as a first-class key at 248044, and which
feeds the n-gram segment boundary;
output_gate_typefalling back tohidden_act;ple_embed_dim <= 0, absent so-2560 % 16 == 0was accepted; and the four PLEfields 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()andhead_dim_per_ngram()divided by zero on a legally-parsed configwith 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.
Qwen4ExpLayerKindhas nokFullAttentionenumerator at all, so the checkpoint's spelling for the twelve layersthat 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_intervalare equal, so if either path is wrong theother says so. And the forward refuses before opening the type-erased handle: while
load_weightsrefuses unconditionally nothing can produce a loaded Qwen4-Exp, so adowncast placed first would turn every reach into a type-mismatch report and leave the
advertised refusal dead. The test pins both directions.
ple_layer_idsis stored already converted to 0-based. It is one-indexed in thecheckpoint 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 doesqwen4exp.ple.layers = [1]in the published GGUF, which is a third independentconfirmation.
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-EXPowns the wiring and#1978 tracks it.
Tracked by #1981, under #1978.
Gates:
test_qwen4_exp_scaffold12 cases / 294 assertions;test_model_registry24/958;
test_model_loader_gguf6/18;check-supported-modelsok at 42architectures;
check-runner-routing-consistencyclassifies qwen4_exp REFUSE;check-windows-portabilityok.agent-preflight.shreports 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]