Files
DumiFlex-ComfyUI-Wildcard-P…/engine/negatives.py
T
Dumitru Picalau 8d0fa337b2 chore: fixes from the 2026-10-21 review (#93)
Shorter release notes, SeedList help, small-model docs; _chat_turn split and pyright in CI; ESLint a11y ratchet and Modal focus trap; tags/models/Image Filter tests; AI LAN warning, web allow expiry, one thinking setting per answer on local models, recommended hints and Reset to recommended (16k context minimum, 32k recommended); Assembler Clear template clears the negative; Vue 3.5.44, SECURITY and Dependabot.
2026-10-22 06:15:13 +02:00

302 lines
10 KiB
Python

"""Per-variable negative prompts (schema v8, send-to-negative).
A wildcard option, a fixed value, a combine and a derivation "Add to
negative" action can carry negative text. The engine files it under the
variable it belongs to, in ``ctx["__wp_negatives__"]``:
{binding: [{"text": str, "pick": int | None, "source": str}, ...]}
``pick`` is the multi-pick slot the entry belongs to (``None`` = the whole
value), so ``$props.1`` can carry only pick 1's negative while ``$props`` and
``$props.AXIS`` carry every pick's. Text is stored already resolved.
Negatives follow USAGE: an Assembler only emits the negatives of variables
its positive template actually rendered. A combine (and a derivation value)
inherits the negatives of the variables its text read.
Seeds never shift: negative text resolves on its own stream,
``derive_module_rng(seed, key + "::neg")``, and in QUIET mode (see
:func:`quiet_resolve`) so an ``@{ref}`` inside a negative binds nothing,
counts no constraint hits and writes nothing to the Test Runner ref log.
Pure engine module: no ComfyUI imports.
"""
from __future__ import annotations
import dataclasses
import random
import re
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager
from typing import Any
NEG_KEY = "__wp_negatives__"
#: Bindings whose negatives a handler set during the current module. The
#: pipeline clears the negatives of every OTHER binding the module wrote
#: (a write replaces the value, so it replaces what the value carried).
TOUCHED_KEY = "__wp_neg_touched__"
Entry = dict[str, Any]
def neg_rng(seed: int, key: str) -> random.Random:
"""The negative stream for a module keyed ``key`` under ``seed``."""
from engine.modules._seed import derive_module_rng # noqa: PLC0415 - cycle
return derive_module_rng(int(seed), f"{key}::neg")
def clean_negative(raw: Any) -> str:
"""A payload ``negative`` field as a stripped string ('' when absent)."""
return raw.strip() if isinstance(raw, str) else ""
def table(ctx: dict[str, Any]) -> dict[str, list[Entry]]:
got = ctx.get(NEG_KEY)
if not isinstance(got, dict):
got = {}
ctx[NEG_KEY] = got
return got
def get_entries(ctx: Any, binding: str) -> list[Entry]:
if not isinstance(ctx, dict):
return []
got = ctx.get(NEG_KEY)
if not isinstance(got, dict):
return []
rows = got.get(binding)
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
def set_entries(
ctx: Any, binding: str, entries: Iterable[Entry], *, add: bool = False,
) -> None:
"""Replace (default) or extend ``binding``'s negatives and mark it touched
so the pipeline's write step keeps them."""
if not isinstance(ctx, dict) or not binding:
return
rows = [e for e in entries if isinstance(e, dict) and str(e.get("text", "")).strip()]
tbl = table(ctx)
if add:
rows = get_entries(ctx, binding) + rows
if rows:
tbl[binding] = rows
else:
tbl.pop(binding, None)
touched = ctx.get(TOUCHED_KEY)
if not isinstance(touched, list):
touched = []
ctx[TOUCHED_KEY] = touched
if binding not in touched:
touched.append(binding)
def settle_writes(ctx: dict[str, Any], written: Iterable[str]) -> None:
"""After a module's bindings land: a written binding the module did not
give negatives to loses its old ones (the value was replaced)."""
touched = ctx.pop(TOUCHED_KEY, None)
touched = set(touched) if isinstance(touched, list) else set()
tbl = ctx.get(NEG_KEY)
if not isinstance(tbl, dict):
return
for key in written:
if key not in touched:
tbl.pop(key, None)
def copy_from(ctx: Any, source: Any, bindings: Iterable[str]) -> None:
"""Held modules reuse their frame-0 value verbatim; reuse its negatives."""
for b in bindings:
set_entries(ctx, b, [dict(e) for e in get_entries(source, b)])
def entries_for_reads(ctx: Any, reads: Iterable[tuple[str, Any]]) -> list[Entry]:
"""The negatives a text inherits from the ``$var`` reads it made.
``(name, None)`` takes every entry; ``(name, K)`` takes the entries of
pick K plus the whole-value ones. Order follows the reads; an entry is
taken once even when read twice. Inherited entries lose their pick slot:
they now belong to the reader as a whole.
"""
out: list[Entry] = []
seen: set[tuple[str, int]] = set()
for name, index in reads:
for i, e in enumerate(get_entries(ctx, name)):
pick = e.get("pick")
if index is not None and pick is not None and pick != index:
continue
if (name, i) in seen:
continue
seen.add((name, i))
out.append({**e, "pick": None})
return out
class Collector:
"""What one resolve touched: ``$var`` reads and nested-ref option picks."""
def __init__(self) -> None:
self.reads: list[tuple[str, Any]] = []
self.ref_negatives: list[str] = []
@contextmanager
def collecting(resolve_ctx: Any) -> Iterator[Collector]:
"""Point a resolve context's read/ref hooks at a fresh collector for the
duration of the block. Works on any ResolveContext; one without the hook
fields simply collects nothing."""
col = Collector()
has = hasattr(resolve_ctx, "_reads") and hasattr(resolve_ctx, "_ref_negs")
if not has:
yield col
return
saved = (resolve_ctx._reads, resolve_ctx._ref_negs)
resolve_ctx._reads = col.reads
resolve_ctx._ref_negs = col.ref_negatives
try:
yield col
finally:
resolve_ctx._reads, resolve_ctx._ref_negs = saved
def quiet_resolve(text: str, resolve_ctx: Any, rng: random.Random) -> str:
"""Resolve negative text without side effects on the positive run.
Draws from ``rng`` (the negative stream), applies no constraints and
counts no hits, writes no ref log, collects no reads, and ignores the
negatives of options its refs pick (no chaining). Warnings still land.
"""
if not text:
return ""
from engine.syntax import resolve_text # noqa: PLC0415 - import cycle
if dataclasses.is_dataclass(resolve_ctx) and not isinstance(resolve_ctx, type):
quiet: Any = dataclasses.replace(
resolve_ctx,
rng=rng,
_constraints=[],
_hits={},
_ref_log=None,
_reads=None,
_ref_negs=None,
# The negative stream is its own; no per-site children of the
# positive's stream key.
stream_key=None,
)
else:
quiet = resolve_ctx
return resolve_text(text, quiet).strip()
def own_entries(
texts: Iterable[str], resolve_ctx: Any, rng: random.Random,
*, pick: int | None, source: str,
) -> list[Entry]:
"""Quietly resolve each raw negative text into an entry."""
out: list[Entry] = []
for raw in texts:
text = quiet_resolve(raw, resolve_ctx, rng) if raw else ""
if text:
out.append({"text": text, "pick": pick, "source": source})
return out
# ---------------------------------------------------------------------------
# Tag handling (Assembler join + Cleaner dedupe)
# ---------------------------------------------------------------------------
_WEIGHT_WRAP = re.compile(r"^\(+\s*(.*?)\s*(?::\s*[0-9.]+)?\s*\)+$")
def split_tags(text: str) -> list[str]:
"""Split on commas that are not inside (), [] or {}."""
out: list[str] = []
depth = 0
cur: list[str] = []
for ch in text:
if ch in "([{":
depth += 1
elif ch in ")]}" and depth > 0:
depth -= 1
if ch == "," and depth == 0:
out.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
out.append("".join(cur).strip())
return [t for t in out if t]
def tag_key(tag: str) -> str:
"""Comparison key: case-folded, whitespace-collapsed, and a single
``(tag:1.2)`` / ``((tag))`` weight wrap removed so emphasis variants of
one tag compare equal."""
t = " ".join(tag.split()).casefold()
m = _WEIGHT_WRAP.match(t)
if m and "(" not in m.group(1) and ")" not in m.group(1):
t = m.group(1).strip()
return t
def join_unique(texts: Iterable[str], skip: Iterable[str] = ()) -> str:
"""Join negative texts tag by tag, dropping repeats (first one wins) and
any tag whose key is in ``skip``."""
seen = {tag_key(s) for s in skip}
out: list[str] = []
for text in texts:
for tag in split_tags(text):
k = tag_key(tag)
if k in seen:
continue
seen.add(k)
out.append(tag)
return ", ".join(out)
NEGATIVES_SLOT = "$negatives"
_SLOT_RE = re.compile(r"\$negatives(?![A-Za-z0-9_])")
def has_slot(template: str) -> bool:
return bool(_SLOT_RE.search(template or ""))
def render_negative(
template: str, collected: str, resolve_raw: Callable[[str], str],
) -> str:
"""Fill the Assembler's negative template.
``$negatives`` marks where the collected words go. An empty template is
just the collected words; a template without the slot gets them appended.
``resolve_raw`` renders the template's other text (its ``$vars``) without
tidying. Tags the template already contains are dropped from the
collected words so nothing is said twice.
"""
tpl = template or ""
if not tpl.strip():
return collected
pieces = _SLOT_RE.split(tpl)
rendered = [resolve_raw(p) if p else "" for p in pieces]
fixed_tags: list[str] = []
for r in rendered:
fixed_tags.extend(split_tags(r))
words = join_unique([collected], skip=fixed_tags)
if len(pieces) == 1:
base = tidy(rendered[0])
if not words:
return base
return f"{base}, {words}" if base else words
return tidy(words.join(rendered))
_COMMA_RUN = re.compile(r"\s*,(?:\s*,)*\s*")
_WS_RUN = re.compile(r"[ \t]{2,}")
def tidy(text: str) -> str:
"""Collapse whitespace and empty comma slots; trim edge commas."""
out = _WS_RUN.sub(" ", text)
out = _COMMA_RUN.sub(", ", out)
return out.strip().strip(",").strip()