Skip to content

Support single-file Wan 2.2 checkpoints - #9503

Merged
lstein merged 16 commits into
invoke-ai:mainfrom
lstein:lstein/fix/wan-single-file-checkpoints
Aug 16, 2026
Merged

Support single-file Wan 2.2 checkpoints#9503
lstein merged 16 commits into
invoke-ai:mainfrom
lstein:lstein/fix/wan-single-file-checkpoints

Conversation

@lstein

@lstein lstein commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

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 — that's what CivitAI and ComfyUI-oriented Hugging Face repos distribute — and no config class matched that shape, so the model manager reported "unidentified model" for all of them.

Reproduced before fixing: synthetic checkpoints matching the three models named in the issue all fell through to Unknown_Config. This also explains the reporter's observation that the Wan VAEs and the umt5 encoder imported fine — those already have checkpoint configs.

Opening as a draft: the filename heuristic below went through three adversarial review rounds and I'd like a second pair of eyes on the judgement calls before this is considered ready.

What's in it

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. Records the variant (T2V-A14B / I2V-A14B / TI2V-5B, from patch_embedding in_channels) and the MoE expert.

WanCheckpointModel loader — strips the ComfyUI prefix, dequantizes ComfyUI fp8_scaled weights, converts native keys to the diffusers layout, and derives the WanTransformer3DModel constructor kwargs from the weights themselves rather than from a table of known repos. The shape inference is factored out of the GGUF loader so both share one implementation; the extracted helper is behaviourally identical to the code it replaced.

wan_model_loader now treats "single-file" as GGUF or checkpoint, for both the main model and the low-noise expert slot. The two experts may mix formats — both loaders produce a plain WanTransformer3DModel, and wan_denoise already computes the sidecar-LoRA decision per expert. Pairing errors now name the offending models and explain that the expert is detected from the filename.

Shared ComfyUI single-file helpers moved out of the Qwen Image loader into comfyui_state_dict_utils rather than adding a third copy. qwen_image re-exports them, so its tests are untouched.

Two things worth a maintainer's judgement

1. The A14B expert heuristic. The expert can't be read off the weights, so it comes from the filename, with declared metadata as a fallback. Two conventions are both common and both have to work:

  • high_noise / low_noise and its spellings — Comfy-Org's repackaged repos.
  • A bare HIGH / LOW token — the Kijai fp8 catalogue and many CivitAI fine-tunes.

Scored against ~4,700 unique filenames enumerated from HuggingFace, the rules land at 0 missed and 0 mislabelled on real A14B transformers. The tradeoffs encoded:

  • A surviving high and low conflict yields none rather than a guess — files serving both experts are real, and for a LoRA none correctly means "apply to both".
  • The last marker wins, not the first: real names put descriptors first and the tag last.
  • A short disqualifier list (vram, cfg, steps, angle, …) suppresses bare markers used as adjectives, and only counts when it follows the marker — "low angle" is a camera angle, "Angle HIGH" is the high-noise expert of a camera-angle LoRA.

This is the least certain part of the change and it rests on conventions that can shift. The durable fix is to make it non-load-bearing: expert currently can't be set at all (ModelRecordChanges has no such field and build_common_fields whitelists it out), so a mis-detection means renaming the file and reimporting. I'd like to file exposing it as a follow-up.

2. The Wan LoRA probe now shares that heuristic instead of carrying a stale copy under a comment claiming the two matched. Side effect: an expert-specific LoRA named with a bare HIGH/LOW is now tagged rather than left untagged, so it applies to that expert alone instead of to both. I believe that's a fix — mis-routing Lightning distills to both experts degrades output — but it changes existing behaviour and is adjacent to the reported issue. Happy to drop that commit if you'd rather keep this narrow.

What is deliberately refused

Wan 2.1 is rejected on architecture rather than on the filename — community fine-tunes routinely drop the version from the name, and rejecting those was the reported bug:

marker meaning
img_emb.* / condition_embedder.image_embedder.* Wan 2.1 I2V — CLIP-vision conditioning. Wan 2.2 I2V-A14B has none; it concatenates VAE latents.
patch_embedding inner dim 1536 Wan 2.1 T2V-1.3B. Wan 2.2 is 5120 or 3072.

