Commit Graph
22 Commits
Author SHA1 Message Date
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 f68d2ece81 feat(engine): per-pick recording + apply constraints via combine_constraint_factor 2026-06-11 05:41:40 +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 ace0486f9d fix(engine): guard junk pick counts + warn on multi-pick constraint source 2026-06-08 21:06:35 +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 55ec1773b1 fix(engine): carrier-claim fires on null/empty pick + honest warning text
Two follow-ups from the user's screenshot showing a misleading
never_applied warning for @color after backdrop rolled its null option:

- wildcard_handler skipped the carrier-claim on the empty-value early
  return — claim_carrier_constraints sat only after resolve_text, but a
  null/empty pick returns before reaching it. So a backdrop that rolled
  'null' never claimed the constraints it carries, letting @color
  either spill or warn. Extracted the claim into a local _claim_carried
  closure and call it on BOTH the empty-value path AND after
  resolve_text. A carrier that rolls ANY option (including null) now
  claims its carried constraints, matching the user's no-spill choice.
  (Explains the color-vs-mood asymmetry too: mood had a top-level
  instance downstream that caught hair_x_mood, color didn't — so only
  color surfaced the bogus warning.)

- the 'present' never_applied message wrongly prescribed 'move the
  constraint above the target's first appearance' and blamed
  registration order. That advice is wrong when the target's carrier is
  ALSO the constraint's source (the source must stay upstream). Reworded
  to state the observation without the bad fix-it, and dropped the
  ordering-only framing.

Regression test test_carrier_claims_on_null_empty_pick locks in the
null-pick claim across 5 seeds. All 1026 pytest + 2381 vitest pass.
2026-05-26 22:43:29 +03:00
DumiFlex e3d5c28612 feat(engine): carrier-claim failsafe + cause-aware never_applied warning
Two changes driven by the user's real chain (shirt option carries
@{color}; backdrop carries @{color}+@{mood}; constraints sit between
them):

Carrier-claim failsafe (no spill past a carrier)
  A constraint is now positionally claimed by the FIRST downstream
  wildcard that could host its target — a direct instance OR a
  nested-ref carrier — matching what the pair badge already shows.
  After a wildcard resolves, claim_carrier_constraints consumes any
  unconsumed constraint whose target the wildcard carries (an
  @{target} ref in ANY of its options), provided the source is
  already picked. So when backdrop rolls a no-ref option, it still
  claims the constraint instead of letting it spill onto a later
  top-level target instance. No-op when the chosen option DID resolve
  the ref (nested path already consumed it) or the source isn't
  picked yet.

Cause-aware never_applied warning
  The end-of-run warning now classifies WHY a constraint didn't fire:
  _target_present_in_chain scans the module list + catalog for a
  direct instance or a carrier of the target. Present → 'target
  exists in the chain but wasn't claimed (rolled before this
  constraint registered, or in a separate branch)'. Absent → 'no
  instance or nested-ref carrier found (check the target uuid)'.
  detail.target_present is stamped so the DebugViewer can later pick
  a distinct icon without re-parsing the message.

Net for the user: the failsafe means a rolling carrier always claims
its constraints, so the confusing per-seed never_applied warnings for
backdrop disappear entirely; the only warnings left are genuine
absent-target / ordering cases, each with actionable wording.

5 regression tests added (carrier claim + no spill across 20 seeds,
source-not-ready no-op, present-vs-absent message). All 1029 pytest
+ 2381 vitest pass.
2026-05-26 22:33:42 +03:00
DumiFlex 3bd71588b3 fix(constraint): empty source/target valid for null-option exceptions 2026-05-26 19:40:05 +03:00
DumiFlex 85cf827d56 chore: pre-pair-badge polish (bundle, cascade, debug, light-theme, cleaner)
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.
2026-05-26 11:24:43 +03:00
DumiFlex 43f9b9807c feat(engine): first-instance constraints + null wildcard option + inverted null filter
- 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.
2026-05-25 00:07:30 +03:00
DumiFlex 789a9aedb8 feat(wildcards): require option.id (8-hex) in payload validation 2026-05-24 06:03:55 +03:00
DumiFlex b950b8fd82 feat(engine,scanner): nested-ref constraints + @{uuid:subcat} filter + smarter target classification
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)
2026-05-19 21:40:08 +03:00
DumiFlex 0a8f376f4f fix(api,engine): qa hardening sweep — validators, concurrency, orphans
Lands the 15-bug catalogue from the 2026-05-19 creative-QA pass.

