Files
EricRollei-Local_LLM_Prompt…/core/variability/variation.py
T
Eric HissandClaude Fable 5.1 4459af9ea6 V2.0.0: wildcard engine, platform profiles, MiniMax H3 default, API models
Every node id, input name and existing widget position is unchanged, so saved
workflows load; new inputs are appended only.

Wildcards
- core/wildcards: Dynamic Prompts / Impact Pack grammar, tag queries, variables,
  conditionals, concept / verbatim / loose bindings, reproducible seeds.
  Wired into every enhancer node; reads ComfyUI/models/wildcards.
- New nodes: Wildcard Processor, Semantic Variation (direction axes),
  Concept Survival Check. Starter lists in wildcards/.
- Random choices use a private generator; global random state is untouched.

Platform profiles
- core/profiles + profiles/*.yaml: one file per model (format, length,
  negative prompt or none, quality tokens, text and dialogue syntax, vendor
  rewriter prompt fetched once and cached). profiles_user/ overrides by id.
- New node: Platform Prompt Rewriter (prose, tags, edit instructions, JSON,
  MiniMax H3 fields, Hailuo brackets), with validators for each format.
- Text-to-Image and Image-to-Image read profiles through platforms.py;
  corrected facts for FLUX, Kontext, Chroma, Wan, AuraFlow, Kolors, NoobAI,
  Qwen-Image, HunyuanImage. Negative prompt is empty for models without one.
- Hosted / API models: Seedream 5, Nano Banana, GPT Image, Midjourney V8,
  FLUX.2 pro, Ideogram 3, Recraft V4, Seedance 2.0 / 2.5, Kling 3.0, Veo 3.1,
  Gemini Omni Flash, Runway, Wan 3.0, Luma, hosted H3, Grok, Vidu, PixVerse.
  Reports say how to switch the vendor's own prompt rewriting off.
- Illustrious: spaced tags, quality tags last, 25-80 words (OnomaAI paper).

Video nodes
- target_model and duration_seconds appended to the three video expanders.
  Default target is MiniMax H3; wan22_video is the original path, unchanged.
  A workflow saved earlier loads with the default until the input is set.

Also included: the Alice, Ideogram Magic Prompt and prompt-database nodes that
were already registered in __init__.py but had not been committed.

Fixes: v1 {animal} / {label:a|b} wildcards were inert in the video nodes;
Image-to-Video save_to_file raised NameError; T2I padded short-prompt models.

.gitattributes added (text=auto) so CRLF working files stop showing as
whole-file changes. prompt_database.db and the cached Ideogram prompt are
git-ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VpCi6nw5UECMdZ7ayYJvN
2026-09-21 00:28:58 -07:00

233 lines
11 KiB
Python

"""
Semantic variation of a wildcard prompt.
The idea (Eric's): keep the meaning of a prompt, but let most of its ideas drift a little and a
few of them jump, in embedding space - then let an LLM rewrite the result. A prompt is not one
vector, and single dimensions of a dense embedding mean nothing on their own, so the drift is
applied per SLOT: every wildcard or {a|b} choice in the prompt is a slot, its list is the set of
real phrases it may land on, and "decoding" a moved vector is simply choosing the list entry
that sits at the wanted distance. The output is always real language.
base pass resolve the prompt with `seed` -> the picks a plain run would make
varied pass same seed, but each top-level pick is moved to a semantic neighbour in its own list
strength 0.1 = one of the nearest few ... 1.0 = one of the farthest few
`far_jumps` slots, chosen by the variation number, jump with `far_strength` instead
avoid_recent make several candidate variations, embed each whole prompt, keep the one least
similar to what was generated recently, and remember it
`variation` numbers are reproducible: same text + seed + variation + settings + lists + embedder
gives the same result (with avoid_recent on, the memory is part of the state by design).
(c) Eric Hiss. Dual license, see LICENSE.
"""
from __future__ import annotations
import hashlib
import random
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from .directions import NONE, DirectionAxis
from .embedders import Embedder
from .memory import RepulsionMemory
from .moves import direction, leaning_pick, max_similarity, neighbour_pick
from .vector_cache import VectorCache
@dataclass
class VariationSettings:
variation: int = 1 # 0 = the base prompt, untouched
strength: float = 0.35 # drift applied to every slot
far_jumps: int = 0 # how many slots jump instead of drifting
far_strength: float = 0.9
avoid_recent: bool = False
attempts: int = 4 # candidate variations compared when avoid_recent is on
recent_window: int = 50 # how many remembered outputs to steer away from
max_candidates: int = 1500 # lists longer than this are sampled (stable sample, base always kept)
direction: str = NONE # name of a direction axis (variability/directions.yaml), or "none"
direction_amount: float = 0.0 # > 0 leans every move toward the axis's `to` side, < 0 toward `from`
@dataclass
class SlotMove:
source: str
key: str
base: str
varied: str
rank: int
ranked: int
cosine: float
jumped: bool
lean: Optional[float] = None # movement along the direction axis; None when no direction was used
@dataclass
class VariationResult:
resolution: Any # core.wildcards Resolution of the chosen variation
base_text: str
moves: List[SlotMove] = field(default_factory=list)
embedder: str = ""
attempts_tried: int = 1
similarity_to_recent: Optional[float] = None
newly_embedded: int = 0
notes: List[str] = field(default_factory=list)
def report(self) -> str:
lines = [f"embedder: {self.embedder}"]
if not self.moves:
lines.append("no slots moved")
for move in self.moves:
tag = "JUMP " if move.jumped else "drift"
name = move.key or move.source
if move.base == move.varied:
lines.append(f" {tag} {name}: {move.base} (unchanged)")
else:
lean = "" if move.lean is None else f", lean {move.lean:+.3f}"
lines.append(f" {tag} {name}: {move.base} -> {move.varied} "
f"[neighbour {move.rank} of {move.ranked}, cos {move.cosine:.2f}{lean}]")
if self.similarity_to_recent is not None:
lines.append(f"avoid_recent: best of {self.attempts_tried} candidates, "
f"closest remembered output at cos {self.similarity_to_recent:.2f}")
if self.newly_embedded:
lines.append(f"embedded {self.newly_embedded} new phrases (cached for next time)")
lines.extend(self.notes)
return "\n".join(lines)
def _stable_subset(candidates: List[str], keep: List[int], limit: int) -> List[int]:
"""Indices of at most `limit` candidates: always `keep`, the rest chosen by text hash (stable across runs)."""
if len(candidates) <= limit:
return list(range(len(candidates)))
by_hash = sorted(range(len(candidates)), key=lambda i: hashlib.md5(candidates[i].encode("utf-8")).hexdigest())
chosen = list(dict.fromkeys(list(keep) + by_hash))[:limit]
return sorted(chosen)
class SemanticVariator:
def __init__(self, engine: Any, embedder: Embedder, cache_dir: str,
directions: Optional[Dict[str, DirectionAxis]] = None):
self.engine = engine
self.embedder = embedder
self.cache = VectorCache(embedder, cache_dir)
self.memory = RepulsionMemory(cache_dir)
self.directions: Dict[str, DirectionAxis] = directions or {}
self._direction_vectors: Dict[str, np.ndarray] = {}
def direction_vector(self, name: str) -> Optional[np.ndarray]:
"""Unit vector of a named axis in this embedder's space, or None for "none" / unknown names."""
if not name or name == NONE or name not in self.directions:
return None
if name not in self._direction_vectors:
axis = self.directions[name]
self._direction_vectors[name] = direction(self.cache.vectors(axis.end), self.cache.vectors(axis.start))
return self._direction_vectors[name]
# -- one varied pass -------------------------------------------------------------
def _varied_pass(self, text: str, seed: Optional[int], settings: VariationSettings, attempt: int,
base_picks: List[Tuple[int, List[int]]], process_kwargs: Dict[str, Any]) -> Tuple[Any, List[SlotMove]]:
variation_rng = random.Random(f"{seed}|{settings.variation}|{attempt}")
slot_count = len(base_picks)
jump_slots = set(variation_rng.sample(range(slot_count), min(max(0, settings.far_jumps), slot_count)))
axis_vector = self.direction_vector(settings.direction) if settings.direction_amount else None
moves: List[SlotMove] = []
ordinal = {"value": -1}
def hook(context):
if context.depth != 0:
return None
ordinal["value"] += 1
position = ordinal["value"]
# Start from the BASE pass's pick for this slot, so an earlier slot that changed (and drew a
# different amount of randomness) cannot knock later slots off their base picks.
chosen = list(context.chosen)
if position < len(base_picks):
expected_count, expected_chosen = base_picks[position]
if expected_count == len(context.candidates) and len(expected_chosen) == len(chosen):
chosen = list(expected_chosen)
jumped = position in jump_slots
strength = settings.far_strength if jumped else settings.strength
if strength <= 0 or len(context.candidates) < 2:
return chosen
subset = _stable_subset(context.candidates, chosen, settings.max_candidates)
vectors = self.cache.vectors([context.candidates[i] for i in subset])
local = {original: row for row, original in enumerate(subset)}
result: List[int] = []
for base_index in chosen:
taken = [local[i] for i in result] + [local[i] for i in chosen if i != base_index]
lean: Optional[float] = None
if axis_vector is not None:
picked_row, rank, ranked, cosine, lean = leaning_pick(
vectors, local[base_index], axis_vector, settings.direction_amount, strength,
variation_rng, exclude=taken)
else:
picked_row, rank, ranked, cosine = neighbour_pick(
vectors, local[base_index], strength, variation_rng, exclude=taken)
picked = subset[picked_row]
result.append(picked)
moves.append(SlotMove(
source=context.source, key=context.key,
base=context.candidates[base_index], varied=context.candidates[picked],
rank=rank, ranked=ranked, cosine=cosine, jumped=jumped, lean=lean,
))
return result
resolution = self.engine.process(text, seed=seed, pick_hook=hook, **process_kwargs)
return resolution, moves
# -- public ------------------------------------------------------------------------
def vary(self, text: str, seed: Optional[int], settings: VariationSettings, **process_kwargs: Any) -> VariationResult:
base_picks: List[Tuple[int, List[int]]] = []
def recorder(context):
if context.depth == 0:
base_picks.append((len(context.candidates), list(context.chosen)))
return None
base = self.engine.process(text, seed=seed, pick_hook=recorder, **process_kwargs)
result = VariationResult(resolution=base, base_text=base.text, embedder=self.embedder.describe())
nothing_to_do = settings.variation == 0 or (settings.strength <= 0 and settings.far_jumps <= 0)
if settings.direction and settings.direction != NONE and settings.direction_amount:
if settings.direction in self.directions:
axis = self.directions[settings.direction]
side = "to" if settings.direction_amount > 0 else "from"
result.notes.append(f"direction: {axis.name} ({axis.description}), amount {settings.direction_amount:+.2f} -> leaning toward its `{side}` side")
else:
result.notes.append(f"direction '{settings.direction}' is not defined in variability/directions.yaml; ignored")
if nothing_to_do or not base_picks:
if not base_picks:
result.notes.append("the prompt has no wildcard or {a|b} slots, so there is nothing to vary")
return result
embedded_before = self.cache.embedded_this_session
attempts = max(1, settings.attempts) if settings.avoid_recent else 1
recent = self.memory.recent_vectors(self.embedder.name, settings.recent_window) if settings.avoid_recent else None
best: Optional[Tuple[float, Any, List[SlotMove], Any]] = None
for attempt in range(attempts):
resolution, moves = self._varied_pass(text, seed, settings, attempt, base_picks, process_kwargs)
if not settings.avoid_recent:
best = (0.0, resolution, moves, None)
break
vector = self.cache.embedder.encode([resolution.text])[0]
closeness = max_similarity(vector, recent)
if best is None or closeness < best[0]:
best = (closeness, resolution, moves, vector)
closeness, resolution, moves, vector = best
result.resolution = resolution
result.moves = moves
result.attempts_tried = attempts
result.newly_embedded = self.cache.embedded_this_session - embedded_before
if settings.avoid_recent:
result.similarity_to_recent = closeness
self.memory.remember(self.embedder.name, resolution.text, vector)
return result