The one irreducibly ambiguous case is Wan 2.1 T2V-14B, shape-identical to a Wan 2.2 A14B expert; it's caught by an explicit wan2.1 in the name and otherwise imports as A14B. The same architectural check now also applies to the GGUF probe, which previously had only a filename gate and would accept a misnamed Wan 2.1 I2V.

Wan variants built on extra conditioning branches are refused with a reason naming the family — Animate (face_adapter, motion_encoder), S2V (audio_injector, cond_encoder, …), Fun-Control / Fun-Camera (control_adapter), VACE (vace_blocks). Each would otherwise build a correctly-shaped transformer, report zero missing keys, and generate with its entire conditioning branch silently absent. Fun-Camera was the worst: it ships as a properly tagged high/low pair, so the expert-pairing check passed too.

Because enumerating families by name will always lag, both loaders now also refuse any state dict carrying keys the transformer has nowhere to put, rather than letting strict=False discard them. Every supported release checked yields zero unexpected keys, so nothing benign is blocked.

diffusers does ship WanVACETransformer3DModel and WanAnimateTransformer3DModel, so VACE and Animate are tractable follow-ups; S2V has no transformer class at all.

Verification

  • Full backend suite green (4,817 passed). Frontend: 1,819 tests, tsc, eslint, prettier, knip, dpdm clean.
  • openapi.json and schema.ts regenerated per the typegen/openapi CI procedure and confirmed byte-identical to the generator output.
  • Loader correctness checked by replaying the real key sets of 10 genuine releases (Comfy-Org t2v/i2v high+low fp16 and fp8_scaled, ti2v_5B, Kijai fp8/bf16) through the full pipeline against a meta-device WanTransformer3DModel: missing=0, unexpected=0, every parameter bf16, correct in_channels 16/36/48 and num_layers 40/40/30.
  • _build_wan_transformer_config diffed against WanTransformer3DModel.__init__ and against the upstream transformer/config.json for all three Wan-AI Diffusers repos: every kwarg the family varies is derived from shapes; everything left at a default is identical across variants.
  • Four rounds of fresh-context adversarial review (the last two with independent reviewers on split scopes — identification, loaders, frontend), each round's findings fixed with regression tests. Every fix was reverted in turn to confirm its test fails against the broken code, and round four additionally mutation-tested the existing tests: four claims turned out to be uncovered (out_channels inference, both new GGUF probe gates, and an unreachable layer-count fallback) and are covered now.

Upgrade note

Existing installs are not re-probed. Anyone who hit #9463 has a persisted Unknown_Config row for their Wan checkpoint, and nothing in this branch repairs it — there is no re-probe endpoint and /scan_folder only reports is_installed. Those models have to be deleted and re-imported to pick up the new config class.

The same applies to expert: it is recorded at probe time and cannot be set through any API (ModelRecordChanges has no such field), so a model installed before this branch keeps whatever tag it was given. Round four found and fixed two heuristic defects, but only new imports benefit.

Not included

No node version bump. Nothing stored in a workflow changed — only the live template's model-picker filter widened — so bumping would flag every saved Wan workflow as needing an update for no benefit.

@github-actions github-actions Bot added python PRs that change python files invocations PRs that change invocations backend PRs that change backend files frontend PRs that change frontend files python-tests PRs that change python tests labels Aug 14, 2026
Pfannkuchensack added a commit to Pfannkuchensack/InvokeAI that referenced this pull request Aug 14, 2026
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.
lstein pushed a commit that referenced this pull request Aug 15, 2026
…#9505)

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

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'.

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

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 — #9503
replaces it wholesale with a shared, better-guarded detector.
lstein and others added 11 commits August 15, 2026 10:34
Closes invoke-ai#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>
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>
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>
…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>
…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>
…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>
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>
…els 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>
…s 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>
…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>
… 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>
@lstein
lstein force-pushed the lstein/fix/wan-single-file-checkpoints branch from 57ae57b to e73f233 Compare August 15, 2026 15:54
@lstein
lstein marked this pull request as ready for review August 15, 2026 16:03
@Pfannkuchensack

