Pack builder: Fix all and updates say why nothing went out while the web search card waits; page follow-up lines are not request words; Only rows narrow only the tag groups they rule on; answers are not request gaps; code-wired story beats skip the lookup lint; left-out rules read plainly and get Fix with AI; a rule overwriting a pick becomes an append; model labels read "For Illustrious"; roomier plan head. Chat: same-name edits refused with the shown item named; search by category for organizing; empty edit answers carry a note; prompt syntax coloured inside cards; every chat format named in the prompt; pointer captions avoid each other; one shared working line. Search: what an allowed search found shows as a card in the Pack builder and the Draft with AI panels; Use what fits sends all results for the model to keep what fits, ticking sends only those, None of these sends nothing.
230 lines
9.7 KiB
Python
230 lines
9.7 KiB
Python
"""`only` constraint rule — linked picks, end to end.
|
|
|
|
`only` turns its row into an allow-list: when the source fires, a target with
|
|
no rule of its own in that row drops out. On an exception it links literal
|
|
values ("maid" forces "frilled apron"); in a matrix row it links tags. The
|
|
pure fold is covered by the shared corpus; these pin what a run produces.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from engine.modules._keys import encode_key
|
|
from engine.modules.constraint_handler import ConstraintHandler
|
|
from engine.pipeline import PipelineEngine
|
|
|
|
|
|
def _wildcard(uid: str, binding: str, options: list[dict], **extra) -> dict:
|
|
return {
|
|
"type": "wildcard", "id": uid, "_uid": uid,
|
|
"payload": {"var_binding": binding, "options": options, **extra},
|
|
"instance": {"variable_binding": binding},
|
|
}
|
|
|
|
|
|
def _opt(value: str, tags: list[str] | None = None) -> dict:
|
|
return {"id": value.replace(" ", "_"), "value": value, "weight": 1,
|
|
"sub_categories": tags or []}
|
|
|
|
|
|
def _constraint(matrix: dict, exceptions: list[dict], instance: dict | None = None) -> dict:
|
|
return {
|
|
"type": "constraint", "id": "con00001", "_uid": "con00001",
|
|
"payload": {"source_wildcard_id": "src00001",
|
|
"target_wildcard_id": "tgt00001",
|
|
"matrix": matrix, "exceptions": exceptions},
|
|
"instance": instance or {},
|
|
}
|
|
|
|
|
|
def _link(src: str, tgt: str, mode: str = "only", factor: float = 1.0) -> dict:
|
|
return {"source_value": src, "target_value": tgt, "mode": mode, "factor": factor}
|
|
|
|
|
|
_ROLES = ["maid", "biker"]
|
|
_OUTFITS = ["frilled apron", "leather jacket", "sundress", "tracksuit", "kimono"]
|
|
|
|
|
|
def _roles(values=_ROLES):
|
|
return _wildcard("src00001", "role", [_opt(v) for v in values])
|
|
|
|
|
|
def _outfits(values=_OUTFITS, **extra):
|
|
return _wildcard("tgt00001", "outfit", [_opt(v) for v in values], **extra)
|
|
|
|
|
|
def _run(modules, seed):
|
|
return PipelineEngine().run(modules, seed=seed)
|
|
|
|
|
|
def test_value_link_forces_the_linked_target():
|
|
# One `only` exception is the whole link: no exclude row per other outfit.
|
|
con = _constraint({}, [_link("maid", "frilled apron")])
|
|
seen = set()
|
|
for seed in range(40):
|
|
ctx = _run([_roles(), con, _outfits()], seed)
|
|
seen.add(ctx["role"])
|
|
if ctx["role"] == "maid":
|
|
assert ctx["outfit"] == "frilled apron", seed
|
|
assert seen == {"maid", "biker"}
|
|
|
|
|
|
def test_value_link_leaves_an_unlinked_source_value_free():
|
|
con = _constraint({}, [_link("maid", "frilled apron")])
|
|
outfits = set()
|
|
for seed in range(60):
|
|
ctx = _run([_roles(["biker"]), con, _outfits()], seed)
|
|
outfits.add(ctx["outfit"])
|
|
assert len(outfits) > 1
|
|
|
|
|
|
def test_value_link_covers_target_options_added_later():
|
|
# The reason `only` exists: an exclude-per-option link silently lets a
|
|
# newly added outfit through. `only` shuts out anything unmentioned.
|
|
con = _constraint({}, [_link("maid", "frilled apron")])
|
|
grown = _outfits(_OUTFITS + ["space suit", "wetsuit"])
|
|
for seed in range(40):
|
|
ctx = _run([_roles(["maid"]), con, grown], seed)
|
|
assert ctx["outfit"] == "frilled apron", seed
|
|
|
|
|
|
def test_value_link_to_several_targets_picks_among_them():
|
|
con = _constraint({}, [_link("maid", "frilled apron"), _link("maid", "kimono")])
|
|
outfits = set()
|
|
for seed in range(60):
|
|
ctx = _run([_roles(["maid"]), con, _outfits()], seed)
|
|
outfits.add(ctx["outfit"])
|
|
assert outfits == {"frilled apron", "kimono"}
|
|
|
|
|
|
_SEASONS = ["summer", "winter"]
|
|
_SHOE_TAGS = ["open", "closed"]
|
|
|
|
|
|
def _tag_link_matrix():
|
|
return {"summer": {"open": {"mode": "only", "factor": 1.0}},
|
|
"winter": {"closed": {"mode": "only", "factor": 1.0}}}
|
|
|
|
|
|
def test_tag_link_forces_options_carrying_the_linked_tag():
|
|
src = _wildcard("src00001", "season",
|
|
[_opt("beach day", ["summer"]), _opt("ski trip", ["winter"])],
|
|
sub_categories=_SEASONS)
|
|
tgt = _wildcard("tgt00001", "shoes",
|
|
[_opt("sandals", ["open"]), _opt("flip flops", ["open"]),
|
|
_opt("boots", ["closed"]), _opt("socks")],
|
|
sub_categories=_SHOE_TAGS)
|
|
con = _constraint(_tag_link_matrix(), [])
|
|
for seed in range(40):
|
|
ctx = _run([src, con, tgt], seed)
|
|
if ctx["season"] == "beach day":
|
|
assert ctx["shoes"] in {"sandals", "flip flops"}, seed
|
|
else:
|
|
assert ctx["shoes"] == "boots", seed
|
|
|
|
|
|
def test_tag_link_pins_a_target_accepts_roll():
|
|
# A target option offering both tags survives the link and its own roll is
|
|
# restricted to the linked tag, so $shoes.STYLE agrees with the season.
|
|
src = _wildcard("src00001", "season", [_opt("beach day", ["summer"])],
|
|
sub_categories=_SEASONS)
|
|
tgt = _wildcard("tgt00001", "shoes", [_opt("any shoe", ["open", "closed"])],
|
|
sub_categories=_SHOE_TAGS,
|
|
tag_groups={"STYLE": _SHOE_TAGS},
|
|
tag_group_kinds={"STYLE": "accepts"})
|
|
con = _constraint(_tag_link_matrix(), [])
|
|
for seed in range(24):
|
|
ctx = _run([src, con, tgt], seed)
|
|
assert ctx["__wp_axes__"]["shoes"]["STYLE"] == "open", seed
|
|
|
|
|
|
def test_value_link_does_not_unpin_a_tag_link_in_the_same_constraint():
|
|
# The axis-menu probe carries no real value. A value-level `only` must not
|
|
# shut the probe out (which would leave the menu unrestricted).
|
|
src = _wildcard("src00001", "season", [_opt("beach day", ["summer"])],
|
|
sub_categories=_SEASONS)
|
|
tgt = _wildcard("tgt00001", "shoes", [_opt("any shoe", ["open", "closed"])],
|
|
sub_categories=_SHOE_TAGS,
|
|
tag_groups={"STYLE": _SHOE_TAGS},
|
|
tag_group_kinds={"STYLE": "accepts"})
|
|
con = _constraint(_tag_link_matrix(), [_link("beach day", "any shoe")])
|
|
for seed in range(24):
|
|
ctx = _run([src, con, tgt], seed)
|
|
assert ctx["shoes"] == "any shoe"
|
|
assert ctx["__wp_axes__"]["shoes"]["STYLE"] == "open", seed
|
|
|
|
|
|
def test_instance_can_override_a_cell_to_only():
|
|
# An instance-only cell: no library rule, the canvas editor sets `only`.
|
|
con = _constraint({}, [], instance={
|
|
"cell_mode_overrides": {encode_key(["summer", "open"]): "only"},
|
|
})
|
|
src = _wildcard("src00001", "season", [_opt("beach day", ["summer"])],
|
|
sub_categories=_SEASONS)
|
|
tgt = _wildcard("tgt00001", "shoes",
|
|
[_opt("sandals", ["open"]), _opt("boots", ["closed"])],
|
|
sub_categories=_SHOE_TAGS)
|
|
for seed in range(20):
|
|
assert _run([src, con, tgt], seed)["shoes"] == "sandals", seed
|
|
|
|
|
|
@pytest.mark.parametrize("where", ["matrix", "exception"])
|
|
def test_only_is_a_valid_mode(where):
|
|
payload = {"source_wildcard_id": "a", "target_wildcard_id": "b",
|
|
"matrix": {}, "exceptions": []}
|
|
if where == "matrix":
|
|
payload["matrix"] = {"x": {"y": {"mode": "only", "factor": 1.0}}}
|
|
else:
|
|
payload["exceptions"] = [_link("x", "y")]
|
|
ConstraintHandler.validate_payload(payload)
|
|
|
|
|
|
def test_legacy_neutral_cell_override_reads_as_allow():
|
|
# Canvas builds before this change stored a neutral override as "neutral",
|
|
# which the engine rejected, failing the whole constraint. It now reads as
|
|
# `allow`, so the library exclude it overrode is lifted.
|
|
src = _wildcard("src00001", "season", [_opt("beach day", ["summer"])],
|
|
sub_categories=_SEASONS)
|
|
tgt = _wildcard("tgt00001", "shoes", [_opt("boots", ["closed"])],
|
|
sub_categories=_SHOE_TAGS)
|
|
key = encode_key(["summer", "closed"])
|
|
con = _constraint({"summer": {"closed": {"mode": "exclude", "factor": 0.0}}}, [],
|
|
instance={"cell_mode_overrides": {key: "neutral"}})
|
|
ctx = _run([src, con, tgt], 3)
|
|
assert not [w for w in ctx.get("__wp_warnings__", []) if w.get("type") == "handler_error"]
|
|
assert ctx["shoes"] == "boots"
|
|
|
|
|
|
def test_tag_link_leaves_a_target_accepts_axis_it_never_mentions_alone():
|
|
# The story-pack loop: Location is the target of a Time of Day -> Location
|
|
# `only` matrix keyed on its classify `time` tags AND carries an `accepts`
|
|
# `kind` group (it is the source of another agreement). The row says
|
|
# nothing about `kind`, so the kind tags are no allow-list to fail: the
|
|
# option keeps its time rule and rolls its kind freely. It used to give
|
|
# every unmentioned kind member 0, exclude every option and fall back.
|
|
src = _wildcard("src00001", "time",
|
|
[_opt("dawn", ["day"]), _opt("midnight", ["night"])],
|
|
sub_categories=["day", "night"])
|
|
kinds = ["forest", "castle"]
|
|
tgt = _wildcard("tgt00001", "location",
|
|
[_opt("sunlit glade", ["day", "forest"]),
|
|
_opt("castle court", ["day", "castle"]),
|
|
_opt("moonlit ruin", ["night", "castle"])],
|
|
sub_categories=["day", "night", *kinds],
|
|
tag_groups={"time": ["day", "night"], "kind": kinds},
|
|
tag_group_kinds={"time": "classify", "kind": "accepts"})
|
|
con = _constraint({"day": {"day": {"mode": "only", "factor": 1.0}},
|
|
"night": {"night": {"mode": "only", "factor": 1.0}}}, [])
|
|
seen = set()
|
|
for seed in range(40):
|
|
ctx = _run([src, con, tgt], seed)
|
|
assert not [w for w in ctx["__wp_warnings__"]
|
|
if w.get("type") == "constraint_excludes_all_options"], seed
|
|
if ctx["time"] == "dawn":
|
|
assert ctx["location"] in {"sunlit glade", "castle court"}, seed
|
|
else:
|
|
assert ctx["location"] == "moonlit ruin", seed
|
|
seen.add((ctx["location"], ctx["__wp_axes__"]["location"]["kind"]))
|
|
assert ("sunlit glade", "forest") in seen
|
|
assert ("castle court", "castle") in seen
|