API layer (wp_api/):
- New _validators.py centralises meta + identifier + body-size checks
  so modules and bundles share the same shape rules
- SPA catch-all now returns JSON 404 for unknown /wp/api/* paths
  instead of the SPA shell (debugging hostile previously)
- create/update/import_from_workflow validate meta (name/desc/tags/
  category_id) before hitting the repo; tag list now rejects non-str
  entries (was silently stored, breaking the SPA type contract)
- IntegrityError on dangling category_id surfaces as 400, not 500
- Optional If-Match header on PUT enables opt-in optimistic
  concurrency — stale version → 409 instead of silent overwrite
- Bundle name collisions auto-suffixed with (copy)/(copy N) mirroring
  the modules-side forkModule rule
- Bundle children deduped by id at create + update time
- Deleting a module flags meta.orphaned=true on every bundle child
  whose snapshot referenced it so the SPA can render a stale badge

Engine layer (engine/modules/*_handler.py):
- wildcard: reject empty + __dunder var_binding, cap at 64 chars,
  reject duplicate option ids
- combine: reject empty template, cap template at 8000 chars, cap
  output_var at 64, reject __dunder output_var
- fixed_values: cap value name length, reject __dunder names, reject
  duplicate names across rows (was silent last-write-wins)
- derivation: reject duplicate rule ids (was nondeterministic for
  disabled_rule_ids / rule_order_override lookups)

Tests:
- New test_qa_hardening.py exercises every endpoint-level fix
- Extended test_payload_validation.py parametrised rejection matrix
- New test_spa_fallback.py case for /wp/api/* JSON 404

655 pytest pass, 1665 vitest pass, ruff clean.
2026-05-19 20:12:41 +03:00
DumiFlex d661c6d87b fix(api): plug payload validation hole + draft save-to-library rethink
Bug 6 fix: wildcard + fixed_values handlers had no validate_payload
method, so the base no-op was silently accepting any shape on every
write boundary. Verified live: PUT /payload with {options: 'not-an-array'}
returned 200 and persisted the garbage; POST accepted {options: 42};
cross-type pollution (combine-shaped payload PUT to a wildcard) also
succeeded with 200.

Changes:
- engine/modules/wildcard_handler.py: validate_payload checks options
  list shape + per-option value/weight types + non-negative weights +
  optional sub_category strings + var_binding identifier + sub_categories
  list-of-strings.
- engine/modules/fixed_values_handler.py: validate_payload checks values
  list shape + per-value name/value types + identifier rule on non-empty
  names.
- wp_api/modules.py: validates payload on POST /modules, PUT /modules/{id}
  (when payload field is present), and POST /modules/import-from-workflow.
  Shared _validate_payload_for_type helper.
- wp_api/bundles.py: validates every child snapshot's payload on POST
  /bundles and PUT /bundles/{id} when children[] is in the body — closes
  the side door that let bundle writes bypass per-module validation.
- tests/wp_api/test_payload_validation.py: 26 cases covering happy paths
  + malformed payloads per kind, cross-type pollution, import-from-
  workflow, and bundle-children side door.
- tests/wp_api/test_bundles_crud.py: fixture combine child gained the
  required output_var field (it was implicitly invalid before).

Plus docs/superpowers/specs/2026-05-19-save-to-library-rethink-design.md:
the broader four-phase plan that addresses Bugs 1-5 (meta drop, bundle
propagation, fork discoverability, modal button visibility, inline
modules can't save). Phase A (this commit) plugs the validation hole.
Phases B-D will land separately after design review.

Live QA reproduction order remains in the design doc.
2026-05-19 17:34:37 +03:00
DumiFlex fdf3d03235 refactor(engine): lift _derive_module_rng to engine/modules/_seed.py
Pure refactor — wildcard_handler.py imports from new shared module
instead of defining inline. Combine + fixed_values cycles will reuse
the same helper for their seed-lock support.

Identical SHA-256 derivation, same call sites. All 210 engine tests
pass after the lift.
2026-05-08 18:15:57 +03:00
DumiFlex 487873822e fix: surface silent-failure paths flagged by audit
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.
2026-05-03 01:54:04 +03:00
DumiFlex 6722a2b8bc feat(engine): wildcard handler consumes constraints + tracks picks
Wires constraint-aware option-pool adjustment into WildcardHandler. Pre-fix the constraint module was a metadata-only stub — its bucket landed in `ctx["__wp_constraints__"]` but no consumer ever read it, so configurations like "hair_style → constraint → mood" produced random mood picks regardless of which hair_style won. Now the wildcard handler reads the bucket each pick, finds constraints whose `target_wildcard_id` matches its own module id, looks up the source's pick, and applies the matrix + exception rules to its option weights before the weighted RNG fires.

Pipeline plumbing: `engine/pipeline.py` stashes the active module's id in `ctx["__wp_current_module_id__"]` for the duration of each `resolve_module` call, and clears it after the run. Handlers that need to identify themselves (constraint-aware wildcards looking up their own target_wildcard_id) read this key — it's the cheapest hand-off without changing the dispatcher signature.

Pick tracking: every successful wildcard pick (random, pinned, weighted) records `{value, sub_category, id}` in `ctx["__wp_picks__"][module_id]`. Source wildcards must therefore run before their constraint targets — pipeline order is load-bearing. When a target's source hasn't been picked yet, the wildcard skips the constraint, picks normally from its un-adjusted pool, and emits an `unknown_constraint_source` warning so the user gets a debug-viewer signal pointing at the chain-order bug.

Constraint application:
- Matrix layer: indexed by `(source.sub_category, target.sub_category)`, expresses bulk rules ("long hair allows formal outfits").
- Exception layer: indexed by the literal `(source.value, target.value)` pair, narrower-rule precedence — wins over the matrix cell when both apply.
- Modes:
  - `allow`   → weight unchanged
  - `exclude` → weight set to 0 (option drops out)
  - `boost`   → weight × factor (factor > 1 typical)
  - `reduce`  → weight × factor (factor < 1 typical)

Multiple constraints targeting the same wildcard compose multiplicatively — they're applied in registration order, each one further adjusting the pool the previous one produced.

Tests: 7 new integration cases driving real PipelineEngine — pick tracking, matrix exclude, matrix boost (statistical convergence over 40 seeds), exception value-pair exclude, exception-overrides-matrix precedence, missing-source warning, no-constraints default path. 464 pytest + 512 vitest.
2026-05-02 17:21:16 +03:00
DumiFlex 6921606570 feat(context): module picker + lock/internal + card indicators (#8)
Phase 5.5.4 (SPA library picker for ContextWidget) plus QA-driven polish.

* New ModulePickerModal with search, kind tabs, single + multi-pick, and
  compact filter UX (favorites/hide-added inline; category + tag chips
  behind popover with active-count badge).
* ModuleEditModal kind-aware body — wildcard option editor with
  enable/disable, weight overrides, mode picker (random/subset/pinned),
  sub-category multi-select.
* Lock + internal per-instance overrides ported and adapted: locked_seed
  derives a per-wildcard rng via sha256(seed:binding); internal hides
  bindings from the public PIPELINE_CONTEXT payload.
* Card indicators: modified dot (option-pool overrides), missing-from-
  library dot (polled hash check), in-card lock + internal toggles.
* RNG-faithful assembler preview via new /wp/api/preview/resolve; header
  shows "PREVIEW · 42". UNRESOLVED chips clickable to strip $var.
* Bulk collapse/expand toggle, focus toast w/ pulsing border overlay
  that tracks node bounds, friendly @name substitution in modal.
* Backend: new /wp/api/modules/import-from-workflow endpoint preserves
  uuid; engine emits per-module effective seeds via WP_Context UI
  payload for authoritative lock-toggle defaults.
2026-04-30 00:53:13 +03:00
DumiFlex 657fe57890 refactor(engine): wildcard handler delegates to resolve_text 2026-04-28 06:39:39 +03:00
DumiFlex 30bf13efc0 feat(engine): wildcardhandler with weighted rng and @{ref} resolution 2026-04-26 16:24:54 +03:00