Canvas Context runs now set an explain flag. With it, each trace row carries
the module's display name and a handler detail dict: which derivation branch
fired, with every test's result and the value it read; a wildcard's option,
odds, pool and the constraints that re-weighted it; a constraint's reach and
rule counts. Warnings a module raised name it as their owner, and trace rows,
warnings and nested @{} picks are tagged with the Context node's graph id.
WP Debug forwards the nested-pick log, node seeds, internal flags, rolled
axes, constraint hits and multi-pick items.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwo837fQ1tm6RacaQGYom1
A constraint rule with mode `only` turns its row into an allow-list: when
the source fires, a target with no rule of its own in that row is excluded.
On an exception it links literal values (maid forces frilled apron); in a
matrix row it links tags. Before, a link needed an exclude rule for every
other target, and a target added later slipped through.
The rule itself weighs 1.0 and targets with any other rule keep it. A matrix
`allow` cell does not count as a rule because the editors show it as
Neutral. The axis-menu probe ignores value-level `only` so a tag link in
the same constraint still pins an accepts roll. Python and TS folds stay in
step through new constraint-corpus cases.
Also reads a legacy `neutral` cell mode override as `allow`: the canvas
matrix stored that value, and the engine rejected it, failing the whole
constraint.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
Reported by the maintainer: the editor accepted the same source/target
combination more than once with contradictory modes and factors. Only one ever
applied. `combine_constraint_factor` files exceptions into a dict keyed by the
pair, so the last one in the list wins and every earlier one is discarded.
Verified against the pure math, and the alarming direction is real:
[boost x3, reduce x0.5] -> 0.5 not the combined 1.5
[reduce x0.5, boost x3] -> 3.0 order decides the result
[boost x3, exclude] -> EXCLUDE
[exclude, boost x3] -> 3.0 the exclusion is thrown away
That last line contradicts the documented model in CLAUDE.md, which says
constraints combine "with `exclude` (factor 0) absorbing". Within duplicate
exceptions it neither combines nor absorbs, and the deciding factor is list
position -- which no surface displays and nothing lets you edit.
Enforced at the AUTHORING boundary, not in `validate_payload`. `resolve` calls
that on every graph run, so a rule there would turn an already-stored payload
into a hard failure mid-execution -- strictly worse than the silent
misbehaviour it replaces, and unreachable for a user who cannot open the editor
without the graph running. Handlers may now expose an optional
`validate_authoring`, called by the module create/update paths and by bundle
child validation; existing rows stay executable.
Engine math is deliberately unchanged. Making duplicates combine would have
been defensible -- it is what the docs describe -- but it silently alters
re-weighting for anyone who already has them, and "you cannot create this
anymore" achieves the same end without touching anyone's output.
Scanned the maintainer's library first: 21 constraints, 0 duplicate pairs, so
nothing existing is affected either way.
The consumed-set keyed on __constraint_module_id__ = the LIBRARY uuid,
so two instances of the same constraint library entry collapsed into a
single one-shot. CLAUDE.md is explicit: 'each constraint module is a
one-shot ... author multiple constraint modules to affect multiple
target instances.' Two authored modules must be independent.
The carrier-claim failsafe made the collision visible: a carrier
(backdrop) claiming the shared cid spent the WHOLE hair_x_mood family,
so a downstream direct mood instance — which the pair badge assigns to
hair_x_mood #2 — then rolled unconstrained.
Changes:
- pipeline.py stashes __wp_current_module_uid__ (the per-instance _uid)
alongside the existing __wp_current_module_id__ (library uuid), and
pops both after each module. The wildcard handler still matches the
library id against constraint targets; only the constraint handler's
consume key switches.
- constraint_handler keys __constraint_module_id__ on the uid, falling
back to the library id when _uid is absent (legacy / hand-built test
modules — preserves their behaviour).
- claim_carrier_constraints now claims at most ONE constraint per
carried target (first unconsumed), so a multi-ref carrier can't
swallow an entire same-target family — it consumes one per target,
exactly like apply_constraints_for_target fires one per call.
Regression test reproduces the user's exact shape (two hair_x_mood with
the same library id + distinct _uid): backdrop claims instance A,
top-level mood claims instance B, mood ends up constrained. All 1027
pytest + 2381 vitest pass.
- Constraint first-instance one-shot: each constraint claims first
downstream target wildcard instance then is consumed. Engine tracks
via ctx __wp_consumed_constraints__ across pipeline + nested ref
resolves. Emits constraint_never_applied (info) for unused at end.
- Null wildcard option: is_null flag opts into no-output slot that
survives category filter + matrix constraint. Reserved sub_category
name guard forbids null as real sub-cat.
- Inverted nested-ref null filter (2026-05-25): null keyword in filter
list now EXCLUDES wildcard null option. Default keeps null in pool
alongside listed sub-cats.
The previous commit let users cycle empty matrix cells in the modal but
the engine only iterated `payload.matrix` — overrides for keys absent
from the library were silently ignored. Cycling produced dead UI: state
saved, engine ignored, runtime identical.
Engine extension: after the library-merge pass, do a second pass over
`cell_mode_overrides` ∪ `cell_factor_overrides` minus `covered_keys`.
Each leftover key becomes a new matrix entry with implicit baseline
mode "allow" + factor 1.0; whichever side(s) the user set populate the
rest. Disabled set still wins — entries in `disabled_matrix_cells` skip
both passes.
Added `decode_key` helper alongside `encode_key` for the second-pass
src/tgt extraction. Round-trip + malformed-input rejection covered by
new key tests. Malformed override keys in the resolve path are skipped
silently rather than crashing the run (defensive — instance JSON could
have rotted across schema migrations).
Updated the existing "unknown key silently ignored" test to assert the
new behavior (entry created from override). Added focused tests for:
factor-only override defaults to allow mode, disabled set still skips
empty-cell overrides, malformed key in override map doesn't crash.
Code review (silent-failure-hunter + feature-dev:code-reviewer) flagged seven paths where the engine or scanner silently swallowed problems instead of warning. Fixed the high-confidence ones:
1. `engine/modules/wildcard_handler.py:_apply_constraint_to_options` — `mode: "allow"` with `factor != 1` silently ignored the factor (SPA editors expose the factor field for every mode, so users naturally try `allow + factor=0.5` expecting weight reduction). Added a `constraint_factor_ignored_on_allow` warning surfaced via the existing `__wp_warnings__` channel. Defensive `unknown_constraint_mode` path also added — currently dead code (validator rejects typo'd modes upfront) but cheap belt-and-suspenders for any future path that bypasses validate.
2. Constraint excludes-all detection — when the matrix excluded every option in the target's pool the post-application total weight was 0 and `_pick_weighted` fell back silently to `options[0]`. The user got the same first option forever with no signal that their constraint over-narrowed. Added a `constraint_excludes_all_options` warning fired when constraints applied AND total weight collapsed to 0.
3. `engine/modules/constraint_handler.py:_ctx_set_constraint` — both the setter API and dict-fallback paths swallowed `Exception` silently, leaving the constraint un-registered with zero signal. Added a `constraint_register_failed` error-severity warning so a fundamental wiring break shows up in WP_Debug instead of being mysterious.
4. `engine/modules/fixed_values_handler.py` — non-list `instance.values_overrides` (string/dict from old SPA save or hand-edited JSON) silently fell back to library payload, dropping the user's edits without explanation. Added a `fixed_values_overrides_malformed` warning naming the wrong type so the fallback is debuggable.
5. `src/extension/preview-resolver.ts` — failure ledger was a `Set<string>` that made every failure permanent for the session. A single page-load 5xx flake left a uuid stuck rendering as `$ae07018b` until full reload. Replaced with `Map<string, {at, permanent}>` plus a 30s retry TTL: 404 stays permanent (server confirms the uuid doesn't exist), every other status retries after the TTL. Network/JSON errors are also retryable now and log once via the same `window.__wp_walker_log__` flag the walker uses.
6. `src/extension/conflicts.ts` — `localWildcardIndex` only tracked ENABLED wildcards. A constraint pointing at a disabled local wildcard would falsely flag as "constraint_source_missing" / "constraint_target_missing" because the disabled module was invisible to the index. Track both enabled and disabled — disabled-state is a separate concern from "does the uuid live in this node."
Tests: 3 new constraint-integration cases (factor-ignored-on-allow, excludes-all, plus the existing positive-path checks unchanged), 2 new fixed_values cases (malformed warning fires, no-warning when overrides absent).
468 pytest, 523 vitest, typecheck clean, entry 2.71 KB / total 186 KB gzipped.
Audit found other items still outstanding (sameAsLibrary index-strict compare, multi-constraint composition surprise, shadows+duplicate dual-emit policy, ctx as growing god-object) — deferring those as design questions rather than reflexive code changes.
QA reported two ordering gaps in the constraint module — the second one surfaced as a runtime ValueError that killed the whole constraint at validate time:
1. Static ordering: a constraint card stayed silent when its source/target wildcards were missing or in the wrong execution order. Runtime emitted `unknown_constraint_source` once it hit a missing source, but the user only saw it in WP_Debug after queuing, and the more nuanced cases (target before constraint, target in upstream Context) had no signal at all.
2. Strict factor > 0: SPA-saved payloads occasionally surface zero-factor cells (the editor exposes them as a legitimate "exclude this pair" form). The handler rejected them with "factor must be a positive number," which made the entire constraint fail validation and skip its bucket registration.
Static ordering — four new conflict types in `extension/conflicts.ts`:
- `constraint_source_after_self` — source in same node at index >= constraint
- `constraint_source_missing` — source uuid not in this node nor in upstream
- `constraint_target_before_self` — target in same node at index <= constraint
- `constraint_target_in_upstream` — target lives in an upstream Context (already picked)
`scanConflicts` accepts a new third arg `upstreamWildcardUuids: string[]` so it can tell "source upstream = good" apart from "source nowhere = bad." The check fires alongside the existing missing/dup/shadow logic; multiple problems on one constraint surface as separate dots in the tooltip. Target NOT in same node and NOT in upstream is intentionally NOT flagged — could still be a valid downstream Context, and the static scanner has no downstream walker to differentiate from a typo. Runtime still catches that case.
Walker plumbing — `extension/graph.ts` adds `collectUpstreamWildcardUuids(rootGraph, node)` returning the set of every wildcard module's id reachable upstream (skipping muted/bypassed nodes, mirroring the existing var walker). Wired through `widgets/context.ts` → `ContextWidget.vue` prop → scanner.
`labelFor` extended for the four new types so card tooltip + subgraph badge agree on wording: "source after constraint", "source missing", "target before constraint", "target already picked upstream".
Validator relax — `engine/modules/constraint_handler.py:_validate_cell` and the exception-list check now allow factor=0 (effectively "exclude") and only reject negative factors. The legitimate SPA-saved zero-factor payload now passes validation; the constraint registers normally and the wildcard handler sees its bucket. Updated the matching test from "rejects zero factor" to "accepts zero factor" with an inline comment naming the regression.
Tests:
- 8 new scanner integration cases — clean chain, source-after-self, source-missing, target-before-self, target-in-upstream, target-unfindable-no-warning, disabled-skip, dual-warning composition.
- 3 new walker cases — uuid collection across upstream Contexts, mute/bypass exclusion, no-upstream-link.
- Updated constraint handler test for the new factor=0 acceptance.
Totals: 523 vitest, 464 pytest, typecheck clean, entry 2.71 KB / total 185 KB gzipped.
Three QA-driven fixes from the post-5.5.6 canvas pass:
1. Conflict label drift. Card tooltip said "overrides upstream / duplicate / missing variable" while the subgraph badge said "shadows / duplicate / missing" — same conflict types, different words. Hoisted a single `labelFor(type)` helper into `extension/conflicts.ts` and routed both consumers through it. Wording is now uniform: "overrides upstream / duplicate / missing".
2. Constraint metadata leak. `ConstraintHandler.resolve` wrote its bucket to `ctx["_constraints"]` (single-underscore key). `engine.context.strip_internals` only filters double-underscore (`__`) keys, so the bucket leaked to the public socket payload — the assembler's autocomplete listed `$_constraints` as a pickable variable even though no module had bound it. Renamed the key to `__wp_constraints__` so the standard internals-strip drops it. Added a regression test that runs the handler then asserts strip_internals removes it.
3. Modal entry-row leave animation never fired. The original CSS had only `wp-medit-list-enter-*` classes; deleting an entry just blanked the row instantly because `*-leave-active` / `*-leave-to` were undefined. Added a 120ms fade + 8px slide on leave with `position: absolute` so the row exits without blocking the FLIP-move on surviving siblings. Matches the new card-style entry container — vanish-without-feedback felt broken in the redesigned look.