Captured (virtual) chain items were excluded wholesale from the exact low-rank
concat fast path by the stack-level has_virtual_loras guard, so every group fell
to _prepare_group_diffs' dense branch — materializing a full [out x in] diff per
contributor per group per candidate. On inline AutoTuner sweeps this made every
weighted_* candidate ~10x slower than the file-based Stack path.
Make captured groups eligible for the fast path when every contributor is a
plain 2D LoRAAdapter (mid None) on a 2D linear target — exactly the conditions
_build_exact_linear_patch already requires for file items:
- _virtual_payload_is_linear_ok: per-payload structural gate (LoRAAdapter,
mid None, 2D up/down; rejects LoKr/LoHa/dense-tensor/("diff",…)/non-2D).
- _virtual_group_is_linear_ok: group gate used in place of the blanket
has_virtual_loras exclusion — also rejects offset-sliced (tuple) target
keys and non-2D (conv) targets, which the dense path reshapes but the
concat can't. Groups with no virtual contributor return True, so the file
path is untouched.
- _build_exact_linear_patch reads factors by target key for virtual items and
folds the same per-LoRA scale (alpha/rank, alpha=None -> 1.0) and mode
weight as file items, so the emitted low-rank patch reconstructs to the
dense _prepare_group_diffs + _merge_diffs result within float tol.
Non-qualifying virtual groups fall through to the unchanged dense path.
Verified: per-group numerical equivalence (allclose atol 1e-6) for
alpha==rank / alpha=None / alpha!=rank / tensor-alpha, single-LoRA,
2-LoRA weighted_sum/weighted_average/normalize, and model_scale!=1; full-merge
fast-vs-forced-dense output identical (max err ~2.9e-6) across all three modes
with auto_strength both on and off; golden TestMultiLoraEndToEnd unchanged plus
a fast-path-engaged assertion; file-path behaviour byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Virtual/captured chain items (_precomputed_diffs) store low-rank adapter
payloads keyed by model target key. _prepare_group_diffs previously expanded
them via up@down on the factors' native CPU device, then shipped the big dense
[out x in] result to the compute device. Move the small factors to the device
BEFORE the matmul instead (via _move_patch_to_device), so the matmul runs
on-device and only the factors cross the bus — mirroring _compute_lora_diff's
factor-first move on the file path. Guarded on use_gpu, so CPU-only merges are
unchanged. Pure device move: merged results are identical (allclose).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes FIX#12: the hint reached optimize_merge but not auto_tune's
_normalize_stack, so inline autotuner-mode candidate scoring + the
community arch bucket used key-based detection (mis-tagging e.g.
attention-only Qwen as ACE-Step). File-based stacks have no virtual
items → hint unreachable → unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The analysis cache FILE key (_compute_names_only_hash) is already content-based
for captured items, so the file is found. But _remap_analysis_indices validated
by comparing raw l["name"], and source_loras_for_cache stored the raw name —
both the per-session SALTED name for inline captures — so sorted(cached) !=
sorted(current) across sessions -> None -> treated as a miss -> Pass 1 recomputed
unnecessarily (pure perf, output identical).
Route both the stored source_loras and the remap name comparison through
_persistent_lora_key (captured:<content_hash> for captures, file name
otherwise). The stored key is now content-stable, so the analysis cache HITS
cross-session for inline chains. File-based items: _persistent_lora_key returns
their name -> byte-identical, no change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attention-only Qwen-Image (diffusion_model.transformer_blocks.N.attn.to_q,
no add_q_proj) and ACE-Step v1.0 captured chains are indistinguishable from
model-space keys alone — Qwen falls through to the ACE-Step regex and merges
under the wrong acestep_dit preset instead of llm. The inline node holds the
real MODEL object, so use type(model.model).__name__ (authoritative) to
disambiguate.
- _MODEL_CLASS_TO_ARCH: comfy model_base class name -> repo arch name.
- _model_class_arch(): MRO-walking, fully guarded (None/no .model/unknown ->
None), never raises.
- Thread an internal _arch_hint through optimize_merge -> _normalize_stack;
the hint is consulted ONLY for virtual (_precomputed_diffs) items when
key-based file detection failed, so file-based stacks stay byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Important 2: the old _resolve_stamp_names matched stamp[i]->group[i] by
absolute index, guarded only by a strength check. It mis-attributes when
same-strength disjoint groups reconstruct in a different order, or when an
unstamped loader precedes stamped ones and a strength collision passes tol.
A wrong _resolved_file_name drives the persistent identity, so the run would
write one file's analysis/memory/community stats under another file's key —
polluting the file-based dataset and degrading future Stack runs of that file
(the merge output itself is unaffected: it runs on the real captured diffs).
Now match by UNIQUE strength, order-independent: name group g iff (i) g's
strength is unique among groups within tol, (ii) exactly one stamp matches
g's strength within tol, and (iii) that stamp's strength is unique among
stamps within tol. Any collision/ambiguity leaves g unnamed (chain lora #N +
captured identity) — better anonymous than a wrong file identity. The
uniqueness-within-tol test also closes the 0.8/0.80001 false-positive window.
Pinned by TestInlineConservativeMatching (6) + TestInlineAmbiguousNoPollution
(1). Distinct-strength naming still works (existing matcher/read-path tests
green). Full suite 509; version unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This wrapper runs for EVERY LoRA load globally, so it must be defensive:
- Important 1: a strength 0/0 load makes stock LoraLoader early-return the
INPUT (model, clip) UNCHANGED (no clone, nodes.py:697). The old wrapper
still stamped it, mutating a shared upstream model's attachment list AND
adding a phantom stamp with no capturable group (shifting stamp<->group
index alignment for every later LoRA). Now skip stamping when
strength_model == 0 and strength_clip == 0.
- Important 3: the wrapper hardcoded (self, model, clip, lora_name,
strength_model, strength_clip). Since it globally replaces the method, a
future comfy that adds/reorders a param would raise TypeError at bind time
(before try/except) and break ALL LoRA loading. Now (self, *args, **kwargs)
calls the original verbatim first, then extracts lora_name/strengths
defensively (positional index with kwargs fallback); extraction failure
skips stamping, never loading.
- Minors: functools.wraps(orig) on the wrapper, and the idempotency sentinel
now walks the __wrapped__ chain so a third-party wrap + our re-import
(ComfyUI-HotReloadHack) can't double-wrap -> double-stamp.
Pinned by TestLoraNameStampHardening (8 tests). File-based/Stack path
untouched; full suite green (502).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
execute_inline now reads the loraopt_chain_names attachment off the incoming
model/clip and aligns each stamped filename to its reconstructed chain group
by order + strength (_resolve_stamp_names, per branch: strength_model for the
model side, strength_clip for the clip side). A matched group:
- displays its REAL filename in the fingerprint (instead of "chain lora #N");
- carries a _resolved_file_name flag that gives the virtual item FILE
identity — _persistent_lora_key returns the file-name key and
_lora_content_hash hashes the file bytes, so an inline-named run and a
Stack run of the same LoRA share the memory/community key.
Unstamped groups (custom loaders, or no attachment) keep the exact previous
behavior: generic "chain lora #N [salt]" display + captured-content-hash
identity. The salted display/merge-cache name stays separate from the
identity name. File-based (Stack) items are untouched (no _resolved_file_name
-> both helpers behave byte-identically).
Matching degrades gracefully: fewer/more stamps than groups, strength
mismatch, and malformed entries all leave the affected group unnamed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add _install_lora_name_stamp(), which idempotently wraps
nodes.LoraLoader.load_lora so every stock/rgthree LoRA load records its
real filename + strengths as an ordered "loraopt_chain_names" attachment
on the returned model (and clip.patcher). One method covers all three
loaders: stock LoraLoader (direct), LoraLoaderModelOnly (via self.load_lora),
and rgthree PowerLoraLoader (via LoraLoader().load_lora, whose LoraLoader
IS nodes.LoraLoader).
The accumulator builds a NEW list every call because ModelPatcher.clone
copies the attachment list by reference; appending in place would corrupt
the upstream (pre-clone) model's chain. Stamping is fully fail-safe: any
error returns the original load result unchanged. __init__ installs the
wrapper at import (idempotent, graceful when comfy's `nodes` is absent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The delegate's in-node patch cache keys on id() of the fresh stripped
clone, so it can never usefully hit for inline and only pins a stale
merged-patch set in RAM (the same id()-reuse staleness the node already
pins off for its own optimize_merge path). Stop forwarding
settings["cache_patches"] to the delegate and pin it "disabled".
Docs: correct the commit-B overclaim. Inline memory now persists across
sessions via captured-content identity, but inline community is a SEPARATE
namespace — captured-weight hashes never collide with file-byte hashes, so
inline chains share configs only with other inline chains, not with
file-based LoRA-Stack recordings — and sharing assumes the same ComfyUI
version (captured factors/keys depend on comfy's key-mapping / QKV-fusion).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured chain items have no file and a per-session salted name
(`chain lora #N [uuid]`), so every persistent key derived from the name
changed every ComfyUI session — memory saved under a fresh key each run
and never read back, and the per-LoRA/pair/whole-stack analysis disk
caches never cross-session hit. The content hash from the prior commit
was also only computed behind the community_cache gate, so with the
Settings default (memory auto, community disabled) nothing persisted.
Add _persistent_lora_key(item): file items key on their name (unchanged);
captured items (_precomputed_diffs) key on `captured:<content_hash>`,
memoized on the item dict via _memo_content_hash (content-hashing captured
factors is expensive; the same active_loras dicts are reused across every
candidate + memory + community lookup in a run). Route it through the
persistent-memory key (_mem_key), the names-only fallback scan, the
persisted source_loras, the settings-hash signature
(_per_lora_merge_signature), and both analysis-cache keys
(_lora_identity_hash, _compute_names_only_hash). Reuse the memo in the
community loop. Every change is guarded on _precomputed_diffs — the
file-based path is byte-identical.
Gating tests: a memory entry saved in "session 1" (one salt) is found in
"session 2" (different salt, identical captured content) via the real
auto_tune; persistent/identity/names-only/signature keys stable across
salts for captured, unchanged for file items. The cross-session test fails
pre-fix (session 2 re-sweeps).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
execute_inline's autotuner-mode settings previously set autotuner_fallback
and merged with optimizer defaults ("not supported inline"). Now that
captured items have a stable content-based identity (prior commit),
delegate mode=="autotuner" to a LoRAAutoTuner exactly like
LoRAOptimizerSimple — but on the inline-prepared STRIPPED model/clip and
the VIRTUAL stack (never the raw model / original chain), with
normalize_keys forced "disabled" (captured keys are already canonical).
auto_tune runs candidates through optimize_merge, which already handles
_precomputed_diffs, and applies the winner off the passed stack (no file
reload). The 6-tuple return is reconciled to the inline 5-tuple with the
chain fingerprint prepended. Advanced-mode and no-settings paths are
unchanged; the fallback message now only fires for genuinely unknown modes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The file-less fallback in _lora_content_hash crashed on tuple keys
(k.encode()) and hashed captured adapter/diff objects via repr() — their
memory address — so the persistent memory/community key was unstable
across runs (and even collided across GC address reuse). Hash the actual
factor VALUES instead: iterate keys in stringified-sorted order, and for
each value recurse into tensors (shape + float32 bytes), adapter .weights
(LoRAAdapter/LoKr/LoHa), and diff/tuple/list payloads. Plain-tensor dicts
(LoRAExtractFromModel) and the file-based path stay byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inline-captured chains are 100% virtual (_precomputed_diffs) items, which
_normalize_stack's architecture detection skipped entirely -> _detected_arch
stayed None -> _resolve_arch_preset("auto","unknown") fell through to sd_unet.
LTX/Z-Image/other DiT merges then got SD/SDXL conflict thresholds, density
floors, and per-prefix gates applied.
Virtual items are keyed by MODEL-SPACE target keys, which still carry
structural architecture markers (diffusion_model.transformer_blocks... for LTX,
diffusion_model.layers.N.attention... for Z-Image). Add a detection FALLBACK in
_normalize_stack: file-based items are tried first exactly as before; only if
still unknown do we detect on the virtual items' stringified key view (fused-QKV
tuple keys handled, real lora dict never mutated). The normalization loop's
virtual-skip is unchanged. execute_inline now detects the arch from the captured
keys too and surfaces it in the fingerprint ("architecture: ltx (detected from
captured keys)"), only warning "unknown" when detection genuinely fails and no
preset is pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slim LoRAOptimizerInline down to model/output_strength/
clip_strength_multiplier + optional clip/settings/chain_options sockets.
The per-LoRA options (settings_visibility, lora_count, and the 7 per-slot
widgets) now arrive through the LoRAInlineChainOptions side node as a
LORA_CHAIN_OPTIONS payload; unconnected merges every captured LoRA with
default options.
- execute_inline derives visibility/slots from chain_options (or
''simple''/[] when unconnected) instead of building slots from
per-slot kwargs.
- The ''slots vs LoRAs detected'' fingerprint note only fires when an
options node is connected (fp_lora_count = len(slots) if connected else
the group count).
- IS_CHANGED keys on chain_options content instead of the per-slot loop.
_chain_groups_to_stack, the name salt, the pinned normalize_keys /
cache_patches, strip/merge/re-apply flow, _SLOT_DEFAULTS, MAX_LORAS,
_payload_rank, _chain_fingerprint, and _prepend_report are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A captured comfy LoRAAdapter has alpha=None when the source LoRA file
carries no .alpha key (PEFT / diffusers / AI-Toolkit format). The
plain-LoRA branch of _expand_patch_to_diff divided alpha/rank
unconditionally, crashing with 'unsupported operand type(s) for /:
NoneType and int' the first time two such LoRAs merged through the
inline node. Comfy (weight_adapter/lora.py) and our own sibling
LoKr/LoHa branches already treat None alpha as scale 1.0; this aligns
the plain-LoRA branch.
Only the inline capture path feeds comfy's raw None-alpha adapters into
this shared code (via _precomputed_diffs -> _expand_patch_to_diff); the
normal Stack path defaults alpha to rank upstream, so it never hit this.
Grep of all alpha/rank sites confirmed no other unguarded division is
reachable with a raw captured adapter (the single-LoRA fast path and
_build_exact_linear_patch are both skipped for _precomputed_diffs, and
_get_lora_key_info cannot resolve their model-target keys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduce a pure widget->data node that carries the per-LoRA option
widgets (settings_visibility, lora_count, and the 7 per-slot widgets)
and emits a LORA_CHAIN_OPTIONS payload {visibility, slots}. Reuses
LoRAOptimizerInline._SLOT_DEFAULTS as the single source for the 7 slot
keys + defaults so the two nodes cannot drift. Registered in both node
mappings. The inline node still carries its own widgets for now; the
next commit slims it to consume this input.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the node to NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS next to
the other optimizer entries as "LoRA Optimizer (Inline Chain)", with a
registration test (class identity + display name). Also clarifies the
IS_CHANGED docstring: under live ComfyUI, linked inputs (model/clip/
settings) arrive as None — upstream changes invalidate via the executor's
ancestor cache keys; the patches_uuid terms matter for harness/direct
callers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the >= 2-LoRA e2e gap left after Task 5 (single-active-LoRA was
already covered by TestSingleLoraVirtualPath): two rank-4 adapters on the
same 16x16 layer key at loader strengths 1.0 / 0.7, captured off a fake
ModelPatcher chain and driven through execute_inline into the REAL
optimize_merge (Pass 1 conflict analysis + Pass 2 group merge on virtual
items).
- Numeric golden: with a weighted_sum override via an advanced Settings
dict (the node's supported surface for engine kwargs — execute_inline
deliberately forwards no loose kwargs), the applied patch is verified
exactly equal to 1.0*(upA@downA) + 0.7*(upB@downB); also asserts the
fingerprint report, add_patches on a clone (returned model IS that
clone), and the untouched original chain entries on the input model.
- Conflict e2e: deliberately near-antiparallel up/down matrices exercise
the per_prefix conflict-analysis path; asserts completion, finite
nonzero merged patch, fingerprints.
- Test-only refactor: extracted TestSingleLoraVirtualPath's fake-model
threading into a shared _pipeline_model helper and fixed its clone
fidelity (clones now copy the CURRENT patch lists, like the real
ModelPatcher.clone, so a clone of the stripped clone stays stripped).
- _adapter now accepts explicit up/down matrices for deterministic diffs.
No engine changes were needed — the virtual-item pipeline handled the
multi-LoRA path as-is. Determinism knobs (cache_patches/patch_compression
disabled, CPU SVD) ride through the existing settings delegation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- IS_CHANGED: the inherited LoRAOptimizer signature expects lora_stack and
raises on inline's inputs -> ComfyUI treated the node as always-changed
and re-merged on every queue press (the in-node merge cache cannot
rescue it: its key folds in id() of the fresh stripped clone). The new
classmethod keys on model/clip patches_uuid + consulted widgets (slots
1..lora_count only) + settings content, so ComfyUI's execution cache
skips unchanged re-queues entirely. Salt comment corrected to
defense-in-depth (S3).
- Passthrough branch: patches present but zero capturable groups now
reports "N non-mergeable patch entries ... passed through" instead of
the wrong "place this node AFTER your loaders" advice (I1).
- Passthrough count includes clip-side remainder (S1).
- Fingerprint warns "architecture: unknown (inline capture)" whenever the
effective preset is auto — virtual items skip key-based detection; a
Settings-node preset suppresses the warning (I2).
- _payload_rank returns None for LoKr (weights[1] is the w2 Kronecker
factor, not a rank) so the fingerprint says "dense" instead of lying (S2).
- New execute-level test: advanced-visibility per-slot widgets reach the
stack items through execute_inline (S4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
optimize_merge's single-LoRA fast path handed the item's lora dict to
comfy.sd.load_lora_for_models, which resolves TRAINER-format keys
({x}.lora_up.weight). Virtual chain items carry model-target keys, so it
loaded nothing, silently — and since the inline node had already stripped
the originals, a 1-LoRA chain (or N LoRAs with all but one disabled) came
out with NO LoRA at all. Guard the fast path on _precomputed_diffs; the
diff pipeline handles 1-item virtual stacks (single-contributor
weighted_sum + native low-rank patch emission).
Tests use a realistic load_lora_for_models stand-in that mimics comfy's
key resolution (records calls, matches only trainer-format keys) — the
default pass-the-model-through stub could never catch this failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
INPUT_TYPES (widgets in required, clip/settings as optional sockets,
clip_strength_multiplier required for a predictable JS control-widget
count), execute_inline flow (reconstruct chain groups -> build salted
virtual stack -> strip captured entries from clones -> optimize_merge
with normalize_keys pinned to "disabled" -> prepend fingerprint report),
per-group fingerprint report with slot-count and model/clip group-count
mismatch warnings plus passthrough-entry count, and advanced-settings
delegation (AutoTuner settings fall back to defaults with a report line).
Virtual item names are salted with the source patcher patches_uuid
digests (model + clip when connected) so two different LoRA files loaded
at identical strengths cannot false-hit the in-node merge cache; the
salt rides an optional name_salt parameter on _chain_groups_to_stack so
existing callers/tests are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>