Commit Graph
75 Commits
Author SHA1 Message Date
Dumitru Picalau 26df88216f feat(ai): chat round, node negatives, pack builder quality fixes (#69)
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).
2026-10-17 01:23:58 +02:00
Dumitru Picalau df9a19a624 fix: engine rng keying, widget teardown, import validation and connection leak (#72)
Derivation and fixed values modules roll on their own rng stream (seeds through their alternations can resolve differently). DOM widgets tear down when their node is removed (effect scope, cleanups, shared onConnectionsChange wrapper, seed state in WeakMaps, capped topbar rAF loop). /import/commit validates module and bundle child payloads. WP_Context and preview close their library connection; the node path migrates once. Pinned option missing warns. CLAUDE.md says which parts of the schema machine are wired; js budget 473 KB.
2026-10-17 01:07:34 +02:00
Dumitru Picalau 657dc07831 feat(ai): assistant overhaul: tool registry, server-kept chats, search index, security, page tools, web search, saved chats, images, permissions, derivation NOT (schema 10), OpenAI Responses API (#64)
Phases 2 to 8 of the AI assistant overhaul plus fixes from real-model chats: one tool registry with native tool calls, server-kept chat logs with prompt caching, an FTS5 library index with opt-in meaning search, keys bound to their address and a guard on every AI route, page tools and opt-in web search, saved chats with streaming, Try again, a context meter and compaction, chat images, delete/category/tag permissions with Undo, constraint and test cards, card fixes and previews, questions with answers to click, the derivation NOT toggle (schema v10, community catalog 0021), request fixes for the newest Claude models, and OpenAI's Responses API. Review fixes: Try again clears the old turn's fixes and answers, category Undo removes only what it made, a failed delete rolls back in order, not_matches patterns are checked, busy chats refuse a second request, and chat search ignores stored JSON.
2026-10-14 14:56:12 +02:00
Claude 417353e92f feat(wildcard): fallback option rolls normally and also stands in
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.
2026-10-02 16:42:36 +00:00
Claude a11ceaac1c feat(wildcard): fallback option used when nothing else is left to pick
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)
2026-10-02 16:10:31 +00:00
Claude cd99ef360c fix(syntax): close the gap an empty @{} ref leaves behind
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
2026-09-29 18:03:16 +00:00
Claude 4449bd9c82 feat(derivation): combine branch tests with AND / OR groups
A derivation branch condition can now be a group ({match: all|any,
conditions: [...]}) of tests and nested groups. The manager editor gets
+ Condition / + Group controls and a clickable AND/OR connector; the
canvas modal shows the whole expression and one value override per test
(override keys "bi" and "bi.k"). The engine gains is_empty and
is_not_empty ops, which the editor already offered.

Payloads using groups or the emptiness ops stamp schema 7
(MAX_KNOWN_SCHEMA_VERSION 6 -> 7, additive, no migrator).

Also fixes the derivation editor rewriting presence ops to equals when a
saved derivation was reopened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkgtCKNLjk77oiA5efKPia
2026-09-28 22:15:39 +00:00
Claude f7419f2156 feat(manager): rebuild the Test Runner as a scenario workbench
The Test Runner page is now a workbench on the real engine: a rail of
saved scenarios, a stack builder that lines library modules and bundles
up like a Context node, pinned values, a seed range, and results in
Variables, Outputs, Samples and Warnings tabs with a per-seed trace.

Every run goes through POST /wp/api/test/run, so the numbers match what
the canvas produces for the same seeds. Send to Test Runner opens an
unsaved quick run and runs it straight away.

The old single-module sampler and its in-browser simulator are removed:
the /wp/api/test route, api.test, TestRequest/TestResponse and
utils/resolver.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwq95EvrKu6WgE9D5rahKy
2026-09-26 02:15:17 +00:00
Claude aef082964d fix(axes): say when a $var.AXIS read is empty because the pick has no tag
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
2026-09-25 21:25:26 +00:00
Claude 4afbb5a08e fix(axes): keep multi-pick axis reads positional
`$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
2026-09-25 20:18:14 +00:00
DumiFlex 934f7dc92d fix(axes): drive constraints by the rolled tag, fix the assembler read, preserve on import
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.
2026-08-20 23:58:44 +03:00
DumiFlex 05d53a0c6c fix(wildcard): an excluded pool emits nothing instead of the first option
_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.
2026-08-18 17:38:05 +03:00
DumiFlex f0117d4230 fix(constraints): a multi-tag source no longer excludes its whole target pool
The reported failure: an outfit option tagged sneakers, high_heels and
sandals excluded every shoe in the constrained target, the pool summed to
zero, and _pick_weighted fell through to options[0] forever - looking like a
working pick until the list was reordered.

The kind map is derived from the pick records rather than passed down from a
payload, since a pick only carries an axes entry when its source declared
that group accepts. That keeps combine_constraint_factor pure and makes a
legacy pick with no axes take the unchanged flat-product path.

Also fixes the SPA preview resolver, which mirrors the applier: it dropped
axes when building its per-pick list, so the preview would have shown a
narrower pool than the run actually produced.
2026-08-17 22:58:23 +03:00
DumiFlex 5d910f5f39 feat(derivation): let a condition test a rolled axis
Conditions store condition.var as raw text rather than tokens, so this path
parses the accessor itself via parse_var_reference. _ctx_set is left on
split_var_accessor: an action writing to $outfit.SHOES should still target
the base var rather than pretend to write an axis.

_ctx_has checks the axes bucket rather than ctx, since $outfit.SHOES can
exist while outfit is unbound and vice versa.

Action values needed no work - they already route through resolve_text.
2026-08-17 22:52:52 +03:00
DumiFlex f4444394e5 feat(wildcard): roll one tag per accepts axis at pick time
Two consumers address a pick differently, so each gets the key it addresses
by. The constraint fold needs a tag-to-axis map and binds by module uuid, so
that rides on the pick entry. A $var.SHOES read binds by variable, so the
rolled winner goes to __wp_axes__ keyed by binding - which also means the
read never inherits the uuid-bucket collision.

Rolled at pick time, never on read: that is what makes two modules, or two
nodes, agree. The draw happens AFTER the option draw on the same rng, so an
existing locked_seed still reproduces its option.

Multi-select rolls per pick in pick order, so $outfit.SHOES mirrors the
shape of $outfit.
2026-08-17 20:49:47 +03:00
DumiFlex b6e6f55cce feat(wildcard): validate tag_group_kinds on the payload
A group may declare itself classify (the default, and what every stored
payload means) or accepts. Only an accepts name has to satisfy the
identifier rule, because only an accepts axis is addressable as $var.AXIS;
a classify group keeps whatever name it has today.

Also narrows the constraint weight multiply with isinstance rather than
sentinel identity, which the newly-typed fold made unprovable.
2026-08-17 20:16:41 +03:00
DumiFlex 7cd08f070c feat(constraints): fold accepts-axis tags with max instead of product
An option tagged with several tags from one group means those tags are
alternatives it offers, not statements that are all true at once. The fold
multiplied across them, so any sibling's exclude zeroed the option and a
source carrying three shoe tags excluded every shoe in the target pool.

Tags inside a group the source declared 'accepts' now fold with max.
Everything else still multiplies, and the path is unreachable without
axis_kinds, so a payload predating the feature folds byte-identically -
locked by the corpus case that omits the kind map.

Also replaces the bare object() sentinel with a narrowable class: 'is
EXCLUDE' never narrowed, leaving every 'factor *= r' unprovable.
2026-08-17 20:09:32 +03:00
DumiFlex 70da7e0faf feat(constraint): cache source/target wildcard names for broken-ref banner
Stamp the source/target wildcard display names onto the constraint payload
(source_wildcard_name / target_wildcard_name) when the source/target is set,
so the broken-reference banner can show "Source wildcard 'Starter subject'
(54693e08)" instead of just the uuid after the wildcard is deleted.

Display-only and additive: the engine resolves by uuid and never reads these
names (diagnostic, like producer_engine_version), so there is no engine
resolution change, no Py=TS parity, and no community schema bump. Legacy
constraints lack the fields and gracefully fall back to uuid-only.

SPA ConstraintEditor stamps on pick + reattach + load and persists when
known; the canvas ConstraintInstanceModal mirrors the reattach stamp and
falls back to the cached name in the banner when the live lookup misses.

Spec: docs/superpowers/specs/2026-06-12-constraint-cached-ref-names-design.md
2026-06-12 21:56:54 +03:00
DumiFlex 9071dfba6d fix(engine): bind constraint to its own bundle copy's source pick 2026-06-12 20:23:36 +03:00
DumiFlex 690169bda3 test(engine): lock constraint source-pick fallback semantics 2026-06-12 20:16:13 +03:00
DumiFlex febb9f3e70 feat(engine): capture constraint's own bundle_origin on registered meta 2026-06-12 20:09:40 +03:00
DumiFlex d6094ed393 feat(engine): record per-origin source pick view in __wp_picks__ 2026-06-12 20:03:02 +03:00
DumiFlex 7bcfcac9ad feat(engine): thread bundle_origin into resolve ctx per module 2026-06-12 19:56:18 +03:00
DumiFlex 0e9b5cfbbf refactor(constraint): centralise derivation branch-key format + py/ts corpus parity
Extract branch_carrier_key() (engine) and branchKey() (TS) so the derivation branch-key format (rule_id:bi / rule_id:else) has a single source per language; route all call sites through them.

Add branch_key_cases to tests/fixtures/constraint-corpus.json; the Py runner (test_constraint_corpus.py) and TS runner (constraint-pairs.test.ts) each assert their production helper against the shared fixture key, so the two formats cannot drift independently.

Resolved-output parity is engine-only (TS never resolves derivation refs); A1 already locks it.
2026-06-11 18:03:29 +03:00
DumiFlex 599c3b17f6 feat(engine): stamp derivation carrier (uid, branch_key) for nested reach
_apply_action now accepts carrier_key and stamps (module_uid, branch_key)
on the resolve context before calling resolve_text, enabling SP3 pick-reach
constraints to target wildcards nested inside a specific derivation branch.
Branch call site passes f"{rule_id}:{bi}"; else call site passes
f"{rule_id}:else".
2026-06-11 17:19:28 +03:00
DumiFlex 059517f740 feat(engine): resolve @{} refs on the derivation surface
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.
2026-06-11 17:09:46 +03:00
DumiFlex 73a00a3760 fix(context): constraint-reach QA, node codenames, bundle-aware toolbar
QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
2026-06-11 16:38:56 +03:00
DumiFlex d826c6ba61 fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup 2026-06-11 12:49:23 +03:00
DumiFlex 4165b2e9f4 feat(engine): thread carrier uid + option id for nested-pick constraint reach 2026-06-11 06:42:16 +03:00
DumiFlex be1f765243 feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot
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.
2026-06-11 06:21:57 +03:00
DumiFlex 8c3c9aedc2 feat(engine): validate + register constraint target_select; instance override field 2026-06-11 05:53:44 +03:00
DumiFlex f68d2ece81 feat(engine): per-pick recording + apply constraints via combine_constraint_factor 2026-06-11 05:41:40 +03:00
DumiFlex dd785c5ca7 feat(engine): pure combine_constraint_factor + shared constraint corpus 2026-06-11 05:29:40 +03:00
DumiFlex e78592839f feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick
Exposes the pick_independent instance field (engine resolution landed in 33b3600): instance schema + snapshot round-trip, TS instance type + _shell INSTANCE_FIELDS_PER_KIND registry (so reset-overrides + modified-state + the TS/Py parity gate all include it) + preview cache-key, and the canvas PoolSection 'Allow repeats' toggle (shown only while multi-pick is active; off = unique). Instance-only (per-node, canvas runtime), additive + tolerant — no community catalog change.
2026-06-10 00:09:10 +03:00
DumiFlex 33b3600bf8 feat(engine): independent (allow-repeats) multi-pick for the wildcard pool
The pool multi-pick (PICK min-max) now honors instance.pick_independent: when true it draws WITH replacement (repeats allowed) and the count is NOT clamped to the pool size, mirroring the inline ~ flag. Default (unique, pick_k_unique) path is byte-unchanged, so existing locked seeds reproduce. Foundation for the 'Allow repeats' pool toggle; schema (instance + library default) + TS parity + UI follow.
2026-06-09 23:44:25 +03:00
DumiFlex d3881c84a1 fix(snapshot): include SP2a pick fields in instance baseline + snapshot type 2026-06-08 22:11:11 +03:00
DumiFlex ace0486f9d fix(engine): guard junk pick counts + warn on multi-pick constraint source 2026-06-08 21:06:35 +03:00
DumiFlex d41be86fae fix(engine): fold list-valued vars in derivation + converter reads 2026-06-08 20:58:59 +03:00
DumiFlex 443db49546 feat(engine): multi-select wildcard binds a list variable 2026-06-08 14:31:26 +03:00
DumiFlex 13de622c6b feat(engine): multi-tag options + boolean sub-category filters (v2)
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.
2026-06-07 00:43:55 +03:00
DumiFlex 789a9aedb8 feat(wildcards): require option.id (8-hex) in payload validation 2026-05-24 06:03:55 +03:00
DumiFlex 6a0c0bf97b refactor: remove pipelines feature; add unified AllItems library view
Pipelines never paid off as a separate module kind, so peel out:

- Engine: drop pipeline_handler, remove "pipeline" from valid module
  types in repositories + schema parity.
- Manager: delete Pipelines/PipelineEditor views, PipelineSteps +
  PipelineStepPicker components and their tests. Router, sidebar,
  topbar, dashboard, TestRunner, ImportExport, resolver and
  bundleSelection repointed off the removed routes/types.
- wp_api: drop pipeline branch from modules.py + import_export.py
  (and its test).

In its place, add a unified library view:

- src/manager/views/AllItems.vue — modules + bundles in one filter
  panel with kind multi-select (OR across the 6 kinds). Reachable
  via the Dashboard "View all" link.
- ModuleListView grew the slots/props AllItems needs; BundleAddChild
  + Dashboard + ImportExport updated alongside.

Misc polish along the way: kind-icons / theme tokens / display-prefs
touch-ups, ContextWidget + DebugViewer + AssemblerHelper small fixes,
MockModuleCard + ModulePickerModal tweaks, _shared widget cleanup.
2026-05-17 20:50:26 +03:00
DumiFlex ef2535548f feat(constraint): instance-only matrix cells — empty-cell overrides become new entries at resolve
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.
2026-05-10 14:50:18 +03:00
DumiFlex 297c4e173d feat(engine): constraint handler honors cell + exception overrides + extras 2026-05-10 06:26:17 +03:00
DumiFlex 949d4dd531 feat(engine): derivation honors branch disable + value overrides + rule reorder + locked_seed 2026-05-10 03:45:11 +03:00
DumiFlex 4bb232efd1 feat(engine): add exists/not_exists/is_set/is_unset ops to derivation handler 2026-05-09 13:24:40 +03:00
DumiFlex a555cc98a9 fix(combine): honor instance.template_override + variable_binding in graph/conflicts/engine 2026-05-09 10:19:30 +03:00
DumiFlex e5eef0a4c5 test(fixed_values): cover lock seed determinism + ref-out-of-surface + mixed-token render 2026-05-08 19:37:15 +03:00
DumiFlex 6cfeec8cef feat(fixed_values): engine resolves inline syntax per value + locked_seed
Each emitted value now passes through resolve_text with
surface="fixed_values" so {a|b|c} alternations + {N\$\$sep\$\$...} repeats
+ escapes resolve. \$var reads + @{uuid} refs are gated off (lenient:
warn + render literal) — fixed_values is a binding PRODUCER, not a
consumer.

Seed lock: instance.locked_seed pins {a|b|c} resolution per instance
via the shared engine.modules._seed.derive_module_rng helper, keyed by
ctx["__wp_module_id__"] when present (else "fixed_values" literal).

Backward compat: ctx=None / minimal-dict callers skip the
syntax-resolution path via _ctx_supports_resolve guard.

5 new tests: alternation per value, locked_seed determinism, escape
renders literal \$, \$var warns + literal, values_overrides precedence.
2026-05-08 18:33:05 +03:00
DumiFlex 1ccad6a349 feat(combine): engine reads instance.template_override + locked_seed
Combine handler now honors two new instance fields the v2 modal will
write:

- template_override (string | None) — full per-instance template
  replacement; falls back to payload.template when null. Same precedence
  pattern wildcard's option_weights and fixed_values' values_overrides
  use.
- locked_seed (int | None) — pins {a|b|c} resolution so the same seed
  reproduces the same roll. RNG derived via shared
  engine.modules._seed.derive_module_rng using output_var as the
  per-module key. Without locked_seed, falls back to the chain seed
  (existing behavior).

Both paths feed the SAME derive_module_rng(seed, output_var), so locking
with locked_seed = chain_seed reproduces the unlocked pick exactly —
that's the contract the lock UX relies on.

5 new tests: template_override precedence, null collapse, locked_seed
determinism, statistical independence across seeds, unlocked uses
chain seed.
2026-05-08 18:26:21 +03:00