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)
188 lines
7.5 KiB
Python
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())
|