Files
DumiFlex 1f3ca9ae93 fix(assembler): roll the preview on each Context's own seed (#276)
The in-node preview rolled every Context on seed 42, so changing a Context's seed never changed it. It now rolls each Context on its own seed widget (frame 1's seed under a Context Loop that overrides seeds).
2026-11-04 21:35:46 +01:00

220 lines
8.6 KiB
Python

"""POST /wp/api/preview/resolve — assembler-preview RNG-faithful resolve.
Frontend assembler widget calls this to get the same `(name → value)`
map a real WP_Context chain would produce at run time when every node
is seeded with the supplied seed (default 42). Returning ground truth
from the engine guarantees the preview matches what the user sees on
queue without porting Python's MT19937 to JS.
Body shape:
{
"chain": [<modules-list-step-1>, <modules-list-step-2>, ...],
"seed": <int, default 42>,
"seeds": [<int | null per step>, ...] (optional)
}
`seeds[i]` is step i's own seed (the Assembler sends each Context's seed
widget, so the preview is what Run prints and follows a seed change);
a missing or null entry uses `seed`.
Each `modules-list-step-N` is the same list shape ContextWidget
serialises into a node's `modules` widget value: list of module dicts
with `id`, `type`, `enabled`, `payload`, `entries`, `instance`, ...
Returns:
{"resolved": {<name>: <value>, ...},
"axes": {<binding>: {<AXIS>: <tag>} | [{<AXIS>: <tag>}, ...], ...},
"negatives": {<binding>: [{"text", "pick", "source"}, ...], ...}}
Empty chain or any malformed step → empty resolved map (never 500).
"""
from __future__ import annotations
import logging
from typing import Any, cast
from aiohttp import web
from engine.context import strip_internals
from engine.db.connection import get_connection
from engine.db.migrations import migrate
from engine.db.repositories import ModuleNotFound, ModuleRepository
from engine.log_once import log_once
from engine.modules.snapshot import ref_seed_uuids, walk_transitive_refs
from engine.pipeline import PipelineEngine
from engine.seed_derive import MAX_SAFE_SEED
from engine.syntax.types import ListVar
from wp_api._helpers import json_error, json_ok
logger = logging.getLogger(__name__)
def _build_catalog(modules: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Wildcard subset → SnapshotEntry-shaped catalog keyed by uuid.
Mirrors `wp_nodes/types.py:deserialize_node_input` so the engine
sees the same shape it gets at run time.
"""
catalog: dict[str, dict[str, Any]] = {}
for m in modules:
if not isinstance(m, dict) or m.get("type") != "wildcard":
continue
mid = m.get("id")
if not isinstance(mid, str):
continue
payload = m.get("payload")
if not isinstance(payload, dict):
continue
meta = _got if isinstance(_got := m.get("meta"), dict) else {}
catalog[mid] = {
"snapshot_version": 1,
"uuid": mid,
"type": "wildcard",
"name": meta.get("name", "") if isinstance(meta, dict) else "",
"payload": payload,
"payload_hash": m.get("payload_hash") or "",
"source": {"kind": "user"},
}
return catalog
def _expand_with_db(
catalog: dict[str, dict[str, Any]],
modules: list[dict[str, Any]] | None = None,
) -> dict[str, dict[str, Any]]:
"""Pull in nested `@{uuid}` refs from the live DB so preview can
resolve them — mirrors `wp_nodes/context_node.py:_expand_catalog_via_live_db`.
`modules` matters for the same reason it does there: a `@{uuid}` written on
a derivation action (or its per-instance override) is invisible to a walk
seeded from wildcard uuids alone. Preview has to seed identically or it
reports a pool the engine will not use — which is exactly how the
"SPA says 1 of 11, the graph says 0 of 10" split arose.
Failure-tolerant: any DB hiccup returns the embedded catalog
unchanged and the resolver falls back to the standard
"unknown ref" warning at expand time.
"""
seeds = ref_seed_uuids(modules)
if not catalog and not seeds:
return catalog
try:
conn = get_connection()
except Exception:
return catalog
try:
migrate(conn)
repo = ModuleRepository(conn)
except Exception:
conn.close()
return catalog
def _fetch(uuid: str) -> dict[str, Any] | None:
embedded = catalog.get(uuid)
if embedded is not None:
return {
"id": uuid,
"type": embedded["type"],
"name": embedded.get("name", ""),
"payload": embedded["payload"],
"payload_hash": embedded.get("payload_hash", ""),
}
try:
return repo.get(uuid)
except ModuleNotFound:
return None
except Exception:
return None
roots = list(catalog.keys()) + [u for u in sorted(seeds) if u not in catalog]
try:
walk = walk_transitive_refs(roots, fetch_module=_fetch)
finally:
# Every preview request used to leave this connection open.
conn.close()
return cast("dict[str, dict[str, Any]]", walk.snapshots)
def _jsonify_resolved(resolved: dict[str, Any]) -> dict[str, Any]:
"""Make a resolved ctx map JSON-serialisable. A multi-pick var is a
`ListVar` dataclass (engine/syntax/types.py) that aiohttp's JSON encoder
can't handle — without this the endpoint 500s. Emit it as
``{"items": [...], "sep": ...}`` so the TS preview can join (bare $name)
or index ($name.K) it, matching the engine's resolution exactly."""
out: dict[str, Any] = {}
for k, v in resolved.items():
out[k] = {"items": list(v.items), "sep": v.sep} if isinstance(v, ListVar) else v
return out
async def resolve_preview(request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
chain = body.get("chain")
if not isinstance(chain, list):
return json_error("chain must be a list", status=400)
try:
seed = int(body.get("seed", 42))
except (TypeError, ValueError):
return json_error("seed must be an integer", status=400)
# Optional per-step seeds (the Assembler sends each Context's own seed so
# the preview is what Run prints); a missing / null / bad entry falls back
# to `seed`. Masked to 50 bits like `effective_chain_seed`.
raw_seeds = body.get("seeds")
step_seeds: list[int] = []
if isinstance(raw_seeds, list):
for v in raw_seeds:
ok = isinstance(v, int) and not isinstance(v, bool)
step_seeds.append((v & MAX_SAFE_SEED) if ok else seed)
# Walk chain steps upstream → downstream; each step's modules run
# in their own PipelineEngine.run() with its step seed. Earlier
# ctx feeds the next step as upstream. Catalog accumulates wildcards
# from every step + their nested refs from the live DB.
ctx: dict[str, Any] = {}
accum_catalog: dict[str, dict[str, Any]] = {}
for i, step in enumerate(chain):
if not isinstance(step, list):
continue
step_seed = step_seeds[i] if i < len(step_seeds) else seed
modules = [m for m in step if isinstance(m, dict)]
accum_catalog.update(_build_catalog(modules))
# Re-expand each step so newly-introduced wildcards pull their
# nested deps too. walk_transitive_refs is idempotent.
accum_catalog = _expand_with_db(accum_catalog, modules)
ctx["__wp_catalog__"] = accum_catalog
try:
ctx = PipelineEngine().run(modules, ctx=ctx, seed=step_seed)
except Exception as e:
# Previews run on every keystroke: one traceback per distinct error.
log_once(logger, logging.ERROR, ("preview", type(e).__name__, str(e)),
"preview resolve: pipeline run failed", exc_info=True)
# Don't return 500 — the user is just typing. Surface
# whatever we resolved up to this point.
break
# The rolled `accepts` axes ride alongside, keyed by binding exactly as
# `__wp_axes__` stores them. `strip_internals` drops that table, and
# without it the preview could only guess `$var.AXIS` from the wildcard's
# tag list — printing "sandals" beside an option that carries no shoes.
axes = ctx.get("__wp_axes__")
# Send-to-negative: each variable's negatives, so the Assembler preview
# can build its negative locally from whatever its template uses.
negs = ctx.get("__wp_negatives__")
return json_ok({
"resolved": _jsonify_resolved(strip_internals(ctx)),
"axes": axes if isinstance(axes, dict) else {},
"negatives": negs if isinstance(negs, dict) else {},
})
def register(router) -> None:
router.add_post("/wp/api/preview/resolve", resolve_preview)