Skip to content

fix(wan): pair Wan 2.2 A14B GGUF experts by wiring, not just filename - #9505

Merged
lstein merged 3 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/wan-gguf-expert-detection
Aug 15, 2026
Merged

fix(wan): pair Wan 2.2 A14B GGUF experts by wiring, not just filename#9505
lstein merged 3 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/wan-gguf-expert-detection

Conversation

@Pfannkuchensack

Copy link
Copy Markdown
Member

Summary

fix — Wan 2.2 A14B GGUF expert pairs failed to load whenever the expert
couldn't be read from the filename:

ValueError: A Wan A14B GGUF expert pair must contain one high and one low expert.

...even with a correct high GGUF and low GGUF wired to the two transformer
slots.

Why: the expert field on Main_GGUF_Wan_Config is a filename heuristic,
but the loader treated it as authoritative and hard-required {"high", "low"}.
The heuristic only recognised the canonical high_noise / low_noise form
(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:

  1. _detect_wan_gguf_expert() now also accepts a bare high / low marker.
    It's matched as a whole word on separator or camelCase boundaries
    ([A-Z]+(?![a-z])|[A-Z][a-z]*|[a-z]+|\d+), so _q5High resolves while
    Flowstate, slowmotion and highres don't produce false positives. A
    name carrying both markers stays ambiguous ('none').

  2. WanModelLoaderInvocation treats the wiring as the stronger signal, since
    the tag is only ever a guess. New pairing rules:

    main slot low-noise slot result
    tagged, complementary tagged, complementary as before, incl. auto-swap if reversed
    tagged untagged untagged file inferred as the complement
    untagged tagged untagged file inferred as the complement
    untagged untagged wiring is taken as-is (main = high), logs a warning
    same expert same expert error, now naming which expert both claim

Fix 2 means installs already recorded with expert='none' work without a
re-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.py duplicates the old heuristic, so a bare-marker LoRA still
falls 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.

  1. Install both GGUFs. In the model manager they probe as
    base=wan, type=main, format=gguf_quantized, variant=*_a14b.
  2. Build a Wan 2.2 graph: high GGUF → Transformer, low GGUF →
    Transformer (Low Noise), plus a standalone Wan VAE and UMT5-XXL encoder.
  3. Invoke. Before this PR it fails at wan_model_loader with the error above;
    now it runs.
  4. Freshly re-scanning/re-installing the two files tags them high / low, and
    the "neither GGUF filename identifies its expert" warning disappears.
  5. Negative check: wiring two GGUFs that both probe as the same expert still
    errors, now stating which one.

Automated:

uv run --extra cuda --extra test pytest \
  tests/app/invocations/test_wan_model_loader.py \
  tests/backend/model_manager/configs/test_wan_gguf_config.py --no-cov

79 passed. Added coverage for the bare-marker filenames, the false-positive
candidates (slowmotion / Flowstate / highres), the both-markers case, and
all five untagged wiring combinations. The previously-expected
(high, none) -> reject case is now (low, low) -> reject, matching the new
conflict-only rule.

Merge Plan

Nothing special — backend only, no DB schema or redux changes. Existing model
records keep working unchanged.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration — n/a
  • Documentation added / updated (if applicable) — n/a
  • Updated What's New copy (if doing a release after this PR)

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'.
@github-actions github-actions Bot added python PRs that change python files invocations PRs that change invocations backend PRs that change backend files python-tests PRs that change python tests labels Aug 14, 2026

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 andor 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-1886services/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-Q4high, Wan2_2_A14B_HIGHQuality_Q4high, Wan2.2-A14B-LOWRes-Q4low ([A-Z]+(?![a-z]) backtracks, splitting LOWRes into LOW/Res). The tests assert only the lowercase forms.
  • configs/lora.py:1144-1146 says 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_fp16 Lightning LoRAs are exactly the case wan_denoise.py:583-589 warns about.
  • Undocumented behaviour change: a name carrying both canonical markers flips highnone (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:19 uses unfiltered useMainModels(), so a low-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 andor 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.

@lstein

lstein commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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 expert tag as authoritative: it requires a {high, low} pair and treats 'none' as fatal. That makes the filename heuristic safety-critical, which is exactly the wrong place to put weight — the tag can't be set or corrected through any API, so a bad guess is only fixable by renaming the file and re-importing. My own PR body admits this and says "the durable fix is to make it non-load-bearing."

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 git merge-tree gives content conflicts in both of the ones that matter — wan_model_loader.py and configs/main.py.

Suggestion: drop the heuristic change from this PR and keep only the pairing semantics.

Two reasons.

It removes the configs/main.py conflict entirely. #9503 replaces _detect_wan_gguf_expert wholesale with a shared _detect_wan_expert used by the checkpoint probe, the GGUF probe, and the Wan LoRA probe. If this PR doesn't touch that function, the two stop overlapping there and only one small hunk in wan_model_loader.py needs merging.

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:

filename this PR #9503 correct
Torstens_Wan2.2-14B_i2v_Low-VRAM_WF_V2 (real) low none none
Wan2.2-A14B-LOWRes-Q4 low none none
wan2.2_a14b_4step_low_cfg_merge low none none
Wan2.2_TI2V_5B_LowVRAM_Q4 low none none
Extream Low Angle HIGH - Wan2.2 none high high

The first four are the blocker-1 false positives from my review — the difference is a short disqualifier list (vram, cfg, res, steps, angle, …) that only counts when it follows the marker, plus a structural pin that forces expert='none' for TI2V-5B since it's single-transformer. Your camelCase word-splitting idea is good and #9503 does the same thing; it just also needs those two guards.

Your bare-marker cases all still pass under the replacement, including DasiwaWAN22I2V14BTastysinV8_q5High and the slowmotion / Flowstate / highres substring traps.

For the record, one case neither of us gets right — Mabrle_wan2.2_low_high_noise and ..._t2v_low_high_noise_14B are real HF repos serving both experts in one file, and both implementations return 'high' instead of 'none'. I'll fix that in #9503.

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: readiness.ts tells the user the low-noise partner is optional, which is only true today when expert == 'high'.

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.
@Pfannkuchensack
Pfannkuchensack requested a review from lstein August 15, 2026 03:50

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good.

@lstein
lstein merged commit 1e3e8aa into invoke-ai:main Aug 15, 2026
20 of 29 checks passed
@Pfannkuchensack
Pfannkuchensack deleted the fix/wan-gguf-expert-detection branch August 15, 2026 15:01
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 15, 2026
…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>
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 15, 2026
…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>
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 15, 2026
…#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.
lstein added a commit that referenced this pull request Aug 16, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend PRs that change backend files invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants