Files
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

188 lines
7.5 KiB
Python

"""Generate engine-parity fixtures for the community validator suite.
WHY THIS EXISTS
---------------
The community-web TS validators (`src/validators/`) are a hand-authored
re-implementation of the engine's payload shapes. Re-implementations
drift: fixed_values was published for weeks as `entries/{variable_name}`
when the engine has always used `values/{id,name,value}`; wildcard
options omitted the real `is_null` field; etc. Every drift surfaced as a
publish failure in front of a user.
This script makes the ENGINE the single source of truth. For each module
subtype it builds a sample payload, runs it through that subtype's own
`Handler.validate_payload` (the engine authority -- raises if the sample
is not engine-valid), and dumps the engine-row shape to a committed
fixture. The companion test `src/validators/__tests__/engine-parity.test.ts`
asserts the STRICT community validator accepts every fixture.
Result: if the engine shape changes (a handler gains/renames a field) the
sample stops validating here and this script fails; if the TS validator
drifts from the engine, the parity test fails. Drift is caught at CI time
instead of in front of a publisher.
USAGE
-----
python scripts/dump_engine_shapes.py
Run it whenever an engine module shape changes, then commit the updated
fixtures. Exit 0 = all samples engine-valid + written; exit 1 = a sample
is no longer engine-valid (fix the sample to match the new shape).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Make `engine` importable when run as a bare script (mirrors the
# tests/conftest.py sys.path shim).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine.modules.combine_handler import CombineHandler # noqa: E402
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
# Output dir, relative to repo root (this file lives in scripts/).
_OUT_DIR = (
Path(__file__).resolve().parent.parent
/ "src" / "validators" / "fixtures" / "engine-parity"
)
# Each entry: (handler, type-specific payload). The payload must pass the
# handler's validate_payload -- keep these as the MINIMAL real shape per
# subtype, including fields that the strip-tolerant validator must
# preserve (e.g. wildcard `is_null`).
_SAMPLES: dict[str, tuple[type, dict]] = {
"wildcard": (
WildcardHandler,
{
"var_binding": "mood",
"sub_categories": ["soft", "bold"],
# Tag axes: an `accepts` group must survive the round-trip, so the
# sample carries both the group map and its kind. A stripped
# tag_group_kinds silently degrades the axis to classify.
"tag_groups": {"TEXTURE": ["soft", "bold"]},
"tag_group_kinds": {"TEXTURE": "accepts"},
"options": [
# `negative` (send-to-negative, v8) must survive the
# strict validator like the value does.
{"id": "opt00001", "value": "serene", "weight": 1, "sub_categories": ["soft"],
"negative": "angry, tense"},
# Null option: the is_null contract. Must round-trip
# through the validator or install rebuilds it as an
# invalid empty-value option.
{"id": "opt00002", "value": "", "weight": 1, "sub_categories": [], "is_null": True},
# Fallback (v9): the flag must survive the strict validator,
# or a shared pack's fallback rolls like any other option.
{"id": "opt00003", "value": "calm", "weight": 1, "sub_categories": [],
"fallback": True},
],
},
),
"fixed_values": (
FixedValuesHandler,
{"values": [{"id": "val00001", "name": "style", "value": "oil painting",
"negative": "photo, 3d render"}]},
),
"combine": (
CombineHandler,
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"],
"negative": "cropped"},
),
"derivation": (
DerivationHandler,
{
"rules": [
{
"id": "rule0001",
"branches": [
{
"condition": {
"var": "mood", "op": "equals", "value": "dramatic",
},
"action": {
"target_var": "accent", "mode": "replace",
"value": "cinematic lighting",
},
# THEN ... AND ... (v8): runs after `action`.
"extra_actions": [
{
"target_var": "accent", "mode": "negative",
"value": "$mood.neg",
},
],
},
],
"else": {
# "Add to negative" (v8): writes no value.
"action": {
"target_var": "accent", "mode": "negative",
"value": "harsh shadows",
},
},
},
],
},
),
"constraint": (
ConstraintHandler,
{
"source_wildcard_id": "src00001",
"target_wildcard_id": "tgt00001",
"exceptions": [],
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
# SP3 reach selector — must survive the round-trip, not strip back
# to the {mode: "all"} default.
"target_select": {"mode": "next", "count": 2},
},
),
}
def _engine_row(type_id: str, payload: dict) -> dict:
"""Wrap a type-specific payload in the canonical engine-row shape
the publisher ships (see manager/import-export/single-row-publish.ts:
buildModulePublishable)."""
return {
"id": f"parity{type_id[:2]}",
"type": type_id,
"name": f"parity-{type_id}",
"description": "",
"category_id": None,
"tags": [],
"is_favorite": False,
"payload": payload,
}
def main() -> int:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
for type_id, (handler, payload) in _SAMPLES.items():
try:
# Engine authority: this raises if the sample is not a valid
# payload for the CURRENT engine handler.
handler.validate_payload(payload)
except Exception as exc: # noqa: BLE001 -- report any validation failure
failures.append(f"{type_id}: sample is no longer engine-valid: {exc}")
continue
row = _engine_row(type_id, payload)
out = _OUT_DIR / f"{type_id}.json"
out.write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
print(f"[parity] wrote {out.relative_to(_OUT_DIR.parent.parent.parent)}")
if failures:
print("\n[parity] FAILED -- update the sample(s) to match the engine:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print(f"[parity] {len(_SAMPLES)} engine-valid fixtures written to {_OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())