Copy link
Copy Markdown
Member

Findings

Medium: invokeai/backend/model_manager/configs/lora.py:1154-1157 regresses TI2V-5B LoRAs to inert.

The LoRA probe now calls the shared _detect_wan_expert, which reads a bare low/high token, but it does not carry over the structural pin the main-model path got. _resolve_wan_expert (invokeai/backend/model_manager/configs/main.py:2158-2160) forces expert='none' for TI2V-5B because the model is single-transformer; lora.py has no equivalent, even though it derives the variant five lines later at invokeai/backend/model_manager/configs/lora.py:1161-1162.

Chain: probe tags a 5B LoRA expert='low' -> invokeai/app/invocations/wan_lora_loader.py:247 calls _resolve_target("auto", "low") -> returns (to_primary=False, to_low_noise=True) -> the LoRA lands only on loras_low_noise -> the single-transformer TI2V-5B denoise path consumes only the primary list -> the LoRA has no effect. _warn_if_low_routing_is_inert (invokeai/app/invocations/wan_lora_loader.py:69-84) logs it, but the linear UI hard-codes target="auto" and surfaces nothing, so the user sees a successful generation with the LoRA silently absent.

Trigger, executed against the PR branch: Wan2.2_TI2V_5B_low_light_v2 -> low, wan2_2_5B_HIGH_detail_v1 -> high. Under the pre-PR heuristic ("low_noise" in name etc.) both returned None, i.e. applied to both, i.e. working. The disqualifier list does not help here -- light and detail are not in _WAN_EXPERT_DISQUALIFIERS, and the PR's own docstring cites ..._high_lighting_fp16 as a name it deliberately keeps tagging.

To expose this issue, add a test that probes a TI2V-5B LoRA whose stem contains a bare low token and asserts expert is None.

Medium: invokeai/frontend/web/src/features/queue/store/readiness.ts:324-329 blocks Invoke on a slot the loader ignores.

pushWanReasons runs for every model?.base === 'wan' and checks the low-noise partner with no gate on the main's variant or format. The loader only reads that slot when the main is single-file and the variant is not TI2V-5B: invokeai/app/invocations/wan_model_loader.py:159-162 logs "ignored for the single-expert TI2V-5B variant" and skips the whole pairing block, and the Diffusers branch at invokeai/app/invocations/wan_model_loader.py:148-153 never reads it at all.

Chain: select a TI2V-5B main -> open Advanced -> pick anything in "Transformer (Low Noise)". invokeai/frontend/web/src/features/nodes/util/graph/generation/buildWanGraph.ts:67 forwards it unconditionally, so the graph is valid and the backend would run it with a warning. The pre-flight instead pushes incompatibleWanLowNoiseExpert and Invoke stays disabled. For a TI2V-5B main this is unavoidable rather than occasional: the picker gates on expert === 'low' (invokeai/frontend/web/src/services/api/types.ts:664) and _resolve_wan_expert pins every TI2V-5B file to 'none', so every offerable partner is an A14B and every pick is a variant mismatch. The auto-repoint in invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/wanComponentSync.ts:324-326 does not rescue this -- it only fires on main-model selection, not on partner selection. Recovery requires clearing a combobox in Advanced that the error text does not name.

The Wan pre-flight tests exercise this only with an A14B main (invokeai/frontend/web/src/features/queue/store/readiness.test.ts:897-908), so the case is uncovered.

To expose this issue, add a test that wires a low-noise partner while the main is TI2V-5B, and one with a Diffusers main, and asserts no reason is pushed.

Low: invokeai/backend/model_manager/load/model_loaders/wan.py:459 turns a previously silent path into a hard failure for GGUF models, untested.

