Skip to content

Feat: pid followup - #9474

Open
Pfannkuchensack wants to merge 13 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/pid-followup
Open

Feat: pid followup#9474
Pfannkuchensack wants to merge 13 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/pid-followup

Conversation

@Pfannkuchensack

@Pfannkuchensack Pfannkuchensack commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #9281 (PiD decode). Backend, tests and docs only — no frontend behaviour changes (the schema.ts diff is a regenerated docstring).

PiD decoder identification checked less than the loader demands and inferred the rest from file paths, so a checkpoint InvokeAI cannot load could be installed and only fail later. This PR closes that gap and the follow-on problem it exposed: a file that identification does reject was still being registered, because a rejection had no way to say "this is my kind of model, and it is broken".

1. Hold a checkpoint to the whole PidNet contract

Only the 71-key LQ projection was ever checked, so a checkpoint carrying every lq_proj weight and none of the 385 backbone weights was registered and then refused by load_pid_decoder. A subset check is not a milder version of the same guarantee: loaders run under skip_torch_weight_init(), so a weight the checkpoint does not supply is uninitialised memory rather than a default — such a file decodes to garbage or NaNs instead of failing.

required_pid_net_shapes() now derives the full contract — 456 keys and their shapes — from a meta-device PidNet, the same probe trick the LQ check already used, applied to the real network instead of one submodule. Missing keys, unexpected keys and wrong shapes are all fatal, because all three are fatal in load_pid_decoder. A stricter installer cannot reject a file that would have loaded.

Probing the real network also retires _LQ_PROBE_DIM, _LQ_NUM_RES_BLOCKS_DEFAULT and the hand-copied num_outputs derivation, along with the test that kept them in sync with the vendored module.

2. Stop guessing the backbone from paths

  • A malformed discriminator was accepted when a filename supplied a backbone. The architecture version, the backbone and the kernel are all read off lq_proj.latent_proj.0.weight, and each read answered None when it was not a 4D conv — so one malformed tensor made all three abstain at once and the file fell through to name-only matching. That weight is now validated first and the three reads only run when it is there. Its absence is a truncation, which the contract check diagnoses far better than a guess about the architecture.
  • Component-wise name matching. Backbone detection concatenated the install source, the parent directory and the filename into one string and substring-matched it with a fixed backbone precedence, so /flux/model_sd3.pth matched flux first and was registered as FLUX although the file says sd3. Components are now matched most-specific-first, and a component naming two different backbones decides nothing rather than being resolved by precedence.
  • A local-path source is not evidence. The model manager sets source to the file's own path when there is no remote one, so trusting it means matching arbitrary ancestor directories of wherever the user keeps their models. Nothing is lost: install_path identifies a local file before it moves it.
  • Requiring the full contract makes the latent channel count always readable, which retires the name-only backbone path entirely. The name can now only break the FLUX.1 / SD3 / Qwen-Image tie, never pick a backbone outright, and an explicit base override beats it.
  • Orthogonal fix found on the way: from_model_on_disk popped variant out of the override dict the factory builds once and shares across every candidate class, so the first PiD class to run consumed it and a later one that actually matched fell back to name inference.

3. Make a rejection stick (InvalidMatchError)

Every config class signals "not mine" with NotAMatchError. The factory collects those and, with allow_unknown_models (default: true), answers no-match-at-all by registering Unknown_Config. That is right for a file nobody recognises and wrong for one that was recognised and found broken: a truncated PiD checkpoint was stored as a normal record and failed only when something tried to load it.

InvalidMatchError is the new signal for "recognised, and unusable". It is deliberately not a NotAMatchError subclass, since the factory catches that one per candidate class and would swallow it. When no class matched and at least one raised it, ModelConfigFactory.from_model_on_disk returns no config regardless of allow_unknown, and ModelInstallService._probe reports the specific reason instead of a misleading "could not identify model".

Every rejection that would rule out all five backbone configs raises it — unsupported lq_hidden_dim, an unmet contract, a latent channel count no backbone uses, a malformed discriminator. Those checks moved out of _validate_base, which is left answering only "not this backbone" and stays entirely NotAMatchError.

