A toggle with a Value writes it to its variable while on and nothing while off; its Negative goes with the variable. $name.bool reads the switch (true/false) across nodes, in text and in conditions, through __wp_toggle_bools__. Payloads reading $name.bool stamp schema 12; bool is reserved as a tag group.
3917 lines
197 KiB
Python
3917 lines
197 KiB
Python
"""Pack graph: the small, typed plan a model writes (slots, axes, relations,
|
||
template order) and the code that turns it into a pack plan whose wiring
|
||
is right by construction.
|
||
|
||
The model decides WHAT a pack is about: which slots exist, what they are
|
||
called, what they vary on (axes), what depends on what (relations) and the
|
||
order the template reads them in. Code decides HOW that is wired: every
|
||
slot is one wildcard, every axis a tag group, every relation a constraint
|
||
or a derivation keyed on those tags, and the template reads every slot
|
||
that shows. Every reference a relation makes is checked against the names
|
||
the same graph declares; a reference that resolves to nothing is dropped
|
||
and reported (``bad_refs``), never guessed.
|
||
|
||
``compile_graph`` cleans and checks the model's answer. ``graph_plan``
|
||
turns it into the plan items ``compile_plan`` takes, so keys, reserved ids,
|
||
sections and the library work as before. ``wired_payload`` writes the
|
||
payload of every item but a slot without a model; ``slot_schema`` and
|
||
``compile_slot`` are the per-slot content call (options tagged on an enum
|
||
of the slot's own axis values).
|
||
|
||
How a request becomes a graph (``compile_graph_plan``, in order):
|
||
|
||
1. The model's answer is cleaned: ``compile_graph`` runs ``_slots`` (names,
|
||
variables, pick ranges), ``_infer_axes`` (axes a relation names but no
|
||
slot declared), ``_fixed``, ``_relations`` (each side checked against
|
||
the graph's own names; a miss goes to ``bad_refs``) and ``_template``.
|
||
On a refine, ``apply_locks`` first puts back what the user locked.
|
||
2. Code then corrects what models get wrong, each step adding a line to
|
||
``applied`` so the plan says what changed: the pack settings
|
||
(``apply_settings``), a story's beat (``code_beat``), pick counts the
|
||
request names ("two accessories", ``apply_picks``; ``_COUNTED`` and
|
||
``_NOT_PICKED``), rules a multi-pick can't take (``drop_multi_pick_rules``),
|
||
quality / negative slots that should be fixed values or the run's own
|
||
(``apply_request_slots``), model-family rules the request never asked for
|
||
(``trim_model``), a person lead on a request with no person
|
||
(``unasked_lead``; ``_PERSON_ASK`` / ``_THING_ASK``) and existing library
|
||
items to reuse (``apply_library``).
|
||
3. ``graph_plan`` turns the graph into plan items and ``compile_plan`` gives
|
||
them keys and reserved ids. ``request_gaps`` lists request words the graph
|
||
says nowhere (content words after ``_REQUEST_STOP`` and ``_stems``), which
|
||
the review asks about once.
|
||
4. Building: ``wired_payload`` writes constraints, derivations and the
|
||
template from the graph; each slot's options come from the model through
|
||
``slot_prompt`` / ``slot_schema`` / ``compile_slot``. After the test run,
|
||
``verify_relations`` checks every relation against the rendered prompts.
|
||
|
||
The word lists and regexes are tuned from real model answers; the tests
|
||
(``test_ai_pack_graph*.py``, ``test_ai_pack_quality.py``) are their record,
|
||
so change one together with the test that shows why.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from engine.ai import derivation as ai_derivation
|
||
from engine.ai.pack._common import (
|
||
_STR,
|
||
_STRS,
|
||
DEFAULT_COUNT,
|
||
MAX_ABOUT,
|
||
MAX_COUNT,
|
||
MAX_INPUTS,
|
||
MAX_NAME,
|
||
MAX_QUESTIONS,
|
||
MAX_SECTIONS,
|
||
MIN_COUNT,
|
||
RUN_MODES,
|
||
STYLES,
|
||
PlanError,
|
||
_clean,
|
||
_model,
|
||
_obj,
|
||
_tag,
|
||
_var,
|
||
)
|
||
from engine.ai.pack.neg_tidy import dedupe, fit_ratings, tidy_negative
|
||
from engine.ai.pack.plan import _NEGATIVEISH, MAX_TAGS, _row_var, _slug_var
|
||
from engine.ai.pack.settings import MAX_SWEEP_OPTIONS, parse_run, parse_settings
|
||
from engine.modules.derivation_handler import _ctx_get
|
||
from engine.negatives import with_slot
|
||
|
||
MAX_SLOTS = 24
|
||
MAX_AXES = 4
|
||
MAX_VALUES = 12
|
||
MAX_RELATIONS = 40
|
||
MAX_FIXED = 6
|
||
MAX_TEMPLATE_PARTS = 40
|
||
MAX_TEXT = 200
|
||
#: Longest negative a slot option may carry in its content call.
|
||
MAX_NEGATIVE = 120
|
||
MAX_WORDS = 6
|
||
#: Relation kinds. The first four key one slot's pick on another's by tag;
|
||
#: ``agree`` and ``accepts`` are "goes with" on a shared axis; ``derive``
|
||
#: and ``append`` write text from a pick; ``draw`` puts a fresh pick from
|
||
#: one slot (a shared list) in front of every option of another.
|
||
RELATIONS = ("exclude", "only", "boost", "reduce", "agree", "accepts", "derive", "append",
|
||
"draw")
|
||
#: The kinds that narrow the target's pick (one constraint per slot pair).
|
||
NARROWING = ("exclude", "only", "boost", "reduce", "agree", "accepts")
|
||
#: Source axes the engine reads as ALTERNATIVES (``accepts`` kind): one
|
||
#: value is rolled per pick and only that value's row fires. A classify
|
||
#: source carrying two values would fire two allow-list rows at once and
|
||
#: leave the target nothing (the 84/100 fallback rate of 2026-10-19).
|
||
_ROLLED_SOURCE = ("derive", "append", "accepts", "agree", "only")
|
||
_WEIGHTED = {"boost": 3.0, "reduce": 0.3}
|
||
SHOW = ("prompt", "internal")
|
||
_AXIS_NAME = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
||
_PICK = re.compile(r"^(\d{1,2})(?:\s*-\s*(\d{1,2}))?$")
|
||
|
||
#: Capped strings for the planner's schema: a 9B can write one field on
|
||
#: until the output limit cuts the plan off (smoke on #118, a plan at
|
||
#: 10k tokens); the compile cuts every field far shorter anyway.
|
||
_S: dict[str, Any] = {"type": "string", "maxLength": 300}
|
||
_SS: dict[str, Any] = {"type": "array", "items": _S}
|
||
|
||
AXIS_SCHEMA: dict[str, Any] = _obj({"name": _S, "values": _SS})
|
||
SLOT_SCHEMA: dict[str, Any] = _obj({
|
||
"name": _S,
|
||
"var": _S,
|
||
"what": _S,
|
||
"section": _S,
|
||
"count": {"type": "integer"},
|
||
"show": {"type": "string", "enum": list(SHOW)},
|
||
"pick": _S,
|
||
"hold": {"type": "boolean"},
|
||
"sweep": {"type": "boolean"},
|
||
"sometimes_none": {"type": "integer"},
|
||
"axes": {"type": "array", "items": AXIS_SCHEMA},
|
||
})
|
||
FIXED_SCHEMA: dict[str, Any] = _obj({
|
||
"name": _S, "var": _S, "text": _S, "negative": _S,
|
||
})
|
||
RELATION_SCHEMA: dict[str, Any] = _obj({
|
||
"kind": {"type": "string", "enum": list(RELATIONS)},
|
||
"from_slot": _S,
|
||
"from_axis": _S,
|
||
"from_value": _S,
|
||
"to_slot": _S,
|
||
"to_axis": _S,
|
||
"to_value": _S,
|
||
"factor": {"type": "number"},
|
||
"var": _S,
|
||
"text": _S,
|
||
"otherwise": _S,
|
||
"words": _SS,
|
||
})
|
||
TEMPLATE_SCHEMA: dict[str, Any] = _obj({
|
||
"lead": _S, "order": _SS, "tail": _S, "negative": _S,
|
||
})
|
||
GRAPH_SCHEMA: dict[str, Any] = _obj({
|
||
"title": _S,
|
||
"summary": _S,
|
||
"design": {"type": "string", "maxLength": 1500},
|
||
"rating": {"type": "string", "enum": ["safe", "nsfw"]},
|
||
"style": {"type": "string", "enum": list(STYLES)},
|
||
"model": _S,
|
||
"anchor": _S,
|
||
"questions": {"type": "array", "items": _obj({
|
||
"header": _S, "question": _S, "multi": {"type": "boolean"}, "choices": _SS})},
|
||
"assumptions": _SS,
|
||
"from_images": _SS,
|
||
"research": _SS,
|
||
"run": _obj({"mode": {"type": "string", "enum": list(RUN_MODES)},
|
||
"frames": {"type": "integer"}}),
|
||
"inputs": {"type": "array", "items": _obj({"var": _S, "about": _S, "example": _S})},
|
||
"sections": {"type": "array", "items": _obj({"name": _S, "about": _S})},
|
||
"slots": {"type": "array", "items": SLOT_SCHEMA},
|
||
"fixed": {"type": "array", "items": FIXED_SCHEMA},
|
||
"relations": {"type": "array", "items": RELATION_SCHEMA},
|
||
"template": TEMPLATE_SCHEMA,
|
||
})
|
||
|
||
|
||
def _axis_name(raw: Any) -> str:
|
||
a = _clean(raw, 40).lower().replace(" ", "_").replace("-", "_").lstrip("$.")
|
||
return a if _AXIS_NAME.match(a) and a not in ("neg", "bool") else ""
|
||
|
||
|
||
def _number(raw: Any, default: float) -> float:
|
||
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw >= 0:
|
||
return float(raw)
|
||
return default
|
||
|
||
|
||
def _pick(raw: Any) -> str:
|
||
"""``""`` for one pick, else ``"N"`` or ``"A-B"`` (at most 20)."""
|
||
m = _PICK.match(_clean(raw, 10))
|
||
if not m:
|
||
return ""
|
||
lo = max(1, min(20, int(m.group(1))))
|
||
hi = max(lo, min(20, int(m.group(2)))) if m.group(2) else lo
|
||
if lo == hi == 1:
|
||
return ""
|
||
return f"{lo}" if lo == hi else f"{lo}-{hi}"
|
||
|
||
|
||
def _lower_map(names: list[str]) -> dict[str, str]:
|
||
return {n.lower(): n for n in names}
|
||
|
||
|
||
def _slots(raw: Any, warnings: list[str], taken_vars: set[str]) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
names: set[str] = set()
|
||
for s in raw if isinstance(raw, list) else []:
|
||
if not isinstance(s, dict):
|
||
continue
|
||
name = _clean(s.get("name"), MAX_NAME)
|
||
var = _var(s.get("var")) or _slug_var(name)
|
||
if not name and not var:
|
||
warnings.append("Left out a slot with no name or variable")
|
||
continue
|
||
name = name or f"${var}"
|
||
if var in taken_vars:
|
||
warnings.append(f"Left out slot {name}: ${var} is already set")
|
||
continue
|
||
if name.lower() in names:
|
||
warnings.append(f"Left out a second slot named {name}")
|
||
continue
|
||
axes: list[dict[str, Any]] = []
|
||
used_values: set[str] = set()
|
||
for a in _got if isinstance(_got := s.get("axes"), list) else []:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
aname = _axis_name(a.get("name"))
|
||
if not aname or any(x["name"] == aname for x in axes):
|
||
if aname:
|
||
warnings.append(f"{name}: a second axis named {aname}")
|
||
continue
|
||
values = []
|
||
for v in _got if isinstance(_got := a.get("values"), list) else []:
|
||
t = _tag(v)
|
||
if not t or t in values:
|
||
continue
|
||
if t in used_values:
|
||
warnings.append(f"{name}: {t} is on two axes; kept the first")
|
||
continue
|
||
values.append(t)
|
||
values = values[:MAX_VALUES]
|
||
if not values:
|
||
continue
|
||
used_values |= set(values)
|
||
axes.append({"name": aname, "values": values})
|
||
if len(axes) >= MAX_AXES:
|
||
break
|
||
count = s.get("count")
|
||
count = int(count) if isinstance(count, (int, float)) and not isinstance(count, bool) \
|
||
else DEFAULT_COUNT
|
||
none = s.get("sometimes_none")
|
||
none = int(none) if isinstance(none, (int, float)) and not isinstance(none, bool) \
|
||
else 0
|
||
out.append({
|
||
"name": name, "var": var, "what": _clean(s.get("what"), MAX_ABOUT),
|
||
"section": _clean(s.get("section"), 40),
|
||
"count": max(MIN_COUNT, min(MAX_COUNT, count)),
|
||
"show": s.get("show") if s.get("show") in SHOW else "prompt",
|
||
"pick": _pick(s.get("pick")), "hold": s.get("hold") is True,
|
||
"sweep": s.get("sweep") is True,
|
||
"sometimes_none": max(0, min(90, none)),
|
||
"axes": axes,
|
||
# A slot folded from kinds of one thing keeps its options when
|
||
# the graph comes back from the browser (`_merge_split_slots`).
|
||
"self_axis": "" if s.get("folded") is True else _self_axis(name, var, axes),
|
||
})
|
||
if s.get("folded") is True:
|
||
out[-1]["folded"] = True
|
||
weights = _weights(s.get("weights"), axes)
|
||
if weights:
|
||
out[-1]["weights"] = weights
|
||
names.add(name.lower())
|
||
taken_vars.add(var)
|
||
if len(out) >= MAX_SLOTS:
|
||
warnings.append(f"Kept the first {MAX_SLOTS} slots")
|
||
break
|
||
return out
|
||
|
||
|
||
def _weights(raw: Any, axes: list[dict[str, Any]]) -> dict[str, float]:
|
||
"""A slot's per-value weight factors (``{value: factor}``), set by the
|
||
code from a rule that wrote a number (``_relations``) and kept when
|
||
the graph comes back from the editor."""
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
values = {v for a in axes for v in a["values"]}
|
||
out: dict[str, float] = {}
|
||
for k, v in raw.items():
|
||
t = _tag(k)
|
||
if t in values and isinstance(v, (int, float)) and not isinstance(v, bool) \
|
||
and 0 < float(v) <= MAX_WEIGHT_FACTOR and float(v) != 1:
|
||
out[t] = round(float(v), 2)
|
||
return out
|
||
|
||
|
||
#: The most a rule's number can weigh a value's options, up or down.
|
||
MAX_WEIGHT_FACTOR = 20.0
|
||
#: A rule's text that is only a number ("1.5", "x2", "0.5×"): weight intent
|
||
#: written as text (9B, 2026-10-20: "$hair_len_prob = 1.5" for "long
|
||
#: styles more common", broken on every run).
|
||
_WEIGHT_TEXT = re.compile(r"^[x×]?\s*(\d+(?:\.\d+)?)\s*[x×]?$", re.I)
|
||
|
||
|
||
def _self_axis(name: str, var: str, axes: list[dict[str, Any]]) -> str:
|
||
"""The axis a slot IS (a Season slot with axis season): its options are
|
||
the axis values, written by the code. "" when it has none."""
|
||
own = set(_axis_name(name).split("_")) | set(var.lower().split("_"))
|
||
own |= {w.rstrip("s") for w in own}
|
||
if len(axes) == 1 and (axes[0]["name"] in own or axes[0]["name"].rstrip("s") in own):
|
||
return axes[0]["name"]
|
||
return ""
|
||
|
||
|
||
def _slot_index(slots: list[dict[str, Any]],
|
||
aliases: dict[str, str] | None = None) -> dict[str, dict[str, Any]]:
|
||
"""Slots by lowercase name, variable and $variable, plus the old names
|
||
of slots ``_merge_split_slots`` folded together (``aliases``: old name
|
||
or variable → the merged slot's name)."""
|
||
by_name: dict[str, dict[str, Any]] = {}
|
||
for s in slots:
|
||
by_name[s["name"].lower()] = s
|
||
by_name[s["var"].lower()] = s
|
||
by_name[f"${s['var']}".lower()] = s
|
||
for old, new in (aliases or {}).items():
|
||
if new.lower() in by_name:
|
||
by_name.setdefault(old.lower(), by_name[new.lower()])
|
||
by_name.setdefault(f"${old}".lower(), by_name[new.lower()])
|
||
return by_name
|
||
|
||
|
||
def _merge_split_slots(slots: list[dict[str, Any]], warnings: list[str]) -> dict[str, str]:
|
||
""""Casual Outfit" [formality: casual] and "Formal Outfit" [formality:
|
||
formal] are one Outfit slot split by its own axis: both print in every
|
||
prompt, and two rules on Footwear ("casual outfits only with casual
|
||
shoes", "formal only with formal") both fire every run, so no shoe is
|
||
left and the fallback is always taken (9B pack 15, 2026-10-24). Shown
|
||
slots whose one axis is the same, each with one value its name says,
|
||
and whose names are the same once that value is taken out, become one
|
||
slot with all the values. Returns old name / variable → merged name."""
|
||
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||
for s in slots:
|
||
if s["show"] != "prompt" or len(s["axes"]) != 1 or len(s["axes"][0]["values"]) != 1:
|
||
continue
|
||
value = s["axes"][0]["values"][0]
|
||
words = re.findall(r"[a-z0-9]+", s["name"].lower())
|
||
rest = [w for w in words if w not in value.split("_")]
|
||
if not rest or len(rest) == len(words):
|
||
continue
|
||
groups.setdefault((s["axes"][0]["name"], " ".join(rest)), []).append(s)
|
||
aliases: dict[str, str] = {}
|
||
taken = {s["var"] for s in slots}
|
||
for (axis, rest), group in groups.items():
|
||
values = [g["axes"][0]["values"][0] for g in group]
|
||
if len(group) < 2 or len(set(values)) != len(values):
|
||
continue
|
||
keep = group[0]
|
||
old = [g["name"] for g in group]
|
||
name = rest.title()
|
||
ids = {id(g) for g in group}
|
||
if any(s["name"].lower() == rest for s in slots if id(s) not in ids):
|
||
name = keep["name"]
|
||
var = _slug_var(name)
|
||
if var in taken and var not in {g["var"] for g in group}:
|
||
var = keep["var"]
|
||
for g in group:
|
||
aliases[g["name"]] = name
|
||
aliases[g["var"]] = name
|
||
taken -= {g["var"] for g in group}
|
||
taken.add(var)
|
||
keep.update({
|
||
"name": name, "var": var,
|
||
"what": "; ".join(dict.fromkeys(g["what"] for g in group if g["what"]))[:MAX_ABOUT],
|
||
"count": min(MAX_COUNT, sum(g["count"] for g in group)),
|
||
"sometimes_none": min(g["sometimes_none"] for g in group),
|
||
"hold": any(g["hold"] for g in group),
|
||
"axes": [{"name": axis, "values": values}],
|
||
})
|
||
# The values were kinds of the thing ("Casual Outfit" / "Formal
|
||
# Outfit"), never its options: with an axis named like the slot
|
||
# ("outfit": casual, formal) a self axis would print the bare kind
|
||
# ("casual") as the outfit (9B pack 15, 2026-10-24).
|
||
keep["self_axis"] = ""
|
||
keep["folded"] = True
|
||
keep.pop("weights", None)
|
||
slots[:] = [s for s in slots if s is keep or id(s) not in ids]
|
||
warnings.append(f"{' and '.join(old)} are one {name} slot with a {axis} axis: "
|
||
"both printed in every prompt and their rules shut each other out")
|
||
return aliases
|
||
|
||
|
||
def _find_slot(sname: str, slots: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
|
||
return slots.get(sname.lower()) or slots.get(_var(sname).lower()) \
|
||
or slots.get(f"${_var(sname)}".lower())
|
||
|
||
|
||
def _infer_axes(raw: Any, slots: list[dict[str, Any]], warnings: list[str],
|
||
aliases: dict[str, str] | None = None) -> None:
|
||
"""An axis a relation names on one slot that only the other slot
|
||
declares is copied over (same name, same values), so the model
|
||
declares each axis once: "exclude Season.season=winter →
|
||
Footwear.season=summer" gives Footwear a season axis when it has none,
|
||
and "agree on season Season → Outfit" likewise. Nothing is added when
|
||
the slot already uses a value of it on another axis, or has 4 axes."""
|
||
by_name = _slot_index(slots, aliases)
|
||
for r in raw if isinstance(raw, list) else []:
|
||
if not isinstance(r, dict) or r.get("kind") not in RELATIONS:
|
||
continue
|
||
kind = r["kind"]
|
||
if kind in ("derive", "append", "draw"):
|
||
continue
|
||
src = _find_slot(_clean(r.get("from_slot"), MAX_NAME), by_name)
|
||
dst = _find_slot(_clean(r.get("to_slot"), MAX_NAME), by_name)
|
||
if src is None or dst is None or src is dst:
|
||
continue
|
||
for slot, other, which in ((dst, src, "to"), (src, dst, "from")):
|
||
axis = _axis_name(r.get(f"{which}_axis")) or _axis_name(r.get("from_axis")) \
|
||
or _axis_name(r.get("to_axis"))
|
||
value = _tag(r.get(f"{which}_value")) if kind not in ("agree", "accepts") else ""
|
||
if not axis or any(a["name"] == axis for a in slot["axes"]):
|
||
continue
|
||
if value and any(value in a["values"] for a in slot["axes"]):
|
||
continue # the value finds its axis on this slot already
|
||
have = next((a for a in other["axes"] if a["name"] == axis), None)
|
||
if have is None or (value and value not in have["values"]):
|
||
continue
|
||
used = {v for a in slot["axes"] for v in a["values"]}
|
||
if used & set(have["values"]) or len(slot["axes"]) >= MAX_AXES:
|
||
continue
|
||
slot["axes"].append({"name": axis, "values": list(have["values"])})
|
||
if not slot.get("folded"):
|
||
slot["self_axis"] = _self_axis(slot["name"], slot["var"], slot["axes"])
|
||
warnings.append(f"{slot['name']}: added the {axis} axis "
|
||
f"({', '.join(have['values'])}) that its {kind} rule with "
|
||
f"{other['name']} needs")
|
||
|
||
|
||
#: Slot words of what a story keeps from frame to frame when the model
|
||
#: holds everything: the cast and where it is.
|
||
_STAYS = {"character", "subject", "person", "girl", "boy", "hero", "protagonist", "race",
|
||
"outfit", "clothes", "clothing", "attire", "costume", "wear", "hair", "face", "body",
|
||
"eye", "skin", "species", "creature", "pet", "animal", "car", "vehicle", "setting",
|
||
"scene", "location", "place", "background", "environment", "style", "quality"}
|
||
|
||
|
||
def _story_holds(slots: list[dict[str, Any]], run: dict[str, Any],
|
||
warnings: list[str]) -> None:
|
||
"""In a story, something must change from frame to frame: when every
|
||
prompt slot is held, the ones that aren't the cast or the place (a
|
||
pose, an angle, the light) are let go, so the frames differ."""
|
||
prompt = [s for s in slots if s["show"] == "prompt"]
|
||
if run["mode"] != "story" or not prompt or not all(s["hold"] for s in prompt):
|
||
return
|
||
freed = []
|
||
for s in prompt:
|
||
words = {w.rstrip("s") for w in re.findall(r"[a-z]+", f"{s['name']} {s['var']}".lower()
|
||
.replace("_", " "))}
|
||
if not words & _STAYS:
|
||
s["hold"] = False
|
||
freed.append(s["name"])
|
||
if freed:
|
||
warnings.append(", ".join(freed) + (" change" if len(freed) > 1 else " changes")
|
||
+ " from frame to frame: a story where every pick is held repeats "
|
||
"one image")
|
||
|
||
|
||
def _fixed(raw: Any, warnings: list[str], taken_vars: set[str]) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for f in raw if isinstance(raw, list) else []:
|
||
if not isinstance(f, dict):
|
||
continue
|
||
name = _clean(f.get("name"), MAX_NAME)
|
||
var = _var(f.get("var")) or _slug_var(name)
|
||
text = _clean(f.get("text"), MAX_TEXT * 2)
|
||
if not var or not text:
|
||
warnings.append(f"Left out fixed values {name or '?'}: no variable or no text")
|
||
continue
|
||
if var in taken_vars:
|
||
warnings.append(f"Left out fixed values {name}: ${var} is already set")
|
||
continue
|
||
taken_vars.add(var)
|
||
out.append({"name": name or f"${var}", "var": var, "text": text,
|
||
"negative": _clean(f.get("negative"), MAX_TEXT)})
|
||
if len(out) >= MAX_FIXED:
|
||
break
|
||
return out
|
||
|
||
|
||
def _side(r: dict[str, Any], which: str, slots: dict[str, dict[str, Any]],
|
||
need_value: bool) -> tuple[dict[str, Any] | None, str]:
|
||
"""``(side, error)``: the slot, axis and value a relation names on one
|
||
side, resolved against the declared slots; ``error`` says what failed."""
|
||
sname = _clean(r.get(f"{which}_slot"), MAX_NAME)
|
||
slot = _find_slot(sname, slots)
|
||
if slot is None:
|
||
return None, f"{which} names no slot ({sname or 'empty'})"
|
||
axis = _axis_name(r.get(f"{which}_axis"))
|
||
value = _tag(r.get(f"{which}_value"))
|
||
if not need_value and not axis and not value:
|
||
return {"slot": slot, "axis": "", "value": ""}, ""
|
||
ax = next((a for a in slot["axes"] if a["name"] == axis), None) if axis else None
|
||
if ax is None and value:
|
||
# The axis left out, or wrong: the value finds it when it is unique.
|
||
hits = [a for a in slot["axes"] if value in a["values"]]
|
||
if len(hits) == 1:
|
||
ax = hits[0]
|
||
if ax is None:
|
||
have = ", ".join(a["name"] for a in slot["axes"]) or "none"
|
||
return None, (f"{slot['name']} has no axis {axis}" if axis else
|
||
f"{slot['name']}: no axis given") + f" (its axes: {have})"
|
||
if need_value and value not in ax["values"]:
|
||
have = ", ".join(ax["values"][:8])
|
||
return None, (f"{slot['name']}.{ax['name']} has no value {value}" if value else
|
||
f"{slot['name']}.{ax['name']}: no value given") + f" (its values: {have})"
|
||
return {"slot": slot, "axis": ax["name"],
|
||
"value": value if need_value or value in ax["values"] else ""}, ""
|
||
|
||
|
||
#: How each relation kind reads to the user, for `plain_ref`.
|
||
_KIND_WORDS = {
|
||
"derive": "A rule that sets a variable", "append": "A rule that adds words to a variable",
|
||
"exclude": "A \"never together\" rule", "only": "An \"only these\" rule",
|
||
"boost": "A \"more likely\" rule", "reduce": "A \"less likely\" rule",
|
||
"agree": "An \"agrees with\" rule", "accepts": "An \"accepts\" rule",
|
||
"draw": "A \"draws from\" rule",
|
||
}
|
||
_REF_VALUE = re.compile(r"^(\w+)(?: \$(\w+) \"([^\"]*)\")?(?: from \S+)?: (.+?)\.(\w+)"
|
||
r"(?: has no value (\S+)|: no value given) \(its values: (.*)\)$")
|
||
_REF_AXIS = re.compile(r"^(\w+)(?: on \S+)?: (.+?)(?: has no axis (\S+)|: no axis given)"
|
||
r" \(its axes: (.*)\)$")
|
||
_REF_SET = re.compile(r"^derive: \$(\w+) is already set by (?:the (.+) slot|a slot)")
|
||
|
||
|
||
def plain_ref(ref: str) -> str:
|
||
"""A relation the compile left out, as the plan shows it to the user.
|
||
`bad_refs` keep the terse form (the repair prompt reads them); this one
|
||
says which rule, why, and what the slot has, in words: "derive:
|
||
Subject.build has no value ?" read like a variable called
|
||
`$subj.build`, and the chat's Explain went looking for one (Dumi
|
||
2026-10-21)."""
|
||
m = _REF_VALUE.match(ref)
|
||
if m:
|
||
kind, var, text, slot, axis, value, have = m.groups()
|
||
who = _KIND_WORDS.get(kind, "A rule")
|
||
if var:
|
||
who = f"A rule that {'adds' if kind == 'append' else 'sets'} ${var} = \"{text}\""
|
||
why = (f"it names {axis} \"{value}\", which {slot} doesn't have" if value
|
||
else f"it names no {axis} value, so it can't say when it applies")
|
||
return (f"Left out a rule: {who} on {slot}'s {axis}: {why} ({slot}'s {axis} "
|
||
f"values: {have}).")
|
||
m = _REF_AXIS.match(ref)
|
||
if m:
|
||
kind, slot, axis, have = m.groups()
|
||
who = _KIND_WORDS.get(kind, "A rule")
|
||
why = (f"it names a {axis} group {slot} doesn't have" if axis
|
||
else "it names no tag group of it")
|
||
return f"Left out a rule: {who} on {slot}: {why} ({slot}'s groups: {have})."
|
||
m = _REF_SET.match(ref)
|
||
if m:
|
||
var, owner = m.groups()
|
||
by = f"the {owner} slot" if owner else "a slot"
|
||
return (f"Left out a rule: A rule that sets ${var}: {by} already picks ${var}, and a "
|
||
"rule can't overwrite a pick. It could add words to it or set a new variable "
|
||
"instead.")
|
||
return f"Left out a rule: {ref}"
|
||
|
||
|
||
#: Derive text that is an instruction, not prompt words.
|
||
_RULE_TEXT = re.compile(r"^\s*(?:(?:exclude|include|avoid|allow|forbid|never|only)s?|not)\b",
|
||
re.I)
|
||
|
||
|
||
def _relations(
|
||
raw: Any, slots: list[dict[str, Any]], fixed_vars: set[str], input_vars: set[str],
|
||
warnings: list[str], bad_refs: list[str], aliases: dict[str, str] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
by_name = _slot_index(slots, aliases)
|
||
slot_vars = {s["var"] for s in slots}
|
||
derived: set[str] = set()
|
||
out: list[dict[str, Any]] = []
|
||
for r in raw if isinstance(raw, list) else []:
|
||
if not isinstance(r, dict) or r.get("kind") not in RELATIONS:
|
||
continue
|
||
kind = r["kind"]
|
||
src, err = _side(r, "from", by_name,
|
||
need_value=kind not in ("agree", "accepts", "draw"))
|
||
if src is None:
|
||
# A rule that writes a variable says which and what, so the note
|
||
# (and a Fix with AI from it) can name the rule it means.
|
||
wvar, wtext = _var(r.get("var")), _clean(r.get("text"), 60).replace('"', "'")
|
||
what = f' ${wvar} "{wtext}"' if kind in ("derive", "append") and wvar else ""
|
||
bad_refs.append(f"{kind}{what}: {err}")
|
||
continue
|
||
rel: dict[str, Any] = {"kind": kind, "from": {"slot": src["slot"]["name"],
|
||
"axis": src["axis"],
|
||
"value": src["value"]},
|
||
"words": _words(r.get("words"))}
|
||
if kind in ("derive", "append"):
|
||
var = _var(r.get("var"))
|
||
if var in (aliases or {}) and var.lower() in by_name:
|
||
var = by_name[var.lower()]["var"] # a slot folded into another
|
||
text = _clean(r.get("text"), MAX_TEXT)
|
||
m = _WEIGHT_TEXT.match(text) if kind == "derive" and src["value"] else None
|
||
if m and 0 < float(m.group(1)) <= MAX_WEIGHT_FACTOR:
|
||
# A number is a weight, not text: it goes on the options
|
||
# tagged with the value, and the rule is spent.
|
||
factor = round(float(m.group(1)), 2)
|
||
if factor != 1:
|
||
src["slot"].setdefault("weights", {})[src["value"]] = factor
|
||
warnings.append(f"{src['slot']['name']}: {src['value']} options weigh "
|
||
f"\u00d7{factor:g} (the rule wrote a number, so it is a weight"
|
||
" on the options, not text)")
|
||
continue
|
||
if not var or not text:
|
||
bad_refs.append(f"{kind} from {src['slot']['name']}: no variable or text")
|
||
continue
|
||
if _RULE_TEXT.match(text):
|
||
# "exclude pastels" written into $color_rule is a rule said as
|
||
# words: the prompt would print it and nothing excludes
|
||
# anything (9B smoke, 2026-10-24). The request check asks
|
||
# for the real rule.
|
||
warnings.append(f"Left out ${var} \"{text[:40]}\": a rule written as prompt "
|
||
"text; a relation between the slots does that")
|
||
continue
|
||
if var == src["slot"]["var"]:
|
||
# "Hat.type fedora → add 'fedora' to $hat" printed "a man in a
|
||
# fedorafedora" and broke every check that reads the pick
|
||
# (9B pack 7, 2026-10-24): a pick's own tags are already on it.
|
||
warnings.append(f"Left out {src['slot']['name']} → ${var} \"{text[:40]}\": a "
|
||
"rule can't add to the pick it reads")
|
||
continue
|
||
if kind == "derive" and (var in slot_vars or var in fixed_vars or var in input_vars):
|
||
# A pick can't be overwritten, but the rule's words can still
|
||
# be added to it: the code turns it into an append instead of
|
||
# dropping it (Dumi 2026-10-21: "how do we actually fix these?").
|
||
owner = next((x["name"] for x in slots if x["var"] == var), "")
|
||
kind = rel["kind"] = "append"
|
||
warnings.append(f"{src['slot']['name']} → ${var}: the rule adds its words to "
|
||
f"${var} instead of replacing it, since "
|
||
+ (f"the {owner} slot" if owner else "something else")
|
||
+ " already sets it")
|
||
if kind == "append" and var not in slot_vars | derived | fixed_vars:
|
||
bad_refs.append(f"append: nothing sets ${var} before it")
|
||
continue
|
||
if not src["axis"]:
|
||
bad_refs.append(f"{kind} from {src['slot']['name']} names no axis value")
|
||
continue
|
||
rel.update({"var": var, "text": text,
|
||
"otherwise": _clean(r.get("otherwise"), MAX_TEXT)
|
||
if kind == "derive" else ""})
|
||
derived.add(var)
|
||
out.append(rel)
|
||
continue
|
||
if kind == "draw":
|
||
dst, err = _side(r, "to", by_name, need_value=False)
|
||
if dst is None:
|
||
bad_refs.append(f"draw: {err}")
|
||
continue
|
||
if dst["slot"] is src["slot"]:
|
||
bad_refs.append(f"draw: {src['slot']['name']} from itself")
|
||
continue
|
||
rel["to"] = {"slot": dst["slot"]["name"], "axis": "", "value": ""}
|
||
out.append(rel)
|
||
continue
|
||
if kind in ("agree", "accepts"):
|
||
dst, err = _side(r, "to", by_name, need_value=False)
|
||
if dst is None:
|
||
bad_refs.append(f"{kind}: {err}")
|
||
continue
|
||
if dst["slot"] is src["slot"]:
|
||
bad_refs.append(f"{kind}: {src['slot']['name']} with itself")
|
||
continue
|
||
axis = src["axis"] or dst["axis"] or _axis_name(r.get("from_axis")) \
|
||
or _axis_name(r.get("to_axis"))
|
||
a_src = next((a for a in src["slot"]["axes"] if a["name"] == (src["axis"] or axis)),
|
||
None)
|
||
a_dst = next((a for a in dst["slot"]["axes"] if a["name"] == (dst["axis"] or axis)),
|
||
None)
|
||
if a_src is None or a_dst is None:
|
||
# Named on neither side, or only one: the one axis the two
|
||
# slots share by values is the one meant.
|
||
pairs = [(a, b) for a in src["slot"]["axes"] for b in dst["slot"]["axes"]
|
||
if set(a["values"]) & set(b["values"])]
|
||
if len(pairs) == 1:
|
||
a_src, a_dst = pairs[0]
|
||
if a_src is None or a_dst is None:
|
||
missing = src["slot"]["name"] if a_src is None else dst["slot"]["name"]
|
||
bad_refs.append(f"{kind} on {axis or '?'}: {missing} has no such axis")
|
||
continue
|
||
shared = [v for v in a_src["values"] if v in a_dst["values"]]
|
||
if not shared:
|
||
bad_refs.append(f"{kind} on {axis}: {src['slot']['name']} and "
|
||
f"{dst['slot']['name']} share no value")
|
||
continue
|
||
rel.update({"from": {"slot": src["slot"]["name"], "axis": a_src["name"],
|
||
"value": ""},
|
||
"to": {"slot": dst["slot"]["name"], "axis": a_dst["name"],
|
||
"value": ""},
|
||
"values": shared})
|
||
out.append(rel)
|
||
continue
|
||
dst, err = _side(r, "to", by_name, need_value=True)
|
||
if dst is None:
|
||
bad_refs.append(f"{kind}: {err}")
|
||
continue
|
||
if dst["slot"] is src["slot"]:
|
||
bad_refs.append(f"{kind}: {src['slot']['name']} with itself")
|
||
continue
|
||
rel["to"] = {"slot": dst["slot"]["name"], "axis": dst["axis"], "value": dst["value"]}
|
||
if kind in _WEIGHTED:
|
||
f = _number(r.get("factor"), _WEIGHTED[kind])
|
||
if kind == "boost" and f < 1:
|
||
f = _WEIGHTED[kind]
|
||
if kind == "reduce" and (f >= 1 or f == 0):
|
||
f = _WEIGHTED[kind]
|
||
rel["factor"] = f
|
||
out.append(rel)
|
||
dupes: set[str] = set()
|
||
kept: list[dict[str, Any]] = []
|
||
for rel in out:
|
||
sig = _rel_key(rel)
|
||
if sig in dupes:
|
||
continue
|
||
dupes.add(sig)
|
||
kept.append(rel)
|
||
if len(kept) > MAX_RELATIONS:
|
||
warnings.append(f"Kept the first {MAX_RELATIONS} relations")
|
||
return kept[:MAX_RELATIONS]
|
||
|
||
|
||
def _words(raw: Any) -> list[str]:
|
||
"""The things a relation is about, as an option would say them
|
||
("sandals", "shorts"): lowercase, distinct, at most 6."""
|
||
out: list[str] = []
|
||
for w in raw if isinstance(raw, list) else []:
|
||
t = _clean(w, 40).lower().strip("\"'")
|
||
if t and t not in out:
|
||
out.append(t)
|
||
return out[:MAX_WORDS]
|
||
|
||
|
||
def _has_word(text: str, word: str) -> bool:
|
||
return re.search(r"(?<!\w)" + re.escape(word) + r"(?!\w)", text) is not None
|
||
|
||
|
||
def _rel_key(rel: dict[str, Any]) -> str:
|
||
src = rel["from"]
|
||
dst = rel.get("to") or {}
|
||
return "|".join([rel["kind"], src["slot"], src["axis"], src["value"], dst.get("slot", ""),
|
||
dst.get("axis", ""), dst.get("value", ""), rel.get("var", ""),
|
||
rel.get("text", "")])
|
||
|
||
|
||
_LORA_INPUT = re.compile(r"lora|lycoris|locon", re.I)
|
||
|
||
|
||
def _template(raw: Any, slots: list[dict[str, Any]], fixed: list[dict[str, Any]],
|
||
relations: list[dict[str, Any]], inputs: list[dict[str, str]],
|
||
warnings: list[str], aliases: dict[str, str] | None = None) -> dict[str, Any]:
|
||
"""The template's parts in order, each resolved to a variable."""
|
||
raw = raw if isinstance(raw, dict) else {}
|
||
by_name: dict[str, str] = {}
|
||
for s in slots:
|
||
if s["show"] == "prompt":
|
||
by_name[s["name"].lower()] = s["var"]
|
||
by_name[s["var"].lower()] = s["var"]
|
||
for f in fixed:
|
||
by_name[f["name"].lower()] = f["var"]
|
||
by_name[f["var"].lower()] = f["var"]
|
||
for r in relations:
|
||
if r["kind"] == "derive":
|
||
by_name[r["var"].lower()] = r["var"]
|
||
for i in inputs:
|
||
by_name[i["var"].lower()] = i["var"]
|
||
for old, new in (aliases or {}).items():
|
||
if new.lower() in by_name:
|
||
by_name.setdefault(old.lower(), by_name[new.lower()])
|
||
internal = {s["var"] for s in slots if s["show"] == "internal"} | {
|
||
s["name"].lower() for s in slots if s["show"] == "internal"}
|
||
order: list[str] = []
|
||
for part in _got if isinstance(_got := raw.get("order"), list) else []:
|
||
p = _clean(part, MAX_NAME).lstrip("$").strip()
|
||
var = by_name.get(p.lower())
|
||
if var is None:
|
||
if p.lower() in internal:
|
||
warnings.append(f"The template doesn't read {p}: it's internal")
|
||
elif p:
|
||
warnings.append(f"The template names {p}, which nothing sets")
|
||
continue
|
||
if var not in order:
|
||
order.append(var)
|
||
lead = _placeholders(_clean(raw.get("lead"), MAX_TEXT), by_name, warnings)
|
||
tail = _placeholders(_clean(raw.get("tail"), MAX_TEXT), by_name, warnings)
|
||
lead, tail, order, read = _text_reads(lead, tail, order, by_name, warnings)
|
||
lead, tail = _plan_words(lead, warnings), _plan_words(tail, warnings)
|
||
for s in slots:
|
||
if s["show"] == "prompt" and s["var"] not in order and s["var"] not in read:
|
||
order.append(s["var"])
|
||
warnings.append(f"Added ${s['var']} to the template so {s['name']} shows")
|
||
# A fixed value and a derived variable exist to be read: the code
|
||
# owns the template, so none is left out of it.
|
||
for f in fixed:
|
||
if f["var"] not in order and f["var"] not in read:
|
||
order.append(f["var"])
|
||
warnings.append(f"Added ${f['var']} to the template so {f['name']} shows")
|
||
for var in dict.fromkeys(r["var"] for r in relations if r["kind"] == "derive"):
|
||
if var not in order and var not in read:
|
||
order.append(var)
|
||
warnings.append(f"Added ${var} to the template so the rule writing it shows")
|
||
# What the user gives (a subject, a name) is there to be shown, first;
|
||
# a LoRA input stands in for a pick and is read by its swap rule.
|
||
for i in reversed(inputs):
|
||
if i["var"] not in order and i["var"] not in read \
|
||
and not _LORA_INPUT.search(f"{i['var']} {i['about']}"):
|
||
order.insert(0, i["var"])
|
||
warnings.append(f"Added ${i['var']} to the template so what the user gives shows")
|
||
negative = _clean(raw.get("negative"), MAX_TEXT * 2)
|
||
# "{{inputs.neg_prompt}} {{fixed.default_neg}}" or "$neg = ugly, …" in
|
||
# the negative (9B, 2026-10-21): a placeholder resolves or goes like in
|
||
# the lead, and an assignment keeps its right-hand side.
|
||
assigned = re.match(r"^\s*\$?[A-Za-z_]\w*\s*(?:=|:)\s*(?=\S)", negative)
|
||
if assigned:
|
||
warnings.append(f"Kept the words of \"{negative[:30]}\" as the negative: it was written "
|
||
"as an assignment")
|
||
negative = negative[assigned.end():]
|
||
negative = _placeholders(negative, by_name, warnings)
|
||
# "$negative, blurry" (9B smoke, 2026-10-24): a $name nothing sets
|
||
# would print as written, so it goes.
|
||
unset = [m.group(0) for m in _VAR.finditer(negative) if m.group(1).lower() not in by_name]
|
||
if unset:
|
||
negative = _VAR.sub(lambda m: m.group(0) if m.group(1).lower() in by_name else "",
|
||
negative)
|
||
negative = re.sub(r"\s*,\s*(?=,|$)|^\s*,\s*", "", negative)
|
||
negative = re.sub(r",\s*,", ",", negative).strip(" ,")
|
||
warnings.append(f"Took {', '.join(unset)} out of the negative: nothing sets it")
|
||
rule_words = {w for r in relations for w in r.get("words") or []}
|
||
if negative and rule_words:
|
||
# "sandals, shorts in winter" is a relation, not a defect.
|
||
kept, gone = [], []
|
||
for chunk in negative.split(","):
|
||
c = chunk.strip()
|
||
if c and any(_has_word(c.lower(), w) for w in rule_words):
|
||
gone.append(c)
|
||
elif c:
|
||
kept.append(c)
|
||
if gone:
|
||
warnings.append(f"Took \"{', '.join(gone)}\" out of the negative: a rule's words "
|
||
"belong in a relation, the negative is for defects")
|
||
negative = ", ".join(kept)
|
||
lead, tail, negative = _defects_to_negative(lead, tail, negative, warnings)
|
||
lead, tail = _fixed_repeats(lead, tail, fixed, order, read, warnings)
|
||
return {"lead": lead, "order": order[:MAX_TEMPLATE_PARTS], "tail": tail,
|
||
"negative": negative}
|
||
|
||
|
||
#: One-word lead or tail parts that name a part of the prompt, not words
|
||
#: of it: "character, look, outfit, pose, expression, place, light" led
|
||
#: every prompt of a 9B pack (bench P7, 2026-10-26).
|
||
_PART_NAMES = frozenset("""
|
||
character subject look looks outfit outfits clothing clothes pose poses expression
|
||
expressions place places location setting settings scene scenes background backgrounds
|
||
light lighting mood moods style styles camera angle angles composition action actions
|
||
hair hairstyle accessory accessories footwear shoes weather season time era species race
|
||
class weapon weapons prop props item items detail details
|
||
""".split())
|
||
#: A "no …" / "never …" part: a rule, which prints as text in a positive
|
||
#: prompt ("no elf, no beast", bench Z18).
|
||
_NEGATED_PART = re.compile(r"^(?:no|not|never|without|avoid)\s+\S", re.I)
|
||
#: Danbooru tags that start with "no" and are picture words: "no humans,
|
||
#: scenery" is how a landscape pack keeps a person out on tag models.
|
||
_NO_TAGS = frozenset(f"no {w}" for w in """
|
||
humans pupils shoes headwear hat bra panties nose mouth eyes socks gloves sclera lineart
|
||
legwear pants shirt pussy penis nipples testicles blush wings tail ears eyewear
|
||
""".split())
|
||
|
||
|
||
#: Loop and Model Info variables: for rules to read, never printed
|
||
#: ("$model_family $model_name $iteration" led a Pony template, 9B bench
|
||
#: T3; critiques #4/#22).
|
||
_RULE_ONLY_VAR = re.compile(r"\$+(?:iteration|iteration_total|model_family|model_variant|"
|
||
r"model_name)", re.I)
|
||
|
||
|
||
def _plan_words(text: str, warnings: list[str]) -> str:
|
||
"""The lead or tail without the planner's own words: parts that only
|
||
name a part of the prompt, and "no …" parts (rules, not picture
|
||
words; a positive prompt can't say "no")."""
|
||
if not text:
|
||
return text
|
||
kept, gone = [], []
|
||
for chunk in text.split(","):
|
||
c = chunk.strip()
|
||
if not c:
|
||
continue
|
||
negated = _NEGATED_PART.match(c) and re.sub(r"\s+", " ", c.lower()) not in _NO_TAGS
|
||
if ((c.lower() in _PART_NAMES or negated) and "$" not in c) \
|
||
or _RULE_ONLY_VAR.fullmatch(c):
|
||
gone.append(c)
|
||
else:
|
||
kept.append(c)
|
||
if gone:
|
||
warnings.append(f"Took \"{', '.join(gone)}\" out of the template text: it names a part "
|
||
"of the prompt or a rule, which would print as words")
|
||
return ", ".join(kept)
|
||
|
||
|
||
def _fixed_repeats(lead: str, tail: str, fixed: list[dict[str, Any]], order: list[str],
|
||
read: set[str], warnings: list[str]) -> tuple[str, str]:
|
||
"""A part of the lead or tail that a fixed value the template prints
|
||
already says ("clean lineart" in the lead of a pack whose style preset
|
||
holds it, Sonnet walkthrough 2026-10-21) prints twice; it goes."""
|
||
said: dict[str, str] = {}
|
||
for f in fixed:
|
||
if f["var"] in order or f["var"] in read:
|
||
for chunk in f["text"].split(","):
|
||
c = chunk.strip()
|
||
m = _WEIGHTED_PART.match(c)
|
||
word = (m.group(1) if m else c).strip().lower().replace("_", " ")
|
||
if word:
|
||
said.setdefault(word, f["name"])
|
||
if not said:
|
||
return lead, tail
|
||
gone: list[str] = []
|
||
|
||
def fix(text: str) -> str:
|
||
kept = []
|
||
for chunk in text.split(","):
|
||
c = chunk.strip()
|
||
if not c:
|
||
continue
|
||
m = _WEIGHTED_PART.match(c)
|
||
word = (m.group(1) if m else c).strip().lower().replace("_", " ")
|
||
if word in said:
|
||
gone.append(f"{c} ({said[word]})")
|
||
else:
|
||
kept.append(c)
|
||
return ", ".join(kept)
|
||
|
||
lead, tail = fix(lead), fix(tail)
|
||
if gone:
|
||
warnings.append(f"Took {', '.join(gone)} out of the template text: the fixed value "
|
||
"already says it, so it printed twice")
|
||
return lead, tail
|
||
|
||
|
||
def _text_reads(lead: str, tail: str, order: list[str], by_name: dict[str, str],
|
||
warnings: list[str]) -> tuple[str, str, list[str], set[str]]:
|
||
"""A pick's name written as plain text in the lead or tail
|
||
("quality_tags, style_preset, pose" led a bikini pack's prompt, 9B
|
||
NSFW eval 2026-10-21) is a read of it: it becomes ``$var``. A variable
|
||
the lead or tail reads leaves the order, so nothing prints twice; the
|
||
set of them is returned so the order isn't told to add them back."""
|
||
read: list[str] = []
|
||
bare: list[str] = []
|
||
|
||
def fix(text: str) -> str:
|
||
parts = []
|
||
for chunk in text.split(","):
|
||
c = chunk.strip()
|
||
var = by_name.get(c.lstrip("$").lower()) if c else None
|
||
if var and _IDENT_PART.match(c):
|
||
if not c.startswith("$"):
|
||
bare.append(c)
|
||
c = f"${var}"
|
||
read.append(var)
|
||
elif _VARS_ONLY.match(c) and all(by_name.get(n.lower()) for n in _VAR.findall(c)):
|
||
# "$quality $style" (9B smoke, 2026-10-24): two reads, one part each.
|
||
parts += [f"${by_name[n.lower()]}" for n in _VAR.findall(c)]
|
||
read.extend(by_name[n.lower()] for n in _VAR.findall(c))
|
||
continue
|
||
else:
|
||
read.extend(by_name[n.lower()] for n in _VAR.findall(c) if by_name.get(n.lower()))
|
||
if c:
|
||
parts.append(c)
|
||
return ", ".join(parts)
|
||
|
||
lead, tail = fix(lead), fix(tail)
|
||
if bare:
|
||
warnings.append(f"The template text names {', '.join(bare)} as words: read as "
|
||
+ ", ".join(f"${by_name[b.lower()]}" for b in bare) + " instead")
|
||
order = [v for v in order if v not in read]
|
||
return lead, tail, order, set(read)
|
||
|
||
|
||
_IDENT_PART = re.compile(r"^\$*[A-Za-z_][\w ]*$")
|
||
_VAR = re.compile(r"\$+([A-Za-z_]\w*)")
|
||
_VARS_ONLY = re.compile(r"^(?:\$+[A-Za-z_]\w*\s+)+\$+[A-Za-z_]\w*$")
|
||
|
||
#: Standing-negative tags that have no place in a positive prompt. Only
|
||
#: whole comma-parts count ("bad anatomy", "(ugly:1.2)"), never a word
|
||
#: inside a phrase ("blurry background" is a look).
|
||
_DEFECTS = frozenset({
|
||
"lowres", "low res", "worst quality", "low quality", "normal quality", "bad anatomy",
|
||
"bad hands", "bad proportions", "bad feet", "bad fingers", "extra fingers", "extra digits",
|
||
"fewer digits", "missing fingers", "extra limbs", "extra arms", "extra legs",
|
||
"missing limbs", "mutated", "mutation", "mutated hands", "deformed", "disfigured",
|
||
"malformed", "ugly", "blurry", "jpeg artifacts", "jpeg artifact", "watermark",
|
||
"signature", "username", "artist name", "error", "cropped", "out of frame",
|
||
"poorly drawn", "poorly drawn face", "poorly drawn hands", "long neck", "cloned face",
|
||
"gross proportions", "bad eyes", "cross-eyed", "dehydrated", "duplicate", "morbid",
|
||
"mutilated", "text", "logo",
|
||
})
|
||
_WEIGHTED_PART = re.compile(r"^\(\s*(.+?)\s*(?::\s*[\d.]+)?\s*\)$")
|
||
|
||
|
||
def _defects_to_negative(lead: str, tail: str, negative: str,
|
||
warnings: list[str]) -> tuple[str, str, str]:
|
||
"""Defect tags written into the lead or tail ("ugly, deformed, bad
|
||
anatomy" in an OC pack's positive prompt, 9B NSFW eval 2026-10-21) go
|
||
to the negative, where they belong."""
|
||
moved: list[str] = []
|
||
|
||
def fix(text: str) -> str:
|
||
kept = []
|
||
for chunk in text.split(","):
|
||
c = chunk.strip()
|
||
if not c:
|
||
continue
|
||
m = _WEIGHTED_PART.match(c)
|
||
word = (m.group(1) if m else c).strip().lower().replace("_", " ")
|
||
if word in _DEFECTS:
|
||
moved.append(c)
|
||
else:
|
||
kept.append(c)
|
||
return ", ".join(kept)
|
||
|
||
lead, tail = fix(lead), fix(tail)
|
||
if moved:
|
||
have = {w.strip().lower() for w in negative.split(",")}
|
||
add = [w for w in moved if w.lower() not in have]
|
||
negative = ", ".join(x for x in [negative.strip(" ,"), *add] if x)
|
||
warnings.append(f"Moved {', '.join(moved)} from the template text to the negative: "
|
||
"those are defects, not the picture")
|
||
return lead, tail, negative
|
||
|
||
|
||
_PLACEHOLDER = re.compile(r"\{\{\s*\$?([A-Za-z][\w .]*?)\s*\}\}|\{\s*\$?([A-Za-z][\w.]*)\s*\}")
|
||
|
||
|
||
def _placeholders(text: str, by_name: dict[str, str], warnings: list[str]) -> str:
|
||
"""``{{quality}}`` / ``{subject}`` written into the lead or tail: a
|
||
variable the graph has becomes ``$var``; the rest is taken out, since
|
||
the Assembler would print it as it is."""
|
||
gone: list[str] = []
|
||
|
||
def repl(m: re.Match[str]) -> str:
|
||
name = (m.group(1) or m.group(2) or "").strip()
|
||
var = by_name.get(name.lower()) or by_name.get(name.lower().replace(" ", "_"))
|
||
if var:
|
||
return f"${var}"
|
||
gone.append(name)
|
||
return ""
|
||
|
||
out = _PLACEHOLDER.sub(repl, text)
|
||
if gone:
|
||
warnings.append(f"Took {', '.join('{' + g + '}' for g in gone)} out of the template "
|
||
"text: nothing sets it, and it would print as it is")
|
||
out = re.sub(r"\s*,\s*(?=,|$)", "", re.sub(r"^\s*,\s*", "", out)).strip()
|
||
out = re.sub(r",\s*,", ",", out).strip(" ,")
|
||
return out
|
||
|
||
|
||
_PERSON_LEAD = re.compile(r"^\s*(?:\d*\s*(?:girls?|boys?|others?)|solo|\(?looking at viewer"
|
||
r"(?::\d+(?:\.\d+)?\)?)?)\s*$", re.I)
|
||
|
||
|
||
_PERSON_ASK = re.compile(
|
||
r"\b(?:subjects?|characters?|people|persons?|figures?|m[ae]n|wom[ae]n|heroe?s?|"
|
||
r"heroines?|someone|protagonists?|models?|who|\d*girls?|\d*boys?|solo|couples?|"
|
||
r"pin-?ups?|lingerie|nude|nsfw|lewd|erotic|sexy|ecchi|hentai|waifus?|oc|outfits?|"
|
||
r"hair\w*|faces?|selfies?|poses?|cosplay\w*|idols?|warriors?|knights?|wizards?|"
|
||
r"witch\w*|elf|elves|dwar(?:f|ves)|orcs?|maids?|nurses?|pirates?|samurai|ninjas?|"
|
||
r"soldiers?|athletes?|dancers?|portraits? of (?:a |the )?(?:wom|m|girl|boy|person))\b",
|
||
re.I)
|
||
_THING_ASK = re.compile(
|
||
r"\b(?:animals?|wildlife|creatures?|cats?|dogs?|lions?|tigers?|wolves|birds?|horses?|"
|
||
r"cars?|vehicles?|motorcycles?|ships?|planes?|landscapes?|scenery|sceneries|interiors?|"
|
||
r"rooms?|architecture|buildings?|cityscapes?|food|dish(?:es)?|meals?|desserts?|drinks?|"
|
||
r"still life|products?|logos?|patterns?|textures?|plants?|flowers?|weapons? (?:only|alone)|"
|
||
r"props?|furniture|gadgets?|planets?|space ?ships?|mechs?|robots? only)\b", re.I)
|
||
|
||
|
||
def unasked_lead(lead: str, request: str) -> tuple[str, list[str]]:
|
||
"""A person the request never asked for ("1girl, solo" on an animal
|
||
pack): those parts of the lead are taken out, only when the request
|
||
names no person and does name a thing (an animal, a car, a room, a
|
||
dish). Returns the lead and the parts taken."""
|
||
if not request.strip() or _PERSON_ASK.search(request) or not _THING_ASK.search(request):
|
||
return lead, []
|
||
kept, gone = [], []
|
||
for chunk in lead.split(","):
|
||
c = chunk.strip()
|
||
if c and _PERSON_LEAD.match(c):
|
||
gone.append(c)
|
||
elif c:
|
||
kept.append(c)
|
||
return (", ".join(kept), gone) if gone else (lead, [])
|
||
|
||
|
||
def _compiled(raw: dict[str, Any]) -> bool:
|
||
"""Whether ``raw`` is a graph ``compile_graph`` made (relations carry
|
||
``from`` objects) rather than one the model wrote."""
|
||
rels = raw.get("relations")
|
||
if isinstance(rels, list) and any(isinstance(r, dict) and isinstance(r.get("from"), dict)
|
||
for r in rels):
|
||
return True
|
||
# No relation at all, but ``bad_refs``: only ``compile_graph`` writes
|
||
# that key (``graph_answer`` and the plan's graph never carry it).
|
||
return isinstance(raw.get("bad_refs"), list) and not rels
|
||
|
||
|
||
def compile_graph(raw: Any) -> dict[str, Any]:
|
||
"""The model's graph, cleaned and checked: ``{title, summary, design,
|
||
rating, style, model, anchor, questions, assumptions, from_images,
|
||
research, run, inputs, sections, slots, fixed, relations, template,
|
||
warnings, bad_refs}``. ``bad_refs`` are the relations that named a slot,
|
||
axis or value the graph doesn't declare (left out, for a repair call).
|
||
Raises PlanError when the graph has no slot."""
|
||
if not isinstance(raw, dict):
|
||
raise PlanError("the answer wasn't a pack graph")
|
||
dropped = [b for b in raw.get("dropped") or [] if isinstance(b, str)] \
|
||
if isinstance(raw.get("dropped"), list) else []
|
||
if _compiled(raw):
|
||
# A graph compiled before (a plan sent back): read it as written,
|
||
# remembering what it had already dropped.
|
||
dropped += [b for b in raw.get("bad_refs") or [] if isinstance(b, str)]
|
||
raw = graph_answer({"graph": raw})
|
||
warnings: list[str] = []
|
||
bad_refs: list[str] = []
|
||
inputs: list[dict[str, str]] = []
|
||
for i in _got if isinstance(_got := raw.get("inputs"), list) else []:
|
||
if isinstance(i, dict) and _var(i.get("var")) and len(inputs) < MAX_INPUTS \
|
||
and not any(x["var"] == _var(i.get("var")) for x in inputs):
|
||
inputs.append({"var": _var(i.get("var")), "about": _clean(i.get("about"), 200),
|
||
"example": _clean(i.get("example"), 200)})
|
||
taken = {i["var"] for i in inputs}
|
||
slots = _slots(raw.get("slots"), warnings, taken)
|
||
if not slots:
|
||
raise PlanError("the graph has no slot: add one wildcard slot per choice the image "
|
||
"makes")
|
||
aliases = _merge_split_slots(slots, warnings)
|
||
taken |= {s["var"] for s in slots}
|
||
fixed = _fixed(raw.get("fixed"), warnings, taken)
|
||
_infer_axes(raw.get("relations"), slots, warnings, aliases)
|
||
relations = _relations(raw.get("relations"), slots, {f["var"] for f in fixed},
|
||
{i["var"] for i in inputs}, warnings, bad_refs, aliases)
|
||
_onto_lists(slots, relations, warnings)
|
||
_list_slots(slots, relations, raw.get("template"), warnings)
|
||
template = _template(raw.get("template"), slots, fixed, relations, inputs, warnings,
|
||
aliases)
|
||
_story_holds(slots, parse_run(raw.get("run")), warnings)
|
||
read_by_relation = {r["from"]["slot"] for r in relations} | {
|
||
r["to"]["slot"] for r in relations if r.get("to")}
|
||
for s in slots:
|
||
if s["show"] == "internal" and s["name"] not in read_by_relation:
|
||
warnings.append(f"{s['name']} is internal but no relation reads it: it does "
|
||
"nothing")
|
||
sections: list[dict[str, str]] = []
|
||
for sec in _got if isinstance(_got := raw.get("sections"), list) else []:
|
||
if isinstance(sec, dict) and _clean(sec.get("name"), 40) and len(sections) < MAX_SECTIONS:
|
||
sections.append({"name": _clean(sec.get("name"), 40),
|
||
"about": _clean(sec.get("about"), 200)})
|
||
questions = []
|
||
for q in _got if isinstance(_got := raw.get("questions"), list) else []:
|
||
if isinstance(q, dict) and _clean(q.get("question"), 300):
|
||
questions.append({"header": _clean(q.get("header"), 60),
|
||
"question": _clean(q.get("question"), 300),
|
||
"multi": q.get("multi") is True,
|
||
"choices": [_clean(c, 120) for c in q.get("choices") or []
|
||
if _clean(c, 120)][:5]})
|
||
anchor = _var(raw.get("anchor"))
|
||
if anchor and anchor not in {s["var"] for s in slots}:
|
||
anchor = ""
|
||
if not anchor:
|
||
# The first pick the prompt shows, never a list or a driver.
|
||
anchor = next((s["var"] for s in slots if s["show"] == "prompt"), "")
|
||
graph: dict[str, Any] = {
|
||
"title": _clean(raw.get("title"), MAX_NAME) or "AI pack",
|
||
"summary": _clean(raw.get("summary"), 300),
|
||
"design": _clean(raw.get("design"), 900),
|
||
"rating": raw.get("rating") if raw.get("rating") in ("safe", "nsfw") else "safe",
|
||
"style": raw.get("style") if raw.get("style") in STYLES else "tags",
|
||
"model": _model(raw.get("model")),
|
||
"anchor": anchor or slots[0]["var"],
|
||
"questions": questions[:MAX_QUESTIONS],
|
||
"assumptions": [_clean(a, 200) for a in raw.get("assumptions") or []
|
||
if _clean(a, 200)][:6],
|
||
"from_images": [_clean(a, 200) for a in raw.get("from_images") or []
|
||
if _clean(a, 200)][:4],
|
||
"research": [_clean(a, 200) for a in raw.get("research") or [] if _clean(a, 200)][:2],
|
||
"run": _got if isinstance(_got := raw.get("run"), dict) else {"mode": "single",
|
||
"frames": 0},
|
||
"inputs": inputs,
|
||
"sections": sections,
|
||
"slots": slots,
|
||
"fixed": fixed,
|
||
"relations": relations,
|
||
"template": template,
|
||
"warnings": warnings,
|
||
"bad_refs": bad_refs,
|
||
"dropped": list(dict.fromkeys(dropped)),
|
||
}
|
||
if _clean(raw.get("quality_words"), MAX_TEXT):
|
||
# The pack's own quality words, kept by the code when the per-family
|
||
# rule takes over $quality: the rule's "otherwise".
|
||
graph["quality_words"] = _clean(raw.get("quality_words"), MAX_TEXT)
|
||
return graph
|
||
|
||
|
||
# --- the graph as plan items ------------------------------------------------
|
||
|
||
def _onto_lists(slots: list[dict[str, Any]], relations: list[dict[str, Any]],
|
||
warnings: list[str]) -> None:
|
||
"""A rule on a drawer's tag that the list it draws from also carries
|
||
goes onto the list: the drawn pick is what the prompt shows, and a rule
|
||
on the drawer's own tag never sees it ("winter never pastel" on Shirt
|
||
while the colour came in through the Color List ref: 4 of 10 winter
|
||
runs pastel, 9B smoke 2026-10-24). A rule on the list reaches every
|
||
drawer's draw."""
|
||
by_name = {s["name"]: s for s in slots}
|
||
lists: dict[str, list[str]] = {}
|
||
for r in relations:
|
||
if r["kind"] == "draw":
|
||
lists.setdefault(r["to"]["slot"], []).append(r["from"]["slot"])
|
||
for r in relations:
|
||
to = r.get("to") or {}
|
||
if r["kind"] not in ("exclude", "only", "boost", "reduce") or not to.get("value") \
|
||
or to["slot"] not in lists:
|
||
continue
|
||
# "soft_pastel" on the drawer is the list's "soft" (9B smoke,
|
||
# 2026-10-24): a part of the value names the list's value.
|
||
parts = [to["value"], *re.split(r"[_\- ]+", to["value"])]
|
||
for name in lists[to["slot"]]:
|
||
hit = next(((a, p) for p in parts for a in by_name[name]["axes"]
|
||
if p in a["values"]), None)
|
||
if hit is None or name == r["from"]["slot"]:
|
||
continue
|
||
ax, value = hit
|
||
warnings.append(f"{r['from']['slot']} → {to['slot']} {to['value']}: the rule goes on "
|
||
f"{name} {value}, the list {to['slot']} draws its {ax['name']} from")
|
||
r["to"] = {"slot": name, "axis": ax["name"], "value": value}
|
||
break
|
||
|
||
|
||
def _list_slots(slots: list[dict[str, Any]], relations: list[dict[str, Any]],
|
||
raw_template: Any, warnings: list[str]) -> None:
|
||
"""A slot other slots only draw from is a list, not a pick of its own:
|
||
it goes internal unless the template names it. A rule that narrows the
|
||
list (it is the rule's target: "colours fit the season") keeps it a
|
||
list; a rule it drives (its pick is a source) makes it a pick."""
|
||
named: set[str] = set()
|
||
order = raw_template.get("order") if isinstance(raw_template, dict) else None
|
||
for part in order if isinstance(order, list) else []:
|
||
named.add(_clean(part, MAX_NAME).lstrip("$").strip().lower())
|
||
for s in slots:
|
||
drawn = [r for r in relations if r["kind"] == "draw" and r["from"]["slot"] == s["name"]]
|
||
other = [r for r in relations if r["kind"] != "draw" and r["from"]["slot"] == s["name"]]
|
||
if drawn and not other and s["show"] == "prompt" \
|
||
and not {s["name"].lower(), s["var"].lower()} & named:
|
||
s["show"] = "internal"
|
||
warnings.append(f"{s['name']} is a list the others draw from, so it isn't a "
|
||
"pick of its own: internal")
|
||
|
||
|
||
def draw_sources(graph: dict[str, Any], slot: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""The ``draw`` relations that put a pick in front of this slot's options."""
|
||
return [r for r in graph["relations"] if r["kind"] == "draw"
|
||
and r["to"]["slot"] == slot["name"]]
|
||
|
||
|
||
def draw_refs(plan: dict[str, Any], built: dict[str, Any]) -> dict[str, str]:
|
||
"""Slot name → built wildcard id, for the refs a ``draw`` writes."""
|
||
graph = plan.get("graph")
|
||
if not isinstance(graph, dict):
|
||
return {}
|
||
out: dict[str, str] = {}
|
||
for s in graph["slots"]:
|
||
sid = _dep_ids(plan, built, s["var"])
|
||
if sid:
|
||
out[s["name"]] = sid
|
||
return out
|
||
|
||
|
||
def _slot_rel_words(rel: dict[str, Any]) -> str:
|
||
src, dst = rel["from"], rel.get("to") or {}
|
||
if rel["kind"] == "draw":
|
||
return (f"{dst['slot']} options each start with a fresh {src['slot']} pick"
|
||
+ (f" ({src['value']})" if src["value"] else ""))
|
||
if rel["kind"] in ("agree", "accepts"):
|
||
return (f"{dst['slot']} goes with {src['slot']} on {src['axis']} "
|
||
f"({', '.join(rel['values'])})")
|
||
if rel["kind"] == "derive":
|
||
return (f"${rel['var']} is \"{rel['text']}\" when {src['slot']}.{src['axis']} is "
|
||
f"{src['value']}" + (f", else \"{rel['otherwise']}\"" if rel["otherwise"]
|
||
else ""))
|
||
if rel["kind"] == "append":
|
||
return (f"\"{rel['text']}\" is added to ${rel['var']} when {src['slot']}.{src['axis']} "
|
||
f"is {src['value']}")
|
||
verb = {"exclude": "never gets", "only": "only gets", "boost": "favours",
|
||
"reduce": "rarely gets"}[rel["kind"]]
|
||
words = f" ({', '.join(rel['words'])})" if rel.get("words") else ""
|
||
return f"{src['slot']} {src['value']} {verb} {dst['slot']} {dst['value']}{words}"
|
||
|
||
|
||
def _use(slot: dict[str, Any]) -> str:
|
||
parts = []
|
||
if slot["pick"]:
|
||
parts.append(f"pick {slot['pick']}")
|
||
if slot["hold"]:
|
||
parts.append("hold")
|
||
if slot["show"] == "internal":
|
||
parts.append("internal")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def derived_vars(graph: dict[str, Any]) -> list[str]:
|
||
"""Variables the relations write, in first-mention order."""
|
||
return list(dict.fromkeys(r["var"] for r in graph["relations"]
|
||
if r["kind"] in ("derive", "append")))
|
||
|
||
|
||
def constraint_pairs(graph: dict[str, Any]) -> list[tuple[str, str]]:
|
||
"""(source slot, target slot) pairs that get one constraint each."""
|
||
out: list[tuple[str, str]] = []
|
||
for r in graph["relations"]:
|
||
if r["kind"] in NARROWING and (r["from"]["slot"], r["to"]["slot"]) not in out:
|
||
out.append((r["from"]["slot"], r["to"]["slot"]))
|
||
return out
|
||
|
||
|
||
# --- what the run style and the model setting need, wired by the code ------
|
||
|
||
#: The story-beat derivation of a story run: the model writes only the
|
||
#: beats; the template reads $beat last.
|
||
BEAT_VAR = "beat"
|
||
#: The per-model-family quality derivation (settings ``model: family``).
|
||
QUALITY_VAR = "quality"
|
||
|
||
|
||
def _graph_sets(graph: dict[str, Any]) -> set[str]:
|
||
return {s["var"] for s in graph["slots"]} | {f["var"] for f in graph["fixed"]} | set(
|
||
derived_vars(graph)) | {i["var"] for i in graph["inputs"]}
|
||
|
||
|
||
def _graph_text(graph: dict[str, Any]) -> str:
|
||
return " ".join([*(s["what"] for s in graph["slots"]),
|
||
*(f["text"] for f in graph["fixed"]),
|
||
*(r.get("text") or "" for r in graph["relations"]),
|
||
*(r.get("var") or "" for r in graph["relations"])]).lower()
|
||
|
||
|
||
def code_beat(graph: dict[str, Any]) -> list[str]:
|
||
"""On a story run the code sets $beat from $iteration: a slot, fixed
|
||
value or relation the planner wrote for $beat is taken out (browser
|
||
check 2026-10-19: a refine turned the code's beat derivation into a
|
||
plain Beat wildcard that never read $iteration, so the story finding
|
||
came back after every Fix all). Returns the applied lines."""
|
||
if parse_run(graph["run"])["mode"] != "story":
|
||
return []
|
||
gone: list[str] = []
|
||
names = {s["name"] for s in graph["slots"] if s["var"] == BEAT_VAR}
|
||
if names:
|
||
graph["slots"] = [s for s in graph["slots"] if s["var"] != BEAT_VAR]
|
||
gone += sorted(names)
|
||
fixed = [f["name"] for f in graph["fixed"] if f["var"] == BEAT_VAR]
|
||
if fixed:
|
||
graph["fixed"] = [f for f in graph["fixed"] if f["var"] != BEAT_VAR]
|
||
gone += fixed
|
||
kept = []
|
||
for r in graph["relations"]: # compiled: {"from": {"slot", …}, "to": {…} | None, "var"}
|
||
ends = {(r.get("from") or {}).get("slot"), (r.get("to") or {}).get("slot")}
|
||
if r.get("var") == BEAT_VAR or ends & names:
|
||
if r.get("var") == BEAT_VAR and f"${BEAT_VAR} rule" not in gone:
|
||
gone.append(f"${BEAT_VAR} rule")
|
||
continue
|
||
kept.append(r)
|
||
graph["relations"] = kept
|
||
if not gone:
|
||
return []
|
||
return [f"The code sets ${BEAT_VAR} from $iteration on a story: dropped the planner's "
|
||
+ ", ".join(gone)]
|
||
|
||
|
||
def story_beat(graph: dict[str, Any]) -> bool:
|
||
"""Whether the code adds the story-beat derivation: a story run whose
|
||
graph doesn't set $beat itself."""
|
||
return parse_run(graph["run"])["mode"] == "story" and BEAT_VAR not in _graph_sets(graph)
|
||
|
||
|
||
def model_families(graph: dict[str, Any]) -> list[str]:
|
||
"""The image model families the graph's ``model`` names ("pony, flux"
|
||
→ pony, flux), in the guide's keys."""
|
||
from engine.ai import guide as ai_guide
|
||
out: list[str] = []
|
||
for part in re.split(r"[,/&+]|\band\b|\bor\b", graph.get("model") or ""):
|
||
fam = ai_guide.family(part.strip())
|
||
if fam and fam not in out:
|
||
out.append(fam)
|
||
return out
|
||
|
||
|
||
def request_families(request: str) -> list[str]:
|
||
"""The image model families a request names, in the guide's keys."""
|
||
from engine.ai import guide as ai_guide
|
||
return ai_guide.families_in(request)
|
||
|
||
|
||
#: Slot variables that name the checkpoint itself. WP Model Info sets
|
||
#: $model_family / $model_variant / $model_name from the loaded model, so a
|
||
#: slot rolling one picks nothing real.
|
||
_MODEL_SLOT_VARS = frozenset({"model_family", "model_variant", "model_name", "model",
|
||
"checkpoint", "base_model", "model_type", "family"})
|
||
#: Tags only Pony reads (score, source and rating tags).
|
||
PONY_ONLY = re.compile(r"(?<![\w-])(?:score_\d(?:_up)?|source_(?:anime|cartoon|furry|pony|"
|
||
r"realistic)|rating_(?:safe|questionable|explicit))(?![\w-])", re.I)
|
||
def _unpony(text: str) -> tuple[str, int]:
|
||
"""``text`` without Pony-only tags, and how many went."""
|
||
if not text or not PONY_ONLY.search(text):
|
||
return text, 0
|
||
n = len(PONY_ONLY.findall(text))
|
||
return ", ".join(t.strip() for t in PONY_ONLY.sub("", text).split(",") if t.strip()), n
|
||
|
||
|
||
#: Families whose row of the quality rule the code names (pony,
|
||
#: illustrious or noobai); any other family gets the rule's otherwise.
|
||
_TAG_QUALITY_FAMILIES = frozenset({"pony", "illustrious", "noobai"})
|
||
|
||
|
||
def drop_model_slots(graph: dict[str, Any]) -> list[str]:
|
||
"""A slot that rolls the image model ("Model Family" [Pony, Flux]) goes:
|
||
the checkpoint decides the model, not a pick, and nothing printed it
|
||
(9B pack 11, 2026-10-26: an internal pick-2 Model Family slot that no
|
||
rule read, its options quality tags). Its families become the graph's
|
||
``model``, so the per-family quality rule takes over, and once the pack
|
||
runs on two families, slots named after one family ("Pony Quality Tags",
|
||
"Flux Quality Phrases") go too: both printed in every prompt whatever
|
||
the model. Returns the lines saying so."""
|
||
from engine.ai import guide as ai_guide
|
||
out: list[str] = []
|
||
gone: list[dict[str, Any]] = []
|
||
for s in graph["slots"]:
|
||
named = ai_guide.families_in(" ".join([s["name"], s["what"],
|
||
*(v for a in s["axes"] for v in a["values"])]))
|
||
if s["var"] in _MODEL_SLOT_VARS or _slug_var(s["name"]) in _MODEL_SLOT_VARS \
|
||
or (len(named) >= 2 and all(ai_guide.family(v) for a in s["axes"]
|
||
for v in a["values"]) and s["axes"]):
|
||
gone.append(s)
|
||
fams = [ai_guide.FAMILY_LABEL[f] for f in named]
|
||
if fams and not model_families(graph):
|
||
graph["model"] = ", ".join(fams)
|
||
out.append(f"{s['name']}: the checkpoint decides the model, not a pick (a WP Model "
|
||
"Info node sets $model_family and $model_variant), so the slot went")
|
||
# A fixed value writing a model variable ("Model Family" $model_family,
|
||
# 9B pack 11, 2026-10-26: "pony, source_cartoon, 1girl, …" printed on
|
||
# every run) writes over what WP Model Info set.
|
||
fixed_gone = [f for f in graph["fixed"] if f["var"] in _MODEL_SLOT_VARS]
|
||
out += [f"{f['name']}: a WP Model Info node sets ${f['var']} from the checkpoint, so the "
|
||
"fixed value went" for f in fixed_gone]
|
||
fams_now = model_families(graph)
|
||
if fams_now and fams_now != ["pony"]:
|
||
# Score, source and rating tags only mean something to Pony; the
|
||
# quality rule's Pony row carries them (9B pack 11: a "Style"
|
||
# preset of "score_9, …, source_cartoon" on every Flux image).
|
||
for f in graph["fixed"]:
|
||
if f in fixed_gone or not PONY_ONLY.search(f["text"]):
|
||
continue
|
||
kept = ", ".join(t.strip() for t in PONY_ONLY.sub("", f["text"]).split(",")
|
||
if t.strip())
|
||
if kept:
|
||
f["text"] = kept
|
||
out.append(f"{f['name']}: took Pony's score tags out; the quality rule gives "
|
||
"them to Pony only")
|
||
else:
|
||
fixed_gone.append(f)
|
||
out.append(f"{f['name']}: only Pony's score tags, which the quality rule gives "
|
||
"Pony, so the fixed value went")
|
||
if len(model_families(graph)) >= 2:
|
||
for s in graph["slots"]:
|
||
if s in gone or s["show"] != "prompt":
|
||
continue
|
||
if ai_guide.family(s["name"].split()[0] if s["name"].split() else ""):
|
||
gone.append(s)
|
||
out.append(f"{s['name']}: printed in every prompt whatever the model; the "
|
||
"per-model quality rule ($quality) gives each model its own words")
|
||
# Fixed values named after one family ("Pony Quality" $pony_q, "Flux
|
||
# Quality" $flux_q; 9B pack 11, 2026-10-26: Pony's "1girl, solo" on
|
||
# every prompt) print whatever the model too. A non-tag family's
|
||
# words become the rule's otherwise, which is what that model gets.
|
||
for f in graph["fixed"]:
|
||
fam = ai_guide.family(f["name"].split()[0] if f["name"].split() else "")
|
||
if not fam or f in fixed_gone:
|
||
continue
|
||
fixed_gone.append(f)
|
||
if fam not in _TAG_QUALITY_FAMILIES and not graph.get("quality_words"):
|
||
graph["quality_words"] = _clean(f["text"], MAX_TEXT)
|
||
out.append(f"{f['name']}: printed in every prompt whatever the model; the "
|
||
"per-model quality rule ($quality) gives each model its own words")
|
||
if not gone and not fixed_gone:
|
||
return []
|
||
names = {s["name"] for s in gone}
|
||
vars_ = {s["var"] for s in gone} | {f["var"] for f in fixed_gone}
|
||
graph["slots"] = [s for s in graph["slots"] if s["name"] not in names]
|
||
graph["fixed"] = [f for f in graph["fixed"] if f["var"] not in vars_]
|
||
graph["relations"] = [
|
||
r for r in graph["relations"]
|
||
if r["from"]["slot"] not in names and (r.get("to") or {}).get("slot") not in names]
|
||
graph["template"]["order"] = [v for v in graph["template"]["order"] if v not in vars_]
|
||
if graph.get("anchor") in vars_:
|
||
graph["anchor"] = graph["slots"][0]["var"] if graph["slots"] else ""
|
||
return out
|
||
|
||
|
||
#: Words of a slot that picks how the prompt is written ("Style Syntax":
|
||
#: Danbooru tags, Natural sentences).
|
||
_WRITING = re.compile(r"\b(?:danbooru|booru|tags?|sentences?|natural language|prose|phrases?|"
|
||
r"syntax|prompt (?:format|style))\b", re.I)
|
||
|
||
|
||
def show_unread(graph: dict[str, Any]) -> list[str]:
|
||
"""Every pick the pack makes reaches the prompt. A prompt slot (or a
|
||
fixed value, a derived variable) the template's order lost after the
|
||
parse goes back in; an internal slot no relation reads and no text
|
||
names is a pick that does nothing, so it shows. When the order would
|
||
still read no slot at all, the internal slots that aren't drawn lists
|
||
show: a template that reads only its lead printed "high quality,
|
||
detailed, The Hero Awakens" for an 11-slot story (9B bench P8 and Z22
|
||
"Reads .", 2026-10-26). Returns the lines saying so."""
|
||
t = graph["template"]
|
||
lines: list[str] = []
|
||
rel_slots = {r["from"]["slot"] for r in graph["relations"]} | {
|
||
(r.get("to") or {}).get("slot") for r in graph["relations"]}
|
||
drawn = {r["from"]["slot"] for r in graph["relations"] if r["kind"] == "draw"}
|
||
texts = " ".join([t["lead"], t["tail"], *(f["text"] for f in graph["fixed"]),
|
||
*(r.get("text") or "" for r in graph["relations"]),
|
||
*(r.get("otherwise") or "" for r in graph["relations"])])
|
||
said = {v.lower() for v in _VAR.findall(texts)}
|
||
for s in graph["slots"]:
|
||
if s["show"] == "internal" and s["name"] not in rel_slots \
|
||
and s["var"].lower() not in said:
|
||
s["show"] = "prompt"
|
||
lines.append(f"{s['name']} shows in the prompt: it was internal, and no rule read it")
|
||
wanted = [s["var"] for s in graph["slots"] if s["show"] == "prompt"]
|
||
wanted += [f["var"] for f in graph["fixed"]]
|
||
wanted += [r["var"] for r in graph["relations"] if r["kind"] == "derive"]
|
||
for var in dict.fromkeys(wanted):
|
||
if var not in t["order"] and var.lower() not in said:
|
||
t["order"].append(var)
|
||
lines.append(f"Added ${var} to the template so it shows")
|
||
slot_vars = {s["var"] for s in graph["slots"]}
|
||
if not (set(t["order"]) | said) & slot_vars:
|
||
shown = [s for s in graph["slots"] if s["name"] not in drawn]
|
||
for s in shown:
|
||
s["show"] = "prompt"
|
||
if s["var"] not in t["order"]:
|
||
t["order"].append(s["var"])
|
||
if shown:
|
||
lines.append("The template read none of the picks, so it reads "
|
||
+ ", ".join(f"${s['var']}" for s in shown) + " now")
|
||
t["order"] = t["order"][:MAX_TEMPLATE_PARTS]
|
||
return lines
|
||
|
||
|
||
def drop_writing_slots(graph: dict[str, Any], locked: list[str]) -> list[str]:
|
||
"""An internal slot that picks how the prompt is written, that no
|
||
relation reads and nothing prints, goes: the checkpoint and the
|
||
"Written as" setting decide that, and the pick did nothing (9B pack 11,
|
||
2026-10-26: "Style Syntax" [Danbooru tags, Natural sentences] and "Model
|
||
Style" [danbooru_tags], whose rules named an axis the slot never had
|
||
and were left out). Hand-locked slots stay. Returns the lines saying
|
||
so."""
|
||
t = graph["template"]
|
||
read = {r["from"]["slot"] for r in graph["relations"]} | {
|
||
(r.get("to") or {}).get("slot") for r in graph["relations"]}
|
||
said = {v.lower() for v in _VAR.findall(" ".join([
|
||
t["lead"], t["tail"], t.get("negative") or "", *(f["text"] for f in graph["fixed"]),
|
||
*(r.get("text") or "" for r in graph["relations"]),
|
||
*(s["what"] for s in graph["slots"])]))}
|
||
gone = [s for s in graph["slots"]
|
||
if s["show"] == "internal" and s["name"] not in read and s["var"] not in said
|
||
and s["var"] not in locked and s["var"] not in t["order"]
|
||
and _WRITING.search(f"{s['name']} {s['what']}".replace("_", " "))]
|
||
if not gone or len(gone) == len(graph["slots"]):
|
||
return []
|
||
names = {s["name"] for s in gone}
|
||
graph["slots"] = [s for s in graph["slots"] if s["name"] not in names]
|
||
graph["warnings"] = [w for w in graph["warnings"]
|
||
if not any(w.startswith(f"{n} is internal but no relation reads it")
|
||
for n in names)]
|
||
if graph.get("anchor") in {s["var"] for s in gone}:
|
||
graph["anchor"] = next((s["var"] for s in graph["slots"] if s["show"] == "prompt"),
|
||
graph["slots"][0]["var"])
|
||
return [f"{s['name']}: how the prompt is written comes from the checkpoint and the "
|
||
"Written as setting, and no rule read this pick, so the slot went" for s in gone]
|
||
|
||
|
||
def trim_model(graph: dict[str, Any], request: str) -> list[str]:
|
||
"""The graph's ``model`` keeps only the families the request names when
|
||
the planner wrote two or more the request didn't ask for ("flux, sd3"
|
||
on a request naming no model; 9B, 2026-10-21, 9 packs): two families
|
||
would add the per-family quality rule, and with no WP Model Info the
|
||
template then read a $quality no run set. Returns the lines saying so."""
|
||
if not (request or "").strip() or len(model_families(graph)) < 2:
|
||
return []
|
||
asked = set(request_families(request))
|
||
if len(asked & set(model_families(graph))) >= 2:
|
||
return []
|
||
from engine.ai import guide as ai_guide
|
||
parts = [p.strip() for p in re.split(r"[,/&+]|\band\b|\bor\b", graph["model"])
|
||
if p.strip()]
|
||
kept = [p for p in parts if ai_guide.family(p) in asked] or parts[:1]
|
||
was, graph["model"] = graph["model"], ", ".join(kept)
|
||
return [f"Model: {graph['model']} (the planner wrote \"{was}\", but the request names "
|
||
"no second family, so there is no per-family quality rule)"]
|
||
|
||
|
||
def model_quality(graph: dict[str, Any], settings: Any) -> bool:
|
||
"""Whether the code adds the per-model-family quality derivation: the
|
||
model setting asks for it, or the request names two families (a pack
|
||
for Pony and Flux), and nothing in the graph reads the model
|
||
variables."""
|
||
if parse_settings(settings)["model"] != "family" and len(model_families(graph)) < 2:
|
||
return False
|
||
text = _graph_text(graph)
|
||
return "model_variant" not in text and "model_family" not in text
|
||
|
||
|
||
def sweep_axes(graph: dict[str, Any]) -> list[str]:
|
||
"""The slot variables a sweep sheet compares: the slots marked
|
||
``sweep``, else the 2 shortest prompt slots that roll one pick. []
|
||
outside a sweep run."""
|
||
if parse_run(graph["run"])["mode"] != "sweep":
|
||
return []
|
||
prompt = [s for s in graph["slots"] if s["show"] == "prompt"]
|
||
marked = [s["var"] for s in prompt if s.get("sweep")]
|
||
if marked:
|
||
return marked[:3]
|
||
free = [s for s in prompt if not s["hold"] and not s["pick"]]
|
||
return [s["var"] for s in sorted(free, key=lambda s: s["count"])[:2]]
|
||
|
||
|
||
def template_order(graph: dict[str, Any], settings: Any = None) -> list[str]:
|
||
"""The template's variables in order, with what the run style and the
|
||
model setting add: $quality first, $beat last."""
|
||
order = list(graph["template"]["order"])
|
||
t = graph["template"]
|
||
said = {n.lower() for n in _VAR.findall(f"{t['lead']} {t['tail']}")}
|
||
if model_quality(graph, settings) and QUALITY_VAR not in order and QUALITY_VAR not in said:
|
||
order.insert(0, QUALITY_VAR)
|
||
if story_beat(graph) and BEAT_VAR not in order and BEAT_VAR not in said:
|
||
order.append(BEAT_VAR)
|
||
return order[:MAX_TEMPLATE_PARTS]
|
||
|
||
|
||
#: What each family's row of the quality rule writes, as the planner is told.
|
||
_QUALITY_ROWS = {
|
||
"pony": "pony → score_9, score_8_up, score_7_up",
|
||
"illustrious": "illustrious or noobai → masterpiece, best quality, very aesthetic",
|
||
"noobai": "illustrious or noobai → masterpiece, best quality, very aesthetic",
|
||
"flux": "flux → a short natural phrase (\"high quality photo, sharp focus\"), no tags",
|
||
"sd3": "sd3 → a short natural phrase (\"high quality, detailed\"), no tags",
|
||
"sdxl": "sdxl → masterpiece, best quality, highly detailed",
|
||
}
|
||
|
||
|
||
def run_items(graph: dict[str, Any], settings: Any = None) -> list[dict[str, Any]]:
|
||
"""The items the run style and the model setting need that the graph
|
||
doesn't carry: the story beat and the model-family quality derivations
|
||
(the model writes their branches; the code wires the rest)."""
|
||
items: list[dict[str, Any]] = []
|
||
if model_quality(graph, settings):
|
||
fams = model_families(graph)
|
||
# One row per family the pack runs on, never rows for others: a
|
||
# Pony and Flux pack got Illustrious rows and Flux no words of its
|
||
# own (9B bench P7, 2026-10-26).
|
||
rows = [_QUALITY_ROWS.get(f, f"{f} → its own quality words, in the way it reads a "
|
||
"prompt") for f in fams] if fams else [
|
||
_QUALITY_ROWS["pony"], _QUALITY_ROWS["illustrious"]]
|
||
items.append({
|
||
"kind": "derivation", "name": "Model quality", "var": QUALITY_VAR, "section": "",
|
||
"about": "Sets $quality from $model_variant"
|
||
+ (f" (the pack runs on {', '.join(fams)})" if len(fams) > 1 else "")
|
||
+ ": " + "; ".join(dict.fromkeys(rows)) + "; else the pack's own quality "
|
||
"words.",
|
||
"count": 0, "tags": [], "source": "", "target": "", "use": ""})
|
||
if story_beat(graph):
|
||
n = parse_run(graph["run"])["frames"]
|
||
# A beat that describes the hero gives every frame a different one
|
||
# (9B smoke, 2026-10-24: a young man, then a young woman, while
|
||
# Hero was held): the held slots print themselves.
|
||
held = [s["var"] for s in graph["slots"] if s["hold"] and s["show"] == "prompt"]
|
||
keep = (" Never describe what " + ", ".join(f"${v}" for v in held) + " already say "
|
||
"(they are the same in every frame and print themselves): only what happens."
|
||
if held else "")
|
||
items.append({
|
||
"kind": "derivation", "name": "Story beat", "var": BEAT_VAR, "section": "",
|
||
"about": f"Sets $beat from $iteration, one branch per frame 1 to {n} (equals "
|
||
"\"1\", \"2\", …), each one short step of the story in order." + keep,
|
||
"count": 0, "tags": [], "source": "", "target": "", "use": ""})
|
||
return items
|
||
|
||
|
||
def library_slots(graph: dict[str, Any], library: list[dict[str, Any]] | None,
|
||
settings: Any) -> dict[str, dict[str, Any]]:
|
||
"""Slot variable → the user's own wildcard that stands in for it
|
||
(settings library "reuse"): a prompt slot with no axes that no relation
|
||
names, matched by name, else by variable, to exactly one library
|
||
wildcard. A slot with axes or rules keeps its tags, which the user's
|
||
wildcard doesn't carry."""
|
||
if parse_settings(settings)["library"] != "reuse" or not library:
|
||
return {}
|
||
rows = [r for r in library if r.get("type") == "wildcard"
|
||
and isinstance(r.get("payload"), dict) and _row_var(r)]
|
||
named = {r["from"]["slot"] for r in graph["relations"]} | {
|
||
r["to"]["slot"] for r in graph["relations"] if r.get("to")}
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for s in graph["slots"]:
|
||
if s["axes"] or s["show"] != "prompt" or s["name"] in named or s.get("self_axis"):
|
||
continue
|
||
hits = [r for r in rows if str(r.get("name") or "").lower() == s["name"].lower()]
|
||
if not hits:
|
||
hits = [r for r in rows if _row_var(r) == s["var"]]
|
||
if len(hits) == 1 and _row_fits(graph, s, hits[0]):
|
||
out[s["var"]] = hits[0]
|
||
return out
|
||
|
||
|
||
def _row_fits(graph: dict[str, Any], slot: dict[str, Any], row: dict[str, Any]) -> bool:
|
||
"""Whether a library wildcard named like a slot is about the same
|
||
things: its options, tags or description share a word with what the
|
||
slot and the pack say (beyond the shared name). A name alone put the
|
||
user's fantasy Race ("stoneborn kin") into a Star Wars pack and a
|
||
couples pack (9B bench N5, Z18, 2026-10-26). A slot that says nothing
|
||
of its own beyond its name takes the row."""
|
||
def words(text: str) -> set[str]:
|
||
out: set[str] = set()
|
||
for w in re.findall(r"[a-z][a-z'-]*[a-z]", text.lower().replace("_", " ")):
|
||
if len(w) >= 3 and w not in _REQUEST_STOP:
|
||
out |= _stems(w)
|
||
return out
|
||
|
||
name = words(f"{slot['name']} {slot['var']} {row.get('name') or ''} {_row_var(row)}")
|
||
if not words(slot["what"]) - name:
|
||
return True
|
||
mine = words(" ".join([slot["what"], graph.get("title") or "",
|
||
graph.get("summary") or ""])) - name
|
||
p = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||
theirs = words(" ".join([str(row.get("description") or ""),
|
||
*(str(t) for t in p.get("sub_categories") or []),
|
||
*(str(o.get("value") or "") for o in p.get("options") or []
|
||
if isinstance(o, dict))])) - name
|
||
return bool(mine & theirs)
|
||
|
||
|
||
def apply_library(graph: dict[str, Any], library: list[dict[str, Any]] | None,
|
||
settings: Any) -> list[str]:
|
||
"""Reuse what fits, on the graph: a slot the library already has takes
|
||
that wildcard's variable (the template and the anchor follow), and the
|
||
plan item for it is the user's own (``graph_plan``). Returns the lines
|
||
saying so."""
|
||
lines: list[str] = []
|
||
for var, row in library_slots(graph, library, settings).items():
|
||
slot = slot_by_var(graph, var)
|
||
mine = _row_var(row)
|
||
if slot is None:
|
||
continue
|
||
if mine != var:
|
||
slot["var"] = mine
|
||
graph["template"]["order"] = [mine if v == var else v
|
||
for v in graph["template"]["order"]]
|
||
if graph["anchor"] == var:
|
||
graph["anchor"] = mine
|
||
lines.append(f"{slot['name']}: your wildcard {row.get('name')} (${mine}) is used as "
|
||
"it is")
|
||
return lines
|
||
|
||
|
||
def graph_plan(graph: dict[str, Any], settings: Any = None,
|
||
library: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
"""The graph as the raw plan ``compile_plan`` takes: one wildcard per
|
||
slot (the user's own, ``library``, where ``library_slots`` says so),
|
||
fixed values, one derivation per written variable, one constraint per
|
||
(source, target) slot pair, and the template. A story run adds the
|
||
story-beat derivation, a sweep run marks its axes and holds the rest,
|
||
and the model setting adds the quality derivation (``run_items``)."""
|
||
slot_by_name = {s["name"]: s for s in graph["slots"]}
|
||
axes = sweep_axes(graph)
|
||
mine = library_slots(graph, library, settings)
|
||
items: list[dict[str, Any]] = []
|
||
for f in graph["fixed"]:
|
||
items.append({"kind": "fixed_values", "name": f["name"], "var": f["var"],
|
||
"section": "", "about": f"${f['var']} = {f['text']}", "count": 0,
|
||
"tags": [], "source": "", "target": "", "use": ""})
|
||
drawn = {r["from"]["slot"] for r in graph["relations"] if r["kind"] == "draw"}
|
||
for s in sorted(graph["slots"], key=lambda s: 0 if s["name"] in drawn else 1):
|
||
tags = [v for a in s["axes"] for v in a["values"]]
|
||
about = s["what"] or s["name"]
|
||
if s["axes"]:
|
||
about += ". Axes: " + "; ".join(f"{a['name']}: {', '.join(a['values'])}"
|
||
for a in s["axes"])
|
||
count, use = s["count"], _use(s)
|
||
if s["var"] in axes:
|
||
about += ". Sweep axis"
|
||
count = min(count, MAX_SWEEP_OPTIONS)
|
||
elif axes and s["show"] == "prompt" and "hold" not in use:
|
||
use = "; ".join(x for x in [use, "hold"] if x)
|
||
if s["var"] in mine:
|
||
row = mine[s["var"]]
|
||
items.append({"kind": "library", "name": str(row.get("name") or s["name"]),
|
||
"var": _row_var(row), "section": s["section"],
|
||
"about": _clean(about, MAX_ABOUT), "count": 0, "tags": [],
|
||
"source": "", "target": "", "use": use})
|
||
continue
|
||
items.append({"kind": "wildcard", "name": s["name"], "var": s["var"],
|
||
"section": s["section"], "about": _clean(about, MAX_ABOUT),
|
||
"count": count, "tags": tags[:MAX_TAGS], "source": "", "target": "",
|
||
"use": use})
|
||
for src, dst in constraint_pairs(graph):
|
||
rels = [r for r in graph["relations"] if r.get("to") and r["from"]["slot"] == src
|
||
and r["to"]["slot"] == dst]
|
||
items.append({"kind": "constraint", "name": f"{src} → {dst}", "var": "",
|
||
"section": slot_by_name[dst]["section"],
|
||
"about": _clean("; ".join(_slot_rel_words(r) for r in rels), MAX_ABOUT),
|
||
"count": 0, "tags": [], "source": slot_by_name[src]["var"],
|
||
"target": slot_by_name[dst]["var"], "use": ""})
|
||
for var in derived_vars(graph):
|
||
rels = [r for r in graph["relations"] if r.get("var") == var]
|
||
title = var.replace("_", " ").title()
|
||
items.append({"kind": "derivation", "name": f"{title} rule", "var": var,
|
||
"section": "",
|
||
"about": _clean("; ".join(_slot_rel_words(r) for r in rels), MAX_ABOUT),
|
||
"count": 0, "tags": [], "source": "", "target": "", "use": ""})
|
||
items += run_items(graph, settings)
|
||
t = graph["template"]
|
||
items.append({"kind": "template", "name": graph["title"], "var": "", "section": "",
|
||
"about": _clean("Reads " + ", ".join(f"${v}" for v in
|
||
template_order(graph, settings))
|
||
+ (f". Standing negative: {t['negative']}"
|
||
if t["negative"] else ""), MAX_ABOUT),
|
||
"count": 0, "tags": [], "source": "", "target": "", "use": ""})
|
||
return {
|
||
"title": graph["title"], "summary": graph["summary"], "design": graph["design"],
|
||
"rating": graph["rating"], "style": graph["style"], "model": graph["model"],
|
||
"anchor": graph["anchor"], "questions": graph["questions"],
|
||
"assumptions": graph["assumptions"], "from_images": graph["from_images"],
|
||
"research": graph["research"], "run": graph["run"], "inputs": graph["inputs"],
|
||
"sections": graph["sections"], "items": items,
|
||
}
|
||
|
||
|
||
# --- wiring: payloads code writes ------------------------------------------
|
||
|
||
def _slot_of(graph: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None:
|
||
return next((s for s in graph["slots"] if s["var"] == item.get("var")), None)
|
||
|
||
|
||
def slot_for(plan: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||
"""The graph slot a plan item builds, or None for a wired item."""
|
||
graph = plan.get("graph")
|
||
item = next((i for i in plan["items"] if i["key"] == key), None)
|
||
if not isinstance(graph, dict) or item is None or item["kind"] != "wildcard":
|
||
return None
|
||
return _slot_of(graph, item)
|
||
|
||
|
||
def is_wired(plan: dict[str, Any], key: str) -> bool:
|
||
"""Whether code writes this item's payload (everything but a slot and
|
||
a library item in a graph plan)."""
|
||
graph = plan.get("graph")
|
||
item = next((i for i in plan["items"] if i["key"] == key), None)
|
||
if not isinstance(graph, dict) or item is None or item["kind"] == "library":
|
||
return False
|
||
if item["kind"] == "wildcard":
|
||
slot = _slot_of(graph, item)
|
||
return bool(slot and slot.get("self_axis"))
|
||
if item["kind"] == "constraint":
|
||
return bool(slot_by_var(graph, item["source"]) and slot_by_var(graph, item["target"]))
|
||
if item["kind"] == "derivation":
|
||
return item["var"] in derived_vars(graph)
|
||
if item["kind"] == "fixed_values":
|
||
return any(f["var"] == item["var"] for f in graph["fixed"])
|
||
return item["kind"] == "template"
|
||
|
||
|
||
def slot_by_var(graph: dict[str, Any], var: str) -> dict[str, Any] | None:
|
||
return next((s for s in graph["slots"] if s["var"] == var), None)
|
||
|
||
|
||
def axis_kinds(graph: dict[str, Any], slot: dict[str, Any]) -> dict[str, str]:
|
||
"""Each axis of ``slot`` as ``classify`` (an option IS one or more of
|
||
its values) or ``accepts`` (one value per option, rolled, readable as
|
||
``$var.AXIS``: what a derivation keys on, or what the slot OFFERS a
|
||
partner in an ``accepts`` relation)."""
|
||
kinds = {a["name"]: "classify" for a in slot["axes"]}
|
||
for r in graph["relations"]:
|
||
if r["from"]["slot"] == slot["name"] and r["kind"] in _ROLLED_SOURCE \
|
||
and r["from"]["axis"] in kinds:
|
||
kinds[r["from"]["axis"]] = "accepts"
|
||
return kinds
|
||
|
||
|
||
def inbound(graph: dict[str, Any], slot: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""Relations that narrow this slot's pick (it is their target)."""
|
||
return [r for r in graph["relations"] if r["kind"] in NARROWING
|
||
and r["to"]["slot"] == slot["name"]]
|
||
|
||
|
||
def _dep_ids(plan: dict[str, Any], built: dict[str, Any], var: str) -> str:
|
||
key = next((i["key"] for i in plan["items"] if i.get("var") == var
|
||
and i["kind"] in ("wildcard", "library")), None)
|
||
b = built.get(key) if key else None
|
||
return b["id"] if isinstance(b, dict) and isinstance(b.get("id"), str) else ""
|
||
|
||
|
||
def wired_payload(plan: dict[str, Any], key: str, built: dict[str, Any]) -> dict[str, Any]:
|
||
"""``{kind, payload, preview, warnings}`` for an item code writes from
|
||
the graph: a fixed values, a constraint (both wildcards must be built,
|
||
for their ids), a derivation or the template. Raises PlanError when a
|
||
wildcard it needs isn't built yet. In a pack rated nsfw the item gets
|
||
the adult pass the model-written items get (`adult_pack.adult_check`:
|
||
minor words out, the youth negative on the template; ValueError when
|
||
nothing would be left)."""
|
||
out = _wired(plan, key, built)
|
||
from engine.ai import ratings as ai_ratings
|
||
fixed, spaced = ai_ratings.payload_rating_tags(out["kind"], out["payload"])
|
||
if spaced:
|
||
out = {**out, "payload": fixed}
|
||
if plan.get("rating") == "nsfw":
|
||
from engine.ai.pack.adult_pack import adult_check
|
||
payload, said = adult_check(out["kind"], out["payload"], plan)
|
||
if said:
|
||
out = {**out, "payload": payload, "warnings": [*out["warnings"], *said]}
|
||
if out["kind"] == "template" and parse_settings(plan.get("settings"))["negatives"] == "auto":
|
||
# Negatives are opt-in: a negative without the slot would drop the
|
||
# variables' negatives this setting asks for.
|
||
neg = out["payload"].get("negative_template")
|
||
if isinstance(neg, str):
|
||
out = {**out, "payload": {**out["payload"], "negative_template": with_slot(neg)}}
|
||
return out
|
||
|
||
|
||
def _wired(plan: dict[str, Any], key: str, built: dict[str, Any]) -> dict[str, Any]:
|
||
graph = plan["graph"]
|
||
item = next(i for i in plan["items"] if i["key"] == key)
|
||
kind = item["kind"]
|
||
if kind == "wildcard":
|
||
slot = _slot_of(graph, item)
|
||
if slot is None or not slot.get("self_axis"):
|
||
raise PlanError(f"{item['name']} isn't wired by the graph")
|
||
return {"kind": kind, **axis_payload(graph, slot)}
|
||
if kind == "fixed_values":
|
||
f = next(x for x in graph["fixed"] if x["var"] == item["var"])
|
||
text, _ = fit_ratings(f["text"], graph.get("rating") or "")
|
||
fwarn: list[str] = []
|
||
if model_families(graph) != ["pony"] and f["var"] != QUALITY_VAR:
|
||
text, n = _unpony(text)
|
||
if n:
|
||
fwarn.append(f"Took Pony's score tags out of ${f['var']}: the pack doesn't run "
|
||
"on Pony alone")
|
||
row: dict[str, Any] = {"id": "val00001", "name": f["var"], "value": text}
|
||
neg, gone = tidy_negative(f["negative"], text)
|
||
if model_families(graph) != ["pony"]:
|
||
neg, _ = _unpony(neg)
|
||
if gone:
|
||
fwarn.append("Took " + ", ".join(f'"{g}"' for g in gone[:6]) + f" out of "
|
||
f"${f['var']}'s negative: it repeated its own words or itself")
|
||
if neg:
|
||
row["negative"] = neg
|
||
return {"kind": kind, "payload": {"values": [row]},
|
||
"preview": [f"${f['var']} = {text}"], "warnings": fwarn}
|
||
if kind == "constraint":
|
||
src, dst = slot_by_var(graph, item["source"]), slot_by_var(graph, item["target"])
|
||
sid, tid = _dep_ids(plan, built, item["source"]), _dep_ids(plan, built, item["target"])
|
||
if not sid or not tid:
|
||
raise PlanError(f"build {src['name'] if not sid else dst['name']} before "
|
||
f"{item['name']}")
|
||
matrix: dict[str, dict[str, dict[str, Any]]] = {}
|
||
preview = []
|
||
for r in graph["relations"]:
|
||
# Only narrowing relations are cells: a "draw" between the same
|
||
# two slots wrote a draw cell with no target tag, which failed
|
||
# every run (9B smoke, shirts by season, 2026-10-24).
|
||
if r["kind"] not in NARROWING or r["from"]["slot"] != src["name"] \
|
||
or r["to"]["slot"] != dst["name"]:
|
||
continue
|
||
if r["kind"] in ("agree", "accepts"):
|
||
for v in r["values"]:
|
||
matrix.setdefault(v, {})[v] = {"mode": "only", "factor": 1}
|
||
else:
|
||
cell = {"mode": r["kind"], "factor": 0 if r["kind"] == "exclude"
|
||
else r.get("factor", 1)}
|
||
matrix.setdefault(r["from"]["value"], {})[r["to"]["value"]] = cell
|
||
preview.append(_slot_rel_words(r))
|
||
return {"kind": kind, "payload": {"source_wildcard_id": sid, "target_wildcard_id": tid,
|
||
"matrix": matrix, "exceptions": []},
|
||
"preview": preview, "warnings": []}
|
||
if kind == "derivation":
|
||
rels = [r for r in graph["relations"] if r.get("var") == item["var"]]
|
||
otherwise = _otherwise(rels)
|
||
# One rule per source axis: rows keyed on a second axis of the slot
|
||
# (or another slot) ADD to the variable in a rule of their own.
|
||
# In one if/elif the first axis always won and every row on the
|
||
# other was "broken" (9B, 2026-10-21, $habitat_preference).
|
||
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||
for r in rels:
|
||
groups.setdefault((r["from"]["slot"], r["from"]["axis"]), []).append(r)
|
||
rules: list[dict[str, Any]] = []
|
||
warnings: list[str] = []
|
||
for n, ((_sname, axis), rows) in enumerate(groups.items()):
|
||
# Two rows on the same value would be two branches of one
|
||
# if/elif, and only the first ever runs (9B smoke on #120:
|
||
# ", volumetric darkness" on night broke every run): their
|
||
# words go in one branch.
|
||
merged: dict[str, dict[str, Any]] = {}
|
||
for r in rows:
|
||
v = r["from"]["value"]
|
||
if v in merged:
|
||
merged[v] = {**merged[v], "text": ", ".join(
|
||
t.strip(" ,") for t in (merged[v]["text"], r["text"]) if t.strip(" ,"))}
|
||
else:
|
||
merged[v] = r
|
||
rows = list(merged.values())
|
||
branches = []
|
||
for r in rows:
|
||
src = next((s for s in graph["slots"] if s["name"] == r["from"]["slot"]), None)
|
||
if src is None:
|
||
raise PlanError(f"{item['name']} reads {r['from']['slot']}, which the "
|
||
"graph no longer has")
|
||
mode = "replace" if r["kind"] == "derive" and n == 0 else "append"
|
||
branches.append({
|
||
"condition": {"var": f"{src['var']}.{axis}", "op": "equals",
|
||
"value": r["from"]["value"]},
|
||
# Added words come after a comma: as written the engine
|
||
# glued them on ("war hammerblade", "matte finishformal",
|
||
# 9B bench 2026-10-26).
|
||
"action": {"target_var": item["var"], "mode": mode,
|
||
"value": ai_derivation.join_piece(mode, r["text"])},
|
||
})
|
||
rule: dict[str, Any] = {"id": f"rule{n + 1:04d}", "branches": branches}
|
||
if n == 0 and otherwise:
|
||
rule["else"] = {"action": {"target_var": item["var"], "mode": "replace",
|
||
"value": otherwise}}
|
||
if n > 0:
|
||
warnings.append(f"${item['var']} is also written from {_sname}.{axis}: those "
|
||
"rows add to it (one rule per axis, so both fire)")
|
||
rules.append(rule)
|
||
others = sorted({r["otherwise"] for r in rels
|
||
if r.get("otherwise") and r["otherwise"] != otherwise})
|
||
if others:
|
||
warnings.append(f"${item['var']} has one \"otherwise\": kept \"{otherwise}\", not "
|
||
+ ", ".join(f'"{o}"' for o in others))
|
||
return {"kind": kind, "payload": {"rules": rules},
|
||
"preview": [_slot_rel_words(r) for r in rels], "warnings": warnings}
|
||
if kind == "template":
|
||
t = graph["template"]
|
||
order = template_order(graph, plan.get("settings"))
|
||
body = ", ".join(x for x in [t["lead"], *(f"${v}" for v in order), t["tail"]] if x)
|
||
# Negatives "none": the planner was told to leave it empty; a 9B
|
||
# writes one anyway.
|
||
negative = "" if parse_settings(plan.get("settings"))["negatives"] == "none" \
|
||
else t["negative"] or ""
|
||
twarn: list[str] = []
|
||
body, n_rated = fit_ratings(body, graph.get("rating") or "")
|
||
if n_rated:
|
||
twarn.append(f"Set {n_rated} rating tag(s) to the pack's rating and spelling")
|
||
negative = dedupe(negative)
|
||
if model_families(graph) != ["pony"]:
|
||
negative, n = _unpony(negative)
|
||
if n:
|
||
twarn.append("Took Pony's score tags out of the negative: the pack doesn't run "
|
||
"on Pony alone")
|
||
# The standing negative never bans what a slot picks ("midnight" in
|
||
# the negative of a pack that rolls midnight, 9B bench 2026-10-26).
|
||
picks = [str(o.get("value") or "") for i in plan["items"]
|
||
if i["kind"] in ("wildcard", "library") and i["key"] in built
|
||
for o in built[i["key"]]["payload"].get("options") or [] if isinstance(o, dict)]
|
||
negative, gone = tidy_negative(negative, siblings=picks)
|
||
if gone:
|
||
twarn.append("Took " + ", ".join(f'"{g}"' for g in gone[:6]) + " out of the "
|
||
"negative: the pack picks them, or they were said twice")
|
||
return {"kind": kind, "payload": {"template_string": body,
|
||
"negative_template": negative},
|
||
"preview": [body], "warnings": twarn}
|
||
raise PlanError(f"{item['name']} isn't wired by the graph")
|
||
|
||
|
||
def axis_payload(graph: dict[str, Any], slot: dict[str, Any]) -> dict[str, Any]:
|
||
"""The wildcard of a self-axis slot: one option per axis value, tagged
|
||
with it, no model call. ``{payload, preview, warnings}``."""
|
||
from engine.ai import wildcard as ai_wildcard
|
||
|
||
axis = next(a for a in slot["axes"] if a["name"] == slot["self_axis"])
|
||
taken: set[str] = set()
|
||
rows = [{"id": ai_wildcard.new_option_id(taken), "value": v.replace("_", " "),
|
||
"weight": 1, "sub_categories": [v]} for v in axis["values"]]
|
||
payload, warnings = _finish_rows(graph, slot, rows, taken)
|
||
return {"payload": payload, "preview": [ai_wildcard.option_line(r) for r in
|
||
payload["options"]], "warnings": warnings}
|
||
|
||
|
||
def _finish_rows(graph: dict[str, Any], slot: dict[str, Any], rows: list[dict[str, Any]],
|
||
taken: set[str]) -> tuple[dict[str, Any], list[str]]:
|
||
"""The payload of a slot from its option rows: the fallback a narrowed
|
||
slot needs (never an option a rule excludes), the null option of
|
||
``sometimes_none``, tag groups from the axes, and the warnings about
|
||
axis values and rule words no option carries."""
|
||
from engine.ai import wildcard as ai_wildcard
|
||
from engine.modules.wildcard_handler import WildcardHandler
|
||
|
||
kinds = axis_kinds(graph, slot)
|
||
values = {a["name"]: a["values"] for a in slot["axes"]}
|
||
warnings: list[str] = []
|
||
for value, factor in (slot.get("weights") or {}).items():
|
||
for r in rows:
|
||
if value in r["sub_categories"] and not r.get("is_null"):
|
||
r["weight"] = round(float(r.get("weight") or 1) * factor, 2)
|
||
narrowed = inbound(graph, slot)
|
||
if narrowed and rows:
|
||
# The option that fits the most of what narrows this slot, and
|
||
# nothing excludes, stands in when a rule leaves nothing: it is
|
||
# never empty, and never the one a rule said no to.
|
||
wanted = {r["to"]["value"] for r in narrowed if r["kind"] in ("only", "boost")
|
||
if r["to"].get("value")}
|
||
wanted |= {v for r in narrowed if r["kind"] in ("agree", "accepts")
|
||
for v in r.get("values") or []}
|
||
banned = {r["to"]["value"] for r in narrowed if r["kind"] in ("exclude", "reduce")
|
||
if r["to"].get("value")}
|
||
best = max(rows, key=lambda r: (
|
||
sum(1 for t in r["sub_categories"] if t in wanted)
|
||
- 2 * sum(1 for t in r["sub_categories"] if t in banned),
|
||
float(r.get("weight") or 0)))
|
||
best["fallback"] = True
|
||
if slot["sometimes_none"]:
|
||
total = sum(float(r["weight"]) for r in rows) or 1.0
|
||
share = slot["sometimes_none"] / 100
|
||
rows.append({"id": ai_wildcard.new_option_id(taken), "value": "", "is_null": True,
|
||
"weight": round(total * share / (1 - share), 2), "sub_categories": []})
|
||
used = {t for r in rows for t in r["sub_categories"]}
|
||
payload: dict[str, Any] = {
|
||
"var_binding": slot["var"],
|
||
"options": rows,
|
||
"sub_categories": [v for vs in values.values() for v in vs if v in used],
|
||
}
|
||
groups = {a: [v for v in vs if v in used] for a, vs in values.items()}
|
||
groups = {a: vs for a, vs in groups.items() if vs}
|
||
if groups:
|
||
payload["tag_groups"] = groups
|
||
payload["tag_group_kinds"] = {a: kinds[a] for a in groups}
|
||
for axis, allowed in values.items():
|
||
gone = [v for v in allowed if v not in used]
|
||
if gone:
|
||
warnings.append(f"No option is {', '.join(gone)} on {axis}")
|
||
for value, axis, word in rule_words(graph, slot):
|
||
if not any(not r.get("is_null") and value in r["sub_categories"]
|
||
and _has_word(_norm(r["value"]), word) for r in rows):
|
||
warnings.append(f"No option mentions {word} tagged {value} on {axis}")
|
||
WildcardHandler.validate_payload(payload)
|
||
return payload, warnings
|
||
|
||
|
||
def rule_words(graph: dict[str, Any], slot: dict[str, Any]) -> list[tuple[str, str, str]]:
|
||
"""``(value, axis, word)`` for every word a relation on this slot is
|
||
about: an option saying that word must carry that value."""
|
||
out: list[tuple[str, str, str]] = []
|
||
for r in inbound(graph, slot):
|
||
if r["kind"] not in ("exclude", "only", "boost", "reduce"):
|
||
continue
|
||
for w in r.get("words") or []:
|
||
t = (r["to"]["value"], r["to"]["axis"], w)
|
||
if t not in out:
|
||
out.append(t)
|
||
return out
|
||
|
||
|
||
# --- the per-slot content call ----------------------------------------------
|
||
|
||
#: Most values a content call groups a slot's options by (it still needs
|
||
#: an option per value, so a slot asks at least that many). 6 left a
|
||
#: 10-hue collar colour ungrouped, and half its hues got no option
|
||
#: (smoke on #118, the pet pack's fallback in 73% of runs).
|
||
MAX_GROUP_VALUES = 10
|
||
|
||
|
||
def group_axis(graph: dict[str, Any], slot: dict[str, Any]) -> str | None:
|
||
"""The axis a slot's content call groups its options by (``groups``:
|
||
a list per value, so every value has options from the first call and
|
||
none is mis-tagged): the one a rule narrows on or names words for,
|
||
else one the slot offers a partner, else the first. None when no axis
|
||
has 2 to ``MAX_GROUP_VALUES`` values. A slot asking for fewer options
|
||
than the axis has values gets one per value (``_group_count``)."""
|
||
axes = [a for a in slot["axes"] if 2 <= len(a["values"]) <= MAX_GROUP_VALUES]
|
||
if not axes:
|
||
return None
|
||
ruled = {r["to"]["axis"] for r in inbound(graph, slot)} \
|
||
| {axis for _, axis, _ in rule_words(graph, slot)}
|
||
kinds = axis_kinds(graph, slot)
|
||
for a in axes:
|
||
if a["name"] in ruled:
|
||
return a["name"]
|
||
return next((a["name"] for a in axes if kinds[a["name"]] == "accepts"), axes[0]["name"])
|
||
|
||
|
||
def _group_count(slot: dict[str, Any], axis: str) -> int:
|
||
"""The options a grouped slot keeps: its count, and never fewer than
|
||
one per value of the axis (a rule on a value with no option never
|
||
fires, and the fallback stands in)."""
|
||
return max(slot["count"], len(next(a["values"] for a in slot["axes"]
|
||
if a["name"] == axis)))
|
||
|
||
|
||
def _group_size(slot: dict[str, Any], axis: str) -> int:
|
||
n = len(next(a["values"] for a in slot["axes"] if a["name"] == axis))
|
||
return 2 if slot["count"] >= 2 * n else 1
|
||
|
||
|
||
def _group_most(slot: dict[str, Any], axis: str) -> int:
|
||
"""Most options one group may hold: its share of the count and one
|
||
more. Unbounded, a 9B wrote groups until the output limit cut it off
|
||
(smoke on #118: a pack's slot calls went from 279 s to 19 min)."""
|
||
n = len(next(a["values"] for a in slot["axes"] if a["name"] == axis))
|
||
return max(_group_size(slot, axis), -(-_group_count(slot, axis) // n) + 1)
|
||
|
||
|
||
def ungroup(slot: dict[str, Any], axis: str, groups: Any) -> list[dict[str, Any]]:
|
||
"""A grouped answer's options as one list, each tagged with the value
|
||
it was listed under; one listed under two values carries both. The
|
||
groups are taken in turn (first of each, then second of each), so a
|
||
cut to the slot's count leaves every value its share (smoke on #118:
|
||
in group order, a count of 8 kept winter's options and dropped every
|
||
summer and autumn one)."""
|
||
allowed = next((a["values"] for a in slot["axes"] if a["name"] == axis), [])
|
||
lists = [(value, [o for o in opts if isinstance(o, dict)])
|
||
for value, opts in (groups.items() if isinstance(groups, dict) else [])
|
||
if _tag(value) in allowed and isinstance(opts, list)]
|
||
turns = [(value, x[k]) for k in range(max((len(x) for _, x in lists), default=0))
|
||
for value, x in lists if k < len(x)]
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for value, o in turns:
|
||
if not isinstance(o.get("text"), str):
|
||
continue
|
||
seen = out.get(_norm(o["text"]))
|
||
if seen is not None:
|
||
if _tag(value) not in seen["tags"][axis]:
|
||
seen["tags"][axis].append(_tag(value))
|
||
continue
|
||
tags = _got if isinstance(_got := o.get("tags"), dict) else {}
|
||
out[_norm(o["text"])] = {**o, "tags": {**tags, axis: [_tag(value)]}}
|
||
return list(out.values())
|
||
|
||
|
||
def slot_schema(graph: dict[str, Any], slot: dict[str, Any],
|
||
fill: dict[str, list[str]] | None = None) -> dict[str, Any]:
|
||
"""The JSON schema of one slot's options: ``text``, ``weight``,
|
||
``negative`` and a ``tags`` object with one key per axis, each an enum
|
||
of that axis's values (a list of them on a classify axis, one on an
|
||
accepts axis). With a ``group_axis`` the options come as ``groups``,
|
||
a list per value of that axis (at least ``_group_size`` each), the
|
||
other axes still tagged. With ``fill`` (``{axis: [values]}``) it is a
|
||
fill call's schema: an ``add`` list whose options can only carry
|
||
those values on that axis, so every new option plugs a gap."""
|
||
kinds = axis_kinds(graph, slot)
|
||
grouped = None if fill else group_axis(graph, slot)
|
||
tag_props: dict[str, Any] = {}
|
||
for a in slot["axes"]:
|
||
if a["name"] == grouped:
|
||
continue
|
||
allowed = (fill or {}).get(a["name"]) or list(a["values"])
|
||
enum = {"type": "string", "enum": list(allowed)}
|
||
if kinds[a["name"]] == "accepts" and not any(
|
||
r["kind"] == "accepts" and r["from"]["slot"] == slot["name"]
|
||
and r["from"]["axis"] == a["name"] for r in graph["relations"]):
|
||
tag_props[a["name"]] = enum
|
||
else:
|
||
tag_props[a["name"]] = {"type": "array", "items": enum, "minItems": 1}
|
||
# Capped strings, and the negative before a number: with nothing after
|
||
# it (a one-axis slot's grouped option has no tags) a 9B wrote one
|
||
# negative on to the output limit, "tattoos, piercings, tattoos, …"
|
||
# (smoke on #118, every slot call ~8k tokens).
|
||
fields: dict[str, Any] = {"text": {"type": "string", "maxLength": MAX_TEXT},
|
||
"negative": {"type": "string", "maxLength": MAX_NEGATIVE},
|
||
"weight": {"type": "number"}}
|
||
if tag_props:
|
||
fields["tags"] = _obj(tag_props)
|
||
option = _obj(fields)
|
||
if grouped:
|
||
size = _group_size(slot, grouped)
|
||
values = next(a["values"] for a in slot["axes"] if a["name"] == grouped)
|
||
most = _group_most(slot, grouped)
|
||
return _obj({"groups": _obj({v: {"type": "array", "items": option, "minItems": size,
|
||
"maxItems": most} for v in values})})
|
||
return _obj({("add" if fill else "options"): {"type": "array", "items": option}})
|
||
|
||
|
||
def fill_gaps(graph: dict[str, Any], slot: dict[str, Any],
|
||
payload: dict[str, Any] | None) -> dict[str, list[str]]:
|
||
"""The axis values no option of ``payload`` carries: ``{axis: [values]}``,
|
||
only the axes with a gap. A relation that names such a value would
|
||
match nothing, so the fill call (``slot_request`` with ``built``) asks
|
||
for options tagged with them."""
|
||
if not isinstance(payload, dict):
|
||
return {}
|
||
used = {t for o in payload.get("options") or [] if isinstance(o, dict)
|
||
and not o.get("is_null") for t in o.get("sub_categories") or []}
|
||
out: dict[str, list[str]] = {}
|
||
for a in slot["axes"]:
|
||
gone = [v for v in a["values"] if v not in used]
|
||
if gone:
|
||
out[a["name"]] = gone
|
||
return out
|
||
|
||
|
||
_COVERAGE = re.compile(r"^No option (is|mentions) ")
|
||
|
||
|
||
def fill_targets(graph: dict[str, Any], slot: dict[str, Any],
|
||
payload: dict[str, Any] | None) -> dict[str, dict[str, list[str]]]:
|
||
"""What a fill call must add: ``{axis: {value: [words]}}``, the values
|
||
no option carries (``fill_gaps``, words ``[]``) and the rule words no
|
||
option tagged with their value says."""
|
||
out: dict[str, dict[str, list[str]]] = {}
|
||
for axis, gone in fill_gaps(graph, slot, payload).items():
|
||
out[axis] = {v: [] for v in gone}
|
||
if not isinstance(payload, dict):
|
||
return out
|
||
rows = [o for o in payload.get("options") or [] if isinstance(o, dict)
|
||
and not o.get("is_null") and isinstance(o.get("value"), str)]
|
||
for value, axis, word in rule_words(graph, slot):
|
||
if not any(value in (r.get("sub_categories") or []) and _has_word(_norm(r["value"]), word)
|
||
for r in rows):
|
||
out.setdefault(axis, {}).setdefault(value, []).append(word)
|
||
return out
|
||
|
||
|
||
def _coverage_only(note: str) -> bool:
|
||
"""Whether a repair note asks for nothing but axis coverage."""
|
||
parts = [x.strip() for x in note.split(";") if x.strip()]
|
||
return bool(parts) and all(_COVERAGE.match(x) for x in parts)
|
||
|
||
|
||
def coverage_split(note: str) -> tuple[list[str], list[str]]:
|
||
"""A repair note's parts: the ones asking for axis coverage (a value
|
||
or rule word no option carries) and the rest. Coverage is what a fill
|
||
call adds; the rest rides along as what the new options must mind."""
|
||
parts = [x.strip() for x in (note or "").split(";") if x.strip()]
|
||
return ([x for x in parts if _COVERAGE.match(x)],
|
||
[x for x in parts if not _COVERAGE.match(x)])
|
||
|
||
|
||
def fills(plan: dict[str, Any], key: str, note: str) -> bool:
|
||
"""Whether a repair note on a built graph slot would be a fill call
|
||
(``slot_request``): the route tells the browser, which then gives a
|
||
rebuilt slot one fill when coverage is still missing."""
|
||
return slot_for(plan, key) is not None and bool(coverage_split(note)[0])
|
||
|
||
|
||
def _norm(text: Any) -> str:
|
||
return " ".join(str(text or "").split()).lower()
|
||
|
||
|
||
#: A rebuild note about scope: this slot names what another slot picks.
|
||
_SCOPE_NOTE = re.compile(r"already name what|names what|leave th(?:ose|at) to", re.I)
|
||
#: The most of another slot's words one rebuild is shown.
|
||
MAX_CLASH_WORDS = 12
|
||
|
||
|
||
def clash_words(plan: dict[str, Any], built: dict[str, Any], key: str) -> dict[str, list[str]]:
|
||
"""For a slot being rebuilt over scope: the words of every other built
|
||
slot that talks about the same things (the same words, or a head noun
|
||
this slot's options share: "eyes", "skin"), by slot name, at most
|
||
``MAX_CLASH_WORDS`` each. The rebuild is told all of them at once; told
|
||
only the words of the problem it fixes, a rebuild of Body picked up
|
||
Race's "freckles" right after Race gave up Body's "eyes" (Sonnet rules
|
||
pack, 2026-10-21: two rounds of ping-pong before it settled)."""
|
||
own = built.get(key)
|
||
if not isinstance(own, dict):
|
||
return {}
|
||
mine = {c.strip() for o in own["payload"].get("options") or [] if isinstance(o, dict)
|
||
for c in _norm(o.get("value")).split(",") if c.strip()}
|
||
heads = {c.split()[-1] for c in mine}
|
||
out: dict[str, list[str]] = {}
|
||
for name, texts in other_texts(plan, built, key).items():
|
||
chunks = list(dict.fromkeys(c.strip() for t in texts for c in t.split(",") if c.strip()))
|
||
shared = [c for c in chunks if c in mine or c.split()[-1] in heads]
|
||
if shared:
|
||
out[name] = shared[:MAX_CLASH_WORDS]
|
||
return out
|
||
|
||
|
||
def other_texts(plan: dict[str, Any], built: dict[str, Any], key: str) -> dict[str, list[str]]:
|
||
"""The option texts of every other built slot, by slot name: what a
|
||
slot's options must not repeat (``compile_slot``'s ``others``)."""
|
||
graph = plan.get("graph")
|
||
if not isinstance(graph, dict):
|
||
return {}
|
||
out: dict[str, list[str]] = {}
|
||
for i in plan["items"]:
|
||
if i["key"] == key or i["kind"] != "wildcard" or i["key"] not in built:
|
||
continue
|
||
slot = _slot_of(graph, i)
|
||
if slot is None or slot.get("self_axis"):
|
||
continue
|
||
out[slot["name"]] = [_norm(o.get("value")) for o in
|
||
built[i["key"]]["payload"].get("options") or []
|
||
if isinstance(o, dict) and _norm(o.get("value"))]
|
||
return out
|
||
|
||
|
||
def scope_notes(rows: list[dict[str, Any]], others: dict[str, list[str]]
|
||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||
"""Scope bleed, checked by code: an option that IS one of another
|
||
slot's options is left out (that slot owns it); an option that names
|
||
one of them inside its text ("red dress" next to a Colours slot with
|
||
"red") is kept with a warning the rebuild is told. Returns ``(rows,
|
||
warnings)``."""
|
||
if not others:
|
||
return rows, []
|
||
owned = {t: name for name, texts in others.items() for t in texts}
|
||
chunks = {name: sorted({c for t in texts for c in t.split(",")
|
||
if len(c.strip()) >= 4}, key=len, reverse=True)
|
||
for name, texts in others.items()}
|
||
kept: list[dict[str, Any]] = []
|
||
warnings: list[str] = []
|
||
for r in rows:
|
||
text = _norm(r.get("value"))
|
||
if r.get("is_null") or not text:
|
||
kept.append(r)
|
||
continue
|
||
if text in owned:
|
||
warnings.append(f"Left out \"{text[:40]}\": {owned[text]} already has it")
|
||
continue
|
||
hit = next(((name, c.strip()) for name, cs in chunks.items() for c in cs
|
||
if re.search(r"(?<!\w)" + re.escape(c.strip()) + r"(?!\w)", text)), None)
|
||
if hit is not None:
|
||
warnings.append(f"\"{text[:40]}\" names what {hit[0]} picks (\"{hit[1]}\"); "
|
||
f"leave that to {hit[0]}")
|
||
kept.append(r)
|
||
return kept, warnings
|
||
|
||
|
||
#: The words a slot never has, as its ``what`` carries them: "…; Never:
|
||
#: fur, wool." (``graph_change.land_drops`` writes it).
|
||
_NEVER_WORDS = re.compile(r"\bNever:\s*([^.;]+)", re.I)
|
||
|
||
|
||
def slot_never(slot: dict[str, Any]) -> list[str]:
|
||
"""The words a slot's options never say (its ``what``'s "Never: …")."""
|
||
out: list[str] = []
|
||
for m in _NEVER_WORDS.finditer(slot.get("what") or ""):
|
||
out += [w.strip().lower() for w in m.group(1).split(",") if w.strip()]
|
||
return list(dict.fromkeys(out))
|
||
|
||
|
||
def compile_slot(
|
||
graph: dict[str, Any], slot: dict[str, Any], answer: Any, *, limit: int | None = None,
|
||
existing: dict[str, Any] | None = None, others: dict[str, list[str]] | None = None,
|
||
refs: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""The model's options for one slot as a wildcard payload: tags from the
|
||
``tags`` object (every axis value an option names), tag groups from the
|
||
slot's axes, a fallback chosen by code on a narrowed slot, the
|
||
``sometimes_none`` share as a null option, and the ``@{list:tag}``
|
||
pick a ``draw`` relation puts in front of every option (``refs``:
|
||
slot name → built id, ``draw_refs``). A fill answer (``add``
|
||
instead of ``options``) is merged into ``existing``, keeping its
|
||
options and ids. ``others`` (``other_texts``) is what the other slots
|
||
hold, for the scope check. Returns ``{payload, preview, warnings}``.
|
||
Raises ValueError when no option is usable."""
|
||
from engine.ai import wildcard as ai_wildcard
|
||
|
||
if not isinstance(answer, dict):
|
||
raise ValueError("the model's answer wasn't a list of options")
|
||
axis = group_axis(graph, slot)
|
||
if axis and isinstance(answer.get("groups"), dict) and not isinstance(
|
||
answer.get("options"), list):
|
||
answer = {**answer, "options": ungroup(slot, axis, answer["groups"])}
|
||
limit = max(limit or slot["count"], _group_count(slot, axis))
|
||
adding = isinstance(answer.get("add"), list) and not isinstance(answer.get("options"), list)
|
||
if adding and not (isinstance(existing, dict) and existing.get("options")):
|
||
answer, adding = {**answer, "options": answer["add"]}, False
|
||
if not adding and not isinstance(answer.get("options"), list):
|
||
raise ValueError("the model's answer wasn't a list of options")
|
||
kinds = axis_kinds(graph, slot)
|
||
values = {a["name"]: a["values"] for a in slot["axes"]}
|
||
# Words the user dropped from this slot ("drop fur"): no option says
|
||
# them, in the first build or any fill or repair after (9B bench P5,
|
||
# 2026-10-26: fur came back in the repair pass).
|
||
banned = slot_never(slot)
|
||
dropped_banned: list[str] = []
|
||
warnings: list[str] = []
|
||
raw_options = []
|
||
for o in answer["add" if adding else "options"]:
|
||
if not isinstance(o, dict):
|
||
continue
|
||
tags: list[str] = []
|
||
picked = _got if isinstance(_got := o.get("tags"), dict) else {}
|
||
missing = []
|
||
for axis, allowed in values.items():
|
||
got = picked.get(axis)
|
||
got = got if isinstance(got, list) else [got] if isinstance(got, str) else []
|
||
hit = [_tag(g) for g in got if _tag(g) in allowed]
|
||
if kinds[axis] == "accepts" and len(hit) > 1 and not any(
|
||
r["kind"] in ("accepts", "agree") and r["from"]["slot"] == slot["name"]
|
||
and r["from"]["axis"] == axis for r in graph["relations"]):
|
||
hit = hit[:1]
|
||
if not hit:
|
||
missing.append(axis)
|
||
tags += [h for h in hit if h not in tags]
|
||
if missing:
|
||
warnings.append(f"Left out \"{_clean(o.get('text'), 40)}\": no {', '.join(missing)}"
|
||
" value")
|
||
continue
|
||
if banned and any(_has_word(str(o.get("text") or "").lower(), w) for w in banned):
|
||
dropped_banned.append(_clean(o.get("text"), 40))
|
||
continue
|
||
raw_options.append({"text": o.get("text"), "weight": o.get("weight", 1),
|
||
"tags": tags, "negative": o.get("negative") or ""})
|
||
# A fill keeps what is there: its options stay, ids and all, and the
|
||
# new ones join them (the null option and the fallback are redone).
|
||
kept: list[dict[str, Any]] = []
|
||
if adding and existing is not None:
|
||
for o in existing["options"]:
|
||
if isinstance(o, dict) and isinstance(o.get("value"), str) and not o.get("is_null"):
|
||
kept.append({k: v for k, v in o.items() if k != "fallback"})
|
||
compiled = ai_wildcard.compile_options(
|
||
{"options": raw_options},
|
||
existing_payload={"options": kept, "sub_categories": [v for vs in values.values()
|
||
for v in vs]},
|
||
limit=len(raw_options) if adding else limit or slot["count"],
|
||
)
|
||
options = compiled["options"]
|
||
if not options and not kept:
|
||
raise ValueError("no usable options" + "".join(
|
||
f"; {s['text'][:40]}: {s['reason']}" for s in compiled["skipped"][:3]))
|
||
taken: set[str] = {o["id"] for o in kept}
|
||
rows: list[dict[str, Any]] = []
|
||
fams = model_families(graph)
|
||
# A pack that doesn't run on Pony alone gets none of its score tags; one
|
||
# with no model named counts too (9B bench P9: Pony score negatives on a
|
||
# pack with no model).
|
||
off_pony = fams != ["pony"]
|
||
unponied = 0
|
||
sloppy: list[str] = []
|
||
rated = 0
|
||
siblings = [str(o["value"]) for o in options]
|
||
for o in options:
|
||
value, negative = o["value"], o["negative"]
|
||
value, n_rated = fit_ratings(value, graph.get("rating") or "")
|
||
rated += n_rated
|
||
if negative:
|
||
negative, gone = tidy_negative(negative, value, siblings)
|
||
sloppy += [g for g in gone if g not in sloppy]
|
||
if off_pony:
|
||
# Score, source and rating tags in a slot's options print on
|
||
# every model; the quality rule gives them to Pony (9B pack 11,
|
||
# 2026-10-26: every Type option began "score_9, …, rating_safe").
|
||
value, n = _unpony(value)
|
||
negative, m = _unpony(negative)
|
||
unponied += bool(n or m)
|
||
if not value:
|
||
continue
|
||
row: dict[str, Any] = {"id": ai_wildcard.new_option_id(taken), "value": value,
|
||
"weight": o["weight"], "sub_categories": o["tags"]}
|
||
if negative:
|
||
row["negative"] = negative
|
||
rows.append(row)
|
||
if dropped_banned:
|
||
warnings.append("Left out " + ", ".join(f'"{t}"' for t in dropped_banned[:4])
|
||
+ ": the slot never has " + ", ".join(banned))
|
||
if unponied:
|
||
warnings.append(f"Took Pony's score tags out of {unponied} option(s): the quality rule "
|
||
"gives them to Pony only")
|
||
if sloppy:
|
||
warnings.append("Took " + ", ".join(f'"{g}"' for g in sloppy[:6])
|
||
+ " out of the options' negatives: a negative never names the "
|
||
"option's own words, another option of the slot, an id or a repeat")
|
||
if rated:
|
||
warnings.append(f"Set {rated} rating tag(s) to the pack's rating and spelling")
|
||
rows, scoped = scope_notes(rows, others or {})
|
||
warnings += scoped
|
||
if not rows and not kept:
|
||
raise ValueError("every option names what another slot picks: " + "; ".join(scoped[:3]))
|
||
warnings += _draw_prefix(graph, slot, rows, refs or {})
|
||
rows = [*kept, *rows]
|
||
if adding:
|
||
warnings += _word_rows(graph, slot, rows, taken)
|
||
payload, more = _finish_rows(graph, slot, rows, taken)
|
||
warnings += more
|
||
warnings += [f"Left out {s['text'][:60]}: {s['reason']}" for s in compiled["skipped"][:8]]
|
||
return {"payload": payload,
|
||
"preview": [ai_wildcard.option_line(r) for r in payload["options"]],
|
||
"warnings": warnings}
|
||
|
||
|
||
def _word_rows(graph: dict[str, Any], slot: dict[str, Any], rows: list[dict[str, Any]],
|
||
taken: set[str]) -> list[str]:
|
||
"""After a fill, a rule word still no option says becomes an option of
|
||
its own, tagged with the rule's value: the rule is about it, and with
|
||
no option saying it the rule never fires (9B pack 15, 2026-10-26: the
|
||
rule kept casual outfits to sneakers, the Shoe options were heels,
|
||
oxfords and loafers even after the fill). Returns the lines saying so."""
|
||
from engine.ai import wildcard as ai_wildcard
|
||
|
||
added: list[str] = []
|
||
for value, _axis, word in rule_words(graph, slot):
|
||
if any(not r.get("is_null") and value in (r.get("sub_categories") or [])
|
||
and _has_word(_norm(r.get("value")), word) for r in rows):
|
||
continue
|
||
same = next((r for r in rows if not r.get("is_null")
|
||
and _norm(r.get("value")) == _norm(word)), None)
|
||
if same is not None:
|
||
same["sub_categories"] = [*(same.get("sub_categories") or []), value]
|
||
else:
|
||
rows.append({"id": ai_wildcard.new_option_id(taken), "value": word, "weight": 1,
|
||
"sub_categories": [value]})
|
||
added.append(f"{word} [{value}]")
|
||
return [f"Added {', '.join(added)} as options: a rule is about them and no option said "
|
||
"them"] if added else []
|
||
|
||
|
||
def _draw_prefix(graph: dict[str, Any], slot: dict[str, Any], rows: list[dict[str, Any]],
|
||
refs: dict[str, str]) -> list[str]:
|
||
"""The ``@{id:tag}`` pick of every ``draw`` relation on this slot, put
|
||
in front of each option's text (once: a fill's kept options already
|
||
carry theirs). A source not built yet is a warning."""
|
||
warnings: list[str] = []
|
||
for r in draw_sources(graph, slot):
|
||
sid = refs.get(r["from"]["slot"])
|
||
if not sid:
|
||
warnings.append(f"{r['from']['slot']} isn't built yet, so its pick isn't in "
|
||
f"front of {slot['name']}'s options")
|
||
continue
|
||
ref = "@{" + sid + (":" + r["from"]["value"] if r["from"]["value"] else "") + "}"
|
||
src = next((x for x in graph["slots"] if x["name"] == r["from"]["slot"]), None)
|
||
said = [v.replace("_", " ") for a in (src or {}).get("axes") or [] for v in a["values"]]
|
||
own = [row for row in rows if not row.get("is_null") and isinstance(row.get("value"), str)
|
||
and not row["value"].startswith("@{")]
|
||
if said and own and 2 * sum(1 for row in own if any(
|
||
_has_word(_norm(row["value"]), v) for v in said)) >= len(own):
|
||
# "purple a person in a forest green shirt" (smoke on #118): the
|
||
# options name the list's values themselves, so a fresh pick in
|
||
# front only says a second, different one.
|
||
warnings.append(f"{slot['name']}'s options already say a {r['from']['slot']} value, "
|
||
"so no fresh pick goes in front of them")
|
||
continue
|
||
for row in rows:
|
||
if row.get("is_null") or not isinstance(row.get("value"), str):
|
||
continue
|
||
if ref in row["value"]:
|
||
continue
|
||
row["value"] = f"{ref} {row['value']}".strip()
|
||
return warnings
|
||
|
||
|
||
def slot_prompt(graph: dict[str, Any], slot: dict[str, Any], *, note: str = "") -> str:
|
||
"""The user prompt of one slot's content call."""
|
||
others = [s["name"] for s in graph["slots"] if s is not slot]
|
||
lines = [f"Write {slot['count']} options for the wildcard \"{slot['name']}\" of the pack "
|
||
f"\"{graph['title']}\" ({graph['summary'] or 'no summary'}).",
|
||
f"What it holds: {slot['what'] or slot['name']}.",
|
||
f"Style: {'short comma tags' if graph['style'] == 'tags' else 'short sentences'}; "
|
||
f"rating: {graph['rating']}."]
|
||
grouped = group_axis(graph, slot)
|
||
for a in slot["axes"]:
|
||
if a["name"] == grouped:
|
||
n, most = _group_size(slot, a["name"]), _group_most(slot, a["name"])
|
||
lines.append(f"List the options by {a['name']} under groups: {n} to {most} under "
|
||
f"each of {', '.join(a['values'])}; one that fits two goes under each.")
|
||
continue
|
||
lines.append(f"Tag every option on {a['name']} with "
|
||
+ ("the one value" if axis_kinds(graph, slot)[a["name"]] == "accepts"
|
||
else "every value") + f" that fits: {', '.join(a['values'])}.")
|
||
if others:
|
||
lines.append("Other wildcards cover " + ", ".join(others[:12])
|
||
+ ": keep those words out of these options.")
|
||
for r in inbound(graph, slot):
|
||
lines.append("Rule on it: " + _slot_rel_words(r) + ".")
|
||
for r in draw_sources(graph, slot):
|
||
src = next((s for s in graph["slots"] if s["name"] == r["from"]["slot"]), None)
|
||
what = (src["what"] if src and src["what"] else r["from"]["slot"]).rstrip(".")
|
||
lines.append(f"The code puts a fresh pick from {r['from']['slot']}"
|
||
+ (f" ({r['from']['value']})" if r["from"]["value"] else "")
|
||
+ f" in front of every option, so write no {what.lower()} in them.")
|
||
by_value: dict[tuple[str, str], list[str]] = {}
|
||
for value, axis, word in rule_words(graph, slot):
|
||
by_value.setdefault((value, axis), []).append(word)
|
||
for (value, axis), ws in by_value.items():
|
||
lines.append(f"Include options that mention {', '.join(ws)}, tagged {value} on {axis}: "
|
||
"a rule is about them.")
|
||
if note.strip():
|
||
lines.append(f"The last version had these problems; fix them: {note.strip()}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# --- relations checked on engine runs ----------------------------------------
|
||
|
||
#: A ``@{…}`` reference inside an option (what a ``draw`` puts in front).
|
||
_REF = re.compile(r"@\{[^}]*\}\s*")
|
||
|
||
|
||
def _otherwise(rels: list[dict[str, Any]]) -> str:
|
||
"""The one ``else`` a variable's derive rows compile to: the first
|
||
given (a rule has one else, however many rows name one)."""
|
||
return next((r["otherwise"] for r in rels if r.get("otherwise")), "")
|
||
|
||
|
||
def _expected(text: str, ctx: dict[str, Any]) -> str:
|
||
"""A rule's text as the run wrote it: "wearing a $top" holds the top
|
||
the run picked, so it is looked for with ``$top`` filled in (9B smoke
|
||
on #120: 13 such rules were scored broken in every run)."""
|
||
return _norm(_VAR.sub(lambda m: _ctx_get(ctx, m.group(1)), text)).strip(" ,")
|
||
|
||
|
||
#: Shortest option text the text check of a rule looks for: shorter
|
||
#: pieces ("red", "hat") show inside too many other words' company.
|
||
_MIN_BANNED = 4
|
||
|
||
|
||
def _banned_texts(rel: dict[str, Any], payload: dict[str, Any] | None, kept: set[str],
|
||
src_vals: set[str]) -> list[str]:
|
||
"""The option texts of the target a rule rules out, as the prompt
|
||
would show them: an ``exclude`` bans the options carrying its target
|
||
value, an ``only`` row the options carrying none of the tags the row
|
||
names (``kept``). A text an allowed option also says, or one of the
|
||
source's own values, is never banned: seeing it proves nothing."""
|
||
if payload is None:
|
||
return []
|
||
banned: set[str] = set()
|
||
fine: set[str] = set()
|
||
target = (rel.get("to") or {}).get("value", "")
|
||
for o in payload.get("options") or []:
|
||
if not isinstance(o, dict) or o.get("is_null"):
|
||
continue
|
||
tags = set(o.get("sub_categories") or [])
|
||
out = target in tags if rel["kind"] == "exclude" else not (tags & kept)
|
||
for c in _norm(_REF.sub("", str(o.get("value") or ""))).split(","):
|
||
c = c.strip(" .")
|
||
if len(c) >= _MIN_BANNED:
|
||
(banned if out else fine).add(c)
|
||
# "boots" banned while "leather boots" is allowed would read every
|
||
# allowed pick as a break.
|
||
return sorted(b for b in banned - fine - src_vals
|
||
if not any(_has_word(f, b) for f in fine))
|
||
|
||
|
||
def _shown(row: dict[str, Any], said: list[str]) -> None:
|
||
"""Keep the first few banned words a broken rule's prompts showed."""
|
||
if said:
|
||
got = row.setdefault("shown", [])
|
||
for w in said:
|
||
if w not in got and len(got) < 4:
|
||
got.append(w)
|
||
|
||
|
||
def _picked_tags(graph: dict[str, Any], payloads: dict[str, dict[str, Any]], slot_name: str,
|
||
ctx: dict[str, Any]) -> tuple[set[str], bool] | None:
|
||
"""The tags the run's pick for a slot carries, and whether that pick
|
||
was the fallback option. None when the slot isn't built; an empty set
|
||
when it picked nothing (a null option)."""
|
||
slot = next(s for s in graph["slots"] if s["name"] == slot_name)
|
||
payload = payloads.get(slot_name)
|
||
if payload is None:
|
||
return None
|
||
value = _norm(ctx.get(slot["var"]))
|
||
if not value:
|
||
return set(), False
|
||
chunks = {c.strip() for c in value.split(",")}
|
||
tags: set[str] = set()
|
||
fallback = False
|
||
for o in payload.get("options") or []:
|
||
if not isinstance(o, dict) or o.get("is_null"):
|
||
continue
|
||
text = _norm(_REF.sub("", str(o.get("value") or "")))
|
||
if text and (text == value or text in chunks
|
||
or re.search(r"(?<!\w)" + re.escape(text) + r"(?!\w)", value)):
|
||
tags |= set(o.get("sub_categories") or [])
|
||
fallback = fallback or o.get("fallback") is True
|
||
kinds = axis_kinds(graph, slot)
|
||
for a in slot["axes"]:
|
||
# What the run rolled on the axis, read the way a rule's
|
||
# ``$hat.type`` reads it (``__wp_axes__``): a plain
|
||
# ``ctx["hat.type"]`` is never there, so every rule keyed on an
|
||
# accepts axis looked like it never fired (9B pack 7, 2026-10-24).
|
||
got = ctx.get(f"{slot['var']}.{a['name']}") or _ctx_get(ctx, f"{slot['var']}.{a['name']}")
|
||
if isinstance(got, str) and got:
|
||
if kinds.get(a["name"]) == "accepts":
|
||
# The option offered alternatives; this run rolled one.
|
||
tags -= set(a["values"])
|
||
tags |= {g.strip() for g in got.split(",") if g.strip()}
|
||
return tags, fallback
|
||
|
||
|
||
def _drawn_tags(graph: dict[str, Any], payloads: dict[str, dict[str, Any]], list_name: str,
|
||
ctx: dict[str, Any]) -> list[set[str]]:
|
||
"""The tags of each pick a ``draw`` put in front of a drawer's text
|
||
this run (the list's option the drawer's value starts with), one set
|
||
per drawer that drew. A rule on the list must hold for these too: they
|
||
are what the prompt shows."""
|
||
payload = payloads.get(list_name)
|
||
if payload is None:
|
||
return []
|
||
options = sorted(((_norm(str(o.get("value") or "")), set(o.get("sub_categories") or []))
|
||
for o in payload.get("options") or []
|
||
if isinstance(o, dict) and not o.get("is_null") and o.get("value")),
|
||
key=lambda x: -len(x[0]))
|
||
out: list[set[str]] = []
|
||
for r in graph["relations"]:
|
||
if r["kind"] != "draw" or r["from"]["slot"] != list_name:
|
||
continue
|
||
drawer = next((s for s in graph["slots"] if s["name"] == (r.get("to") or {}).get("slot")),
|
||
None)
|
||
value = _norm(ctx.get(drawer["var"])) if drawer else ""
|
||
hit = next((tags for text, tags in options if text and (
|
||
value == text or value.startswith(text + " "))), None)
|
||
if hit is not None:
|
||
out.append(hit)
|
||
return out
|
||
|
||
|
||
def verify_relations(plan: dict[str, Any], built: dict[str, Any],
|
||
runs: list[dict[str, Any]],
|
||
rendered: list[str] | None = None) -> list[dict[str, Any]]:
|
||
"""Every relation of a graph plan checked against engine runs (the
|
||
contexts ``PipelineEngine.run`` returned): ``[{text, kind, checked,
|
||
seen, broken, holds}]``. ``exclude`` must never co-occur, ``only``
|
||
rows must leave the target in the row (or on its fallback), ``agree``
|
||
/ ``accepts`` must share a value (or fall back), ``derive`` /
|
||
``append`` must write their text when their value fires and the
|
||
``otherwise`` when none does. With ``rendered`` (the prompt of each
|
||
run), an ``exclude`` with ``words`` also fails when a word shows in a
|
||
prompt its source value fired in. A relation that never fired in the
|
||
runs doesn't hold either (``why`` says so): a rule about things no
|
||
option has is no rule. ``boost`` / ``reduce`` are odds, not rules, so
|
||
they are not checked; nor is a relation whose slots aren't built."""
|
||
graph = plan.get("graph")
|
||
if not isinstance(graph, dict) or not runs:
|
||
return []
|
||
payloads: dict[str, dict[str, Any]] = {}
|
||
for i in plan["items"]:
|
||
if i["kind"] == "wildcard" and i["key"] in built:
|
||
slot = _slot_of(graph, i)
|
||
if slot is not None:
|
||
payloads[slot["name"]] = built[i["key"]]["payload"]
|
||
only_rows: dict[tuple[str, str, str], set[str]] = {}
|
||
for r in graph["relations"]:
|
||
if r["kind"] == "only":
|
||
only_rows.setdefault((r["from"]["slot"], r["from"]["value"], r["to"]["slot"]),
|
||
set()).add(r["to"]["value"])
|
||
# Every tag a source row says something about (only, boost, reduce):
|
||
# an "only" row keeps those options, so their text isn't banned.
|
||
row_tags: dict[tuple[str, str, str], set[str]] = {}
|
||
for r in graph["relations"]:
|
||
if r["kind"] in ("only", "boost", "reduce") and r.get("to"):
|
||
row_tags.setdefault((r["from"]["slot"], r["from"]["value"], r["to"]["slot"]),
|
||
set()).add(r["to"]["value"])
|
||
by_var: dict[str, list[dict[str, Any]]] = {}
|
||
for r in graph["relations"]:
|
||
if r["kind"] == "derive":
|
||
by_var.setdefault(r["var"], []).append(r)
|
||
out: list[dict[str, Any]] = []
|
||
for r in graph["relations"]:
|
||
kind, src, dst = r["kind"], r["from"], r.get("to") or {}
|
||
row = {"text": _slot_rel_words(r), "kind": kind,
|
||
"checked": kind not in _WEIGHTED and kind != "draw",
|
||
"seen": 0, "broken": 0, "holds": True, "why": ""}
|
||
out.append(row)
|
||
if not row["checked"]:
|
||
continue
|
||
words = r.get("words") or [] if kind == "exclude" else []
|
||
# "casual" in the words of "casual never gets heels" is the source,
|
||
# not a forbidden thing: it showed in every casual prompt, so the rule
|
||
# counted broken in 27 of 27 runs (9B smoke, 2026-10-24).
|
||
src_slot = next((s for s in graph["slots"] if s["name"] == src["slot"]), None)
|
||
src_vals = {v.lower() for a in (src_slot or {}).get("axes") or [] for v in a["values"]}
|
||
words = [w for w in words if w.lower() not in src_vals]
|
||
# What the row rules out, as text: a rule held by tags while another
|
||
# item (a derivation, another slot) printed the very thing it bans
|
||
# ("lingerie set … fully nude", "nude … leather armor": "rules held,
|
||
# no warnings", 9B bench P13/Z18 2026-10-26).
|
||
banned = _banned_texts(r, payloads.get(dst.get("slot", "")),
|
||
only_rows.get((src["slot"], src["value"], dst.get("slot", "")),
|
||
set()) | row_tags.get(
|
||
(src["slot"], src["value"], dst.get("slot", "")), set()),
|
||
src_vals) if kind in ("exclude", "only") else []
|
||
for n, ctx in enumerate(runs):
|
||
got_src = _picked_tags(graph, payloads, src["slot"], ctx)
|
||
if got_src is None:
|
||
row["checked"] = False
|
||
break
|
||
st, _ = got_src
|
||
if kind in ("exclude", "only"):
|
||
if src["value"] not in st:
|
||
continue
|
||
got_dst = _picked_tags(graph, payloads, dst["slot"], ctx)
|
||
if got_dst is None:
|
||
row["checked"] = False
|
||
break
|
||
dt, fell = got_dst
|
||
prompt = rendered[n] if rendered and n < len(rendered) else ""
|
||
own = str(ctx.get(src_slot["var"]) or "").lower() if src_slot else ""
|
||
said = [w for w in [*words, *banned] if _has_word(prompt, w)
|
||
and not _has_word(own, w)] if prompt else []
|
||
if fell:
|
||
# The fallback stands in when nothing else is left: its
|
||
# own text is allowed, a copy from another item isn't.
|
||
dst_slot = next((x for x in graph["slots"] if x["name"] == dst["slot"]),
|
||
None)
|
||
mine = _norm(ctx.get(dst_slot["var"])) if dst_slot else ""
|
||
said = [w for w in said if not _has_word(mine, w)]
|
||
nested = _drawn_tags(graph, payloads, dst["slot"], ctx)
|
||
if not dt and not said and not nested:
|
||
continue
|
||
row["seen"] += 1
|
||
allowed = only_rows.get((src["slot"], src["value"], dst["slot"]), set())
|
||
if kind == "exclude" and ((not fell and dst["value"] in dt) or said):
|
||
row["broken"] += 1
|
||
_shown(row, said)
|
||
elif kind == "only" and ((not fell and dt and not (dt & allowed)) or said):
|
||
row["broken"] += 1
|
||
_shown(row, said)
|
||
elif kind == "exclude" and any(dst["value"] in t for t in nested):
|
||
row["broken"] += 1
|
||
elif kind == "only" and any(t and not (t & allowed) for t in nested):
|
||
row["broken"] += 1
|
||
elif kind in ("agree", "accepts"):
|
||
got_dst = _picked_tags(graph, payloads, dst["slot"], ctx)
|
||
if got_dst is None:
|
||
row["checked"] = False
|
||
break
|
||
dt, fell = got_dst
|
||
shared = set(r["values"])
|
||
nested = [t & shared for t in _drawn_tags(graph, payloads, dst["slot"], ctx)]
|
||
if not (st & shared) or not (dt or any(nested)):
|
||
continue
|
||
row["seen"] += 1
|
||
if (dt and not (st & dt & shared) and not fell) \
|
||
or any(t and not (st & t) for t in nested):
|
||
row["broken"] += 1
|
||
else:
|
||
got = _norm(ctx.get(r["var"]))
|
||
if src["value"] in st:
|
||
row["seen"] += 1
|
||
if _expected(r["text"], ctx) not in got:
|
||
row["broken"] += 1
|
||
elif kind == "derive" and _otherwise(by_var[r["var"]]):
|
||
fired = False
|
||
for d in by_var[r["var"]]:
|
||
t = _picked_tags(graph, payloads, d["from"]["slot"], ctx)
|
||
fired = fired or bool(t and d["from"]["value"] in t[0])
|
||
if not fired:
|
||
row["seen"] += 1
|
||
if _expected(_otherwise(by_var[r["var"]]), ctx) not in got:
|
||
row["broken"] += 1
|
||
if row["checked"] and row["seen"] == 0:
|
||
row["why"] = f"never fired in {len(runs)} runs"
|
||
row["holds"] = row["checked"] and row["broken"] == 0 and row["seen"] > 0
|
||
return out
|
||
|
||
|
||
# --- the planner call -------------------------------------------------------
|
||
|
||
GRAPH_BLUEPRINTS = """\
|
||
Worked examples (shapes to adapt, never a menu to pick from; the request \
|
||
decides what the slots are):
|
||
1. "Fantasy heroes; elves never use guns; mages get a title": slots Race \
|
||
(axis kind: elf, dwarf, human), Class (axis role: caster, martial), Weapon \
|
||
(axis type: gun, blade, staff), Outfit, Setting; relations exclude \
|
||
Race.kind=elf → Weapon.type=gun; only Class.role=caster → Weapon.type=staff; \
|
||
derive $title "Archmage" when Class.role=caster, otherwise "Adventurer"; \
|
||
template order Quality, title, Race, Class, Outfit, Weapon, Setting.
|
||
2. "Outfits by season; never sandals or shorts in winter, never a parka in \
|
||
summer": slot Season (internal, axis season: winter, spring, summer, \
|
||
autumn; its options are those values), Outfit (axis season with the same \
|
||
values; an outfit lists every season it fits), Footwear (axis season), \
|
||
Weather (axis season); relations agree on season Season → Outfit and \
|
||
Season → Weather; exclude Season.season=winter → Footwear.season=summer with \
|
||
words ["sandals"]; exclude Season.season=winter → Outfit.season=summer, \
|
||
words ["shorts"]; exclude Season.season=summer → Outfit.season=winter, words \
|
||
["parka"]; append ", scarf" to $outfit when Season.season=winter.
|
||
3. "Shoes that match the outfit": Outfit (axis shoes: sneakers, boots, \
|
||
heels, loafers: what each outfit goes with), Shoes (axis shoes, the same \
|
||
values: what each pair is); relation accepts Outfit → Shoes on shoes.
|
||
4. "A 4-frame story of one hero": run {mode: story, frames: 4}; Hero and \
|
||
Outfit with hold true; a Beat slot of 4 options read per frame is planned \
|
||
by the code from the run, so leave $beat out.
|
||
5. "The user types the subject": inputs [{var: subject, about: who, \
|
||
example: "a red-haired knight"}]; no Subject slot; template order starts \
|
||
with subject.
|
||
6. "A scarf, a hat and mittens, each in a warm colour from one shared colour \
|
||
list": slot Colours (axis warmth: warm, cool; its options are colours), \
|
||
Scarf, Hat, Mittens; relations draw Colours.warmth=warm → Scarf, draw \
|
||
Colours.warmth=warm → Hat, draw Colours.warmth=warm → Mittens; order Scarf, \
|
||
Hat, Mittens (Colours is the list, not a pick of its own).
|
||
7. "An anime style preset with quality tags and its own negatives, for Pony \
|
||
and Flux": model "pony, flux"; fixed [{name: Anime preset, var: preset, \
|
||
text: "anime screencap, clean lineart, flat colors", negative: "photo, \
|
||
realistic, 3d render"}]; order starts with preset.
|
||
"""
|
||
|
||
GRAPH_SYSTEM = """\
|
||
You plan prompt packs for Wildcard Pipeline, a ComfyUI extension that builds \
|
||
image-generation prompts from modules that run in order on a WP Context node \
|
||
and set $variables; a WP Prompt Assembler renders a template from them. You \
|
||
write the pack as a graph; the code wires it. Decide WHAT: which slots, what \
|
||
they vary on, what depends on what, the prompt order. Never write option \
|
||
text, tags on options, rule syntax or the template string: the code and a \
|
||
later call per slot do that.
|
||
|
||
Answer with:
|
||
- title (short pack name), summary (one sentence), design (2-4 sentences on \
|
||
how the pack fits together: which pick decides which, and why each relation \
|
||
is there).
|
||
- rating: "nsfw" if sexual or explicit, else "safe". style: "tags" \
|
||
(comma-separated Danbooru-style tags: Pony, Illustrious, NoobAI) or \
|
||
"sentences" (short natural phrases: Flux, SD3, Qwen-Image); follow the \
|
||
settings, else the request, else "tags". model: the image model family or \
|
||
families the request names ("pony", "pony, flux"; two families get a \
|
||
per-family quality rule from the code) or "". anchor: the slot variable the \
|
||
others follow (setting, class, season), or "".
|
||
- questions: up to 4 things you can't guess that would change the pack \
|
||
(header 1-3 words; 2-5 short choices; multi true when several apply \
|
||
together). Never re-ask a question listed under "Decided". [] when nothing \
|
||
important is open. Write the full graph anyway with the likeliest answers.
|
||
- assumptions (up to 6 short lines on what you guessed), from_images (up to \
|
||
4 lines on what you took from attached images, else []), research (up to 2 \
|
||
web queries when web search is on and facts would help, else []).
|
||
- run: {mode: "single", frames: 0} for one image per run; {mode: "story", \
|
||
frames: 2-12} for a story or sequence a WP Context Loop plays frame by \
|
||
frame (the code adds the story beat per frame, so plan no beat slot and \
|
||
no $iteration rule; mark what stays the same with hold); {mode: "sweep", \
|
||
frames: 0} for a sheet comparing options (the code holds everything but \
|
||
the sweep slots).
|
||
- inputs: what comes from outside at run time (a subject the user types, a \
|
||
LoRA): {var, about, example}. A named fixed subject or "whatever I give it" \
|
||
is an input and gets no slot; a LoRA is an input named <slot>_lora. [] when \
|
||
the pack makes everything itself.
|
||
- sections: the pack's parts ({name, about}), each a sub-bundle; [] for a \
|
||
small pack. Every slot names its section or "".
|
||
- slots: one per choice the image makes (setting, character, outfit, pose, \
|
||
lighting, …), up to 24. Each: name; var (its variable, letters digits _); \
|
||
what (what its options are, one concept only: poses hold poses, not hair); \
|
||
section; count (options to write, 4-40); show "prompt" (read by the \
|
||
template) or "internal" (a driver rules need but the prompt shouldn't say: \
|
||
a season, a mood, a rating); pick "" for one pick, "2" or "2-3" for several \
|
||
("2 to 3 accessories" is pick "2-3" on Accessories); \
|
||
hold true when the pick must stay the same for a whole batch or story; \
|
||
sweep true on the 2 or 3 short slots a sweep sheet compares (run mode \
|
||
sweep only); sometimes_none 0, or the percent of runs that get nothing (a hat 30% of the \
|
||
time); axes: the ways its options are sorted that a relation or another \
|
||
slot needs, each {name (a lowercase word), values (2-12 lowercase words)}. \
|
||
An axis exists only for a relation: no relation, no axis. Two slots that \
|
||
must agree share an axis NAME and VALUES (Season.season and Outfit.season: \
|
||
winter, summer). Values are tags, so keep them single words. A slot whose \
|
||
only axis is itself (a Season slot with axis season) gets its options from \
|
||
the axis values by the code, no option call: its options ARE winter, spring, \
|
||
summer, autumn, never outfits.
|
||
- fixed: variables set to the same text every run ({name, var, text, \
|
||
negative}): quality tags, a style preset. negative is what that preset keeps \
|
||
out of the image (its own negative words), "" when it has none. Up to 6.
|
||
- relations, each with every field ("" / 0 where unused):
|
||
- exclude / only / boost / reduce: from_slot.from_axis = from_value → \
|
||
to_slot.to_axis = to_value, with factor for boost (>1) and reduce (<1). \
|
||
"never" is exclude; "just these" / "goes with" is only. "X only in Y" \
|
||
restricts X, so X is the source: "snow only in cold biomes" is only \
|
||
Weather.kind=snow → Biome.climate=cold (the code picks the source first), \
|
||
never only Biome.climate=cold → Weather.kind=snow, which would make every \
|
||
cold biome snow.
|
||
- agree: from_slot and to_slot share an axis (from_axis): the target's \
|
||
pick carries the source's value (shoes for the outfit, outfit for the \
|
||
season). The code adds a fallback so the target is never empty.
|
||
- accepts: like agree, but the source's options each LIST the values they \
|
||
go with (an outfit: sneakers, boots) while the target's options ARE one \
|
||
value each.
|
||
- derive: var (a NEW variable), text (what it becomes) when \
|
||
from_slot.from_axis = from_value; otherwise (text for every other pick, or \
|
||
"" to leave it unset). Several derive rows on one var are its cases.
|
||
- append: text added to var (a slot's or a derived variable) when \
|
||
from_slot.from_axis = from_value.
|
||
- draw: every option of to_slot starts with a fresh pick from from_slot, a \
|
||
shared list (colours, materials, names), narrowed to from_axis = from_value \
|
||
when given ("each in a warm colour from one colour list": draw Colours.warmth\
|
||
=warm → Scarf, and the same for Hat and Mittens). The list slot's options are \
|
||
the things drawn; the target's options then say nothing of them.
|
||
Every relation also has words: the things it is about as an option would \
|
||
say them, taken from the request ("sandals", "shorts" for "no sandals or \
|
||
shorts in winter"; "parka"), up to 6, or [] when it is about a whole axis \
|
||
value. The code makes the target slot hold options saying those words, \
|
||
tagged with to_value, and checks the finished prompts for them.
|
||
Every slot, axis and value a relation names must be declared on the slot \
|
||
it names: a relation that names something else is dropped.
|
||
- template: lead (always-first words, never a slot name: for tag models the \
|
||
count the request implies, "1girl, solo" for one woman, "1boy, 1girl" for a \
|
||
couple, "solo" only with one person; or ""), order (slot names, fixed names, \
|
||
derived variables and inputs in the order the image model should read them: \
|
||
subject and quality first, then character, outfit, pose, setting, lighting, \
|
||
style; internal slots never), tail (always-last words or ""), negative (the \
|
||
standing negative prompt in the pack's style: usual defects only, which never \
|
||
go in the lead or tail; "" for none). A rule ("no sandals in winter") is \
|
||
never a negative: it is a relation.
|
||
|
||
Plan what the request needs. Don't add commentary; the output is data.
|
||
|
||
A pack rated nsfw is for adults only: every character is an adult, and no \
|
||
child or real-person subject is ever planned. A costume or role the user \
|
||
asks for (a school uniform, a maid) is worn by an adult.
|
||
|
||
""" + GRAPH_BLUEPRINTS
|
||
|
||
SLOT_SYSTEM = """\
|
||
You write the options of one wildcard in a prompt pack for Wildcard Pipeline \
|
||
(image-generation prompts). Answer with options, each {text, weight, \
|
||
negative, tags}: text is the option as it appears in the prompt (short, in \
|
||
the pack's style, one concept, no quotes, no $variables, no braces); weight \
|
||
is 1 for usual, 2-3 for common, 0.5 for rare; negative is what to keep out \
|
||
of the image when this option is picked ("" when nothing); tags gives, for \
|
||
every axis named, the value(s) that fit this option, from that axis's own \
|
||
values only. Spread the options over the axis values so no value is left \
|
||
without options. Never list another slot's words (hair in an outfit, a \
|
||
pose in a setting). Vary the options; no two should mean the same thing.\
|
||
"""
|
||
|
||
|
||
def slot_request(plan: dict[str, Any], key: str, note: str = "",
|
||
built: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""``{system, user, schema, schema_name, temperature}`` of one slot's
|
||
content call. When the note asks for nothing but axis coverage and the
|
||
slot is built (``built``), it is a FILL call: the model adds options
|
||
tagged with the missing values and the rest stays as it is."""
|
||
slot = slot_for(plan, key)
|
||
if slot is None:
|
||
raise PlanError(f"{key} isn't a slot")
|
||
from engine.ai import guide as ai_guide
|
||
graph = plan["graph"]
|
||
old = (built or {}).get(key)
|
||
coverage, rest = coverage_split(note)
|
||
# A coverage part makes it a fill whatever else the note says: a
|
||
# value no option carries breaks every rule on it, so the options
|
||
# that are there stay and the new ones mind the rest of the note.
|
||
targets = fill_targets(graph, slot, old["payload"]) if isinstance(old, dict) \
|
||
and coverage else {}
|
||
if targets:
|
||
axis = max(targets, key=lambda a: len(targets[a]))
|
||
user = fill_prompt(graph, slot, old["payload"], targets, axis, also=rest)
|
||
schema = slot_schema(graph, slot, fill={axis: list(targets[axis])})
|
||
name = "pack_slot_fill"
|
||
else:
|
||
user = slot_prompt(graph, slot, note=note)
|
||
if _SCOPE_NOTE.search(note or "") and built:
|
||
for other, words in clash_words(plan, built, key).items():
|
||
user += "\n" + (f"{other} already says: {', '.join(words)}. Keep every one of "
|
||
f"these out of these options; they are {other}'s.")
|
||
schema = slot_schema(graph, slot)
|
||
name = "pack_slot"
|
||
for r in draw_sources(graph, slot):
|
||
# What the code will put in front, so the options read well with it
|
||
# ("red" + "flannel shirt") and say nothing of it.
|
||
src = next((x for x in graph["slots"] if x["name"] == r["from"]["slot"]), None)
|
||
key_of = next((i["key"] for i in plan["items"] if src and i.get("var") == src["var"]
|
||
and i["kind"] in ("wildcard", "library")), None)
|
||
payload = ((built or {}).get(key_of) or {}).get("payload") if key_of else None
|
||
drawn = [str(o.get("value")) for o in (payload or {}).get("options") or []
|
||
if isinstance(o, dict) and o.get("value") and not o.get("is_null")
|
||
and (not r["from"]["value"] or r["from"]["value"] in (
|
||
o.get("sub_categories") or []))][:12]
|
||
if drawn:
|
||
user += (f"\nWhat goes in front: {r['from']['slot']} options like "
|
||
+ ", ".join(f'"{d[:30]}"' for d in drawn) + ".")
|
||
if parse_settings(plan.get("settings"))["negatives"] != "auto":
|
||
user += "\nLeave every negative \"\": the pack keeps one standing negative."
|
||
fmt = ai_guide.format_line(plan.get("model") or "")
|
||
if fmt:
|
||
user += f"\nThe image model: {fmt}"
|
||
if plan.get("rating") == "nsfw":
|
||
user += "\n" + ai_guide.NSFW.strip()
|
||
return {"system": SLOT_SYSTEM, "user": user, "schema": schema,
|
||
"schema_name": name, "temperature": 0.8}
|
||
|
||
|
||
def fill_prompt(graph: dict[str, Any], slot: dict[str, Any], payload: dict[str, Any],
|
||
targets: dict[str, dict[str, list[str]]], axis: str,
|
||
also: list[str] | None = None) -> str:
|
||
"""The user prompt of a fill call: add options for the values of
|
||
``axis`` no option carries, and ones saying the rule words no option
|
||
tagged with their value says (``fill_targets``; other axes' gaps are
|
||
mentioned)."""
|
||
want = targets[axis]
|
||
count = min(MAX_COUNT, sum(max(2, len(ws)) for ws in want.values()))
|
||
lines = [f"Add {count} options to the wildcard \"{slot['name']}\" of the pack "
|
||
f"\"{graph['title']}\" ({graph['summary'] or 'no summary'}).",
|
||
f"What it holds: {slot['what'] or slot['name']}.",
|
||
f"Style: {'short comma tags' if graph['style'] == 'tags' else 'short sentences'}; "
|
||
f"rating: {graph['rating']}."]
|
||
bare = [v for v, ws in want.items() if not ws]
|
||
if bare:
|
||
lines.append(f"No option is {', '.join(bare)} on {axis} yet: every new option must be "
|
||
"one of those (at least two for each).")
|
||
for v, ws in want.items():
|
||
if ws:
|
||
lines.append(f"No option mentions {', '.join(ws)} yet: add at least one option "
|
||
f"saying each, tagged {v} on {axis} (a rule is about them).")
|
||
for a in slot["axes"]:
|
||
if a["name"] != axis:
|
||
lines.append(f"Tag every option on {a['name']} with "
|
||
+ ("the one value" if axis_kinds(graph, slot)[a["name"]] == "accepts"
|
||
else "every value") + f" that fits: {', '.join(a['values'])}"
|
||
+ (f" (none is {', '.join(targets[a['name']])} yet)"
|
||
if a["name"] in targets else "") + ".")
|
||
have = [_norm(o.get("value")) for o in payload.get("options") or []
|
||
if isinstance(o, dict) and _norm(o.get("value"))]
|
||
if have:
|
||
lines.append("Already there, so don't repeat them: " + "; ".join(have[:MAX_COUNT]) + ".")
|
||
others = [s["name"] for s in graph["slots"] if s is not slot]
|
||
if others:
|
||
lines.append("Other wildcards cover " + ", ".join(others[:12])
|
||
+ ": keep those words out of these options.")
|
||
if also:
|
||
lines.append("The last options had these problems; the new ones must not: "
|
||
+ "; ".join(also)[:400] + ".")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# --- picks the request counts ----------------------------------------------
|
||
|
||
_NUMBER_WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
||
"seven": 7, "eight": 8, "nine": 9, "ten": 10}
|
||
_NUM = r"(\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten)"
|
||
#: "2 to 3 accessories", "2-3 accessories", "two or three accessories",
|
||
#: "three accessories each".
|
||
_COUNTED = re.compile(
|
||
_NUM + r"(?:\s*(?:to|or|-|–)\s*" + _NUM + r")?\s+([a-z][a-z-]{2,})", re.I)
|
||
#: Counted things that are no pick of a slot: how the pack runs, not what
|
||
#: an image holds.
|
||
_NOT_PICKED = frozenset("""
|
||
frames frame images image pictures picture panels panel steps step scenes scene runs run
|
||
options option slots slot items item choices choice values value seeds seed words word
|
||
lines line tags tag sentences sentence prompts prompt variations variation wildcards
|
||
wildcard packs pack times time kinds kind types type sets set lists list axes axis
|
||
""".split())
|
||
|
||
|
||
def _count(raw: str) -> int:
|
||
return int(raw) if raw.isdigit() else _NUMBER_WORDS.get(raw.lower(), 0)
|
||
|
||
|
||
def request_picks(request: str) -> list[tuple[int, int, str]]:
|
||
"""``(low, high, noun)`` for every counted thing the request asks for
|
||
("2 to 3 accessories" → (2, 3, "accessories")), at least 2 of
|
||
something, up to 20."""
|
||
out: list[tuple[int, int, str]] = []
|
||
for m in _COUNTED.finditer(request or ""):
|
||
lo, hi, noun = _count(m.group(1)), _count(m.group(2) or m.group(1)), m.group(3).lower()
|
||
if hi < lo:
|
||
lo, hi = hi, lo
|
||
if hi < 2 or hi > 20 or noun in _NOT_PICKED or noun in _REQUEST_STOP:
|
||
continue
|
||
out.append((max(1, lo), hi, noun))
|
||
return out
|
||
|
||
|
||
def drop_multi_pick_rules(graph: dict[str, Any]) -> list[str]:
|
||
"""A derive or append rule keyed on a slot that picks several options
|
||
goes: the engine sets ``$var.axis`` for one pick only, so the rule never
|
||
fired (9B, 2026-10-21: $animal_biome on a 1-3 pick, broken 11-17 runs
|
||
in 14-21). A variable nothing writes any more leaves the order."""
|
||
lines: list[str] = []
|
||
multi = {s["name"]: s for s in graph["slots"] if s["pick"] and s["pick"] != "1"}
|
||
gone = [r for r in graph["relations"] if r["kind"] in ("derive", "append")
|
||
and r["from"]["slot"] in multi]
|
||
if not gone:
|
||
return []
|
||
graph["relations"] = [r for r in graph["relations"] if r not in gone]
|
||
still = {r["var"] for r in graph["relations"] if r["kind"] == "derive"}
|
||
for r in gone:
|
||
s = multi[r["from"]["slot"]]
|
||
lines.append(f"{s['name']} picks {s['pick']}, so ${r['var']} can't follow one "
|
||
f"{s['name']}.{r['from']['axis']} value: that rule went")
|
||
if r["kind"] == "derive" and r["var"] not in still:
|
||
graph["template"]["order"] = [v for v in graph["template"]["order"]
|
||
if v != r["var"]]
|
||
return lines
|
||
|
||
|
||
def apply_picks(graph: dict[str, Any], request: str,
|
||
locked: list[str] | tuple[str, ...] = ()) -> list[str]:
|
||
"""A slot the request counts ("2 to 3 accessories") picks that many
|
||
when the model left its ``pick`` empty, and a slot a rule runs between
|
||
picks one when the model gave it several the request doesn't count
|
||
(unless the user set it by hand, ``locked``). Returns the lines saying
|
||
so."""
|
||
lines: list[str] = []
|
||
counted = request_picks(request)
|
||
# A slot the model gave several picks that a rule runs between, with no
|
||
# count for it in the request: both rows fire, and only an option fit
|
||
# for every pick is left ("pick 2" outfits, 9B smoke 2026-10-24: one
|
||
# casual and one formal outfit, boots in every run).
|
||
# A derive / append keyed on a multi-pick goes (drop_multi_pick_rules),
|
||
# so its source counts too (hair lengths "pick 2-3": nine length rules
|
||
# dropped and the length never shown, 9B smoke 2026-10-24).
|
||
ruled = {x for r in graph["relations"] if r["kind"] in NARROWING
|
||
for x in (r["from"]["slot"], r["to"]["slot"])} | {
|
||
r["from"]["slot"] for r in graph["relations"] if r["kind"] in ("derive", "append")}
|
||
for s in graph["slots"]:
|
||
if request.strip() and s["pick"] and s["pick"] != "1" and s["name"] in ruled \
|
||
and s["var"] not in locked and not any(
|
||
_stems(noun) & _stems(w) for _, _, noun in counted
|
||
for w in re.findall(r"[a-z]+", f"{s['name']} {s['var']} {s['what']}".lower())):
|
||
lines.append(f"{s['name']} picks one: the request doesn't ask for {s['pick']}, and "
|
||
"its rules need one pick")
|
||
s["pick"] = ""
|
||
for lo, hi, noun in counted:
|
||
stems = _stems(noun)
|
||
for s in graph["slots"]:
|
||
if s["pick"] or s.get("self_axis"):
|
||
continue
|
||
words = set(re.findall(r"[a-z]+", f"{s['name']} {s['var']} {s['what']}".lower()))
|
||
if not any(stems & _stems(w) for w in words):
|
||
continue
|
||
s["pick"] = f"{lo}-{hi}" if lo != hi else f"{hi}"
|
||
lines.append(f"{s['name']}: pick {s['pick']}, from the request")
|
||
break
|
||
return lines
|
||
|
||
|
||
# --- slots that aren't picks -------------------------------------------------
|
||
|
||
#: Slot names (or variables) that hold what never rolls: quality words
|
||
#: are the same every run, a negative prompt is the template's.
|
||
_QUALITY_SLOT = frozenset({"quality", "qualities", "quality_tags", "quality_tag", "qual"})
|
||
_NEGATIVE_SLOT = frozenset({"negative", "negatives", "negative_prompt", "neg_prompt",
|
||
"negative_tags", "neg"})
|
||
_PRESET_NEGATIVES = re.compile(r"\bpresets?\b.{0,80}\b(?:its|their)\s+own\s+negatives?\b"
|
||
r"|\b(?:its|their)\s+own\s+negatives?\b.{0,80}\bpresets?\b",
|
||
re.I | re.S)
|
||
|
||
|
||
def _negative_name(name: str, var: str) -> bool:
|
||
"""Whether a name or variable says negative: ``compile_plan``'s test
|
||
(``_NEGATIVEISH``) or a bare "neg" word ("default_neg", "neg_tags")."""
|
||
words = set(re.split(r"[\s_\-]+", f"{name} {var}".lower()))
|
||
return bool(_NEGATIVEISH.search(f"{name} {var}")) or bool(words & {"neg", "negs"})
|
||
|
||
|
||
def _slot_key(slot: dict[str, Any]) -> set[str]:
|
||
return {slot["var"].lower(), _axis_name(slot["name"]), slot["name"].lower()}
|
||
|
||
|
||
def _in_relations(graph: dict[str, Any], slot: dict[str, Any]) -> bool:
|
||
return any(r["from"]["slot"] == slot["name"] or (r.get("to") or {}).get("slot")
|
||
== slot["name"] for r in graph["relations"])
|
||
|
||
|
||
def apply_request_slots(graph: dict[str, Any], request: str) -> list[str]:
|
||
"""What the code lands when the planner wrote a pick for something
|
||
that never rolls (an evals finding on small models): a Quality slot
|
||
is fixed values (its text, or the style's usual quality words), a
|
||
Negative slot is the template's negative, and a preset asked to
|
||
carry "its own negatives" carries the standing negative itself
|
||
instead of the template. A slot that has axes or sits in a relation
|
||
is left alone: the model meant it. Returns the lines saying so."""
|
||
lines: list[str] = []
|
||
for s in list(graph["slots"]):
|
||
key = _slot_key(s)
|
||
if key & _NEGATIVE_SLOT or _negative_name(s["name"], s["var"]):
|
||
# The same test ``compile_plan`` drops the wildcard by: a slot
|
||
# it would drop can't stay in the order or a relation, or the
|
||
# template reads a variable nothing sets (7 of 39 packs).
|
||
graph["slots"].remove(s)
|
||
graph["template"]["order"] = [v for v in graph["template"]["order"]
|
||
if v != s["var"]]
|
||
gone = [r for r in graph["relations"] if r["from"]["slot"] == s["name"]
|
||
or (r.get("to") or {}).get("slot") == s["name"]]
|
||
graph["relations"] = [r for r in graph["relations"] if r not in gone]
|
||
if not graph["template"]["negative"] and "," in s["what"]:
|
||
graph["template"]["negative"] = s["what"]
|
||
lines.append(f"{s['name']} is the negative prompt, not a pick: it went to the "
|
||
"template's negative"
|
||
+ (f" (and its {len(gone)} rule{'s' if len(gone) > 1 else ''} "
|
||
"went with it)" if gone else ""))
|
||
continue
|
||
if s["axes"] or _in_relations(graph, s):
|
||
continue
|
||
if key & _QUALITY_SLOT and len(graph["fixed"]) < MAX_FIXED \
|
||
and not any(f["var"] == s["var"] for f in graph["fixed"]):
|
||
words = s["what"] if "," in s["what"] else (
|
||
"masterpiece, best quality" if graph["style"] == "tags"
|
||
else "high quality, detailed")
|
||
graph["slots"].remove(s)
|
||
graph["fixed"].append({"name": s["name"], "var": s["var"], "text": words,
|
||
"negative": ""})
|
||
lines.append(f"{s['name']} is fixed values: quality words are the same every run")
|
||
for f in list(graph["fixed"]):
|
||
if _negative_name(f["name"], f["var"]):
|
||
# Dropped by compile_plan like the slot above; the graph kept
|
||
# it in the order, so the template read $neg (9 packs).
|
||
graph["fixed"].remove(f)
|
||
graph["template"]["order"] = [v for v in graph["template"]["order"]
|
||
if v != f["var"]]
|
||
neg = graph["template"]["negative"]
|
||
if f["text"] and _norm(f["text"]) not in _norm(neg):
|
||
graph["template"]["negative"] = f"{neg}, {f['text']}" if neg else f["text"]
|
||
lines.append(f"{f['name']} is the negative prompt, not a preset: its words went "
|
||
"to the template's negative")
|
||
for i in list(graph["inputs"]):
|
||
if _negative_name(i["var"], i["about"]):
|
||
graph["inputs"].remove(i)
|
||
graph["template"]["order"] = [v for v in graph["template"]["order"]
|
||
if v != i["var"]]
|
||
lines.append(f"${i['var']} is a negative, not an input: negatives are the "
|
||
"template's")
|
||
if _PRESET_NEGATIVES.search(request or "") and graph["fixed"] \
|
||
and graph["template"]["negative"] and not any(f["negative"] for f in graph["fixed"]):
|
||
f = graph["fixed"][0]
|
||
f["negative"] = graph["template"]["negative"]
|
||
graph["template"]["negative"] = ""
|
||
lines.append(f"{f['name']} carries its own negative (it was the template's), as asked")
|
||
return lines
|
||
|
||
|
||
# --- the request against the graph -------------------------------------------
|
||
|
||
#: Request words that name no content: function words, builder-speak and
|
||
#: how-often words. The rest ("sandals", "winter", "cabin") must show up
|
||
#: somewhere in the graph.
|
||
_REQUEST_STOP = frozenset("""
|
||
a an the and or but nor not no yes of to in on at by for as is are be been being was
|
||
were do does did done have has had having can could will would shall should may might
|
||
must let lets it its i me my mine we us our you your he she his her they them their
|
||
one ones two three four five six seven eight nine ten this that these those there here
|
||
from into onto over under about around between through across after before during per
|
||
via with within without when where which while than then so if either neither own such
|
||
very just really quite rather also too else out up down off how what why who whom whose
|
||
whether please thanks thank ok okay etc eg ie something anything everything nothing
|
||
some any all both more less most least few many several much lot lots every each other
|
||
another same different various variety varied mix mixed matching match matches matched
|
||
fit fits fitting suit suits suited belong belongs goes go with come comes out keep keeps
|
||
stay stays get gets got give gives given make makes made making create creates created
|
||
generate generates generated build builds built write writes written use uses used using
|
||
want wants wanted need needs needed like likes would prefer include includes included
|
||
including featuring feature features show shows showing look looks looking seem seems
|
||
based draw drawn drawing random randomly chance always never sometimes often rarely rare
|
||
common usually mostly only just about around roughly percent time times once twice
|
||
pack packs prompt prompts image images picture pictures render renders set sets list
|
||
lists option options choose chooses pick picks picked choice choices slot slots variable
|
||
variables rule rules template wildcard wildcards bundle bundles story frame frames sweep
|
||
sheet series sequence loop batch hold held whole same single simple style styles tag
|
||
tags tagged sentence sentences quality negative negatives preset presets model models
|
||
checkpoint checkpoints family families lora loras works work working pony flux sdxl
|
||
illustrious noobai nsfw sfw safe adult explicit way ways thing things stuff bit kind
|
||
kinds type types sort sorts shared type typed myself itself must empty narrow
|
||
none call decide decides decided either any whatever default defaults small large big
|
||
specialized specialised specific general separation separate separated concern concerns
|
||
structure structured section sections held vary varies varying varied each
|
||
add adds added adding drop drops dropped dropping remove removes removed removing replace
|
||
replaces replaced swap swaps swapped change changes changed changing instead rid lose
|
||
bad good better worse happen happens happened happening shot shots
|
||
""".split())
|
||
|
||
|
||
def _stems(word: str) -> set[str]:
|
||
"""The word and the singulars it may be the plural of ("heroes" →
|
||
hero, heroe; "ponies" → pony; "elves" → elf), so either spelling matches. A British
|
||
"-our" also matches its American "-or" ("colours" → color: the shirts
|
||
pack's "colour" was a request gap next to a Color List, 9B smoke
|
||
2026-10-24)."""
|
||
out = {word, word.replace("-", "")}
|
||
if len(word) > 4 and word.endswith("ies"):
|
||
out.add(word[:-3] + "y")
|
||
if len(word) > 3 and word.endswith("es"):
|
||
out.add(word[:-2])
|
||
if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
|
||
out.add(word[:-1])
|
||
if len(word) > 4 and word.endswith("ves"):
|
||
out |= {word[:-3] + "f", word[:-3] + "fe"} # elves → elf, knives → knife
|
||
if len(word) > 5 and "our" in word[2:]:
|
||
out |= {w.replace("our", "or") for w in out}
|
||
return out
|
||
|
||
|
||
def _content_words(text: str) -> list[str]:
|
||
out: list[str] = []
|
||
for w in re.findall(r"[a-z][a-z'-]*[a-z]|[a-z]", text.lower()):
|
||
# "character's" is the character (9B and Sonnet, 2026-10-21: the
|
||
# possessive never matched the graph's "character" and the gap
|
||
# came back on every Fix all).
|
||
w = re.sub(r"'s?$", "", w.strip("'-"))
|
||
if len(w) < 3 or w in _REQUEST_STOP:
|
||
continue
|
||
if w not in out:
|
||
out.append(w)
|
||
return out
|
||
|
||
|
||
def request_gaps(graph: dict[str, Any], request: str) -> list[str]:
|
||
"""The content words of the request that the graph says nowhere: not
|
||
in a slot (name, variable, what), an axis (name, values), a relation
|
||
(words, text, variable), fixed text, the template's lead or tail or
|
||
an input. "no sandals in winter" with no "sandals" anywhere is a design
|
||
gap the build can't repair; the planner gets the list back."""
|
||
if not request.strip():
|
||
return []
|
||
said: list[str] = []
|
||
for s in graph["slots"]:
|
||
said += [s["name"], s["var"], s["what"]]
|
||
for a in s["axes"]:
|
||
said += [a["name"], *a["values"]]
|
||
for r in graph["relations"]:
|
||
said += [*(r.get("words") or []), r.get("text") or "", r.get("var") or ""]
|
||
for f in graph["fixed"]:
|
||
said += [f["name"], f["var"], f["text"]]
|
||
said += [graph["template"]["lead"], graph["template"]["tail"]]
|
||
for i in graph["inputs"]:
|
||
said += [i["var"], i["about"], i["example"]]
|
||
have: set[str] = set()
|
||
for w in re.findall(r"[a-z]+", " ".join(said).lower().replace("_", " ")):
|
||
have |= _stems(w)
|
||
return [w for w in _content_words(request) if not _stems(w) & have][:MAX_GAPS]
|
||
|
||
|
||
#: Gaps past this many mean the request is prose, not a list of things.
|
||
MAX_GAPS = 8
|
||
|
||
|
||
# --- the settings and the user's hand edits, applied by the code ----------
|
||
|
||
#: What a hand edit may lock: slots by variable, relations by signature,
|
||
#: relations the user took out, and template fields.
|
||
LOCK_KEYS = ("slots", "relations", "removed_relations", "template")
|
||
TEMPLATE_FIELDS = ("lead", "order", "tail", "negative")
|
||
|
||
|
||
def parse_locks(raw: Any) -> dict[str, list[str]]:
|
||
out: dict[str, list[str]] = {k: [] for k in LOCK_KEYS}
|
||
if isinstance(raw, dict):
|
||
for k in LOCK_KEYS:
|
||
v = raw.get(k)
|
||
if isinstance(v, list):
|
||
out[k] = list(dict.fromkeys(x for x in v if isinstance(x, str) and x))[:200]
|
||
out["template"] = [f for f in out["template"] if f in TEMPLATE_FIELDS]
|
||
return out
|
||
|
||
|
||
def raw_rel_sig(r: dict[str, Any]) -> str:
|
||
"""A relation's signature in the model's shape, equal to ``_rel_key``
|
||
of its compiled form (and to the manager's ``relSig``)."""
|
||
return "|".join(str(r.get(k) or "") for k in (
|
||
"kind", "from_slot", "from_axis", "from_value", "to_slot", "to_axis", "to_value",
|
||
"var", "text"))
|
||
|
||
|
||
def _sig_like(a: str, b: str) -> bool:
|
||
"""Whether two relation signatures name the same relation: the same
|
||
kind, slots, values, variable and text, case aside, and the same
|
||
axis where both give one (the model may leave an axis blank, the
|
||
value finds it)."""
|
||
pa, pb = a.lower().split("|"), b.lower().split("|")
|
||
if len(pa) != 9 or len(pb) != 9:
|
||
return a == b
|
||
for n, (x, y) in enumerate(zip(pa, pb, strict=True)):
|
||
if x.strip() == y.strip():
|
||
continue
|
||
if n in (2, 5) and (not x.strip() or not y.strip()):
|
||
continue # an axis one side left blank
|
||
return False
|
||
return True
|
||
|
||
|
||
def apply_locks(raw: dict[str, Any], previous: dict[str, Any],
|
||
locks: dict[str, list[str]]) -> list[str]:
|
||
"""Put what the user set by hand (``locks``, parts of ``previous``'s
|
||
graph) back over a new model answer ``raw`` (the model's shape, edited
|
||
in place): locked slots replace the answer's by variable (added when
|
||
the answer dropped them), locked relations are added when missing,
|
||
removed ones are taken out, locked template fields are copied. Returns
|
||
the lines saying what was kept."""
|
||
kept: list[str] = []
|
||
prev = graph_answer({"graph": previous})
|
||
slots = _got if isinstance(_got := raw.get("slots"), list) else []
|
||
raw["slots"] = slots
|
||
for var in locks["slots"]:
|
||
mine = next((s for s in prev["slots"] if s["var"] == var), None)
|
||
if mine is None:
|
||
continue
|
||
at = next((i for i, s in enumerate(slots)
|
||
if isinstance(s, dict) and (_var(s.get("var")) == var
|
||
or str(s.get("name") or "").lower()
|
||
== mine["name"].lower())), -1)
|
||
if at >= 0:
|
||
slots[at] = dict(mine)
|
||
else:
|
||
slots.append(dict(mine))
|
||
kept.append(mine["name"])
|
||
rels = [r for r in (raw.get("relations") if isinstance(raw.get("relations"), list) else [])
|
||
if isinstance(r, dict)]
|
||
gone = set(locks["removed_relations"])
|
||
rels = [r for r in rels if not any(_sig_like(raw_rel_sig(r), g) for g in gone)]
|
||
have = {raw_rel_sig(r) for r in rels}
|
||
added = 0
|
||
for sig in locks["relations"]:
|
||
mine = next((r for r in prev["relations"] if raw_rel_sig(r) == sig), None)
|
||
if mine is not None and not any(_sig_like(h, sig) for h in have):
|
||
rels.append(dict(mine))
|
||
have.add(sig)
|
||
added += 1
|
||
raw["relations"] = rels
|
||
lines = []
|
||
if kept:
|
||
lines.append("Kept your " + ", ".join(kept) + (" slots" if len(kept) > 1 else " slot"))
|
||
if added:
|
||
lines.append(f"Kept {added} relation{'s' if added > 1 else ''} you set")
|
||
if locks["template"]:
|
||
tpl = _got if isinstance(_got := raw.get("template"), dict) else {}
|
||
for f in locks["template"]:
|
||
tpl[f] = prev["template"][f]
|
||
raw["template"] = tpl
|
||
lines.append("Kept the template " + ", ".join(locks["template"]) + " you set")
|
||
return lines
|
||
|
||
|
||
def apply_settings(graph: dict[str, Any], settings: Any) -> list[str]:
|
||
"""The pack settings landed on the compiled graph, so nothing the model
|
||
wrote contradicts them: style and rating when set, the option count as
|
||
a cap on every slot, no sections for a flat pack. Returns the lines
|
||
saying what changed."""
|
||
cfg = parse_settings(settings)
|
||
lines: list[str] = []
|
||
if cfg["style"] != "auto" and graph["style"] != cfg["style"]:
|
||
graph["style"] = cfg["style"]
|
||
lines.append(f"Style {cfg['style']}, as set")
|
||
if cfg["rating"] != "auto" and graph["rating"] != cfg["rating"]:
|
||
graph["rating"] = cfg["rating"]
|
||
lines.append(f"Rated {cfg['rating']}, as set")
|
||
if cfg["count"]:
|
||
capped = []
|
||
for s in graph["slots"]:
|
||
if s["count"] > cfg["count"] and not s.get("self_axis"):
|
||
s["count"] = cfg["count"]
|
||
capped.append(s["name"])
|
||
if capped:
|
||
lines.append(f"{cfg['count']} options: capped " + ", ".join(capped))
|
||
if cfg["structure"] == "flat" and (graph["sections"] or any(s["section"]
|
||
for s in graph["slots"])):
|
||
graph["sections"] = []
|
||
for s in graph["slots"]:
|
||
s["section"] = ""
|
||
lines.append("One bundle: no sections")
|
||
return lines
|
||
|
||
|
||
def locks_note(plan: dict[str, Any]) -> str:
|
||
"""What a refine tells the model about the parts the user set by hand."""
|
||
locks = parse_locks(plan.get("locks"))
|
||
g = _got if isinstance(_got := plan.get("graph"), dict) else None
|
||
if g is None or not any(locks[k] for k in LOCK_KEYS):
|
||
return ""
|
||
bits = []
|
||
names = [s["name"] for s in g["slots"] if s["var"] in locks["slots"]]
|
||
if names:
|
||
bits.append("the slots " + ", ".join(names))
|
||
if locks["relations"]:
|
||
bits.append(f"{len(locks['relations'])} relation(s)")
|
||
if locks["removed_relations"]:
|
||
bits.append(f"{len(locks['removed_relations'])} relation(s) they removed (don't "
|
||
"write them again)")
|
||
if locks["template"]:
|
||
bits.append("the template " + ", ".join(locks["template"]))
|
||
return ("The user set these by hand and they stay as they are: " + "; ".join(bits)
|
||
+ ". Change the rest only.")
|
||
|
||
|
||
# --- questions the code asks (and the browser answers by code) -------------
|
||
|
||
_STORY_WORDS = re.compile(
|
||
r"\b(story|stories|storyboard|sequence|comic|panels?|frames?|step[- ]by[- ]step|"
|
||
r"progression|episodes?|over (?:a|the) loop)\b", re.I)
|
||
_FRAMES = re.compile(r"\b(\d{1,2})\s*(?:frames?|panels?|steps|images|pictures|scenes)\b", re.I)
|
||
STORY_QUESTION = ("The request reads like a story. Run it as a story over a Loop (one frame "
|
||
"per step, the cast held), or as single images?")
|
||
SWEEP_QUESTION = ("Which slots does the sweep sheet compare? Pick 2 or 3 short ones; the rest "
|
||
"are held.")
|
||
#: A request about a pair of people that never says who they are.
|
||
_PAIR_ASK = re.compile(r"\b(?:couples?|partners?|lovers|pairs? of (?:people|lovers)|two people|"
|
||
r"duos?|romance|romantic)\b", re.I)
|
||
_PAIR_SAID = re.compile(r"\b(?:m[ae]n|wom[ae]n|girls?|boys?|guys?|males?|females?|gay|lesbians?|"
|
||
r"yuri|yaoi|straight|het(?:ero)?|hetero(?:sexual)?|husband|wife|"
|
||
r"boyfriend|girlfriend|1girl|1boy|2girls|2boys|mixed)\b", re.I)
|
||
PAIR_QUESTION = "Who are the two people in the pack?"
|
||
|
||
|
||
def code_questions(graph: dict[str, Any], request: str,
|
||
decisions: Any = None) -> list[dict[str, Any]]:
|
||
"""Questions the code asks when the graph has a fork the request didn't
|
||
settle, each with ``binds`` saying what the answer sets (the browser
|
||
applies it; no model call): the run style when the request reads like
|
||
a story but runs as single images; the sweep axes when a sweep run has
|
||
none marked. One already answered (``decisions``) is not asked again."""
|
||
from engine.ai.pack.settings import DEFAULT_FRAMES, MAX_FRAMES, MIN_FRAMES, parse_decisions
|
||
asked = {d["question"] for d in parse_decisions(decisions)}
|
||
run = parse_run(graph["run"])
|
||
out: list[dict[str, Any]] = []
|
||
if run["mode"] == "single" and _STORY_WORDS.search(request or "") \
|
||
and STORY_QUESTION not in asked:
|
||
m = _FRAMES.search(request or "")
|
||
frames = max(MIN_FRAMES, min(MAX_FRAMES, int(m.group(1)))) if m else DEFAULT_FRAMES
|
||
out.append({"header": "Story or single", "question": STORY_QUESTION, "multi": False,
|
||
"choices": [f"A story of {frames} frames", "Single images"],
|
||
"binds": {"set": "run", "frames": frames}})
|
||
prompt = [s for s in graph["slots"] if s["show"] == "prompt"]
|
||
if run["mode"] == "sweep" and len(prompt) >= 3 and not any(s.get("sweep") for s in prompt) \
|
||
and SWEEP_QUESTION not in asked:
|
||
out.append({"header": "Sweep axes", "question": SWEEP_QUESTION, "multi": True,
|
||
"choices": [s["name"] for s in prompt][:8], "binds": {"set": "sweep"}})
|
||
# A couples pack that never says who the couple is: the planner took
|
||
# both partners as women from the library's tags (9B bench Z17,
|
||
# 2026-10-26). Asked, never guessed; the answer goes to the planner.
|
||
if _PAIR_ASK.search(request or "") and not _PAIR_SAID.search(request or "") \
|
||
and PAIR_QUESTION not in asked:
|
||
out.append({"header": "The couple", "question": PAIR_QUESTION, "multi": True,
|
||
"choices": ["A woman and a man", "Two women", "Two men",
|
||
"Any of these, varied"]})
|
||
return out
|
||
|
||
|
||
_LOOP_ASK = re.compile(
|
||
r"\b(?:every|each|all)\s+(?:image|frame|panel|picture|shot)s?\s+(?:of|in|over)\s+"
|
||
r"(?:a|the)\s+loop\b|\b(?:over|across|in)\s+(?:a|the)\s+loop\b", re.I)
|
||
|
||
|
||
def loop_run(graph: dict[str, Any], request: str, decisions: Any = None) -> list[str]:
|
||
"""A first answer that runs single images when the request asks for a
|
||
Loop ("the same hero in every image of a loop") runs as a story
|
||
instead, so what the request holds stays held (single images clear
|
||
every hold). Not when the user already answered the story question."""
|
||
from engine.ai.pack.settings import DEFAULT_FRAMES, MAX_FRAMES, MIN_FRAMES, parse_decisions
|
||
if parse_run(graph["run"])["mode"] != "single" or not _LOOP_ASK.search(request) \
|
||
or any(d["question"] == STORY_QUESTION for d in parse_decisions(decisions)):
|
||
return []
|
||
m = _FRAMES.search(request)
|
||
frames = max(MIN_FRAMES, min(MAX_FRAMES, int(m.group(1)))) if m else DEFAULT_FRAMES
|
||
graph["run"] = {"mode": "story", "frames": frames}
|
||
return [f"Runs as a story of {frames} frames: the request asks for a Loop"]
|
||
|
||
|
||
def compile_graph_plan(raw: Any, **kw: Any) -> dict[str, Any]:
|
||
"""A graph answer as a plan (``compile_plan`` on its items), carrying
|
||
the graph as ``plan["graph"]``, the settings applied to it
|
||
(``apply_settings``), the user's hand edits kept over a new answer
|
||
(``apply_locks``, from ``previous["locks"]``) and ``applied``, the
|
||
lines saying what the code did. ``kw`` goes to ``compile_plan``
|
||
(library, previous, decisions, settings, base, canvas)."""
|
||
from engine.ai.pack.plan import compile_plan
|
||
sent_back = isinstance(raw, dict) and isinstance(raw.get("graph"), dict)
|
||
graph = raw["graph"] if sent_back else raw
|
||
previous = _got if isinstance(_got := kw.get("previous"), dict) else None
|
||
for field in ("settings", "decisions"):
|
||
# A caller that leaves them out means the plan's own (a graph plan's
|
||
# items never carry them, so ``compile_plan`` can't fall back).
|
||
if kw.get(field) is None:
|
||
kw[field] = (raw.get(field) if sent_back else None) \
|
||
or (previous.get(field) if previous else None)
|
||
locks = parse_locks(raw.get("locks") if sent_back else
|
||
previous.get("locks") if previous else None)
|
||
applied: list[str] = []
|
||
if not sent_back and previous is not None and isinstance(previous.get("graph"), dict) \
|
||
and isinstance(graph, dict) and not _compiled(graph):
|
||
applied += apply_locks(graph, previous["graph"], locks)
|
||
graph = compile_graph(graph)
|
||
if isinstance(raw, dict) and isinstance(raw.get("graph"), dict) \
|
||
and isinstance(raw.get("run"), dict):
|
||
# A plan sent back: "Runs as" is edited on the plan, so it wins
|
||
# over what the graph carried.
|
||
graph["run"] = parse_run(raw["run"], graph["run"])
|
||
elif isinstance(kw.get("previous"), dict) and isinstance(kw["previous"].get("run"), dict):
|
||
# A refine: how the pack runs is the user's setting by now, never
|
||
# re-decided by the model's next answer.
|
||
graph["run"] = parse_run(kw["previous"]["run"], graph["run"])
|
||
else:
|
||
applied += loop_run(graph, kw.get("request") or "", kw.get("decisions"))
|
||
applied += apply_settings(graph, kw.get("settings"))
|
||
applied += code_beat(graph)
|
||
applied += apply_picks(graph, kw.get("request") or "", locks["slots"])
|
||
applied += drop_multi_pick_rules(graph)
|
||
applied += apply_request_slots(graph, kw.get("request") or "")
|
||
from engine.ai.pack.request_needs import apply_request_needs
|
||
applied += apply_request_needs(graph, kw.get("request") or "")
|
||
applied += drop_model_slots(graph)
|
||
applied += drop_writing_slots(graph, locks["slots"])
|
||
applied += trim_model(graph, kw.get("request") or "")
|
||
if model_quality(graph, kw.get("settings")):
|
||
own = [f["name"] for f in graph["fixed"] if f["var"] == QUALITY_VAR]
|
||
if own:
|
||
# The per-model-family rule writes $quality; a fixed value of
|
||
# the same name would be written over every run. Its words
|
||
# become the rule's "otherwise" (``compile_item``).
|
||
graph["quality_words"] = next(f["text"] for f in graph["fixed"]
|
||
if f["var"] == QUALITY_VAR)
|
||
graph["fixed"] = [f for f in graph["fixed"] if f["var"] != QUALITY_VAR]
|
||
applied.append(", ".join(own) + ": the per-model-family rule writes $quality, so "
|
||
"the fixed value went")
|
||
if parse_run(graph["run"])["mode"] == "single":
|
||
# A hold only means something over a Loop: in single images it
|
||
# just makes every prompt the same.
|
||
held = [s["name"] for s in graph["slots"] if s["hold"]]
|
||
for s in graph["slots"]:
|
||
s["hold"] = False
|
||
if held:
|
||
applied.append("Single images: nothing held (" + ", ".join(held) + " vary)")
|
||
lead, gone = unasked_lead(graph["template"]["lead"], kw.get("request") or "")
|
||
if gone:
|
||
graph["template"]["lead"] = lead
|
||
applied.append("Took \"" + ", ".join(gone) + "\" out of the lead: the request names "
|
||
"no person")
|
||
applied += apply_library(graph, kw.get("library"), kw.get("settings"))
|
||
applied += show_unread(graph)
|
||
_story_holds(graph["slots"], parse_run(graph["run"]), graph["warnings"])
|
||
if graph["anchor"] not in {s["var"] for s in graph["slots"]}:
|
||
# The code took the anchor's slot out (a Quality slot now fixed
|
||
# values, a Beat slot the story owns): the first prompt pick leads.
|
||
graph["anchor"] = next((s["var"] for s in graph["slots"] if s["show"] == "prompt"), "")
|
||
plan = compile_plan(graph_plan(graph, kw.get("settings"), kw.get("library")), **kw)
|
||
plan["graph"] = {k: v for k, v in graph.items() if k not in ("warnings", "bad_refs")}
|
||
plan["locks"] = locks
|
||
plan["applied"] = applied
|
||
plan["questions"] = [*plan["questions"],
|
||
*code_questions(graph, kw.get("request") or "", plan["decisions"])]
|
||
plan["warnings"] = [*graph["warnings"],
|
||
*(plain_ref(b) for b in [*graph["dropped"], *graph["bad_refs"]]),
|
||
*plan["warnings"]]
|
||
plan["bad_refs"] = graph["bad_refs"]
|
||
# Request words the graph says nowhere, and the ones a refine was
|
||
# already asked about and left unplaced: the review asks once, then
|
||
# stops calling them a fix (Sonnet 5.5 and the 9B, 2026-10-21: "give
|
||
# each a slot" came back on every Fix all).
|
||
prior = previous if previous is not None else (raw if sent_back else None)
|
||
gaps = request_gaps(graph, kw.get("request") or "")
|
||
before = set(prior.get("request_gaps") or []) if isinstance(prior, dict) else set()
|
||
tried = set(prior.get("gaps_tried") or []) if isinstance(prior, dict) else set()
|
||
if previous is not None:
|
||
tried |= before & set(gaps)
|
||
plan["request_gaps"] = gaps
|
||
plan["gaps_tried"] = sorted(tried & set(gaps))
|
||
return plan
|
||
|
||
|
||
def graph_answer(plan: dict[str, Any]) -> dict[str, Any]:
|
||
"""The plan's graph in the shape the model wrote it, to replay a
|
||
hand-edited plan as the model's last answer."""
|
||
g = plan["graph"]
|
||
rels = []
|
||
for r in g["relations"]:
|
||
dst = r.get("to") or {}
|
||
rels.append({"kind": r["kind"], "from_slot": r["from"]["slot"],
|
||
"from_axis": r["from"]["axis"], "from_value": r["from"]["value"],
|
||
"to_slot": dst.get("slot", ""), "to_axis": dst.get("axis", ""),
|
||
"to_value": dst.get("value", ""), "factor": r.get("factor", 0),
|
||
"var": r.get("var", ""), "text": r.get("text", ""),
|
||
"otherwise": r.get("otherwise", ""), "words": list(r.get("words") or [])})
|
||
var_name = {s["var"]: s["name"] for s in g["slots"]}
|
||
var_name.update({f["var"]: f["name"] for f in g["fixed"]})
|
||
return {k: g[k] for k in ("title", "summary", "design", "rating", "style", "model",
|
||
"anchor", "questions", "assumptions", "from_images", "research",
|
||
"run", "inputs", "sections", "slots", "fixed")} | {
|
||
"relations": rels,
|
||
"template": {**g["template"], "order": [var_name.get(v, v)
|
||
for v in g["template"]["order"]]}}
|
||
|
||
|
||
def refs_schema(graph: dict[str, Any]) -> dict[str, Any]:
|
||
"""The relation schema with every slot, axis and value field an enum of
|
||
what the graph declares: the repair call for ``bad_refs`` can only
|
||
write references that resolve."""
|
||
slots = [s["name"] for s in graph["slots"]]
|
||
axes = sorted({a["name"] for s in graph["slots"] for a in s["axes"]}) or [""]
|
||
values = sorted({v for s in graph["slots"] for a in s["axes"] for v in a["values"]}) or [""]
|
||
rel = _obj({
|
||
"kind": {"type": "string", "enum": list(RELATIONS)},
|
||
"from_slot": {"type": "string", "enum": slots},
|
||
"from_axis": {"type": "string", "enum": axes},
|
||
"from_value": {"type": "string", "enum": [*values, ""]},
|
||
"to_slot": {"type": "string", "enum": [*slots, ""]},
|
||
"to_axis": {"type": "string", "enum": [*axes, ""]},
|
||
"to_value": {"type": "string", "enum": [*values, ""]},
|
||
"factor": {"type": "number"},
|
||
"var": _STR,
|
||
"text": _STR,
|
||
"otherwise": _STR,
|
||
"words": _STRS,
|
||
})
|
||
return _obj({"relations": {"type": "array", "items": rel}})
|
||
|
||
|
||
def refs_prompt(graph: dict[str, Any]) -> str:
|
||
"""The repair ask: the dropped relations and what the graph declares."""
|
||
lines = ["These relations named a slot, axis or value the graph doesn't declare and "
|
||
"were dropped:"]
|
||
lines += [f"- {b}" for b in graph["bad_refs"]]
|
||
lines.append("The graph declares:")
|
||
for s in graph["slots"]:
|
||
axes = "; ".join(f"{a['name']}: {', '.join(a['values'])}" for a in s["axes"]) \
|
||
or "no axes"
|
||
lines.append(f"- {s['name']} (${s['var']}): {axes}")
|
||
lines.append("Write each dropped relation again using only those names, or leave it out "
|
||
"when none fits. Answer with the relations to add.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def with_refs(graph: dict[str, Any], answer: Any) -> dict[str, Any]:
|
||
"""``graph`` with the repair call's relations added (re-checked like
|
||
the rest; what still doesn't resolve stays out)."""
|
||
extra = answer.get("relations") if isinstance(answer, dict) else None
|
||
if not isinstance(extra, list) or not extra:
|
||
return graph
|
||
raw = graph_answer({"graph": graph})
|
||
raw["relations"] = [*raw["relations"], *extra]
|
||
raw["dropped"] = list(graph.get("dropped") or [])
|
||
fixed = compile_graph(raw)
|
||
if len(fixed["relations"]) - len(graph["relations"]) < len(graph["bad_refs"]):
|
||
# Fewer came back than were asked for: the ones not written again
|
||
# stay listed as left out (which ones, the messages can't say).
|
||
fixed["dropped"] = list(dict.fromkeys([*fixed["dropped"], *graph["bad_refs"]]))
|
||
fixed["warnings"] = graph.get("warnings", [])
|
||
return fixed
|