_raise_for_incompatible_keys (invokeai/backend/model_manager/load/model_loaders/wan.py:517-548) now raises on unexpected_keys, and it is applied to the pre-existing GGUF loader as well as the new one. Before this PR the GGUF loader checked missing_keys only, so any key the rename table at invokeai/backend/model_manager/load/model_loaders/wan.py:113-142 does not produce was silently discarded by load_state_dict(strict=False); now it is a RuntimeError at first generation on a model that installs fine. _drop_benign_extra_keys covers the two classes the PR anticipated (all-in-one bundles, merged-LoRA residue), but the safety of the change for GGUF rests entirely on the rename table being exhaustive for every community conversion, and every test of the new gate goes through the safetensors loader. The blast radius is existing, working installs.

To expose this issue, add a test that runs the GGUF loader over a native-layout state dict carrying one key the rename table does not map, and pin the intended outcome (refuse, or extend _drop_benign_extra_keys).

Low: invokeai/frontend/web/src/services/api/types.ts:664 leaves an untagged expert pair unwireable from the linear UI.

isWanSingleFileLowNoiseMainModelConfig requires expert === 'low', so a pair that probes to none/none appears twice in the main picker (selectPrimaryMainModelOptions at invokeai/frontend/web/src/services/api/types.ts:686 only hides models tagged low) and never in the low-noise picker. The user cannot construct the A14B pair outside the workflow editor. This is the same class of gap the readiness comment at invokeai/frontend/web/src/features/queue/store/readiness.ts:283-287 calls "the common case this whole branch exists to support". The improved heuristic shrinks the set, and pre-PR records stored with expert='none' are not re-probed, so they stay in it permanently -- expert is absent from ModelRecordChanges, so the only correction is rename plus delete-and-reinstall, which mints a new model key.

To expose this issue, add a test that asserts the low-noise picker predicate accepts an untagged single-file A14B main when the primary picker is showing its partner.

lstein added 2 commits August 15, 2026 16:59
…#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.
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.
@lstein

lstein commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — all four confirmed, all four fixed, and the first one led somewhere worse than reported. Fixes are in aaf3d63c90 and 98079579ea. Each of the four tests you asked for is included.

1. TI2V-5B LoRAs regressed to inert — confirmed, and the fix needed to go further than the probe

Reproduced exactly as you described. Probing against the branch:

Wan2.2_TI2V_5B_low_light_v2  -> expert='low'
wan2_2_5B_HIGH_detail_v1     -> expert='high'

This is my regression: widening the LoRA probe to the shared _detect_wan_expert picked up the bare-token convention without carrying over the structural pin _resolve_wan_expert already applies on the main-model side. Your routing trace is right — _resolve_target("auto", "low")(False, True)loras_low_noise only → the single-transformer denoise path never reads it.

I first fixed it where you pointed, by resolving the variant before the expert tag and skipping the tag for Wan5B. Then an adversarial pass showed that alone does not close it, so thank you for the pointer — the residual is nastier than the original:

detect_wan_lora_variant reads the inner dim off an attn1.to_q LoRA pair. A LoKr or LoHa adapter, or one that patches only to_k/to_v, returns variant=None, so the pin never fires and the low tag survives. And _assert_lora_variant_matches_main returns early when either variant is unknown, so nothing downstream catches it either. Verified against the branch with the probe-side fix already applied:

lokr_5b     Wan2.2_TI2V_5B_low_light_v2 -> variant=None  expert='low'
no_to_q_5b  Wan2.2_TI2V_5B_low_light_v2 -> variant=None  expert='low'

Records written before the pin existed are in the same position, and main's narrower low_noise heuristic produced some of those.

So the fix now lives in both places. _warn_if_low_routing_is_inert becomes _correct_inert_low_routing: where the main is TI2V-5B and the routing came out low-only, the LoRA is applied to the single transformer rather than warned about. The main model's own variant is the one signal in this chain that cannot be wrong, and on a single-transformer model there is no ambiguity about where the LoRA should go. Warning while doing nothing was the weaker half of the old behaviour.

Tests: test_ti2v5b_lora_is_never_tagged_with_an_expert (the one you asked for), plus test_reroutes_a_low_tagged_lora_whose_variant_could_not_be_detected for the variant=None residual.

One small correction to the report: only the low half is inert. A high tag routes to the primary list, which the 5B path does read — harmless, though still meaningless on a single-transformer model, so the pin suppresses both.