Two hazards this class of change surfaced, both fixed:

  • The architecture check must run before the contract check. A v1.5 checkpoint is intact, just built to a shape build_pid_net cannot construct; judged against the legacy contract it would be reported as a pile of missing and unexpected keys rather than as the newer architecture it is. Ordering it first costs the contract check nothing — the hidden dim comes from the same weight, so a file truncated past it falls straight through.
  • A crash in an unusability check fails as a silent accept. A bare (un-prefixed) checkpoint is passed through strip_net_prefix untouched by design, so a .pth can hand identification keys that are not strings — and sorting {1, "not_a_pid_key"} for the unexpected-key report raises TypeError. The factory catches that as a generic candidate failure, so all five configs drop out and the file is registered as Unknown_Config. Both key sets now sort with key=str, and strip_net_prefix / pid_net_shapes / _Shapes no longer annotate a key type they do not guarantee (the old signature carried a type: ignore for exactly that pass-through, which is what made a str-only assumption look checked).

4. Startup orphan scan

_register_orphaned_models runs inside ModelInstallService.start(), which re-raises anything that escapes it. ModelSearch._walk_directory already contains every on_model_found exception, so startup was never actually at risk — verified. Handling InvalidModelConfigException in the callback makes skipping a bad file a property of the scan rather than of its caller, and names the file and the reason in the log.

5. Gemma-2 GGUF loader tests

test_gemma2_encoder_gguf_loader.py's only load test pointed at a hardcoded local Windows path and therefore always skipped in CI. It is replaced by a synthetic 2-layer Gemma-2 built from mocked GGUF tensors (Q8_0 projections, F32 norms), exercising the real loader path: projections stay GGMLTensor with the correct dequantized shape, embedding and RMSNorms are materialized (including llama.cpp's folded +1 norm convention), the rotary inv_freq meta-buffer is rebuilt, no meta parameters or buffers remain, a forward pass with quantized weights in place returns finite values, and an unexpected tensor fails loudly. The real-file comparison against transformers' own GGUF loader is kept as an opt-in test driven by INVOKEAI_TEST_GEMMA2_GGUF.

6. Docs

  • pid-decode.mdx claimed PiD "is not a separate upscale pass" although Feat: Add PiD (Pixel Diffusion Decoder) 4× super-resolution decode for FLUX / FLUX.2 / SD3 / SDXL / Z-Image / Qwen-Image #9281 added the pid_upscale node. Generation PiD and the prototype node are now documented separately, with the node's FLUX-AutoEncoder-only constraint spelled out.
  • The starter checkpoints are spread over checkpoints/ and checkpoints_deprecated/ on Hugging Face, not all under the latter.
  • Gemma2Encoder_GGUF_Config still said transformers dequantizes the GGUF; it is loaded natively. Added a note that a Gemma-2-2b GGUF encoder is a supported manual install. openapi.json / schema.ts regenerated for the docstring.

Related Issues / Discussions

Follow-up to #9281#9281 (review)

Also addresses the reviews on this PR: 4889406183, 4889527773, 4891646254, 4893418632, 4896390812.

QA Instructions

Automated

  • pytest tests/backend tests/model_identification tests/app/services/model_install — green. 83 tests across the four touched test files (tests/backend/pid/, test_pid_decoder_config.py, test_orphan_scan.py, test_gemma2_encoder_gguf_loader.py).
  • The 4 failures in tests/app/services/model_install/test_model_install.py on my machine are network-bound (they resolve a local HF_ENDPOINT) and are identical on main.
  • ruff check / ruff format --check clean; schema.ts regenerated via the standard typegen.

Every rejection path has a factory-level test asserting result.config is None, that no Unknown_Config was produced, and that the specific reason survives for _probe to report — partial file, missing backbone weight, unexpected key, wrong shape, intact v1.5, truncated v1.5, unsupported latent channels, malformed discriminator, and a bare checkpoint with a non-string key.

Verified against the real NVIDIA checkpoints

