A toggle with a Value writes it to its variable while on and nothing while off; its Negative goes with the variable. $name.bool reads the switch (true/false) across nodes, in text and in conditions, through __wp_toggle_bools__. Payloads reading $name.bool stamp schema 12; bool is reserved as a tag group.
* fix(extension): explain an empty wildcard pool when no option has tags
* feat(wildcards): filter a wildcard's pool by a run-time variable
A $name term in a Pool filter or a reference filter reads the variable
when the prompt runs: options tagged with its value stay, options with no
tag in that value's group stay too. An unset variable or a value that is
no tag on the wildcard drops that term and files filter_variable_no_tag.
Constraints still apply on top. Stamps schema 11 (MAX_KNOWN 11).
* test(syntax): cover the filter variable helpers in the syntax suite
Shorter release notes, SeedList help, small-model docs; _chat_turn split and pyright in CI; ESLint a11y ratchet and Modal focus trap; tags/models/Image Filter tests; AI LAN warning, web allow expiry, one thinking setting per answer on local models, recommended hints and Reset to recommended (16k context minimum, 32k recommended); Assembler Clear template clears the negative; Vue 3.5.44, SECURITY and Dependabot.
Chat round (explain on selection, page starters, reactions, streamed notes, thinking), negatives on node fixed values and combines, Pack builder quality fixes from 31 real-model eval rounds, and small-model support (scoped tools, checked tool arguments on local servers, two-phase steps, fixed prefix shown in Test connection).
The flagged option now keeps its weight and takes part in normal draws.
It is still taken, without a draw, when constraints or weights leave
nothing to pick. Flagging an option never changes a normal pick, and a
fallback weighted 0 only ever stands in. Editors give it its normal
share and show a fallback chip beside it.
A wildcard option can be marked as the fallback. It never takes part in a
normal draw and is used only when constraints (an Only rule included), a
filter or zero weights leave nothing else to pick, instead of resolving to
an empty string. Works for top-level wildcards and nested @{} refs, and
emits an info-level `fallback_used` note in place of
`constraint_excludes_all_options`.
- engine: engine/modules/_fallback.py shared by the handler and
ref_option_pool; payload validation allows one boolean fallback on a
non-null option
- schema 9 (additive): FALLBACK_SCHEMA_VERSION stamped in TS and Python,
MAX_KNOWN_SCHEMA_VERSION 9, strict validator keeps the flag, parity
fixture regenerated
- library editor: "set as fallback" on an option's probability cell, a
fallback chip, its weight disabled and excluded from the shares
- canvas: FALLBACK badge on the option row, no share in probabilities
- WP Debug: "Fallback used" label and step fact
- docs, release note, CLAUDE.md
- bundle budget raised 420 -> 424 KB (maintainer approved)
A derivation branch (and its ELSE) now runs its action and then each
entry of an optional extra_actions list, in order; each action sees the
previous one's write. The library editor gets "+ And" rows, the canvas
modal shows every action and gives each its own value override (keyed
"bi.K" / "else.K"), and WP Debug lists every action a branch ran.
$name.neg / $name.K.neg reads the negatives a variable carries. It rides
the accessor grammar's axis slot, so "neg" is reserved as an accepts
group name. Reading .neg is not a read of the variable, so it carries no
negatives along. Works in values, combines, the Assembler's negative
template and conditions; autocomplete offers it after the dot.
Both stamp schema v8. Cascade fixers and scans now walk the real
derivation action shape (action, extra_actions, else) instead of a stale
"actions" list, and read-only previews keep a var chip's accessor.
Wildcard options, fixed values and combines can carry a `negative`, and a
derivation action gains the `negative` ("Add to negative") mode. The
engine files negatives per variable in `__wp_negatives__`, carries them
across nodes, and the Assembler gets a `negative` output built from the
variables its template rendered, filled into a `negative_template` at
`$negatives`. Negatives resolve on their own seed stream and refs inside
them resolve quietly, so picks never shift. Injector rows can set a
negative, the Prompt Cleaner cleans an optional negative with its own
rule column, and the preview endpoint returns the negatives table.
Schema v8 stamps payloads that use a negative.
A ref that resolves to nothing (placeholder or deleted module, a filter
with no match, an empty option) left a double space or a doubled comma
in wildcard values and editor previews. Tidy the seam in both the
engine resolver and its TS mirror; text away from the seam is kept as
written.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEU7uBVCJn1isN2RowbhhT
Samples always has a column for the output variable, so a one-module
stack no longer shows a table of bare seeds.
The seed trace now shows the nested @{ref} picks each module made, as a
tree, with what each ref expanded to. The engine records them only when
the caller seeds ctx["__wp_ref_log__"] (the Test Runner does; canvas runs
don't), and nested picks count toward their wildcard's pick totals.
A stack card now says when it reads a $var that no pin and no earlier
item sets, which is the usual reason a combine keeps producing the same
text, and the Outputs tab points at it when the output never changes.
The trace no longer shows "no change" under a module that wrote values.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
When the picked option carried no tag on a declared accepts axis, the run
warned "$outfit has no 'SHOES' axis — is that tag group marked
'accepts'?", sending users to fix a group that was fine. Wildcards now
record which accepts axes they declare (`__wp_axis_decl__`, carried across
nodes and held seeds like `__wp_axes__`), so the resolver can tell the
cases apart: an untagged pick becomes an info note, and a genuinely
unknown axis names the axes that do exist.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
`$outfit.K.AXIS` compacted away picks whose option has no tag on the
axis, so every later index slid down and `$outfit.0.SHOES` could print
pick 1's shoe beside pick 0. Both the resolver (`get_axis`) and the
derivation condition reader now keep one slot per pick.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fQ7Lkc8AxwXqsRZrFBxE9
An accepts axis rolls one tag per pick (read as $var.AXIS), but a constraint
sourced from it folded over the option's whole menu instead of that rolled tag,
so $outfit.SHOES and the constrained $shoes could disagree. The source now
sends only the rolled winner; a target accepts axis OR-folds so a multi-tag
option survives when any alternative is allowed, then its own roll is pinned to
the allowed tag -- $source.AXIS == $target.AXIS under a diagonal. Classify
groups are unchanged (multiply).
The assembler also rendered $var.AXIS empty: __wp_axes__ crosses the socket on
ContextPayload.internals but the node only merged the internal-flag map; it now
merges the whole carve-out so the accessor resolves.
Import: the community validators silently dropped tag_group_kinds (and the
constraint target_select) because the lenient payload object never listed them,
degrading a shared accepts axis to classify. Both are now in the v2/v1
validators + the engine-parity fixtures, with preservation tests. Engine math
mirrored in TS via the shared corpus.
_pick_weighted returned options[0] when every weight was zero. That looked
like a working pick - a real option, right shape, right place - and stayed
wrong silently until the list was reordered and the choice changed. It hid
both over-narrowed constraints and a wildcard whose options were all
disabled.
Now returns None, which every caller already turned into an empty binding.
That is what the null option means, so the engine had the vocabulary for
this already.
Applies to all three pools that constraints can zero: the chain handler, the
nested-@{} path in the resolver, and the SPA preview. The inline {a|b}
branch weights are left alone - a user writing {0::a|0::b} is a different
situation and constraints never touch it.
Three tests asserted the old behaviour and are rewritten to state the new
one; the constraint_excludes_all_options warning stays, and now
distinguishes an over-narrowed pool from a legitimate null roll.
get_axis sits beside get_var as a plain dict lookup over a projection
build_resolve_ctx makes at frame construction, so the resolver stays a pure
consumer with no ctx dependency. Keyed by binding, matching what the
reference addresses.
Branches before the var lookup: $outfit.SHOES must resolve even when
nothing bound $outfit itself, and must not trip unknown_var on the way
past. Never routes through deref_var_value either - an axis value is not a
ListVar, so the SP2a accessor contract is untouched.
A missing axis renders empty and warns unknown_tag_axis rather than
failing, matching how every other unresolvable reference behaves.
A reference now carries at most one pick index and one axis, in either
order - $o.0.SHOES and $o.SHOES.0 name the same value. Accepting both is
deliberate: $o.0 is already meaningful on its own, so appending .SHOES is
composition rather than a second syntax, and rejecting it would make the
natural spelling the broken one.
Two mirrored regex alternatives, so .0.1 and .SHOES.BELTS reject by not
matching their second segment and the tail stays literal text - no
validation pass. Both tokenizers normalise to one (name, index, axis)
triple immediately, so nothing downstream learns which spelling was used.
Replaces the var_nondigit_accessor_is_text corpus case, which pinned the
behaviour this changes, with cases for both orders and both nonsense forms.
Two halves of the same gap, left over from the nested-ref fix.
**The sub-category picker offered the wrong pool's tags.** Its checkbox list
came from `buildWildcardRefData(catalog)` — a library fetch — while the engine
filters the ref against THIS node's snapshot whenever the uuid is a module
here. On a drifted snapshot the picker listed a tag no option in the running
pool carries: pick it, and the ref resolves to nothing, with the checkbox list
itself being the thing that misled you.
`overlayContextPools` applies the same precedence `resolvePoolFor` already
gives the hover card, so card, picker and engine now describe one pool.
`ContextPool` grew the declared vocabulary (`subCategories`, `tagGroups`,
`hasNull`) the picker needs — distinct from `tagSets`, which is what options
actually carry; a declared tag no option uses is offerable but matches
nothing. `uuidToName` is deliberately NOT overlaid: it holds the variable
binding a chip labels itself with, not a display name.
Only the derivation modal needed it. The constraint modal already builds from
node modules, and the SPA editors have no node, where the overlay is a no-op.
**"Matched no options" told you nothing.** The engine emitted
`ref_subcategory_empty_pool` all along, but `WARNING_LABELS` had no entry, so
Debug rendered the raw slug — and even read, the message could not distinguish
a wrong filter from a right filter aimed at a stale copy. That ambiguity is
exactly what made this bug a hunt.
`_stamp_pool_provenance` records, per catalog entry, whether the pool came
from this node or the library, plus the library's option count when a node
copy has drifted. The warning now reads:
@{05c54db9:test} matched no options — searched this node's copy
(1 option); the library now has 2
with `pool_origin`, `pool_total` and the pool's `available_sub_categories` in
`detail` — the tags it DOES offer being the whole explanation. Labels added
for the two nested-ref warnings and `ref_out_of_surface`.
Provenance is runtime-only: `__wp_catalog__` is stripped at the socket and the
workflow-embedded snapshot is built elsewhere, so neither key reaches saved
JSON. The extra reads are one per embedded wildcard on an already-open
connection, and the stamping never raises — diagnostic garnish must not take a
graph run down.
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
open the surface gate in _resolve_ref for surface="derivation" so
action.value strings can embed @{uuid} wildcard refs. conditions
stay raw (they compare literal text, never roll). constraint bucket
threads through build_resolve_ctx unchanged, so chain constraints
re-weight nested targets for free.
update parametrized ref_out_of_surface tests to remove derivation
from the gated surface list — it now resolves like wildcard.
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.
Resolve the SP2b multi-pick semantics:
- range count drawn in [min,max] (a fixed {N$$} keeps min==max and does NOT
draw, so its seed sequence is byte-for-byte unchanged);
- independent (~) = N draws WITH replacement, each branch resolved
independently (a lone ref re-rolls every time);
- unique lone-ref (count>=2, wildcard surface) = N DISTINCT options from the
ref filtered pool — a per-site SP2a multi-select. count==1 stays on the
legacy single-pick branch path (seed safe).
Extracts ref_option_pool (catalog lookup + :expr/!null filter + chain
constraints, incl. the strict-unknown raise + warnings) from _resolve_ref so
both the single pick and the lone-ref multi-pick share it. Also closes the
coverage gap: a $var branch inside a multi-block on the combine surface.
The {N$$sep$$} brace prefix now also accepts a count range ({N-M$$}) and a
~ independent flag ({N~$$} / {N-M~$$}, repeats allowed; absent = unique).
Both tokenizers (Python engine + TS richTokenize) parse it identically
into dp-multi meta {min, max, independent}; legacy `count` is kept (== max)
so existing readers + the resolver behave unchanged for a fixed {N$$}.
Shared syntax-corpus.json locks Py≡TS parity (existing multi_pick entries
gain the new keys; new range/lone-ref cases added).
Resolution of the range/independent semantics lands next (engine resolver);
this commit is tokenizer + parity only — a fixed {N$$} is byte-for-byte
unchanged.
Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.
4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.
validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.
Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
Accumulated branch work that was uncommitted alongside the pair-badge feature in the prior commit. Captured as a single sweep commit so the branch can merge cleanly; future work should commit per-feature to avoid this.
- 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.
Three improvements aimed at making constraints actually useful in
composed pipelines, with the conflict scanner keeping up.
## Phase A — nested constraints (engine)
Pre-2026-05 constraints only fired when the target wildcard ran as a
top-level chain module. Wildcards reached through `@{uuid}` refs from
inside another wildcard's option value silently bypassed every rule.
Composition (outfit-set wildcards, palette reuse) was a constraint
blind spot.
- New `engine/modules/_constraints.py` holds the shared application
logic (loop constraints by target uuid, look up source pick, apply
matrix/exception cells, emit unknown_constraint_source +
constraint_excludes_all_options warnings)
- `wildcard_handler.resolve` now delegates to the helper instead of
inlining the loop
- `engine/syntax/resolve._resolve_ref` calls the same helper after
fetching the catalog wildcard but before rolling — nested target
wildcards now get constrained identically to chain-level ones
- `ResolveContext` exposes `get_constraints()` + `get_picks()` so the
resolver has the back-channel it needs without depending on the
full pipeline ctx dict
## Phase B — `@{uuid:subcat[,subcat]}` filter
New optional ref syntax. `@{color:warm}` resolves the catalog wildcard
`color` but restricts its option pool to entries whose `sub_category`
is in the filter list. Same semantics as chain-level
`instance.category_filter`, but scoped per call site so one library
wildcard stays reusable.
- Python tokenizer regex extended to capture optional `:subcat,subcat`
group; `_resolve_ref` filters options pre-pick + pre-constraint
- TS `richTokenize.ts` mirrors the grammar so previews + RichTextInput
recognise the new form
- `extension/graph.ts:WC_REF_RE` + `preview-tokens.ts:REF_RE` updated
to skip the filter when extracting uuids (uuid is still group 1)
- Empty pool after filter → empty string output + new
`ref_subcategory_empty_pool` warning so the user sees an
unsatisfiable filter instead of falling back silently
## Phase C — scanner reach-set + classification refinement
Constraint scanner pre-fix:
- flagged `constraint_target_in_upstream` whenever target had ANY
upstream instance, even if a downstream / local-after / nested-after
instance was also present
- flagged `constraint_target_missing` for targets reachable only
through nested `@{}` refs
Both false-positives. Post-fix scanner computes
`localNestedReachAfter[i]` — transitive `@{}` walk through the local
catalog starting from modules at positions ≥ i — and uses it to:
- skip `target_in_upstream` when a bindable instance exists elsewhere
(downstream, local-after-self, nested-after)
- skip `target_missing` when target is reachable via nested ref from
any later chain wildcard
- still flag both when no bindable instance exists (sole-upstream,
truly-missing both remain caught)
## Tests
- 8 new Python tests under `TestNestedConstraints` +
`TestNestedSubcategoryFilter` (constraint applies through nested,
excludes-all warning, source-not-yet-picked warning, subcat narrows,
multi-subcat union, empty pool warning, empty filter no-op,
subcat+constraint composition)
- 7 new TS tests under `scanConflicts — constraint nested-reach
classification` (nested reachable doesn't flag missing, transitive
deep reach, upstream+downstream silent, upstream+local-after silent,
sole-upstream still flags, nested-from-before-constraint still
flags, nested with subcat filter still reaches)
## Numbers
- 696 pytest pass (+8)
- 1672 vitest pass (+7)
- ruff clean
- bundle 306899/307200 (301 bytes headroom)
Wildcard + fixed_values surfaces are binding PRODUCERS, not consumers
— they don't read upstream \$var. Combine + derivation + assembler do
(template interpolation is their reason to exist).
Adds VarOutOfSurfaceError to engine.syntax.types + extends SurfaceKind
literal with "fixed_values". _resolve_var checks surface membership
in _VAR_SURFACES_ALLOWED frozenset; out-of-surface refs warn + render
literal \$name in lenient mode (existing default), raise in strict
mode (used by tests + dev mode).
Lenient strategy means workflows saved before this cycle keep
resolving — the warning surfaces in toast / conflict scanner without
crashing the queue.
Test fixture adjustments document the new contract:
- tests/syntax/test_resolve.py default surface flips wildcard -> combine
(these tests cover VAR resolution mechanics, not surface gating)
- tests/syntax/test_determinism.py corpus single_var + var_in_text
cases use surface=combine
- tests/engine/modules/test_wildcard_handler.py:
* test_resolve_var_substitution_in_option renamed to
test_resolve_var_in_option_renders_literal_warns + asserts new
lenient path
* test_resolve_pinned_mode_resolves_inline_syntax swaps \$color for
a {red|red} alternation that DOES resolve on wildcard surface
Surface gating tests live in tests/engine/syntax/test_resolve_var_surface.py
covering all 5 surfaces + lenient vs strict modes + internal-key skip.
Combine semantics: read upstream bindings + emit one composed
output_var. When a template references a var nothing upstream has
bound (e.g. user mistypes "$colur" or removes a wildcard), the
resolver silently collapsed the token to empty string and the
combined output came out wrong with no signal — user had to
reverse-engineer "why is half my prompt blank?"
Add a `unknown_var` warning at the lone _resolve_var call site:
type "unknown_var", severity "warn", detail carries the var name +
the surface. Surface-agnostic — wildcards, derivations, and
constraints get the same signal when they reference a var that
isn't in ctx. Internal-key lookups (`__wp_*` engine bookkeeping)
are still skipped silently — those legitimately won't be in vars.
The TODO at the original site (before this commit) acknowledged
the silent-empty behaviour was a gap pending user feedback. User
asked for it explicitly today.
Tests:
- combine handler now asserts the warn fires + carries the right
detail when a template var is missing
- sanity test that bound vars don't trigger the warning
Wire walk_transitive_refs into _build_ctx_with_catalog so the SPA Test
Runner populates __wp_catalog__ from the live DB before resolution. Also
fixes _resolve_ref to support SnapshotEntry payload.options shape alongside
the legacy flat options shape, and updates db_session to accept an injected
connection from request.app["wp_db"] for test isolation.