2. Readiness blocks a slot the loader ignores — confirmed

Right on every point, including that this is unavoidable rather than occasional for a TI2V-5B main: the combobox renders for every Wan main (ParamWanModelSelects says as much in its own comment), the picker can only offer A14Bs, so every possible pick failed the variant check — and the error text never names the slot to clear.

pushWanReasons now gates the low-noise block on isWanSingleFileMainModelConfig(model) && !isWanTi2v5b(model), mirroring the loader. Both tests you asked for are in readiness.test.ts.

A fresh-context reviewer then enumerated every 1–3 model library over a 17-model universe (2 single-file formats × 3 variants × 3 expert tags, plus 3 Diffusers variants) against a transcription of the loader's pairing block: 0 false positives and 0 false negatives introduced. It did surface one pre-existing hole that neither of us named — the loader's primary_expert == low_expert != "none" raise has no readiness mirror, so two same-variant mains both tagged low are each offered in both pickers and can be wired together with Invoke enabled. I've left that alone as out of scope here; happy to file it.

3. GGUF unexpected-key gate — behaviour change confirmed, coverage claim not quite

The behaviour change is real and deliberate: main raised on missing_keys only, and the gate is now applied to the pre-existing GGUF loader too.

Two corrections on the supporting evidence, though. The gate is not untested through the GGUF path — test_gguf_loader_still_refuses_an_unknown_conditioning_branch and test_gguf_loader_drops_all_in_one_bundled_components_before_loading both drive WanGGUFCheckpointModel._load_from_singlefile with the extras in the real state dict, so the loader's own classification runs. The genuine gap was narrower: the native-layout path, where an unmapped key is possible at all. That is now pinned, both directions.

I'd also put the blast radius lower than "existing, working installs". An unmapped key that should have become a real parameter also leaves that parameter unfilled, which the pre-existing missing_keys check already caught and raised on. What the new gate adds is refusal of a whole extra branch riding along — and generating with conditioning silently absent is the outcome the backstop exists to prevent.

Worth noting for whoever reads this next: the family names one reaches for here are all foreclosed upstream. _find_unsupported_wan_variant_marker rejects face_adapter., audio_injector., control_adapter. and vace_blocks. with NotAMatchError at identification, so those models never reach a loader at all. My first attempt at this test used VACE and was therefore pinning an unreachable scenario; it now uses an un-enumerated branch, which is what the backstop is actually for.

4. Untagged expert pair unwireable from the linear UI — confirmed

Fixed. isWanSingleFileLowNoiseMainModelConfig (the narrow expert === 'low' test) is now un-exported and used only by selectPrimaryMainModelOptions; a new isWanLowNoisePartnerOption feeds the picker and accepts any single-file Wan main that is not tagged high and not TI2V-5B. Keeping the two predicates separate is the point — widening the one the primary picker uses would hide every untagged A14B from the main list as soon as a same-variant high expert is installed.

Your test suggestion is in, and the review caught that it wasn't enough on its own: with two untagged models the wide predicate classes both as low experts, so neither has a partner and neither is hidden — the mistake hides behind itself. There's now also a [high, untagged] case, which does fail against that mutation.

One correction here too, on my side rather than yours. My code comment claimed installed records "are never re-probed"; that's wrong — reidentify_model and bulk_reidentify_models re-run the probe in place and keep the model key, and both are exposed in the UI. The comment is fixed. The conclusion is unchanged, just for the right reason: re-probing an untagged filename returns none again, so the tag still can't be corrected without a rename.


Every fix is mutation-verified — reverting each one fails the test that covers it. Reverting the re-route fails 3 tests; reverting the primary filter to the wide predicate fails the new guard. Full suites green locally (4711 backend, 1859 frontend), ruff 0.11.2 and all five frontend lints clean.

@Pfannkuchensack

Copy link
Copy Markdown
Member

Findings

Low: invokeai/frontend/web/src/services/api/types.ts:684-692 and :718-727 can leave a model invisible in both pickers.