11 files locally — all five supported backbones, both presets, plus the two unsupported (dinov2, siglip) decoders. 22/22 assertions pass (each checkpoint both in place and as a direct single-file install):

  1. No regression from the stricter check: every checkpoint matches the derived contract exactly — missing=0, unexpected=0, shape-mismatch=0.
  2. In-place install: all 9 supported decoders identify with the correct base and variant.
  3. Direct single-file install: hard-linked into a fresh <uuid>/model_ema_bf16.pth with no base / variant override, all 9 still come out correct (before this work: SD3 2K, SD3 2K-to-4K and Qwen-Image were identified as flux).
  4. Unsupported backbones: the dinov2 (768ch) and siglip (1152ch) decoders are rejected by latent channel count rather than registered as unknown models.

Manual

  • Install a PiD decoder from Starter Models — unchanged behaviour, base/variant as before.
  • Install a decoder directly (paste e.g. nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth) into Model Manager → registers as sd-3 / 2K and is accepted by the SD3 PiD decode node.
  • Truncate a checkpoint (drop some tensors) and install it: identification must reject it with a "missing N of the weights required by PidNet" message and no model record, instead of registering it as unknown.

Merge Plan

Self-contained.

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 — no slice changes)
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

Follow-up to invoke-ai#9281, addressing the review items:

- Require the complete LQ projection. Identification accepted a checkpoint
  with a single `lq_proj.*` key and `load_pid_decoder` tolerated every
  missing `lq_proj.*`. Models are built under `skip_torch_weight_init()`,
  so those weights stayed uninitialised and would decode to garbage/NaNs.
  `required_lq_proj_keys()` derives the expected key set from the vendored
  network, and both identification and load now reject any missing key.
- Match the install source when identifying backbone and variant. A direct
  single-file install is stored as `<uuid>/model_ema_bf16.pth`, so the name
  carried no `res2k…` marker and no backbone hint: SDXL/Qwen-Image decoders
  were labelled `res2k_sr4x` although only the 2K-to-4K preset exists, and
  SD3/Qwen-Image decoders were registered as `flux`, which their decode node
  then rejects. The source (HF path/URL) survives the download and is now
  matched alongside the on-disk name, with a per-backbone variant fallback.
- Replace the hardcoded-path Gemma-2 GGUF loader test (always skipped in CI)
  with a synthetic tiny Gemma-2 built from mocked GGUF tensors: asserts
  quantized retention, norm materialisation, meta-buffer repair, absence of
  meta parameters and a finite forward. The real-file comparison is now
  opt-in via INVOKEAI_TEST_GEMMA2_GGUF.
- Drop stale doc claims: PiD as a decode is now documented separately from
  the prototype `pid_upscale` node, the starter checkpoints are spread over
  `checkpoints/` and `checkpoints_deprecated/`, and the GGUF encoder is
  loaded natively instead of being dequantized by transformers.
@github-actions github-actions Bot added python PRs that change python files backend PRs that change backend files frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Aug 8, 2026

@JPPhoto JPPhoto 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.

Merge blockers:

  • invokeai/backend/model_manager/configs/pid_decoder.py:218: Completeness check runs after backbone inference. Anonymous truncated files missing lq_proj.latent_proj.0.weight fail with “cannot determine backbone,” then default allow_unknown_models=True registers Unknown_Config, contradicting PR QA requirement to reject them. Test: classify a temp file containing only net.lq_proj.latent_proj.1.weight with no base/source and allow_unknown=True; current result is Unknown_Config.

Other findings/issues:

  • invokeai/backend/pid/decode.py:195: required_lq_proj_keys() initializes Conv/Linear weights only to inspect names, consuming global CPU RNG during model identification. Later unseeded randomness changes with install order. Test: compare the next torch.rand() after calling required_lq_proj_keys.cache_clear(); required_lq_proj_keys(...) against a seeded control; values differ.

Alternative implementation ideas

  • Instead of constructing initialized LQProjection2D, build it under meta initialization or torch.random.fork_rng; this keeps key probing deterministic and side-effect free.

  • Instead of validating LQ keys after backbone inference, validate normalized LQ keys first using a base-independent expected set; this catches missing diagnostic keys and prevents Unknown_Config fallback.

  • Instead of duplicating net. normalization in invokeai/backend/model_manager/configs/pid_decoder.py and the loader, share one normalizer; this prevents future identification/runtime key-contract drift.

