'Awful' / 'Wrong appearance' banked on intrusion semantics; now any rating whose profile reward marks the run a bad OUTCOME (<= -0.25: Missing quality, Missing details+quality, Missing action+quality, Missing details+action+quality) also feeds the negative bank - degradation is visible in the latent, so steering off that attractor is meaningful. Near-miss ratings with positive reward (Missing details +0.35, Missing action +0.05) stay excluded: those latents are mostly-wanted content and banking them would repel future runs from material the user asked for. Decision moves into negative_memory.is_negative_profile (intrusion keys OR reward threshold, robust to future profiles); promoted entries now carry the rating key as provenance for severity-weighted steering later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
6.2 KiB
Python
148 lines
6.2 KiB
Python
"""DynaShift negative latent memory.
|
|
|
|
Per-refinement-key store of generations the user rated as containing something
|
|
UNWANTED (an extra person, wrong appearance, ...). The Scene Chain sampler saves the
|
|
final video latent of every keyed run as a PENDING entry; the rating that scores that
|
|
run either promotes it into the bank (negative ratings) or discards it. At sampling
|
|
time the bank acts as a latent-space negative prompt at CFG=1: see
|
|
samplers._build_dynashift_wrapper for the steering math.
|
|
|
|
This is the latent-space sibling of the Refiner's conditioning-space `bad_dir`:
|
|
bad_dir repels the INPUT (what we ask for), the negative bank repels the OUTPUT
|
|
(what actually appeared). Files ride the refinement-key sidecar convention
|
|
(<key>.negative_latents.pt / <key>.pending_negative.pt), so delete_refinement_key's
|
|
stem glob removes them atomically with the key - no orphaned-sidecar drift.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
|
|
import torch
|
|
|
|
# Bank size: enough to cover the recurring failure modes of one key without turning
|
|
# the store into a museum. Oldest entries roll off; keys are disposable anyway.
|
|
MAX_NEGATIVES = 8
|
|
|
|
# V2 rating profile keys whose semantics are "something appeared that I did not want" -
|
|
# these bank regardless of reward (wrong_appearance carries reward 0.0 because it is a
|
|
# repair signal, but it is THE canonical intrusion rating).
|
|
NEGATIVE_RATING_KEYS = ("awful", "wrong_appearance")
|
|
|
|
# Ratings without an intrusion label still bank when the profile reward marks the run a
|
|
# genuinely bad OUTCOME: the quality-missing family (Missing quality -0.25 through
|
|
# Missing details+action+quality -0.65) - degradation is visible in the latent, so
|
|
# steering off that attractor is meaningful. Deliberately excludes the near-miss
|
|
# ratings with positive reward (Missing details +0.35, Missing action +0.05): those
|
|
# latents are mostly-correct content, and banking them would repel future runs from
|
|
# material the user actually wants.
|
|
NEGATIVE_REWARD_THRESHOLD = -0.25
|
|
|
|
|
|
def is_negative_profile(learning_profile):
|
|
"""True when a V2 rating profile should feed the negative bank."""
|
|
if not isinstance(learning_profile, dict):
|
|
return False
|
|
if learning_profile.get("key") in NEGATIVE_RATING_KEYS:
|
|
return True
|
|
try:
|
|
return float(learning_profile.get("reward", 0.0)) <= NEGATIVE_REWARD_THRESHOLD
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _state_path(refinement_key, mode):
|
|
try:
|
|
from .conditioning import refinement_state_path
|
|
except ImportError:
|
|
from conditioning import refinement_state_path
|
|
return refinement_state_path(refinement_key, mode, prefix="refine_v2", extension="pt")
|
|
|
|
|
|
def _atomic_save(payload, path):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
torch.save(payload, tmp)
|
|
os.replace(tmp, path) # a concurrent read never sees a partial write
|
|
|
|
|
|
def save_pending(refinement_key, video_latent, conditioning=None):
|
|
"""Persist this run's final video latent (+ mean-pooled conditioning) as the
|
|
pending candidate the NEXT rating will judge. fp16 CPU, batch squeezed; one
|
|
pending per key (a new run overwrites - each rating scores its own run)."""
|
|
if not refinement_key or not isinstance(video_latent, torch.Tensor):
|
|
return False
|
|
try:
|
|
lat = video_latent.detach()
|
|
if lat.dim() >= 4 and lat.shape[0] == 1:
|
|
lat = lat.squeeze(0)
|
|
cond_vec = None
|
|
if isinstance(conditioning, torch.Tensor):
|
|
c = conditioning.detach().float()
|
|
if c.dim() == 3:
|
|
c = c.squeeze(0)
|
|
if c.dim() == 2:
|
|
c = c.mean(dim=0)
|
|
cond_vec = c.to(torch.float16).cpu()
|
|
_atomic_save({
|
|
"latent": lat.to(torch.float16).cpu(),
|
|
"cond": cond_vec,
|
|
"stamp": time.strftime("%Y-%m-%d_%H-%M-%S"),
|
|
}, _state_path(refinement_key, "pending_negative"))
|
|
return True
|
|
except Exception as e:
|
|
print(f"[FunPack DynaShift] pending negative save failed: {e}")
|
|
return False
|
|
|
|
|
|
def consume_pending(refinement_key, promote, rating_key=None):
|
|
"""Pair the pending latent with the rating that scores its run: promote it into
|
|
the negative bank (ring buffer) or discard it. Always removes the pending file so
|
|
a stale candidate can never be promoted by a later, unrelated rating. Returns the
|
|
bank size after promotion, or None when nothing was promoted. `rating_key` is
|
|
stored on the entry as provenance (which rating banked it - available for
|
|
severity-weighted steering later)."""
|
|
if not refinement_key:
|
|
return None
|
|
pending_path = _state_path(refinement_key, "pending_negative")
|
|
if not os.path.exists(pending_path):
|
|
return None
|
|
count = None
|
|
try:
|
|
if promote:
|
|
pending = torch.load(pending_path, map_location="cpu", weights_only=False)
|
|
if isinstance(pending, dict) and isinstance(pending.get("latent"), torch.Tensor):
|
|
if rating_key:
|
|
pending["rating"] = str(rating_key)
|
|
store_path = _state_path(refinement_key, "negative_latents")
|
|
entries = load_negatives(refinement_key)
|
|
entries.append(pending)
|
|
entries = entries[-MAX_NEGATIVES:]
|
|
_atomic_save({"version": 1, "entries": entries}, store_path)
|
|
count = len(entries)
|
|
except Exception as e:
|
|
print(f"[FunPack DynaShift] negative bank update failed: {e}")
|
|
finally:
|
|
try:
|
|
os.remove(pending_path)
|
|
except OSError:
|
|
pass
|
|
return count
|
|
|
|
|
|
def load_negatives(refinement_key):
|
|
"""Bank entries ([{latent: fp16 [C,T,H,W], cond: fp16 [D]|None, stamp}], oldest
|
|
first), or [] when the key has no bank yet."""
|
|
if not refinement_key:
|
|
return []
|
|
path = _state_path(refinement_key, "negative_latents")
|
|
if not os.path.exists(path):
|
|
return []
|
|
try:
|
|
data = torch.load(path, map_location="cpu", weights_only=False)
|
|
entries = data.get("entries") if isinstance(data, dict) else None
|
|
return [e for e in (entries or [])
|
|
if isinstance(e, dict) and isinstance(e.get("latent"), torch.Tensor)]
|
|
except Exception as e:
|
|
print(f"[FunPack DynaShift] negative bank load failed: {e}")
|
|
return []
|