isWanLowNoisePartnerOption excludes every variant === 'ti2v_5b' from the partner picker, but selectPrimaryMainModelOptions still hides any single-file main tagged low as soon as hasPartner finds another single-file Wan main of the same variant -- and hasPartner matches ti2v_5b against ti2v_5b like any other variant. A TI2V-5B record carrying expert='low' therefore satisfies both exclusions at once.

Confirmed by execution against the branch, two TI2V-5B single-file configs, one tagged low and one none:

PRIMARY: ["ti2v-plain"]   PARTNER: []

The tagged file is offered nowhere in the linear UI. Trigger: a Wan GGUF probed before this branch whose stem contained low_noise / lownoise -- the pre-branch _detect_wan_gguf_expert had no TI2V pin, only _resolve_wan_expert added one -- plus any second TI2V-5B single-file model installed. It is self-healing through Reidentify, since re-probing now returns none for a TI2V-5B, and the filename it needs is unusual for a single-expert model, which is why this is Low rather than Medium. It is however exactly the "0 models invisible in both pickers" claim in the second commit message, and the enumeration behind that claim evidently did not include a TI2V-5B carrying an expert tag.

The narrow fix is one condition: hasPartner (or isLowExpert) should decline to hide a ti2v_5b, since a single-transformer model has no partner slot to be redirected to.

To expose this issue, add a test that passes a TI2V-5B config with expert: 'low' alongside a second TI2V-5B model and asserts it survives selectPrimaryMainModelOptions.

Low: invokeai/app/invocations/wan_lora_loader.py:154-156 now describes behaviour the node no longer has.

_correct_inert_low_routing is applied after _resolve_target, so it rewrites an explicit target="low" to primary-only whenever the main is TI2V-5B -- not just the auto routing the fix was aimed at. The branch's own test drives exactly that path (WanLoRALoaderInvocation(..., target="low") against a TI2V-5B main, asserting the LoRA lands on loras and loras_low_noise is empty), so this is intended rather than accidental.

The behaviour is defensible: a single-transformer model has one list, and applying with a warning beats a silent no-op. The contract shown to the user has not followed it. The field description still reads "'both'/'high'/'low' override it", and the routing table comment at invokeai/app/invocations/wan_lora_loader.py:20-28 still says low means "append only to the low-noise list". On a TI2V-5B main neither is true any more, and the field description is rendered in the node editor.

No test needed; amend the description and the comment to say that low is re-pointed at the single transformer on TI2V-5B.