… RNG

Review follow-ups for invoke-ai#9474.

1. `_raise_if_lq_projection_incomplete` ran after `_validate_base`, but the
   backbone is read from `lq_proj.latent_proj.0.weight` — one of the weights a
   truncated file may be missing. Such a file therefore failed with "cannot
   determine PiD decoder backbone" instead of the "missing … LQ projection
   weights" message the install flow promises. Completeness is now checked first,
   against `common_required_lq_proj_keys()`, the key set every backbone requires.
   The per-backbone check stays after the backbone is known: the two sets are the
   same 71 keys today, so it is a no-op that keeps the check from silently
   weakening to the intersection if a backbone ever adds LQ parameters of its own.

   Note this does not change the `Unknown_Config` fallback: `ModelConfigFactory`
   applies that to any file no config matches, and `allow_unknown_models` defaults
   to true. With it disabled the truncated file is rejected outright. The PR's QA
   step is worded as if rejection were unconditional; it is not, and that is a
   model-manager-wide behaviour rather than anything PiD-specific.

2. `required_lq_proj_keys()` built a real `LQProjection2D` just to read parameter
   names, running every `reset_parameters()` and so drawing from the global CPU
   RNG during model identification — leaving later unseeded randomness dependent
   on how many candidate files were probed. It is now built on the meta device
   inside `torch.random.fork_rng`.

3. The `net.` normalisation existed twice, and the copies had already diverged:
   only the loader dropped the distill-only submodules (`net_ema.`, `fake_score.`,
   `discriminator.`). Since `net_ema.*` shadows PidNet's own parameter names, the
   drift ran in the direction where identification accepts what the loader then
   refuses. Both now share `backend/pid/state_dict_utils.py`.
@Pfannkuchensack
Pfannkuchensack requested a review from JPPhoto August 8, 2026 18:57

@JPPhoto JPPhoto 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.

Prior RNG and key-normalization findings are fixed!

This has surfaced:

  • invokeai/backend/model_manager/configs/pid_decoder.py:197: NotAMatchError is caught by factory.py:665-688; default allow_unknown_models=true returns Unknown_Config, and _probe persists it. Test: partial .pth containing only net.lq_proj.latent_proj.1.weight with allow_unknown=True returns Unknown_Config. Violates PR QA contract.

Other ideas:

  • Instead of routing invalid PiD files through generic Unknown_Config, make recognized-but-invalid PiD classification fatal; this prevents bad database records.

  • Instead of intersecting per-backbone probes, expose one normalized expected-key contract; this removes duplicate checks and future key drift.

…gistering it

Identification rejected a truncated PiD checkpoint with NotAMatchError, which
only means "not my kind of model": ModelConfigFactory collects those, finds no
match, and with allow_unknown_models (default: true) falls back to
Unknown_Config. A file that had already identified itself as a PiD decoder and
was then found to be missing LQ projection weights was therefore still
installed, as an unknown model with a database record, and only failed once
something tried to load it.

Add InvalidMatchError for "recognised, and unusable". It is deliberately not a
NotAMatchError subclass, since the factory catches that one per candidate class
and would swallow it. When no config class matched and at least one raised it,
classification returns no config regardless of allow_unknown, and
ModelInstallService._probe reports the specific reason rather than the
misleading "could not identify model".

Order the architecture check ahead of the completeness check. A v1.5 checkpoint
is intact, just built to a shape InvokeAI cannot construct; judged against the
legacy key set it would be misreported as truncated and now hard-rejected on top
of that. It stays a plain no-match, so it remains registrable as an unknown
model - only a broken file is fatal. This costs the completeness check nothing:
the hidden dim is read from lq_proj.latent_proj.0.weight, so a file truncated
past that weight falls straight through to it.

