fix(wan): pair Wan 2.2 A14B GGUF experts by wiring, not just filename - #9505
Conversation
The A14B expert tag is a filename heuristic, but the loader treated it as
authoritative and aborted with "A Wan A14B GGUF expert pair must contain one
high and one low expert" whenever it couldn't tag both files.
Two fixes:
- Broaden the filename heuristic. It only matched `high_noise` / `low_noise`
and variants, so community finetunes tagged with a bare marker (e.g.
`Finetune_q5High.gguf`) resolved to 'none'. A bare `high` / `low` is now
matched as a whole word, on separator *or* camelCase boundaries, so
`q5High` is recognised while `Flowstate`, `slowmotion` and `highres` are
not. Names carrying both markers stay ambiguous ('none').
- Trust the explicit wiring in the loader. Which slot the user picked is a
stronger signal than the filename: an untagged file is taken at its wired
position, or inferred as the complement of a tagged partner. Only a genuine
conflict — both files claiming the same expert — is an error, now with a
message naming the expert. Untagged pairs log a warning.
Existing installs need no re-probe; the loader change covers records already
stored with expert='none'.
lstein
left a comment
There was a problem hiding this comment.
Adversarial review at 0d51582277, plus an independent fresh-context pass that was told to assume the change is broken. Everything below is verified against the code — regex results reproduced by execution, loader combinations traced through invoke(). 79 passed reproduces locally.
Verdict: the pairing truth table is correct in isolation — all 9 (main, low) tag combinations resolve to a defensible assignment, and the and → or swap condition is right. The defects are all at the seams with code the PR didn't touch, and two of them are user-visible regressions.
Blockers
1. False-positive low tags silently remove a model from the main model picker
configs/main.py:1855-1886 → services/api/types.ts:644 / MainModelPicker.tsx:26-38
The frontend filters every Wan GGUF main with expert === 'low' out of the main model dropdown, and lists only those in the low-noise picker — with no variant gate. Verified flips introduced by the new bare-word branch (old → new):
| stem | old | new |
|---|---|---|
Wan2.2_TI2V_5B_LowVRAM_Q4 |
none | low |
wan22_ti2v_5b_low_denoise_q4 |
none | low |
Wan2.2-A14B-LOWRes-Q4 |
none | low |
Wan2.2-I2V-A14B-Low-Light-v3-q5 |
none | low |
Wan2.2-A14B-Low-Step-Lightning-Q4 |
none | low |
The TI2V-5B rows are the bad ones: a single-expert model becomes selectable only in the "Transformer (Low Noise)" slot, where wan_model_loader.py:151-154 explicitly ignores it. The model installs, probes fine, and is unreachable from the linear UI — with no error anywhere.
There is no user-facing correction path either: expert is absent from ModelRecordChanges (model_records_base.py:87-153), so it can't be set at install time (_probe passes config.model_dump(), model_install_default.py:1151-1154; pydantic drops extras) and can't be PATCHed afterwards, and /scan_folder only reports is_installed — there's no re-probe. The only remedy is renaming the file on disk and delete + reinstall, which mints a new model key and breaks saved workflows and metadata that reference the old one.
Cheap fix: variant is already computed at main.py:1932, two lines above the expert probe. Skipping the heuristic (or at least the bare-word branch) for TI2V_5B eliminates the entire single-expert false-positive class for free.
2. A single tag silently inverts the explicit wiring
wan_model_loader.py:192
(low, none) and (none, high) both swap with zero warning, while (none, none) — where the loader is less certain — gets one. Trigger: main slot = Wan2.2-I2V-A14B-Low-Light-v3-q5-part1.gguf (really the high expert, falsely tagged low per finding 1), low slot = its untagged partner. The two experts are inverted, the run completes, and the output is silently wrong. The old code raised a clear error here.
The commit message says "which slot the user picked is a stronger signal than the filename", but for the mixed cases the code lets the tag win. That was defensible when the old and required both tags to agree before swapping; it is weaker now that a single bare word can set the tag. Minimum ask: log a warning naming which file was moved, so a bad tag is diagnosable from the log.
Should fix
3. The unpaired branch still treats the tag as authoritative. wan_model_loader.py:200-201 hard-rejects expert != "high", so Finetune_q5.gguf on its own is a fatal error while the paired path now infers it — the PR's own premise ("'none' is common on community finetunes") applies identically here. It also contradicts readiness.ts:403-415, which promises the unpaired case runs high-only: readiness passes, the user hits Invoke, and the graph dies at the loader. Pinned in place by the ["low", "none"] parametrize at test_wan_model_loader.py:161.
4. The relaxation is mostly unreachable from the linear UI. The low-noise picker lists only expert === 'low' (types.ts:644, modelsByType.ts:129), so 4 of the 5 newly-supported combinations — including the motivating (none, none) — can only be produced from the workflow editor. Combined with finding 3, a pre-PR install of the exact pair described in the PR body still fails end-to-end in the linear UI: the partner can't be selected, and unpaired hard-errors. So "Fix 2 means installs already recorded with expert='none' work without a re-probe" holds only for node-editor/API users; for everyone else it's the heuristic half of the PR plus a delete + reinstall that delivers the fix.
5. The same model wired to both slots is now silently accepted (it used to fail {none, none} != {high, low}). _ExpertSwapper.get() (wan_denoise.py:194-240) keys on the label, not on model identity, so every boundary crossing does full_unload_from_vram() + empty_cache() + a full reload of the same ~9 GB model — and the "only the high-noise expert will run" warning never fires. A key comparison in the loader guards it in one line.
Minor
- The docstring's false-positive guarantee holds only for the lowercase spelling:
Wan2.2-A14B-HighRes-Fix-Q4→high,Wan2_2_A14B_HIGHQuality_Q4→high,Wan2.2-A14B-LOWRes-Q4→low([A-Z]+(?![a-z])backtracks, splittingLOWResintoLOW/Res). The tests assert only the lowercase forms. configs/lora.py:1144-1146says it "mirrors the GGUF transformer probe's heuristic" — no longer true. Deferring the LoRA fix is fine and clearly stated in the PR body, but the comment should say so; the lightx2v..._HIGH_fp16Lightning LoRAs are exactly the casewan_denoise.py:583-589warns about.- Undocumented behaviour change: a name carrying both canonical markers flips
high→none(Wan2.2-A14B-HighNoise-LowNoise-merged). Only affects newly probed models, so stored records are safe, but the commit message presents this as preserved behaviour rather than a change. - Pre-existing, not introduced here:
InitialStateMainModelPicker.tsx:19uses unfiltereduseMainModels(), so alow-tagged GGUF is still offered as a main model there. Broader tagging widens the exposure.
Tests
79 passed reproduced. The 5 new loader rows are load-bearing (all raise under the old code), and ("low", "none") specifically pins the and → or change. 4 of the 9 new heuristic rows pass under the old implementation too — fine as regression guards, but they only guard the all-lowercase spelling, so the actual new false-positive class (CamelCase / hyphenated Low-*, LOW*) is untested. No coverage was deleted. Untested new behaviour: the (none, none) warning, the absence of a warning on the two inversion paths, and same-model-both-slots.
Attacks that found nothing
Truth-table correctness given accurate tags (all 9 combinations right; the chained ==/!= is correct Python and doesn't misfire on ("none", "none")); lowercase substring false positives (slowmotion, flowstate, lowvram, lowrank, lowmem, highres, glow, yellow, below, allow — all none); all 10 Wan GGUF starter models and every Wan filename in tests/ resolve identically old vs new; validation ordering in the pair branch (base/type, format and variant checks all precede the expert logic); metadata-recall paths; regex backtracking (linear, no ReDoS); and a false tag on a TI2V-5B is inert on the backend (main_variant isn't in the A14B tuple at line 200) — the damage there is frontend-only, which is finding 1.
|
Follow-up to my review above, with a scoping suggestion now that I've checked this against #9503. The core idea here is right, and it's better than what I did in #9503. #9503 treats the That's what this PR does. Wiring is explicit user intent; the tag should be advisory. I'd like to build on it rather than around it. The problem is that we're both rewriting the same two hunks. All four files here are also touched by #9503, and Suggestion: drop the heuristic change from this PR and keep only the pairing semantics. Two reasons. It removes the The replacement scores better on the cases you're targeting. I ran both functions over a shared set. Five divergences, and they all go the same way:
The first four are the blocker-1 false positives from my review — the difference is a short disqualifier list ( Your bare-marker cases all still pass under the replacement, including For the record, one case neither of us gets right — One thing to fix even after narrowing. The unpaired branch still hard-rejects an untagged main: if main_variant in (T2V_A14B, I2V_A14B) and primary_expert != "high":
raise ValueError("An unpaired Wan A14B GGUF model must be the high-noise expert.")That's inconsistent with the new rule in the paired path. The same untagged file is now welcome when a second slot is filled and rejected when it isn't. Since "wiring is intent" applies just as well to a single wired transformer, I think this should degrade to the existing "only the high-noise expert will run" warning rather than raising. That would also fix a readiness/backend disagreement on my side: Blockers 2 and 5 from my review (the silent wiring inversion off one tag, and the same model in both slots) still stand as written. Sequencing. If you narrow this, I'm happy for it to land first — it's small and nearly done — and I'll rebase #9503 on top and extend wiring-first to the checkpoint path. No need for you to wait on me. |
The A14B expert tag is a filename heuristic, but the loader treated it as
authoritative and aborted with "A Wan A14B GGUF expert pair must contain one
high and one low expert" whenever it couldn't tag both files.
Trust the explicit wiring instead. Which slot the user picked is a stronger
signal than the filename: an untagged file is taken at its wired position, or
inferred as the complement of a tagged partner. Only a genuine conflict — both
files claiming the same expert — is an error, now with a message naming the
expert. Untagged pairs log a warning.
The same reasoning applies to an unpaired transformer, so the "an unpaired A14B
GGUF must be the high-noise expert" hard rejection is gone too: a single wired
transformer is intent as much as a pair is, and readiness.ts already promises
the low-noise partner is optional. It degrades to the existing single-expert
quality warning, with an extra hint when the file is tagged low.
Two guards for the cases the relaxation opens up:
- Swapping the experts on the strength of a filename tag now logs a warning
naming both files and their tags, so a mistagged file can't invert the pair
invisibly.
- The same model wired to both slots is rejected. It used to fail the
{high, low} pair check; without a guard it would unload and reload the same
multi-GB expert at every boundary crossing.
Existing installs need no re-probe; the loader covers records stored with
expert='none'. The filename heuristic itself is left untouched — invoke-ai#9503
replaces it wholesale with a shared, better-guarded detector.
…nt + guards Round four. Three fresh-context reviewers on split scopes (identification, loaders, frontend). Five blockers, each fix mutation-verified by reverting it and confirming its test fails. 1. The unexpected-key backstop was a regression on the GGUF path. Before this branch, `WanGGUFCheckpointModel` raised only on missing keys and let `strict=False` drop the rest; now any extra key raises. The "all-in-one" packaging convention bundles transformer + VAE + CLIP in one file so ComfyUI's `Load Checkpoint` node can supply all three — Phr00t/WAN2.2-14B- Rapid-AllInOne documents exactly that, and befox/WAN2.2-14B-Rapid-AllInOne- GGUF mirrors ~110 conversions of it. Those installed and generated fine on main and died at the first Invoke here, blaming Animate/S2V/Fun-Camera. 2. Worse, the backstop contradicted this branch's own probe. The docstring on `_has_wan_transformer_block_weights` says main models with merged-in LoRA weights sometimes retain those keys, and uses a positive structural test precisely so they aren't turned away — and then the loader turned them away. Both are fixed by classifying extra keys instead of blanket-refusing them: bundled components and merged-LoRA residue are dropped with a log line, anything else still raises. The generic backstop's purpose is intact — an unenumerated conditioning branch still fails loudly, with a test to prove the allowlist didn't switch it off. 3. `_detect_wan_variant_from_state_dict` mapped the variant from in_channels alone while the transformer width sat unused in the same tuple (and is already read two functions away by the Wan 2.1 marker). The wider Wan family reuses these channel counts at other widths, so a 5120-wide 48-channel derivative was labelled TI2V-5B — which pins expert='none', selects TI2V-5B default settings and hides the low-noise partner picker. A14B is uniquely 5120-wide and TI2V-5B uniquely 3072-wide; require both to agree so an unsupported derivative falls through to unidentified rather than mislabelled. 4. readiness.ts claimed the low-noise A14B partner was optional. It is optional only for expert='high'; 'low' and 'none' are a hard ValueError in the loader, and 'none' is routine because the tag is a filename heuristic with no UI to correct it. Invoke was enabled for a graph that could only fail. The pre-flight now checks it, and the two copies of the Wan block are one shared helper so they cannot drift. Relaxable once the loader takes pairing from the wiring — see invoke-ai#9505, which the comment points at. 5. `isWanSingleFileMainModelConfig` took an all-optional structural type and returned plain boolean. That is a weak type: TypeScript accepts any object sharing one property, so passing `ModelIdentifierField` (base + type, no format) compiled clean and silently returned false, disabling every gate below it. The bare `format === 'gguf_quantized'` it replaced was at least a compile error there. Now takes AnyModelConfigWithExternal and returns a real type predicate — which immediately surfaced a latent mismatch at both readiness call sites. Also: the invoke-blocked string still said "GGUF Wan 2.2 models" while being shown for checkpoints, and WAN_SINGLE_FILE_FORMATS was an export with no external consumer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e frontend invoke-ai#9505 made the loader take the A14B expert pairing from the wiring rather than the filename tag: an unpaired or untagged A14B now runs with a warning instead of raising, and the only hard error left is two files claiming the same expert. Three things on this branch were still enforcing the old contract. - `readiness.ts` blocked Invoke when an unpaired A14B main was tagged anything but 'high'. That would now stop a generation the backend is happy to run — and 'none' is the common case for community checkpoints, which is what this branch exists to support. Rule and its string removed; the VAE/encoder rule stays. - The checkpoint path in `wan_model_loader.py` carried its own copy of the same rejection (the conflict this rebase had to resolve). It now shares invoke-ai#9505's wiring-first logic, with the messages generalised from "GGUF" to "single-file" since checkpoints reach all of them, and the variant-mismatch error extended to name both models and their variants — it was the only pairing error that named neither. - `isSelectableAsPrimaryMainModel` hid every Wan low-noise expert from all three primary-main entry points, justified in its own comment by "the loader refuses it". That justification is gone. Hiding is still the right steer — a low expert belongs in the Transformer (Low Noise) slot and running it alone looks worse — but unconditional hiding would leave someone whose only Wan file is a low expert with their model missing from every picker and no way to reach it. Replaced with `selectPrimaryMainModelOptions`, which hides a low expert only while a partner of the same variant is installed, so the list degrades instead of dead-ending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#9503 1. TI2V-5B LoRAs were regressed to inert. Widening the LoRA probe to the shared `_detect_wan_expert` made it read the bare high/low token convention, but it did not carry over the structural pin `_resolve_wan_expert` applies on the main-model side. A 5B LoRA whose stem happens to contain a standalone `low` ("Wan2.2_TI2V_5B_low_light_v2") was tagged, routed by `_resolve_target("auto")` into `loras_low_noise` alone, and then never read — the single-transformer 5B denoise path consumes only the primary list. The generation succeeded with the LoRA silently absent. Variant is now resolved first and only A14B gets a tag. 2. Readiness blocked Invoke on a slot the loader ignores. `pushWanReasons` judged the low-noise partner for every Wan main, but the loader reads it only for a single-file A14B: it logs "ignored for the single-expert TI2V-5B variant" and skips the pairing block, and the Diffusers branch never looks at the slot. For a TI2V-5B main this was unavoidable rather than occasional — the partner picker can only offer A14Bs, so every possible pick failed the variant check, and the error text does not name the combobox to clear. 3. Pinned the GGUF side of the unexpected-key backstop. The gate is applied to the pre-existing GGUF loader, which previously checked `missing_keys` only. Its diffusers-layout behaviour was already covered; the native-layout path, where an unmapped key is possible at all, was not. 4. An untagged expert pair was unwireable from the linear UI. The partner picker required `expert === 'low'`, so a pair probing to none/none appeared twice in the main picker and never in the low-noise one. Since invoke-ai#9505 the wiring is authoritative and the tag advisory, so the picker now takes anything single-file that is not tagged `high` and not TI2V-5B. `expert` is absent from `ModelRecordChanges` and records are never re-probed, so this was permanent for anything already installed. Every fix is mutation-verified: each new test fails against the unfixed code.
* feat(model manager): support single-file Wan 2.2 checkpoints Closes #9463. Wan 2.2 main models could only be imported as a Diffusers folder or a GGUF file. The community ships fine-tunes as a single `.safetensors` per transformer (CivitAI, ComfyUI-oriented HF repos), and no config class matched those, so the model manager reported "unidentified model". - Add `Main_Checkpoint_Wan_Config`: probes for Wan transformer keys in either the native upstream or the diffusers key layout, tolerating the ComfyUI `model.diffusion_model.` prefix, and records variant + MoE expert. Wan 2.1 is rejected on architecture (CLIP image embedder, 1536-dim transformer, VACE control blocks) rather than by demanding the filename say "wan2.2" — community fine-tunes routinely drop the version from the name, and rejecting those was the reported bug. The same architectural check is now also applied to the GGUF probe, which previously only had the filename gate. - Add `WanCheckpointModel` loader: strips the ComfyUI prefix, dequantizes ComfyUI `fp8_scaled` weights, converts native keys to the diffusers layout, and derives the `WanTransformer3DModel` config from the weights themselves. The shape inference is factored out of the GGUF loader so both share it. - Broaden the A14B expert filename heuristic to bare `high` / `low` tokens (matched at token boundaries so "slow"/"flow"/"highway" can't trip it). - Teach `wan_model_loader` that single-file means GGUF *or* checkpoint, for both the main model and the low-noise expert slot; the two experts may mix formats. Its pairing errors now name the offending models and explain that the expert comes from the filename. - Move the shared ComfyUI single-file helpers out of the Qwen Image loader into `comfyui_state_dict_utils` rather than adding a third copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(model manager): tighten the Wan checkpoint probe Addresses three defects found by an adversarial review of the previous commit. 1. The broadened expert filename heuristic fired on any bare `high`/`low` token, so `...-4step-low-cfg-merge`, `..._lowVRAM`, `...HighQuality` and friends were labelled as MoE experts. That is worse than not guessing: 'none' raises a clear pairing error, but a mislabelled expert satisfies the {high, low} pair check, gets swapped into the wrong slot, and silently runs the same expert for both denoise phases. A marker now has to include `noise` — adjacent (`high noise`, `noise_high`) or fused (`highnoise`). Matching is per token rather than by substring, so `slow_noise` and `flownoise` no longer match either; the original substring heuristic got those two wrong as well. 2. A Wan LoRA can carry a full replacement `patch_embedding` (I2V adapters change in_channels 16->36) plus the text projection, which is everything `_has_wan_keys` looks for. Because Main outranks LoRA in `matches_sort_key`, such a file was pulled out of the LoRA pickers into the main-model dropdown, where it could only fail to load. The probe now also requires an undecorated `blocks.0.<attn>.<q>.weight`, which a LoRA never has. Deliberately a positive structural test rather than a "reject anything with lora_A keys" exclusion — main models with merged-in LoRA weights sometimes retain those keys. 3. `.ckpt`/`.pt`/`.pth`/`.bin` files were claimed by the probe but the loader reads safetensors unconditionally, so they installed cleanly and then died with an opaque header error at generation time. Restricted to `.safetensors`. Also reworded the VACE rejection: Wan 2.2 VACE variants exist, so calling it a Wan 2.1 marker was wrong. The refusal stands — this loader builds a plain WanTransformer3DModel with no control branch — but the reason now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(model manager): read the bare HIGH/LOW expert convention again Round-two adversarial review found the previous commit over-corrected. Requiring `noise` in the expert marker turned off detection for the convention used by the most widely mirrored single-file Wan 2.2 releases — the Kijai fp8 catalogue names every file `Wan2_2-T2V-A14B-HIGH_fp8_e4m3fn_scaled_KJ.safetensors` — leaving them unpairable, with no UI to set the expert after install. Scored against 108 real filenames pulled from the Kijai, Comfy-Org and QuantStack repo trees: pre-branch main missed=25 mislabelled=0 bare-token (1st attempt) missed= 0 mislabelled=0 noise-required (2nd) missed=25 mislabelled=0 this commit missed= 0 mislabelled=0 So a bare `high`/`low` token counts again, but only when no neighbouring token marks it as an adjective about something else (`lowVRAM`, `low-cfg`, `highRes`). That disqualifier list is deliberately short: real releases put markers next to plenty of unrelated words (`..._LOW_lightning_edition`), and a false negative is what this whole change exists to avoid. An explicit `...noise` marker anywhere in the name still outranks a bare token found earlier. The review's suggested remedy — read the expert from safetensors metadata, as the GGUF probe reads `general.name` — does not work: sampling the Kijai catalogue, 0 of 8 files carry any `__metadata__` at all. Two mislabel cases from the review are now handled structurally instead: TI2V-5B pins `expert='none'` because it is single-transformer, so `...5B-lowVRAM` can no longer leak into the low-noise expert picker. Also in this commit: - Wan Animate is now refused with an accurate reason. It is 36-channel with undecorated block weights and no VACE blocks, so nothing else turned it away, and `strict=False` would silently drop its 127 face-adapter/motion-encoder keys. Checked before the Wan 2.1 markers, since Animate carries `img_emb` too and was otherwise reported as a Wan 2.1 I2V model. - Two frontend format gates that the first commit missed: `MainModelPicker` still hid only GGUF low-noise experts from the primary dropdown, and the readiness pre-flight skipped the VAE/encoder check for checkpoint mains, so Invoke was enabled for a graph that could only fail in the loader. Both now go through one shared `isWanSingleFileMainModelConfig` guard so they cannot drift again. - `Main_GGUF_Wan_Config` gained the LoRA-vs-transformer check for symmetry. - The Wan LoRA probe now shares `_detect_wan_expert` instead of carrying a stale copy of the old heuristic under a comment claiming the two matched. Side effect worth a maintainer's eye: an expert-specific LoRA named with a bare HIGH/LOW is now tagged rather than left untagged, so it is applied to that expert alone instead of to both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(model manager): correct the expert heuristic and refuse more Wan variants Round three. Two independent fresh-context reviewers, one on the identification layer and one on everything else. Findings below; the corpus figures are theirs, built from ~4,700 unique HuggingFace filenames, not from this branch's tests. **My "no metadata" claim was wrong, and the previous commit message repeated it.** Every Wan 2.2 safetensors in Kijai/WanVideo_comfy_fp8_scaled carries `__metadata__["model_type"]` naming the expert. My check missed it because it range-fetched a fixed 128 KB window while those headers are ~146 KB, and silently treated a truncated read as "no metadata". Comfy-Org's files genuinely have none, so the filename heuristic is still required — but metadata is now consulted as a fallback when the name yields nothing. Filename first, deliberately: renaming is the only lever a user has to correct a mis-detection, and letting an embedded `model_type` outrank it would take that away. Expert heuristic, three real defects: - The fused-marker test was an equality check, so `WAN2.2t2vLOWNOISEFP8` (a real 14 GB transformer) tokenized to `lownoisefp8` and matched nothing. It is now anchored with startswith/endswith — which is still not a bare substring test, so `slownoise` stays unmatched. - First-bare-marker-wins was backwards. Real names put descriptors first and the expert tag last, so `Extream Low Angle HIGH` resolved to 'low' on a HIGH file. Last marker wins now. - Files serving *both* experts (`... I2V HIGH+LOW ...`, seven real examples, all physically 2x the size of their single-expert siblings) were tagged with one of them. A surviving high/low conflict now yields 'none' — which for a LoRA means "apply to both", the correct answer. Also `angle` joins the disqualifier list, and disqualifiers now only count when they *follow* the marker: "low angle" is a camera angle, but "Angle HIGH" is the high-noise expert of a camera-angle LoRA. Two more unsupported Wan variants refused, both verified against real headers: - **S2V** (`audio_injector`, `casual_audio_encoder`, `cond_encoder`, `frame_packer` — 165 of 1260 keys) was importing as plain T2V-A14B. - **Fun-Control-Camera** (`control_adapter`) was importing as I2V-A14B. This one was the dangerous case: it ships as a correctly tagged high/low pair, so the expert-pairing check passed and it would have rendered as an ordinary I2V while silently ignoring every camera input. And a generic backstop, since enumerating families by name will always lag: both Wan loaders now refuse any state dict with keys the transformer has nowhere to put, instead of letting `strict=False` discard them. Every supported release checked yields zero unexpected keys, so there is no benign case being blocked. Frontend: `modelSelected.ts` still auto-filled the VAE/encoder slots for GGUF mains only, so selecting a checkpoint main populated nothing and immediately blocked Invoke — the readiness check demanded components that nothing offered to fill. It now shares `isWanSingleFileMainModelConfig` with readiness, and the two carry comments pointing at each other. Adds the first Wan cases to readiness.test.ts, which had none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(model manager): stop refusing all-in-one Wan files; tighten variant + guards Round four. Three fresh-context reviewers on split scopes (identification, loaders, frontend). Five blockers, each fix mutation-verified by reverting it and confirming its test fails. 1. The unexpected-key backstop was a regression on the GGUF path. Before this branch, `WanGGUFCheckpointModel` raised only on missing keys and let `strict=False` drop the rest; now any extra key raises. The "all-in-one" packaging convention bundles transformer + VAE + CLIP in one file so ComfyUI's `Load Checkpoint` node can supply all three — Phr00t/WAN2.2-14B- Rapid-AllInOne documents exactly that, and befox/WAN2.2-14B-Rapid-AllInOne- GGUF mirrors ~110 conversions of it. Those installed and generated fine on main and died at the first Invoke here, blaming Animate/S2V/Fun-Camera. 2. Worse, the backstop contradicted this branch's own probe. The docstring on `_has_wan_transformer_block_weights` says main models with merged-in LoRA weights sometimes retain those keys, and uses a positive structural test precisely so they aren't turned away — and then the loader turned them away. Both are fixed by classifying extra keys instead of blanket-refusing them: bundled components and merged-LoRA residue are dropped with a log line, anything else still raises. The generic backstop's purpose is intact — an unenumerated conditioning branch still fails loudly, with a test to prove the allowlist didn't switch it off. 3. `_detect_wan_variant_from_state_dict` mapped the variant from in_channels alone while the transformer width sat unused in the same tuple (and is already read two functions away by the Wan 2.1 marker). The wider Wan family reuses these channel counts at other widths, so a 5120-wide 48-channel derivative was labelled TI2V-5B — which pins expert='none', selects TI2V-5B default settings and hides the low-noise partner picker. A14B is uniquely 5120-wide and TI2V-5B uniquely 3072-wide; require both to agree so an unsupported derivative falls through to unidentified rather than mislabelled. 4. readiness.ts claimed the low-noise A14B partner was optional. It is optional only for expert='high'; 'low' and 'none' are a hard ValueError in the loader, and 'none' is routine because the tag is a filename heuristic with no UI to correct it. Invoke was enabled for a graph that could only fail. The pre-flight now checks it, and the two copies of the Wan block are one shared helper so they cannot drift. Relaxable once the loader takes pairing from the wiring — see #9505, which the comment points at. 5. `isWanSingleFileMainModelConfig` took an all-optional structural type and returned plain boolean. That is a weak type: TypeScript accepts any object sharing one property, so passing `ModelIdentifierField` (base + type, no format) compiled clean and silently returned false, disabling every gate below it. The bare `format === 'gguf_quantized'` it replaced was at least a compile error there. Now takes AnyModelConfigWithExternal and returns a real type predicate — which immediately surfaced a latent mismatch at both readiness call sites. Also: the invoke-blocked string still said "GGUF Wan 2.2 models" while being shown for checkpoints, and WAN_SINGLE_FILE_FORMATS was an export with no external consumer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(model manager): read a both-experts filename as neither, in both spellings `low_high_noise` names one file holding both A14B experts — moriqqe/Mabrle_wan2.2_low_high_noise and Chromatraining/v1_FGO_nitocris_morgan_wan2.2_t2v_low_high_noise_14B_fp16 are real releases — but the heuristic returned on the first marker adjacent to a `noise` token and reported it as the high-noise expert. The bare spelling of the same meaning (`...HIGH-LOW`) already returned 'none', so the two disagreed. A `noise` token now qualifies the whole run of adjacent markers rather than the one it happens to touch, and the explicit and bare tiers are reconciled the same way: one distinct marker wins, both means the file serves both, so 'none'. Two more from the same pass: - A disqualifier following the run now outranks an adjacent `noise`, which was previously short-circuited: `noise_LOW_VRAM` is describing VRAM. - The docstring advertised "the last surviving bare marker wins". That rule was unreachable — the return was guarded on the marker set being a singleton, so `bare[-1]` was always `bare[0]` — and the test offered as cover for it passes identically with either, because the `angle` disqualifier is what actually resolves that name. Documented what the code does instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): keep the Wan component slots in step with the selected variant The Wan auto-fill could build a graph the loader is guaranteed to reject, and then readiness would pass it. Three separate ways: - It fell back to *any* installed Wan Diffusers model as the Component Source when no variant match existed — twelve lines below a comment explaining that a mismatched source "would silently load the wrong VAE and produce broken images". `_validate_component_source_vae` raises on exactly that. - The standalone VAE was first-match out of an unsorted entity adapter, on the stated grounds that "the standalone VAE / encoder configs don't carry variant info". The VAE configs do: `latent_channels` is 16 or 48, and `_validate_standalone_vae` compares against it. - Every write was gated on the slot being empty, and nothing anywhere clears these slots — paramsSlice carries all four across a base change and modelsLoaded has no Wan handler. So the variant matching only ever ran on a fresh slot. Selecting A14B then TI2V-5B left the 16-channel VAE wired, and because the loader prefers a standalone VAE over a Diffusers main's own, that stale slot also broke the next self-contained Diffusers model the user picked. Slots are now re-validated rather than only filled, the VAE is chosen by latent_channels, and there is no mismatched fallback — if nothing compatible is installed the slot is cleared, which reads as "pick one" instead of looking handled. Resolving the wired identifiers against the installed models also means a slot pointing at a deleted model is treated as empty. Extracted as a pure `getWanComponentUpdates`, following krea2ComponentSync, so it can be tested without driving the listener. Each of the three defects above is covered by a test that fails when the old behaviour is restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): route every primary-main selection through one offerable-models filter MainModelPicker hid Wan low-noise experts from the main dropdown, but it was the only one of three places a primary main gets chosen. The other two were unfiltered: - InitialStateMainModelPicker (the launchpad "Select your model" picker) used the raw useMainModels() list. - modelsLoaded's handleMainModels auto-selects allMainModels[0] whenever the current selection becomes unavailable, filtered only by isNonRefinerMainModelConfig. A user whose only Wan single-file is the low-noise expert had it selected for them, silently. Either way the loader then refuses it — "An unpaired Wan A14B model must be the high-noise expert" — so the model was reachable but unusable. All three now share `isSelectableAsPrimaryMainModel`, which names the concept so a fourth entry point has something to reach for. Also pins the dequantization behaviour that `test_scale_bookkeeping_never_reaches_the_model` was quietly relying on. Its fixture pairs a bf16 weight with a scale, so the loader multiplies it, and the test asserted only that the scale *keys* were gone — it constructed a 4x-scaled weight and said nothing about it. Now asserted, with the reason there is no fp8 gate written down in `_dequantize_comfyui_fp8`: not every checkpoint using these keys stores fp8 weights, and skipping the multiply for those is as wrong as applying a stale scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(model manager): cover the Wan inferences and gates that mutations survived Mutation-tested the branch's own tests by breaking each thing they claim to cover. Four survived untouched: - `out_channels` was inferred from `proj_out.weight` with a five-line comment explaining why it must not be assumed equal to `in_channels` — and every fixture was 16-in/16-out, so `out_channels = in_channels` kept the suite green. Now covered by an asymmetric 36-in/16-out model, the I2V-A14B shape the comment is about. - `Main_GGUF_Wan_Config` gained the branch-family refusal and the LoRA-vs-transformer guard for parity with the checkpoint probe, and neither had a test: deleting both left all 526 config tests passing. The checkpoint side has tests for all five behaviours, so the omission was asymmetry rather than intent. - `layer_count_fallback` was unreachable. `num_layers == 0` means no key starts with `blocks.`, and `require("blocks.0.ffn.net.0.proj.weight")` has already raised by then; setting the fallback to 999 changed nothing. It was also the only use of the `variant` argument, so dropping it makes `_build_wan_transformer_config` derive the config purely from the weights, which is what its docstring says it is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): adopt #9505's wiring-first expert pairing across the frontend #9505 made the loader take the A14B expert pairing from the wiring rather than the filename tag: an unpaired or untagged A14B now runs with a warning instead of raising, and the only hard error left is two files claiming the same expert. Three things on this branch were still enforcing the old contract. - `readiness.ts` blocked Invoke when an unpaired A14B main was tagged anything but 'high'. That would now stop a generation the backend is happy to run — and 'none' is the common case for community checkpoints, which is what this branch exists to support. Rule and its string removed; the VAE/encoder rule stays. - The checkpoint path in `wan_model_loader.py` carried its own copy of the same rejection (the conflict this rebase had to resolve). It now shares #9505's wiring-first logic, with the messages generalised from "GGUF" to "single-file" since checkpoints reach all of them, and the variant-mismatch error extended to name both models and their variants — it was the only pairing error that named neither. - `isSelectableAsPrimaryMainModel` hid every Wan low-noise expert from all three primary-main entry points, justified in its own comment by "the loader refuses it". That justification is gone. Hiding is still the right steer — a low expert belongs in the Transformer (Low Noise) slot and running it alone looks worse — but unconditional hiding would leave someone whose only Wan file is a low expert with their model missing from every picker and no way to reach it. Replaced with `selectPrimaryMainModelOptions`, which hides a low expert only while a partner of the same variant is installed, so the list degrades instead of dead-ending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wan): repair the defects a fresh-context review found in the last two rounds Two independent reviewers on split scopes. Six real defects, three of them regressions introduced by the very commits meant to fix things. Regressions: - `_detect_wan_expert` let a disqualifier consume the whole run of adjacent markers instead of only the one it qualifies, so `...-A14B-HIGH_lowVRAM_fp8_scaled_KJ` went from 'high' to 'none'. That is not a safe default: for a main it disables both pair checks, and for a LoRA it applies a single-expert distill to both experts. - `handleMainModels` tested the *selected* model against the visibility-filtered list, conflating "hidden" with "uninstalled". Installing the high-noise partner therefore read as "your model vanished" and swapped the user onto an unrelated model — firing the whole base-changed cascade (LoRAs disabled, VAE cleared, bbox resized) for what was only a file install. Availability is now tested against what exists; only the auto-pick uses the filtered list. - The `wan_model_loader.py` conflict resolution duplicated the unpaired-A14B warning block, logging it twice. The test used `any(...)` and could not see it. Gaps: - The merged-LoRA allowlist covered kohya and PEFT but not LoKr, LoHa, DoRA or OFT, which `LoRA_LyCORIS_Wan_Config` does accept — so the probe took a merged file the loader then refused, blaming Animate/S2V/Fun-Camera. It now tracks the same families, and matches path segments rather than substrings so a future branch named `..._lora_adapter` still trips the backstop instead of being swallowed. - Benign extras were classified but never removed, so an all-in-one checkpoint's bundled VAE and UMT5-XXL were dequantized, upcast to bf16 and reserved in the RAM cache before `load_state_dict` discarded them — several GB for the exact family this branch added support for. They are now dropped straight after the prefix strip, ahead of all three costs. - Readiness checked only that the Wan slots were *populated*, while the Advanced comboboxes offer every Wan VAE and Diffusers main with no variant filter and nothing re-runs the auto-fill on a hand-picked slot. Four loader errors were reachable with Invoke enabled. It now shares the compatibility predicates with the auto-fill rather than restating them, and covers the duplicate-transformer case that became reachable once low experts could appear in the main picker. Also: a self-contained Diffusers main no longer gets a standalone VAE force-wired over its own (the loader ranks the wired one higher, and clearing it just refilled on the next selection); a low-noise partner left over from another variant is cleared; the encoder slot is resolved against installed models like the other two; and the variant-mismatch error prints a value rather than a raw enum. Every fix is mutation-verified. Two tests that asserted the old behaviour were rewritten, and the tautological `hasattr(model, "vae")` assertions were replaced with ones that read the dict actually handed to `load_state_dict`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wan): address Pfannkuchensack's four review findings on #9503 1. TI2V-5B LoRAs were regressed to inert. Widening the LoRA probe to the shared `_detect_wan_expert` made it read the bare high/low token convention, but it did not carry over the structural pin `_resolve_wan_expert` applies on the main-model side. A 5B LoRA whose stem happens to contain a standalone `low` ("Wan2.2_TI2V_5B_low_light_v2") was tagged, routed by `_resolve_target("auto")` into `loras_low_noise` alone, and then never read — the single-transformer 5B denoise path consumes only the primary list. The generation succeeded with the LoRA silently absent. Variant is now resolved first and only A14B gets a tag. 2. Readiness blocked Invoke on a slot the loader ignores. `pushWanReasons` judged the low-noise partner for every Wan main, but the loader reads it only for a single-file A14B: it logs "ignored for the single-expert TI2V-5B variant" and skips the pairing block, and the Diffusers branch never looks at the slot. For a TI2V-5B main this was unavoidable rather than occasional — the partner picker can only offer A14Bs, so every possible pick failed the variant check, and the error text does not name the combobox to clear. 3. Pinned the GGUF side of the unexpected-key backstop. The gate is applied to the pre-existing GGUF loader, which previously checked `missing_keys` only. Its diffusers-layout behaviour was already covered; the native-layout path, where an unmapped key is possible at all, was not. 4. An untagged expert pair was unwireable from the linear UI. The partner picker required `expert === 'low'`, so a pair probing to none/none appeared twice in the main picker and never in the low-noise one. Since #9505 the wiring is authoritative and the tag advisory, so the picker now takes anything single-file that is not tagged `high` and not TI2V-5B. `expert` is absent from `ModelRecordChanges` and records are never re-probed, so this was permanent for anything already installed. Every fix is mutation-verified: each new test fails against the unfixed code. * fix(wan): close the gaps a fresh-context review found in the last commit Two adversarial reviewers on split scopes. The frontend gate and picker survived an enumeration of every 1-3 model library over a 17-model universe (0 false positives, 0 models invisible in both pickers). The backend found a real hole. - The TI2V-5B expert pin only fires when the variant was detected, and `detect_wan_lora_variant` reads the inner dim off an `attn1.to_q` LoRA pair. A LoKr/LoHa adapter, or one patching only `to_k`/`to_v`, comes back `variant=None`, keeps its `low` tag, and then sails past `_assert_lora_variant_matches_main`, which returns early on an unknown variant. Records written before the pin are in the same position. Confirmed by probing: a LoKr 5B LoRA named `Wan2.2_TI2V_5B_low_light_v2` still yields `expert='low'`. `_warn_if_low_routing_is_inert` becomes `_correct_inert_low_routing`: where the main is TI2V-5B and the routing came out low-only, apply the LoRA to the single transformer instead of warning that it will do nothing. The main's variant is the one signal that cannot be wrong. - The GGUF native-layout test pinned a scenario the probe forecloses. `_find_unsupported_wan_variant_marker` rejects `vace_blocks.` with `NotAMatchError` at identification, so a VACE GGUF never reaches a loader. Switched to an un-enumerated branch, which is what the backstop is actually for. The bundle test's stated mechanism was also wrong: benign extras are dropped before the rename table runs, so the two passes never interact. - `selectPrimaryMainModelOptions` had no test that could catch it being keyed on the wide partner predicate — with two untagged models the wide test classes both as low experts, so neither has a partner and the mistake hides behind itself. Added the `[high, untagged]` case, which fails against that mutation. - Corrected a false claim in the picker comment: models *can* be re-probed, via the Reidentify endpoints. Re-probing an untagged file just returns `none` again, which is the actual reason the tag cannot be corrected. Mutation-verified: reverting the re-route fails 3 tests, reverting the primary filter to the wide predicate fails the new guard. * fix(wan): stop a tagged TI2V-5B falling through the gap between both pickers Two low-severity findings from Pfannkuchensack's second round. - `isWanLowNoisePartnerOption` excludes every TI2V-5B from the partner picker, but `selectPrimaryMainModelOptions` still hid any single-file main tagged `low` once `hasPartner` found another single-file Wan main of the same variant — and it matched `ti2v_5b` against `ti2v_5b` like anything else. A TI2V-5B carrying `expert='low'` satisfied both exclusions at once and was offered nowhere in the linear UI. Reproduced: with two TI2V-5B single-file configs, one tagged `low`, the primary picker returned only the untagged one and the partner picker returned nothing. Such a record is reachable, which is where my previous round's "0 models invisible in both pickers" claim went wrong: that enumeration assumed the TI2V pin made the combination impossible, but the pin is new here. `main`'s `_detect_wan_gguf_expert` applies the tag without consulting the variant, so a 5B named `...-low_noise.gguf` installed before this branch still carries `expert='low'` today. Both predicates now share one `isWanTi2v5bConfig` test, so they cannot drift apart again — the failure mode here was precisely the two disagreeing. - `_correct_inert_low_routing` re-points an explicit `target="low"` as well as an inferred one, which is intended, but the contract shown to the user still described the old behaviour. The `target` field description (rendered in the node editor) and the routing-table comment now say that `low` is applied to the single transformer on TI2V-5B. schema.ts and openapi.json regenerated for the description change; both diffs are that one line. Mutation-verified: dropping the 5B exclusion from the hide test fails the new `never leaves a TI2V-5B invisible in both pickers`. * docs(wan): correct the two remaining node-editor strings about 5B LoRA routing Follow-up from a fresh-context review of the previous commit, which fixed one user-facing string and left two others contradicting it. Invocation class docstrings are rendered in the workflow editor — they reach `openapi.json` as the schema `description`, which `parseSchema.ts` puts on `template.description` and `InvocationNodeInfoIcon` displays. So hovering the "Apply LoRA - Wan 2.2" node's info icon said a low-only routing "logs a warning" and is inert, while the Target field one row down said it is applied to the transformer. Both classes now describe the correction, including `WanLoRACollectionLoader`, which is the node the linear UI actually emits. Also narrowed the routing-table comment: it claimed the correction "overrides all four" targets, but `_correct_inert_low_routing` returns early unless the routing came out low-only, so `both` and `high` are never touched. Text only — no behaviour change. schema.ts and openapi.json regenerated. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
fix — Wan 2.2 A14B GGUF expert pairs failed to load whenever the expert
couldn't be read from the filename:
...even with a correct high GGUF and low GGUF wired to the two transformer
slots.
Why: the
expertfield onMain_GGUF_Wan_Configis a filename heuristic,but the loader treated it as authoritative and hard-required
{"high", "low"}.The heuristic only recognised the canonical
high_noise/low_noiseform(plus hyphenated/concatenated variants). Community finetunes frequently tag the
expert with a bare marker instead — e.g.
DasiwaWAN22I2V14BTastysinV8_q5High.gguf— so both files probed to
expert='none'and every such pair was rejected.How:
_detect_wan_gguf_expert()now also accepts a barehigh/lowmarker.It's matched as a whole word on separator or camelCase boundaries
(
[A-Z]+(?![a-z])|[A-Z][a-z]*|[a-z]+|\d+), so_q5Highresolves whileFlowstate,slowmotionandhighresdon't produce false positives. Aname carrying both markers stays ambiguous (
'none').WanModelLoaderInvocationtreats the wiring as the stronger signal, sincethe tag is only ever a guess. New pairing rules:
Fix 2 means installs already recorded with
expert='none'work without are-probe. Fix 1 means a fresh install of such a file gets tagged correctly.
Out of scope, noted for a follow-up: the Wan LoRA probe in
configs/lora.pyduplicates the old heuristic, so a bare-marker LoRA stillfalls back to expert-agnostic (applied to both experts). That's a silent
quality issue rather than a crash, and LoRA filenames carry a higher
false-positive risk, so it's left alone here.
Related Issues / Discussions
QA Instructions
Requires a Wan 2.2 A14B GGUF expert pair whose filenames tag the expert with a
bare marker rather than
high_noise/low_noise.base=wan, type=main, format=gguf_quantized, variant=*_a14b.Transformer (Low Noise), plus a standalone Wan VAE and UMT5-XXL encoder.
wan_model_loaderwith the error above;now it runs.
high/low, andthe "neither GGUF filename identifies its expert" warning disappears.
errors, now stating which one.
Automated:
79 passed. Added coverage for the bare-marker filenames, the false-positive
candidates (
slowmotion/Flowstate/highres), the both-markers case, andall five untagged wiring combinations. The previously-expected
(high, none) -> rejectcase is now(low, low) -> reject, matching the newconflict-only rule.
Merge Plan
Nothing special — backend only, no DB schema or redux changes. Existing model
records keep working unchanged.
Checklist
What's Newcopy (if doing a release after this PR)