Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).
- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
_occurrence_matches matcher (carrier_ctx None from callers — nested-pick
threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
without consumption); its import + both wildcard_handler call sites + the
_claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
selector covered zero, new constraint_partial_reach when next N / pick
matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
_CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.
Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
via explicit target_select={mode:"first"}; added an all-mode-default test and
a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
no longer "claims" anything, so the constraint applies on the next actual
resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
two-all-constraints-compose-on-one-target test (both excludes zero the pool →
excludes-all fallback), plus a single-all-constraint survivor test.
Three coordinated changes to the WP_* node family.
1. New WP_SeedList node
Emits a list of N derived seeds for a downstream loop (is_output_list).
Three independent override toggles (override_seed / override_count /
override_strategy) each mirror the wired Loop's base_seed / count /
strategy. Connecting loop_config auto-flips override_count +
override_strategy ON so the natural intent of wiring in a loop gets
the defaults right; override_seed stays explicit opt-in.
All emitted seeds are masked to 50 bits via the new MAX_SAFE_SEED
constant in engine.seed_derive so values sit inside ComfyUI's
frontend randomize range and copy-paste reproduces identically in a
standalone sampler.
2. Context Loop type split + downstream Context seed widget lock
Split WP_CONTEXT_LOOP_CONFIG (wire payload) from a new
WP_CONTEXT_LOOP_WIDGET (DOM-widget-only TYPE). Loop's own widget
mounts on the widget type; the wire type has no factory so consumers
like WP_SeedList render their loop_config socket as a plain input
instead of being hidden by a fallback-undefined widget.
New graph walker hasUpstreamLoopOverridingSeed: WP_Context's widget
glue greys out the local seed stock widget when an upstream Loop has
override_seed ON, so users read "this value is dead while the Loop
drives it" without inspecting the Loop config separately.
3. wp_ prefix sweep + tooltips across every WP_* node input
Custom DOM widget input names now carry the wp_ prefix so canvas
socket labels read as branded WP widgets: modules -> wp_modules,
rows -> wp_rows, viewer -> wp_viewer, cleaner -> wp_cleaner,
context_loop_config -> wp_context_loop_config. Every stock input
across the node family gets a tooltip describing what it does and
when to wire it from another node. Tests + comfy_api stub updated.
CLAUDE.md notes the wp_ convention for future custom widgets.
Bundle size budget bumped 388 -> 395 KB to fit the SeedList Vue chunk
(~4.9 KB net gzip) + override lock UI; entry stays at ~4 KB gzipped.
Three bugs surfaced when the user pasted a real WP_Debug snapshot
showing 4 constraint_never_applied warnings for a chain with only 3
constraint instances (2 hair_x_mood siblings + 1 shirt_x_color_compat),
across two sequential WP_Context nodes:
- __wp_consumed_constraints__ wasn't in _CROSS_NODE_INTERNAL_KEYS, so
a downstream Context inherited the constraint bucket from upstream
but started with an empty consumed set. End-of-run check then
re-warned every constraint upstream already consumed. Add it to the
cross-node-internal list + handle set serialisation in
WP_Context.execute's defensive-copy loop.
- engine/pipeline.py's end-of-run loop walked every entry in the
bucket without dedup. Two sibling instances of the same constraint
library entry share __constraint_module_id__, so the user got the
same warning twice for one logical constraint. Add a warned_cids
set so each distinct cid emits at most one warning per run.
- wp_nodes/types.py:build_payload concatenated upstream + this-run
warnings without dedup. Even with the consumed set now propagating,
a constraint that legitimately never fires anywhere still gets
warned once per node along the way. Dedup by (type, module_id) for
constraint_never_applied at merge time.
Confirmed the engine's first-instance targeting already matches the
user's mental model: when the SOURCE wildcard's own option carries a
nested @{target} ref, that pre-registration roll is skipped and the
constraint claims the NEXT target instance downstream (e.g. @{color}
inside a backdrop wildcard placed after the constraint). Locked in by
test_constraint_targets_next_color_not_source_nested across 5 seeds.
Net: user's 4 warnings collapse to 1 (shirt_x_color_compat, which is
legitimate for any iteration where backdrop's RNG picks a no-@{color}
option). 5 regression tests added; all 1024 pytest + 2381 vitest pass.
Sibling rows in a Context share `module.id` (library uuid) but have
distinct `_uid` values. The pipeline trace emitted only `id` on each
entry, and the context node's `module_seeds` UI payload keyed that
map by `id`, so the per-instance "last seed used" reader collapsed
all siblings onto one value — whichever sibling executed last
overwrote the others. Locking a sibling's seed therefore fell back to
some other sibling's roll rather than its own.
Three coordinated changes:
1. `engine/pipeline.py` reads `module["_uid"]` (or `getattr` for
dataclass-shaped modules) alongside `module["id"]` at the start
of each iteration, and stamps `"_uid"` on every `__wp_trace__`
append — disabled-skip, coerce-fail, unknown-type-skip,
handler-error, and ok-completion. Missing `_uid` defaults to ""
so pre-migration entries are still well-formed.
2. `wp_nodes/context_node.py` builds `module_seeds` keyed by
`entry["_uid"]` when present, falling back to `entry["id"]` for
pre-migration entries. The map still travels through the UI
payload as opaque string-keyed JSON; the widget doesn't care
what's behind the key.
3. `src/components/context/ContextWidget.vue` calls
`lastUsedSeedReader(m._uid ?? m.id)` instead of `m.id`. The
`?? m.id` fallback covers two cases: a workflow saved before the
`_uid` migration (Vue's ModuleEntry has _uid optional), and an
engine running pre-this-commit (no `_uid` in trace → key stays
library-id → fallback hits same key).
Net effect: lock-toggle on sibling A restores A's last roll, not
whatever sibling executed most recently. Previous fixes in this
audit moved the conflict scanner + drag fallbacks onto `_uid`; this
finishes the propagation through the engine layer.
User's QA debug confirmed: a 3-node chain (hair wildcard in Context A → constraint in Context B → mood wildcard in Context C) produced "long flowing hair" + "melancholic mood" despite a `long → exclude negative` constraint. Mood wildcard ignored the constraint entirely.
Root cause: `strip_internals` filters every `__`-prefixed key at the PIPELINE_CONTEXT socket boundary, including `__wp_picks__` (where the hair wildcard recorded its pick) and `__wp_constraints__` (where the constraint module registered its bucket). Both got dropped between Context A → B and again B → C. By the time C's mood wildcard ran, its ctx was empty of both — so:
1. The constraint loop in `WildcardHandler.resolve` saw no `__wp_constraints__`, skipped constraint application entirely.
2. Even if it had seen them, looking up `__wp_picks__["ae07018b"]` for the source pick would've returned None.
Fix — `ContextPayload` gets a third dataclass field, `internals: dict`. `build_payload` extracts a deliberately narrow set of cross-node-internal keys (`__wp_picks__`, `__wp_constraints__`) and emits them on `internals` while still stripping them from `context` (the user-facing payload the assembler consumes). `WP_Context.execute` reads `upstream.internals` and merges the entries back into ctx before running its own pipeline. Defensive shallow-copy so this node's mutations don't corrupt the upstream payload (frozen dataclass shares dict references).
Listed the cross-node key set in a module-level `_CROSS_NODE_INTERNAL_KEYS` constant so future additions are an opt-in choice — every other `__wp_*` key still gets dropped at the boundary by default.
Tests: 4 new cross-node integration cases driving real `WPContext.execute`:
1. Picks surface on payload.internals + don't leak to context.
2. Constraints surface on payload.internals + don't leak to context.
3. Three-node chain matching the QA scenario — long hair only allows positive mood across 20 seeds.
4. Symmetric short-hair-only-allows-negative-mood case.
472 pytest, 529 vitest, typecheck clean, size budget green.