Collapse the LQ key contract to one entry point. common_required_lq_proj_keys()
and the per-backbone re-check are gone; required_lq_proj_keys() takes no
backbone, the probe lives in the private _probe_lq_proj_keys(), and
test_pid_decode.py pins that every backbone agrees, so key drift fails in CI
instead of silently weakening the install-time check.
@github-actions github-actions Bot added the services PRs that change app services label Aug 9, 2026
@Pfannkuchensack
Pfannkuchensack requested a review from JPPhoto August 9, 2026 05:44

@JPPhoto JPPhoto 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.

To fix:

  • invokeai/backend/model_manager/configs/pid_decoder.py:196-211 / invokeai/backend/model_manager/configs/factory.py:693-706: A truncated 1024-dim/v1.5 PiD file bypasses completeness, falls back to Unknown_Config, and is registered despite PR metadata promising partial checkpoints are rejected. Test: remove one LQ key from a 1024-dim checkpoint; factory currently returns Unknown_Config.

  • invokeai/app/services/model_install/model_install_default.py:1122-1153: Startup orphan scanning catches only duplicate errors; the new invalid-checkpoint exception propagates and aborts start() when one corrupt PiD file exists. Test: enable scan_models_on_startup, add a partial PiD orphan, and verify startup continues.

Ideas:

  • Instead of short-circuiting on non-512 dimensions, validate the relevant architecture contract first (or reject unsupported PiD files explicitly); this prevents truncated files entering the unknown-model fallback.

  • Instead of propagating InvalidModelConfigException from orphan scanning, catch/log it and return False; this skips one bad file without aborting startup.

The previous commit made a truncated checkpoint fatal but left the architecture
check a plain no-match, on the reasoning that an intact v1.5 file is merely
unsupported and should stay registrable. That opened a hole: a file that is both
1024-dim and truncated is rejected by the architecture check first, never
reaches the completeness check, and lands back in Unknown_Config - the exact
outcome the previous commit set out to prevent.

The distinction does not survive contact with the failure mode. Once a file has
identified itself as a PiD decoder, any rejection that does not depend on which
backbone it is will be raised identically by all five config classes, so the
file ends up with no match and is registered through the Unknown_Config
fallback. Those rejections are now all InvalidMatchError: unsupported
lq_hidden_dim, an incomplete LQ projection, a latent channel count no backbone
uses, and a checkpoint whose backbone cannot be determined at all.

Splitting them out of _validate_base is what makes that legible. _validate_base
now only ever answers "not *this* backbone", which four of the five classes are
supposed to say about every valid checkpoint, and every rejection in it stays a
NotAMatchError. The backbone-independent checks run ahead of it in
from_model_on_disk, architecture first so an intact v1.5 file is diagnosed as
unsupported rather than judged against the legacy key set and misreported as
truncated.

Also handle InvalidModelConfigException in the startup orphan scan.
ModelSearch._walk_directory already contains anything the on_model_found
callback raises, so startup was never actually at risk; catching it in the
callback makes skipping a bad file a property of the scan rather than of its
caller, and names the file and the reason in the log.
@Pfannkuchensack
Pfannkuchensack requested a review from JPPhoto August 9, 2026 19:48

@JPPhoto JPPhoto 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.

Merge blockers:

  • invokeai/backend/model_manager/configs/pid_decoder.py:241: Arbitrary local source paths affect backbone detection; /flux/model_sd3.pth is classified as FLUX because flux matches first. Test: classify that path with a complete 16-channel PiD fixture; current result is FLUX, not SD3.

  • invokeai/backend/model_manager/configs/pid_decoder.py:45: Completeness checks only LQ keys. A file with all LQ keys but no 385 backbone keys is registered, then load_pid_decoder() rejects it. Test: factory-classify an LQ-only checkpoint; expect no config, but current result is a FLUX config.

Other findings/issues:

  • invokeai/backend/model_manager/configs/pid_decoder.py:87: Wrong-shaped required tensors are accepted when the filename supplies a backbone. Loading later fails with a shape mismatch. Test: use complete key names with rank-1 lq_proj.latent_proj.0.weight; classification currently succeeds.

