Wildcard steps list their options with weights and why each was cut, the pool chain, locked seed, separator and matched value; fixed values, combines and node templates show what they read; constraint steps link the steps they re-weighted; Model Info, Toggles, Injector and Context Loop rows carry their own facts.
245 lines
9.9 KiB
Python
245 lines
9.9 KiB
Python
"""Explain mode — the per-module detail the WP Debug node shows.
|
|
|
|
A run opts in with ``ctx["__wp_explain__"] = True`` (the canvas Context node
|
|
does). Each trace row then carries the module's display name and a ``detail``
|
|
dict from its handler, and warnings raised while a module ran name it as
|
|
their owner. Without the flag the trace is unchanged.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from engine.pipeline import PipelineEngine
|
|
|
|
|
|
def _wildcard(uid, binding, values, name="", **instance):
|
|
return {
|
|
"type": "wildcard", "id": uid, "_uid": uid, "name": name,
|
|
"payload": {
|
|
"var_binding": binding,
|
|
"options": [
|
|
{"id": v, "value": v, "weight": w, "sub_categories": []}
|
|
for v, w in values
|
|
],
|
|
},
|
|
"instance": {"variable_binding": binding, **instance},
|
|
}
|
|
|
|
|
|
def _t(var, op="equals", value=""):
|
|
return {"var": var, "op": op, "value": value}
|
|
|
|
|
|
def _derivation(rules, uid="der00001", instance=None):
|
|
return {
|
|
"type": "derivation", "id": uid, "_uid": uid, "meta": {"name": "Lighting"},
|
|
"payload": {"rules": rules}, "instance": instance or {},
|
|
}
|
|
|
|
|
|
def _explain_run(modules, seed=0, **ctx_vars):
|
|
ctx = {"__wp_explain__": True, **ctx_vars}
|
|
return PipelineEngine().run(modules, ctx=ctx, seed=seed)
|
|
|
|
|
|
def _row(ctx, uid):
|
|
return next(r for r in ctx["__wp_trace__"] if r.get("_uid") == uid)
|
|
|
|
|
|
def test_trace_is_unchanged_without_the_flag():
|
|
ctx = PipelineEngine().run([_wildcard("w1", "mood", [("calm", 1)], name="Mood")], seed=1)
|
|
row = _row(ctx, "w1")
|
|
assert "detail" not in row and "name" not in row
|
|
assert "__wp_module_detail__" not in ctx
|
|
|
|
|
|
def test_trace_rows_carry_name_and_detail():
|
|
ctx = _explain_run([_wildcard("w1", "mood", [("calm", 1), ("tense", 3)], name="Mood")])
|
|
row = _row(ctx, "w1")
|
|
assert row["name"] == "Mood"
|
|
d = row["detail"]
|
|
assert d["option_id"] == ctx["mood"]
|
|
assert d["pool"] == 2 and d["live"] == 2
|
|
assert d["chance"] == (0.25 if ctx["mood"] == "calm" else 0.75)
|
|
assert "__wp_module_detail__" not in ctx
|
|
|
|
|
|
def test_name_falls_back_to_library_meta():
|
|
ctx = _explain_run([_derivation([])])
|
|
assert _row(ctx, "der00001")["name"] == "Lighting"
|
|
|
|
|
|
def test_multi_pick_lists_every_option():
|
|
w = _wildcard("w1", "props", [("a", 1), ("b", 1), ("c", 1)], pick_min=2, pick_max=2)
|
|
d = _row(_explain_run([w]), "w1")["detail"]
|
|
assert len(d["option_ids"]) == 2 and d["range"] == [2, 2]
|
|
|
|
|
|
def test_derivation_reports_the_fired_branch_and_each_test():
|
|
rule = {
|
|
"id": "r1",
|
|
"branches": [
|
|
{"condition": _t("time", value="day"),
|
|
"action": {"target_var": "light", "mode": "replace", "value": "sun"}},
|
|
{"condition": {"match": "all", "conditions": [
|
|
_t("time", value="night"),
|
|
{"match": "any", "conditions": [_t("sky", value="rain"), _t("sky", value="fog")]},
|
|
]},
|
|
"action": {"target_var": "light", "mode": "replace", "value": "lamps"}},
|
|
{"condition": _t("time", op="exists"),
|
|
"action": {"target_var": "light", "mode": "replace", "value": "never"}},
|
|
],
|
|
"else": {"action": {"target_var": "light", "mode": "replace", "value": "dim"}},
|
|
}
|
|
ctx = _explain_run([_derivation([rule])], time="night", sky="fog")
|
|
assert ctx["light"] == "lamps"
|
|
(rd,) = _row(ctx, "der00001")["detail"]["rules"]
|
|
assert rd["fired"] == 1 and rd["has_else"] is True
|
|
# Branches after the one that fired are never evaluated.
|
|
assert [b["index"] for b in rd["branches"]] == [0, 1]
|
|
assert rd["branches"][0]["matched"] is False
|
|
assert rd["branches"][0]["condition"] == {
|
|
"var": "time", "op": "equals", "value": "day", "actual": "night", "result": False,
|
|
}
|
|
group = rd["branches"][1]["condition"]
|
|
assert group["match"] == "all" and group["result"] is True
|
|
inner = group["conditions"][1]
|
|
assert inner["match"] == "any"
|
|
# Every member of a group is evaluated, even past the first match.
|
|
assert [c["result"] for c in inner["conditions"]] == [False, True]
|
|
assert rd["action"] == {
|
|
"target": "light", "mode": "replace", "value": "lamps", "result": "lamps",
|
|
}
|
|
|
|
|
|
def _set(var, value):
|
|
return {"target_var": var, "mode": "replace", "value": value}
|
|
|
|
|
|
def test_derivation_else_disabled_and_unset_reads():
|
|
rules = [
|
|
{"id": "r1", "branches": [{"condition": _t("missing", value="x"),
|
|
"action": {"target_var": "a", "mode": "replace", "value": "1"}}],
|
|
"else": {"action": {"target_var": "a", "mode": "replace", "value": "fallback"}}},
|
|
{"id": "r2", "branches": [{"condition": _t("a", value="fallback"),
|
|
"action": _set("b", "2")}]},
|
|
{"id": "r3", "branches": [{"condition": _t("a", op="exists"),
|
|
"action": _set("c", "3")}]},
|
|
]
|
|
ctx = _explain_run([_derivation(rules, instance={"disabled_rule_ids": ["r3"]})])
|
|
r1, r2, r3 = _row(ctx, "der00001")["detail"]["rules"]
|
|
assert r1["fired"] == "else" and r1["branches"][0]["condition"]["actual"] is None
|
|
# r2 sees r1's write — the explanation is taken when the rule runs.
|
|
assert r2["fired"] == 0 and r2["branches"][0]["condition"]["actual"] == "fallback"
|
|
assert r3["disabled"] is True and r3["fired"] is None
|
|
|
|
|
|
def test_constraint_detail_and_pick_attribution():
|
|
con = {
|
|
"type": "constraint", "id": "con00001", "_uid": "con00001",
|
|
"payload": {
|
|
"source_wildcard_id": "src00001", "target_wildcard_id": "tgt00001",
|
|
"matrix": {},
|
|
"exceptions": [{"source_value": "maid", "target_value": "apron",
|
|
"mode": "only", "factor": 1.0}],
|
|
"target_select": {"mode": "first"},
|
|
},
|
|
"instance": {},
|
|
}
|
|
ctx = _explain_run([
|
|
_wildcard("src00001", "role", [("maid", 1)]),
|
|
con,
|
|
_wildcard("tgt00001", "outfit", [("apron", 1), ("jacket", 1)]),
|
|
])
|
|
cd = _row(ctx, "con00001")["detail"]
|
|
assert cd == {"uid": "con00001", "reach": {"mode": "first"}, "cells": 0,
|
|
"exceptions": 1, "only": True}
|
|
td = _row(ctx, "tgt00001")["detail"]
|
|
assert td["live"] == 1 and td["chance"] == 1.0
|
|
assert td["constraints"] == [{"id": "con00001", "uid": "con00001", "name": "",
|
|
"source": "src00001", "source_value": "maid"}]
|
|
|
|
|
|
def test_warnings_name_the_module_that_raised_them():
|
|
w = _wildcard("w1", "look", [("@{deadbeef#hat} on", 1)], name="Look")
|
|
ctx = _explain_run([w])
|
|
(warn,) = [x for x in ctx["__wp_warnings__"] if x["type"] == "unknown_ref"]
|
|
assert warn["owner_uid"] == "w1" and warn["owner_id"] == "w1"
|
|
assert warn["detail"]["name"] == "hat"
|
|
|
|
|
|
# ── Options table, pool steps, seeds (WP Debug "why did it never pick X") ──
|
|
|
|
|
|
def test_wildcard_lists_every_option_and_why_it_is_out():
|
|
w = _wildcard(
|
|
"w1", "mood", [("calm", 1), ("tense", 3), ("angry", 2), ("sad", 0)], name="Mood",
|
|
enabled_options=["calm", "tense", "sad"], option_weights={"tense": 5},
|
|
locked_seed=7,
|
|
)
|
|
w["payload"]["options"][0]["sub_categories"] = ["soft"]
|
|
w["payload"]["options"][1]["sub_categories"] = ["soft"]
|
|
w["payload"]["options"][3]["sub_categories"] = ["soft"]
|
|
w["instance"]["category_filter"] = "soft"
|
|
d = _row(_explain_run([w]), "w1")["detail"]
|
|
rows = {r["id"]: r for r in d["options"]}
|
|
assert rows["angry"]["cut"] == "filter" and rows["angry"]["weight"] is None
|
|
assert "cut" not in rows["calm"] and rows["calm"]["weight"] == 1
|
|
tense = rows["tense"]
|
|
assert tense["base"] == 3 and tense["override"] == 5 and tense["weight"] == 5
|
|
assert rows["sad"]["cut"] == "weight"
|
|
assert d["steps"] == {"library": 4, "filter": 3, "toggles": 3, "live": 2}
|
|
assert d["locked_seed"] is True
|
|
|
|
|
|
def test_toggled_off_option_is_marked_toggle():
|
|
w = _wildcard("w1", "mood", [("calm", 1), ("tense", 1)], enabled_options=["calm"])
|
|
d = _row(_explain_run([w]), "w1")["detail"]
|
|
assert {r["id"]: r.get("cut") for r in d["options"]} == {"calm": None, "tense": "toggle"}
|
|
assert d["steps"]["toggles"] == 1
|
|
|
|
|
|
def test_options_table_is_capped():
|
|
from engine.modules.wildcard_handler import OPTIONS_SHOWN
|
|
w = _wildcard("w1", "n", [(f"o{i}", 1) for i in range(OPTIONS_SHOWN + 5)])
|
|
d = _row(_explain_run([w]), "w1")["detail"]
|
|
assert len(d["options"]) == OPTIONS_SHOWN and d["options_more"] == 5
|
|
|
|
|
|
def test_multi_pick_reports_its_separator():
|
|
w = _wildcard(
|
|
"w1", "outfit", [("a", 1), ("b", 1), ("c", 1)],
|
|
pick_min=2, pick_max=2, pick_separator=" + ",
|
|
)
|
|
d = _row(_explain_run([w]), "w1")["detail"]
|
|
assert d["separator"] == " + " and d["range"] == [2, 2]
|
|
|
|
|
|
def test_match_variable_reports_the_value_it_read():
|
|
w = _wildcard("w1", "style", [("pony", 1), ("sdxl", 1)], match_variable="model_variant")
|
|
d = _row(_explain_run([w], model_variant="pony"), "w1")["detail"]
|
|
assert d["match_variable"] == "model_variant" and d["match_value"] == "pony"
|
|
|
|
|
|
def test_fixed_values_report_source_rows_and_toggled_off():
|
|
fv = {
|
|
"type": "fixed_values", "id": "f1", "_uid": "f1",
|
|
"payload": {"values": [
|
|
{"id": "a", "name": "quality", "value": "{best|best} quality"},
|
|
{"id": "b", "name": "steps", "value": "28"},
|
|
]},
|
|
"instance": {"enabled_options": ["a"]},
|
|
}
|
|
d = _row(_explain_run([fv]), "f1")["detail"]
|
|
assert d["source"] == "library" and d["toggled_off"] == ["steps"]
|
|
assert d["rows"] == [{"name": "quality", "raw": "{best|best} quality"}]
|
|
|
|
|
|
def test_combine_reports_template_and_reads():
|
|
cb = {
|
|
"type": "combine", "id": "c1", "_uid": "c1",
|
|
"payload": {"template": "$mood $missing light", "output_var": "scene", "input_vars": []},
|
|
"instance": {},
|
|
}
|
|
d = _row(_explain_run([cb], mood="calm"), "c1")["detail"]
|
|
assert d["template"] == "$mood $missing light" and d["template_source"] == "library"
|
|
assert d["reads"] == {"mood": "calm", "missing": None}
|