lstein added 2 commits August 16, 2026 17:31
…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`.
…A 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.
@lstein

lstein commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Both confirmed, both fixed — 4aadc791df and 627b998a6c. The second one turned out to have two more instances than reported.

1. A TI2V-5B tagged low is invisible in both pickers

Reproduced exactly, two TI2V-5B single-file configs with one tagged low:

PRIMARY: ["ti2v-plain"]   PARTNER: []

And your closing point is the important one, so let me be direct about it: you're right, and the "0 models invisible in both pickers" claim was wrong. The enumeration behind it did generate ti2v_5b × expert:'low' — it discarded the case as unreachable, on the grounds that _resolve_wan_expert pins TI2V-5B to 'none' before consulting the filename. That pin is new on this branch. I checked main:

expert = explicit_expert or _detect_wan_gguf_expert(mod.path.stem)

No variant gate anywhere on that path, and _detect_wan_gguf_expert matches low_noise / low-noise / lownoise. So a TI2V-5B GGUF with low_noise in its stem, installed before this branch, carries expert='low' in the database right now. The state is reachable through exactly the route you described, and a reachability argument is what the enumeration got wrong — worth remembering for the next one of these.

On the fix: I went slightly wider than the one condition you suggested. Both predicates now go through a single shared isWanTi2v5bConfig, rather than each carrying its own exclusion, because the two disagreeing is what produced the hole — one excluded 5B, the other didn't, and the model fell through the gap. Sharing the test makes that specific failure mode structural rather than something to remember.

Your test is in (never leaves a TI2V-5B invisible in both pickers) and is mutation-verified: dropping the exclusion from the hide test fails it and nothing else, so no pre-existing test covered this.

I also re-ran the enumeration properly this time, over a 33-config universe that includes the awkward records — no variant key, variant: null, no expert key, external/API configs, and a non-Wan impostor carrying variant:'ti2v_5b', expert:'low' — across all 6017 libraries of size ≤ 3. Zero configs invisible in both pickers. There's also a structural argument now, which I trust more than the enumeration: being hidden from the primary picker requires single-file ∧ ¬5B ∧ expert==='low', and every config satisfying that passes isWanLowNoisePartnerOption by construction. The related worry — that a 5B can now count as a partner and hide a non-5B low — is impossible: that would require the hidden model's variant to be ti2v_5b, which the hide test excludes outright.

2. The node contract no longer matches the behaviour

Confirmed, and thank you for pointing at the field description, because it led to two more strings you didn't name.

Invocation class docstrings are also rendered in the workflow editor: they reach openapi.json as the schema description, parseSchema.ts puts that on template.description, and InvocationNodeInfoIcon displays it. So hovering the node's info icon said a low-only routing "logs a warning" and is inert, while the Target field one row below said it is applied to the transformer — two contradictory claims one row apart. WanLoRACollectionLoader's docstring had the same omission, and that is the node the linear UI actually emits (addWanLoRAs builds wan_lora_collection_loader), so it was the more visible of the two.

All three strings now describe the correction: both class docstrings and the target field description. schema.ts and openapi.json are regenerated for the docstring and description changes; the diffs are only those strings.

One more correction, to my own first attempt at this rather than to your report. I initially wrote that the 5B correction "overrides all four" target values. It doesn't — _correct_inert_low_routing returns early unless the routing came out low-only, so both and high never reach it. Verified: target="both" against a 5B main still appends to both lists. The comment now says which targets it can affect.

Residual, disclosed rather than fixed

There is a third copy of the ti2v_5b test in wanComponentSync.ts. I left it alone — importing a feature module into services/api/types.ts would invert the layering — so the consolidation covers the two predicates that were actually disagreeing. All three copies are individually pinned by tests: mutating the literal in any one of them fails between 1 and 9 tests, so drift is detectable rather than silent.

Still open from last round, if you have a view: the loader's primary_expert == low_expert != "none" raise has no readiness mirror, so two same-variant mains both tagged low can be wired together with Invoke enabled. Pre-existing, unrelated to this branch. Happy to file it or fold it in, whichever you prefer.


Locally: 1861 frontend tests, full backend suite, ruff 0.11.2 and all five frontend lints clean, and schema.ts regeneration verified idempotent the way typegen-checks does it. CI is running on 627b998a6c.

@Pfannkuchensack Pfannkuchensack left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good work.

@lstein

lstein commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Good work.

Thanks so much!

@lstein
lstein enabled auto-merge (squash) August 16, 2026 22:05
@lstein
lstein merged commit 36a8de5 into invoke-ai:main Aug 16, 2026
17 checks passed
@lstein
lstein deleted the lstein/fix/wan-single-file-checkpoints branch August 16, 2026 22:09
Pfannkuchensack added a commit to Pfannkuchensack/InvokeAI that referenced this pull request Aug 17, 2026
Brings in the three commits this branch was behind: Intel XPU device support
(invoke-ai#9401), single-file Wan 2.2 checkpoints (invoke-ai#9503) and the opt-in Wan low-VRAM
mode (invoke-ai#9462).

Only uv.lock conflicted. This branch lifts fastapi to 0.141.1, which adds
annotated-doc and typing-inspection as dependencies, while invoke-ai#9401 rewrote every
marker string in the file to carry the new xpu extra. Regenerated with 'uv lock'
instead of hand-merging those marker chains; the result has both (fastapi
0.141.1, 776 xpu markers).

openapi.json and schema.ts merged without conflict, and were checked rather than
assumed: both are supersets of main (143 -> 145 paths, 870 -> 877 schemas,
187 -> 189 operations), so nothing from main was dropped.
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 frontend PRs that change frontend 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.

[bug]: Wan 2.2 models fail to import in Model Manager

2 participants