Alternative implementation ideas:

  • Instead of concatenating full source paths with model names, parse only trusted HF metadata and prefer explicit overrides; this prevents incidental directory names from changing base or variant.

  • Instead of checking only LQ keys, derive and compare the full normalized PidNet key contract on meta tensors; this keeps installation and loading acceptance identical.

  • Instead of accepting malformed diagnostic tensors by filename, validate required tensor ranks and shapes during identification; this turns runtime load failures into clear install errors.

…sing from paths

Identification checked less than the loader demands and inferred the rest from
file paths. Three consequences, all reported in review:

A checkpoint with every lq_proj weight and none of the 385 backbone weights was
registered, then refused by load_pid_decoder. Only the 71-key LQ projection was
ever checked. A subset check is not a milder version of the same guarantee:
loaders run under skip_torch_weight_init(), so a weight the checkpoint does not
supply is uninitialised memory rather than a default.

required_pid_net_shapes() now derives the whole contract - 456 keys and their
shapes - from a meta-device PidNet, the same trick the LQ probe already used but
applied to the real network instead of one submodule. Missing keys, unexpected
keys and wrong shapes are all fatal, because all three are fatal in
load_pid_decoder; a stricter installer cannot reject a file that would have
loaded. Probing the real net also removes the reason _LQ_PROBE_DIM,
_LQ_NUM_RES_BLOCKS_DEFAULT and the hand-copied num_outputs derivation existed,
along with the test that kept them in sync.

Wrong-shaped tensors were accepted when a filename supplied a backbone. The
architecture, the backbone and the kernel are all read off lq_proj.latent_proj.0
.weight, and each read answered None when it was not a 4D conv - so one
malformed tensor made all three abstain at once and the file fell through to
name-only matching. That weight is now validated first, and the three reads only
run when it is there; its absence is a truncation, which the contract check
diagnoses better than a guess about the architecture.

Backbone detection concatenated the install source, the parent directory and the
filename into one string and substring-matched it with a fixed precedence, so
/flux/model_sd3.pth matched flux first and was registered as FLUX although the
file says sd3. Name components are now matched most-specific-first, a component
naming two different backbones decides nothing rather than being resolved by
precedence, and a local-path source is not evidence at all - the model manager
sets source to the file's own path when there is no remote one, so trusting it
means matching arbitrary ancestor directories of the user's model library.
Nothing is lost: install_path identifies a local file before it moves it.

Requiring the full contract also makes the latent channel count always readable,
which retires the name-only backbone path entirely. The name can now only break
the FLUX.1 / SD3 / Qwen-Image tie, never pick a backbone outright, and an
explicit base override - already validated against one class's Literal - beats
it. The checks that would rule out all five configs move out of _validate_base,
leaving it to answer only "not this backbone".

Also fixes, orthogonally: from_model_on_disk popped `variant` out of the
override dict the factory builds once and shares across every candidate class,
so the first PiD class to run consumed it and a later one that actually matched
fell back to name inference.

Verified against all 11 NVIDIA checkpoints: every one matches the contract
exactly (missing=0, unexpected=0, no shape mismatch), the 9 supported decoders
identify with the right base and variant both in place and as a direct
single-file install, and the dinov2 / siglip decoders are rejected by latent
channel count rather than registered as unknown models.
@JPPhoto
JPPhoto self-requested a review August 10, 2026 11:49

@JPPhoto JPPhoto 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.

Merge blockers:

  • invokeai/backend/model_manager/configs/pid_decoder.py:155 and invokeai/backend/pid/state_dict_utils.py:76: A complete bare-PidNet state dict with both a non-string key and an unexpected string key reaches sorted(shapes.keys() - contract.keys()); bare checkpoints are intentionally preserved, so mixed int/str keys raise TypeError. ModelConfigFactory catches that as a generic candidate failure, then allow_unknown=True returns and registers Unknown_Config, bypassing this PR's unusable-checkpoint rejection. Test: build a bare fixture from required_pid_net_shapes() plus {1: torch.zeros(1), "not_a_pid_key": torch.zeros(1)}, call ModelConfigFactory.from_model_on_disk(..., allow_unknown=True), and assert result.config is None; current code returns Unknown_Config after the TypeError. Exact-head execution was unavailable because the immutable head is absent from the read-only local Git objects.

