fix: forget fires only once the first run is queued, one owner per Generate; velocity bias banks likes only, mixed sizes import, says when no structure step, refuses windows/second-order/negative-only calls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
59c2153137
commit
b6319c51c4
+11
-6
@@ -4019,10 +4019,6 @@
|
||||
return false;
|
||||
}
|
||||
_genRunActive = true;
|
||||
if (_tasteGenPending) { // the first run of a Generate: what the last one left unrated is forgotten
|
||||
_tasteGenPending = false;
|
||||
await _newTasteGeneration();
|
||||
}
|
||||
// A run started through the normal click path owns its own clock
|
||||
// lifecycle (the _clockedX wrapper's `finally`, spanning the whole
|
||||
// batch) — any stale flag left over from a PRIOR adopted run must not
|
||||
@@ -4074,6 +4070,13 @@
|
||||
// clobber it. Setting it last means an unwired reference is always
|
||||
// what the person sees, never lost to a race with an unrelated note.
|
||||
if (unwiredNotice) { state.notice = unwiredNotice; notify(); }
|
||||
if (queued && _tasteGenPending) {
|
||||
// The first run of a Generate is really queued: what the last Generate left unrated is
|
||||
// forgotten now (a refused or failed click above never gets here). Sampling takes seconds;
|
||||
// this takes milliseconds, so it lands before this run's own capture.
|
||||
_tasteGenPending = false;
|
||||
await _newTasteGeneration();
|
||||
}
|
||||
if (!queued) {
|
||||
// Refused before it ever reached run.start() (an incomplete pipeline,
|
||||
// or the queue itself saying no) -- the bridge's own "say" hook
|
||||
@@ -4338,14 +4341,16 @@
|
||||
}
|
||||
// Runs `fn` as the owner of one Generate -- unless one is already in flight, in which case this click
|
||||
// is refused later anyway and must not make the running Generate forget its unrated clips.
|
||||
let _generateOwned = false; // an entry point holds a Generate (set before its first await)
|
||||
async function _ownsGenerate(fn) {
|
||||
const owner = !_genRunActive;
|
||||
const owner = !_genRunActive && !_generateOwned;
|
||||
if (owner) {
|
||||
_generateOwned = true;
|
||||
_tasteGenPending = true;
|
||||
// The settings of this Generate, taken once: every run of it is queued with these.
|
||||
_frozenSettings = window.PipelineState && window.PipelineState.frozenInputs ? window.PipelineState.frozenInputs() : null;
|
||||
}
|
||||
try { return await fn(); } finally { if (owner) { _tasteGenPending = false; _frozenSettings = null; } }
|
||||
try { return await fn(); } finally { if (owner) { _tasteGenPending = false; _frozenSettings = null; _generateOwned = false; } }
|
||||
}
|
||||
async function _clockedGenerate(onlyScene) {
|
||||
_genClockStart(onlyScene ? "scene" : "all");
|
||||
|
||||
@@ -18,6 +18,7 @@ import torch.nn.functional as F
|
||||
from comfy.patcher_extension import WrappersMP
|
||||
|
||||
from ..._core import dit_hooks, log, registry, streams
|
||||
from ..score_slider import cond_row
|
||||
|
||||
ID = "velocity_bias"
|
||||
TITLE = "Velocity bias"
|
||||
@@ -123,46 +124,71 @@ def install(patcher, values, key):
|
||||
return None
|
||||
strength = max(0.0, min(3.0, float(values.get("strength", 0.15))))
|
||||
nearest = bool(values.get("nearest"))
|
||||
live = {"rows": []}
|
||||
live = {"rows": [], "hit": False, "seen": {}, "multi": False}
|
||||
|
||||
def fresh():
|
||||
live["hit"], live["multi"] = False, False
|
||||
live["seen"].clear()
|
||||
live["rows"] = taste.rows(KIND)
|
||||
liked = sum(1 for r in live["rows"] if r["reward"] > 0)
|
||||
log.once(f"{ID}:state", log.INFO, "FunPack Velocity bias",
|
||||
f"key {taste.key!r}: {liked} liked clip(s) banked" if liked else
|
||||
f"key {taste.key!r}: learning, nothing liked banked yet")
|
||||
|
||||
captured = taste.collect(patcher, key, KIND, keep=MAX_ROWS, fresh=fresh)
|
||||
# Only liked clips are banked (nothing here learns from a disliked one); clips of different sizes
|
||||
# are meant to coexist, so a key holding two resolutions still exports and imports.
|
||||
captured = taste.collect(patcher, key, KIND, keep=MAX_ROWS, fresh=fresh, only="liked", mixed=True)
|
||||
|
||||
def apply_model(executor, x, t, *args, **kwargs):
|
||||
named = streams.model_args(args, kwargs)
|
||||
to = named.get("transformer_options")
|
||||
if dit_hooks.probing(to) or not structure_step(to):
|
||||
if dit_hooks.probing(to):
|
||||
return executor(x, t, *args, **kwargs)
|
||||
if (to or {}).get("context_window") is not None:
|
||||
_say("off this run: it cannot follow clips cut into context windows (each window is a different size)")
|
||||
return executor(x, t, *args, **kwargs)
|
||||
where = dit_hooks.current_step(to)
|
||||
if where is not None and where[0] >= where[1] - 1 and not live["hit"] and not live["multi"]:
|
||||
_say("did nothing this run: no step of this schedule is near 90% of the starting noise, "
|
||||
"so there was no structure step to bias or learn from")
|
||||
if not structure_step(to):
|
||||
return executor(x, t, *args, **kwargs)
|
||||
kinds = tuple(int(v) for v in ((to or {}).get("cond_or_uncond") or ()))
|
||||
c = named.get("c_crossattn")
|
||||
row = cond_row(c, to) if torch.is_tensor(c) and c.dim() == 3 else 0
|
||||
if row is None: # only the negative prompt in this call
|
||||
return executor(x, t, *args, **kwargs)
|
||||
if live["seen"].get(kinds) == where[0] or live["multi"]:
|
||||
if not live["multi"]:
|
||||
live["multi"] = True
|
||||
_say("off for the rest of this run: the model is called more than once per step "
|
||||
"(a second-order sampler); use euler-style sampling")
|
||||
return executor(x, t, *args, **kwargs)
|
||||
live["seen"][kinds] = where[0]
|
||||
sig = None
|
||||
if torch.is_tensor(c) and c.dim() == 3:
|
||||
words = registry.current().ask("text_rows", named, int(c.shape[1]))
|
||||
row = c[0] if words is None else c[0][words.to(c.device)]
|
||||
sig = row.float().mean(0).detach().cpu()
|
||||
r = c[row] if words is None else c[row][words.to(c.device)]
|
||||
sig = r.float().mean(0).detach().cpu()
|
||||
split_in = streams.video_of(x, named)
|
||||
if split_in is None:
|
||||
_say("off this run: could not find the picture in this model's latent")
|
||||
return executor(x, t, *args, **kwargs)
|
||||
live["hit"] = True
|
||||
video_in, rebuild = split_in
|
||||
sigma = float(t.max())
|
||||
ratio = sigma / float(to["sample_sigmas"][0])
|
||||
direction, how = reference(live["rows"], video_in[0].shape, sig, nearest) if strength > 0 else (None, "")
|
||||
direction, how = reference(live["rows"], video_in[row].shape, sig, nearest) if strength > 0 else (None, "")
|
||||
run_x = x
|
||||
if direction is not None:
|
||||
run_x = rebuild(rotate(video_in, direction.unsqueeze(0), strength, ratio))
|
||||
log.once(f"{ID}:applied", log.INFO, "FunPack Velocity bias", f"applied: {how}")
|
||||
elif strength > 0 and live["rows"]:
|
||||
_say("not applied this run: " + reference(live["rows"], video_in[0].shape, sig, nearest)[1])
|
||||
log.once(f"{ID}:applied:{how}", log.INFO, "FunPack Velocity bias", f"applied: {how}")
|
||||
elif strength > 0:
|
||||
_say("not applied this run: " + reference(live["rows"], video_in[row].shape, sig, nearest)[1])
|
||||
out = executor(run_x, t, *args, **kwargs)
|
||||
split_out, split_run = streams.video_of(out, named), streams.video_of(run_x, named)
|
||||
if split_out is not None and split_run is not None:
|
||||
d = ((split_run[0].float() - split_out[0].float()) / max(sigma, 1e-6))[0] # where the model moves the picture
|
||||
d = ((split_run[0].float() - split_out[0].float()) / max(sigma, 1e-6))[row] # where the model moves the picture
|
||||
captured["v"] = d.detach().half().cpu()
|
||||
if sig is not None:
|
||||
captured["sig"] = sig
|
||||
|
||||
@@ -111,3 +111,49 @@ def test_strength_zero_only_learns(tiny_h3):
|
||||
seen = []
|
||||
wrap(lambda x, *a, **k: seen.append(x) or x0, x0, sched[1:2], None, None, None, _to(1, sched), latent_shapes=shapes)
|
||||
assert torch.equal(seen[0], x0)
|
||||
|
||||
|
||||
def _packed(shapes_seed=0):
|
||||
from conftest import packed_av
|
||||
torch.manual_seed(shapes_seed)
|
||||
return packed_av(torch.randn(1, 4, 3, 8, 8), torch.randn(1, 8, 5))
|
||||
|
||||
|
||||
def test_a_schedule_with_no_structure_step_says_so_on_its_last_step(tiny_h3):
|
||||
from core import log
|
||||
wrap, _ = _load(tiny_h3, strength=1.0)
|
||||
x0, shapes = _packed()
|
||||
sched = torch.tensor([1.0, 0.75, 0.5, 0.25, 0.0])
|
||||
log.new_run()
|
||||
before = len(log.history())
|
||||
for i in range(4):
|
||||
wrap(lambda x, *a, **k: x0, x0, sched[i:i + 1], None, None, None, _to(i, sched), latent_shapes=shapes)
|
||||
assert any("no step of this schedule is near 90%" in e["message"] for e in log.history()[before:])
|
||||
|
||||
|
||||
def test_context_windows_and_second_order_calls_are_refused_in_words(tiny_h3):
|
||||
from core import log
|
||||
x0, shapes = _packed()
|
||||
sched = torch.tensor([1.0, 0.909375, 0.725, 0.421875, 0.0])
|
||||
wrap, _ = _load(tiny_h3, strength=1.0)
|
||||
log.new_run()
|
||||
wrap(lambda x, *a, **k: x0, x0, sched[1:2], None, None, None, {**_to(1, sched), "context_window": object()},
|
||||
latent_shapes=shapes)
|
||||
assert any("context windows" in e["message"] for e in log.history())
|
||||
wrap2, outer = _load(tiny_h3, strength=1.0)
|
||||
log.new_run()
|
||||
for o in outer[:1]:
|
||||
o(lambda: None) # a run starts: the state is fresh
|
||||
for _ in range(2): # the same structure step twice: a corrector call
|
||||
wrap2(lambda x, *a, **k: x0, x0, sched[1:2], None, None, None, _to(1, sched), latent_shapes=shapes)
|
||||
assert any("more than once per step" in e["message"] for e in log.history())
|
||||
|
||||
|
||||
def test_a_call_that_carries_only_the_negative_prompt_neither_biases_nor_learns(tiny_h3):
|
||||
wrap, _ = _load(tiny_h3, strength=1.0)
|
||||
x0, shapes = _packed()
|
||||
sched = torch.tensor([1.0, 0.909375, 0.725, 0.421875, 0.0])
|
||||
seen = []
|
||||
to = {**_to(1, sched), "cond_or_uncond": [1]}
|
||||
wrap(lambda x, *a, **k: seen.append(x) or x0, x0, sched[1:2], None, torch.ones(1, 4, 8), None, to, latent_shapes=shapes)
|
||||
assert torch.equal(seen[0], x0)
|
||||
|
||||
@@ -38,8 +38,8 @@ class Handle:
|
||||
def __init__(self, key):
|
||||
self.key = key
|
||||
|
||||
def capture(self, kind, rows, keep=store.MAX_ROWS):
|
||||
store.capture(self.key, kind, rows, keep=keep)
|
||||
def capture(self, kind, rows, keep=store.MAX_ROWS, only=None, mixed=False):
|
||||
store.capture(self.key, kind, rows, keep=keep, only=only, mixed=mixed)
|
||||
|
||||
def rows(self, kind, blind_to=None):
|
||||
"""Every rated row of `kind`, oldest first: {"reward", "rows", "prompt_id"}.
|
||||
@@ -47,7 +47,7 @@ class Handle:
|
||||
neutral -- for a learner that cannot tell whether that half was the problem."""
|
||||
return store.blind(store.load(self.key, kind)["rows"], blind_to)
|
||||
|
||||
def collect(self, patcher, key, kind, keep=store.MAX_ROWS, fresh=None):
|
||||
def collect(self, patcher, key, kind, keep=store.MAX_ROWS, fresh=None, only=None, mixed=False):
|
||||
"""A dict to fill during sampling; kept as `kind`'s capture when the
|
||||
sampling call finishes. An interrupted or failed run keeps nothing, so
|
||||
a half-finished clip can never be what a rating teaches.
|
||||
@@ -64,7 +64,7 @@ class Handle:
|
||||
if fresh is not None:
|
||||
fresh()
|
||||
out = executor(*args, **kwargs)
|
||||
self.capture(kind, rows, keep=keep)
|
||||
self.capture(kind, rows, keep=keep, only=only, mixed=mixed)
|
||||
return out
|
||||
|
||||
patcher.add_wrapper_with_key(WrappersMP.OUTER_SAMPLE, key, outer)
|
||||
|
||||
@@ -134,12 +134,20 @@ def _pending_path(key, kind, prompt_id=None):
|
||||
return _dir(key) / (f"{kind}.pending.pt" if prompt_id is None else f"{kind}.{prompt_id}.pending.pt")
|
||||
|
||||
|
||||
def capture(key, kind, rows, prompt_id=None, keep=MAX_ROWS):
|
||||
def _prune_aside(key, kind):
|
||||
aside = sorted(_dir(key).glob(f"{kind}.*.pending.pt"), key=lambda p: p.stat().st_mtime_ns)
|
||||
for old in aside[:-MAX_PENDING]:
|
||||
old.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def capture(key, kind, rows, prompt_id=None, keep=MAX_ROWS, only=None, mixed=False):
|
||||
"""This run's capture for `kind`, waiting for a rating until the next Generate starts.
|
||||
|
||||
The previous run's capture of the same Generate is kept aside, not overwritten. Kept in the
|
||||
dtype given: a banked latent stays half precision. `keep` caps how many rated rows this kind
|
||||
holds, oldest dropped first.
|
||||
holds, oldest dropped first. `only="liked"`: a kind that learns from liked clips alone does not
|
||||
bank a disliked one (it could only push the liked ones out under `keep`). `mixed`: its rows are
|
||||
legitimately of different shapes (a clip bank is matched by size), so import does not demand one.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
@@ -152,10 +160,8 @@ def capture(key, kind, rows, prompt_id=None, keep=MAX_ROWS):
|
||||
if earlier is not None and earlier != prompt_id:
|
||||
if _PID.match(str(earlier)):
|
||||
os.replace(latest, _pending_path(key, kind, earlier))
|
||||
_write(latest, {"prompt_id": prompt_id, "rows": clean, "keep": int(keep)})
|
||||
aside = sorted(_dir(key).glob(f"{kind}.*.pending.pt"), key=lambda p: p.stat().st_mtime_ns)
|
||||
for old in aside[:-MAX_PENDING]:
|
||||
old.unlink(missing_ok=True)
|
||||
_write(latest, {"prompt_id": prompt_id, "rows": clean, "keep": int(keep), "only": only, "mixed": bool(mixed)})
|
||||
_prune_aside(key, kind)
|
||||
state = _read_latest()
|
||||
runs = state.get("runs", {})
|
||||
kinds = runs.setdefault(prompt_id, {}).setdefault(key, [])
|
||||
@@ -195,7 +201,7 @@ def blind(rows, to):
|
||||
return [dict(r, reward=0.0) if r.get("axis") == to else r for r in rows]
|
||||
|
||||
|
||||
def _restore_pending(key, kind, prompt_id, rows, keep):
|
||||
def _restore_pending(key, kind, prompt_id, rows, keep, only=None, mixed=False):
|
||||
"""A cleared rating puts the capture back to waiting (until the next Generate), so the clip can be
|
||||
rated again. The latest slot if it is free or already this run's, else aside under the prompt id."""
|
||||
latest = _pending_path(key, kind)
|
||||
@@ -204,7 +210,8 @@ def _restore_pending(key, kind, prompt_id, rows, keep):
|
||||
if not _PID.match(str(prompt_id)):
|
||||
return
|
||||
latest = _pending_path(key, kind, prompt_id)
|
||||
_write(latest, {"prompt_id": prompt_id, "rows": rows, "keep": int(keep)})
|
||||
_write(latest, {"prompt_id": prompt_id, "rows": rows, "keep": int(keep), "only": only, "mixed": bool(mixed)})
|
||||
_prune_aside(key, kind)
|
||||
|
||||
|
||||
def rate(prompt_id, rating, axis=None):
|
||||
@@ -237,7 +244,8 @@ def rate(prompt_id, rating, axis=None):
|
||||
if rating is None:
|
||||
data["rows"] = [r for r in data["rows"] if r.get("prompt_id") != prompt_id]
|
||||
if path.stem in entry.get(key, []):
|
||||
_restore_pending(key, path.stem, prompt_id, hit[-1]["rows"], hit[-1].get("keep", MAX_ROWS))
|
||||
_restore_pending(key, path.stem, prompt_id, hit[-1]["rows"], hit[-1].get("keep", MAX_ROWS),
|
||||
hit[-1].get("only"), hit[-1].get("mixed", False))
|
||||
else:
|
||||
for r in hit:
|
||||
r["reward"] = REWARD[rating]
|
||||
@@ -269,7 +277,12 @@ def rate(prompt_id, rating, axis=None):
|
||||
continue
|
||||
data = load(key, kind)
|
||||
keep = int(pending.get("keep", MAX_ROWS))
|
||||
row = {"prompt_id": prompt_id, "reward": REWARD[rating], "rows": pending["rows"], "keep": keep}
|
||||
if pending.get("only") == "liked" and rating != "liked":
|
||||
pending_path.unlink(missing_ok=True) # nothing here learns from it: not banked
|
||||
out.setdefault("skipped", []).append(kind)
|
||||
continue
|
||||
row = {"prompt_id": prompt_id, "reward": REWARD[rating], "rows": pending["rows"], "keep": keep,
|
||||
"only": pending.get("only"), "mixed": bool(pending.get("mixed"))}
|
||||
if axis:
|
||||
row["axis"] = axis
|
||||
data["rows"].append(row)
|
||||
@@ -277,7 +290,7 @@ def rate(prompt_id, rating, axis=None):
|
||||
_write(_dir(key) / f"{kind}.pt", data)
|
||||
pending_path.unlink(missing_ok=True)
|
||||
out["recorded"].append(kind)
|
||||
if not out["recorded"]:
|
||||
if not out["recorded"] and not out.get("skipped"):
|
||||
out["why"] = ("this clip's capture was not kept (more runs waited than are kept, or it was "
|
||||
"cleared); nothing was learned")
|
||||
return out
|
||||
@@ -384,6 +397,8 @@ def _sound_rows(data):
|
||||
return False
|
||||
# What is stacked later must agree: one name, one shape (a key trained across two model
|
||||
# families would otherwise abort every sampling that reads it).
|
||||
if row.get("mixed") is True:
|
||||
continue # a clip bank: rows of different sizes are the point
|
||||
for name, v in captured.items():
|
||||
if shapes.setdefault(name, tuple(v.shape)) != tuple(v.shape):
|
||||
return False
|
||||
|
||||
@@ -210,3 +210,33 @@ def test_a_run_waiting_under_two_keys_teaches_both():
|
||||
store.capture("wolf", "reins", {25: _v(2)}, prompt_id="p1")
|
||||
assert store.rate("p1", "liked")["recorded"] == ["reins", "reins"]
|
||||
assert store.counts("fox", "reins") == (1, 0) and store.counts("wolf", "reins") == (1, 0)
|
||||
|
||||
|
||||
def test_a_liked_only_kind_banks_likes_and_never_lets_dislikes_push_them_out():
|
||||
for i in range(3):
|
||||
store.capture("fox", "vel", {"v": _v(i)}, prompt_id=f"l{i}", keep=4, only="liked")
|
||||
assert store.rate(f"l{i}", "liked")["recorded"] == ["vel"]
|
||||
for i in range(10):
|
||||
store.capture("fox", "vel", {"v": _v(9)}, prompt_id=f"d{i}", keep=4, only="liked")
|
||||
out = store.rate(f"d{i}", "disliked")
|
||||
assert out["recorded"] == [] and out["skipped"] == ["vel"] and out["why"] is None
|
||||
assert store.counts("fox", "vel") == (3, 0)
|
||||
|
||||
|
||||
def test_a_kind_of_mixed_sizes_exports_and_imports(tmp_path):
|
||||
store.capture("fox", "vel", {"v": torch.ones(2, 2)}, prompt_id="a", mixed=True)
|
||||
store.rate("a", "liked")
|
||||
store.capture("fox", "vel", {"v": torch.ones(3, 3)}, prompt_id="b", mixed=True)
|
||||
store.rate("b", "liked")
|
||||
out = tmp_path / "k.zip"
|
||||
store.export_key("fox", out)
|
||||
assert store.import_key("wolf", out) == 1
|
||||
assert store.counts("wolf", "vel") == (2, 0)
|
||||
# a kind that did not say so still must hold one shape per name
|
||||
store.capture("bear", "reins", {"v": torch.ones(2, 2)}, prompt_id="c")
|
||||
store.rate("c", "liked")
|
||||
store.capture("bear", "reins", {"v": torch.ones(3, 3)}, prompt_id="d")
|
||||
store.rate("d", "liked")
|
||||
store.export_key("bear", tmp_path / "b.zip")
|
||||
with pytest.raises(ValueError):
|
||||
store.import_key("cub", tmp_path / "b.zip")
|
||||
|
||||
Reference in New Issue
Block a user