A bare (un-prefixed) PidNet checkpoint is passed through strip_net_prefix
untouched, on purpose: without the net. prefix there is no evidence the file is
a distill serialisation, so a stray key must reach the unexpected-key checks
rather than be dropped. A .pth unpickles to whatever it contains, so those keys
need not all be strings - and reporting the unexpected ones sorts them. Sorting
{1, "not_a_pid_key"} raises TypeError.

That failure does not surface as a failure. ModelConfigFactory catches an
unexpected exception from a candidate class as a generic no-match, so all five
PiD configs drop out and allow_unknown_models registers the file as
Unknown_Config - the exact fallback these checks exist to close. A complete bare
contract plus one non-string key and one unexpected string key was therefore
installed as an unknown model.

Sort both key sets with key=str, and stop the type annotations claiming
otherwise: strip_net_prefix and pid_net_shapes return dict[Any, ...], not
dict[str, ...], and _Shapes follows. The old signature was not merely imprecise
- it carried a type: ignore for the pass-through return, which is what let a
str-only assumption look checked.

Verified against the eleven real NVIDIA checkpoints: unchanged, all five
supported backbones identify in place and as a direct single-file install, and
the dinov2 / siglip decoders are still rejected by latent channel count.

@JPPhoto JPPhoto 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.

  • invokeai/backend/pid/decode.py:219 (fed by invokeai/backend/pid/state_dict_utils.py:48): Bare checkpoints preserve non-string keys, but the loader passes them directly to PyTorch, which raises AttributeError on .startswith() before reporting an unexpected key. Test: pass a complete state dict plus {1: torch.zeros(1)} to load_pid_decoder(); current head raises AttributeError instead of InvokeAI's RuntimeError.

    • Instead of passing non-string keys into load_state_dict(), reject them in load_pid_decoder() first; this keeps malformed-checkpoint handling explicit and avoids PyTorch's low-level error.

The previous commit taught identification to tolerate a bare checkpoint whose
keys are not all strings, and justified keeping those keys with a claim about
the loader that is simply wrong: nn.Module.load_state_dict calls .startswith()
on every key, so a non-string one raises AttributeError from inside torch before
any unexpected key is reported. Passing a complete state dict plus {1: tensor}
to load_pid_decoder raised that AttributeError rather than the RuntimeError the
function reports every other unusable checkpoint with.

load_pid_decoder now checks for non-string keys before it hands anything to
torch, and says what is actually wrong with the file. Identification already
rejects such a checkpoint, so this is the second line rather than the first -
but load_pid_decoder is public, the model cache reaches it for records written
before this PR, and a file can be swapped on disk after install.

The reasoning in strip_net_prefix and its test is corrected to match what torch
does. Keeping non-string keys is still right - dropping them would hide a
malformed file from the checks meant to catch it - but the burden it puts on
consumers is the opposite of what was written there: neither may assume the key
type, so identification sorts its key reports with key=str and the loader
rejects non-strings up front.

Verified: the reviewer's repro now raises "PiD checkpoint has 1 keys that are
not strings and so cannot name a PidNet parameter: [1]". The eleven real NVIDIA
checkpoints are unaffected, 22/22 as before.

@JPPhoto JPPhoto 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.

You might want to look into this or make another followup:

  • invokeai/backend/pid/state_dict_utils.py:34 and invokeai/backend/pid/state_dict_utils.py:67: Prefixed checkpoints silently drop non-string keys before contract validation and loader rejection. Test: add {1: torch.zeros(1)} to a complete net.-prefixed fixture and classify with allow_unknown=True; current head returns a PiD config instead of rejecting the malformed checkpoint.

Alternative implementation ideas:

  • Instead of dropping non-string keys during prefixed normalization, preserve and reject them before loading; this gives bare and prefixed checkpoints one consistent contract and prevents silent acceptance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14 Nice-to-Have 6.14.1 backend PRs that change backend files docs PRs that change docs frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants