Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
"""Decisiveness: the maths, the learner, and the wrapper on a packed H3 latent."""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _needs_comfy(comfyui):
|
|
"""Imports comfy."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def root(tmp_path, monkeypatch):
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(store, "ROOT", tmp_path / "taste")
|
|
monkeypatch.setattr(store, "current_prompt_id", lambda: "run-1")
|
|
|
|
|
|
def _sched(steps):
|
|
return torch.linspace(1.0, 0.0, steps + 1)
|
|
|
|
|
|
def _to(i, steps=8):
|
|
s = _sched(steps)
|
|
return {"sample_sigmas": s, "sigmas": s[i:i + 1]}
|
|
|
|
|
|
def test_k_of_one_is_exactly_off_and_the_rescale_scales_only_the_noise_part():
|
|
from modules.sampling.decisiveness import factor, rescale_x0
|
|
x, x0 = torch.randn(1, 4, 2, 4, 4), torch.randn(1, 4, 2, 4, 4)
|
|
assert factor(0.5, 1.0) == pytest.approx(1.0)
|
|
assert rescale_x0(x, x0, 0.5, 1.0) is x0
|
|
out = rescale_x0(x, x0, 0.5, 1.3)
|
|
eps = (x - 0.5 * x0) / 0.5
|
|
assert torch.allclose(x - 0.5 * factor(0.5, 1.3) * eps, 0.5 * out, atol=1e-5)
|
|
assert rescale_x0(x, x0, 1.0, 1.3) is x0 # pure noise: nothing to rescale
|
|
|
|
|
|
def test_the_learner_moves_toward_liked_and_away_from_disliked_and_is_bounded():
|
|
from modules.sampling.decisiveness import LOGK_MAX, learned_logk
|
|
assert learned_logk([]) == 0.0
|
|
assert learned_logk([(0.2, 1.0)]) > 0.0
|
|
assert learned_logk([(0.2, -1.0)]) < 0.0 + 1e-9 and learned_logk([(0.2, -1.0)]) == pytest.approx(-0.1)
|
|
assert learned_logk([(5.0, 1.0)] * 20) == LOGK_MAX
|
|
|
|
|
|
def test_simple_at_four_steps_is_inert_and_longer_schedules_are_not():
|
|
from modules.sampling.decisiveness import INERT_SHARE, reach
|
|
simple4 = [1.0, 0.95, 0.89, 0.80, 0.0] # a few-step schedule whose last step starts near 0.8
|
|
assert reach(simple4, 0.97) < INERT_SHARE < reach(simple4, 1.3)
|
|
assert reach(_sched(12).tolist(), 1.3) > INERT_SHARE
|
|
assert reach(_sched(4).tolist(), 1.0) == 0.0
|
|
|
|
|
|
def _load(tiny, **values):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers
|
|
settings = {"taste": {"key": "fox"}, "decisiveness": {"enabled": True, **values}}
|
|
patched, status = FunUILoadModifiers.execute(tiny.patcher, settings).result
|
|
wrap = [w for ws in patched.wrappers[WrappersMP.APPLY_MODEL].values() for w in ws][-1]
|
|
outer = [w for ws in patched.wrappers[WrappersMP.OUTER_SAMPLE].values() for w in ws]
|
|
return wrap, outer, status
|
|
|
|
|
|
def _run(outer, fn):
|
|
call = fn
|
|
for o in reversed(outer):
|
|
call = (lambda w, inner: (lambda: w(lambda: inner())))(o, call)
|
|
return call()
|
|
|
|
|
|
def _packed():
|
|
from conftest import packed_av
|
|
torch.manual_seed(2)
|
|
return packed_av(torch.randn(1, 4, 3, 8, 8), torch.randn(1, 8, 5))
|
|
|
|
|
|
def test_manual_k_steers_the_picture_into_the_next_step_and_never_the_answer_or_the_sound(tiny_h3):
|
|
from conftest import unpacked
|
|
wrap, outer, status = _load(tiny_h3, mode="manual", level=10)
|
|
assert "manual k" in status
|
|
x0, shapes = _packed()
|
|
seen = []
|
|
|
|
def model(x, *a, **k):
|
|
seen.append(x)
|
|
return x0
|
|
|
|
def call(i):
|
|
t = _sched(8)[i:i + 1]
|
|
return wrap(model, x0.clone(), t, None, None, None, _to(i), latent_shapes=shapes)
|
|
|
|
answers = _run(outer, lambda: [call(i) for i in range(8)])
|
|
assert all(a is x0 for a in answers) # the model's own answer, every step
|
|
assert torch.equal(seen[0], x0)
|
|
push = unpacked(seen[3] - x0, shapes)
|
|
assert not torch.allclose(push[0], torch.zeros_like(push[0]))
|
|
assert torch.equal(push[1], torch.zeros_like(push[1])) # sound untouched
|
|
|
|
|
|
def test_learned_mode_banks_k_only_when_it_acts_and_a_rating_teaches_it(tiny_h3, monkeypatch):
|
|
import random
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(random, "gauss", lambda *a: 0.2) # the exploration draw, fixed
|
|
wrap, outer, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
_run(outer, lambda: [wrap(lambda *a, **k: x0, x0, _sched(8)[i:i + 1], None, None, None, _to(i),
|
|
latent_shapes=shapes) for i in range(8)])
|
|
assert store.rate("run-1", "liked")["recorded"] == ["decisiveness"]
|
|
row = store.load("fox", "decisiveness")["rows"][0]
|
|
assert abs(float(row["rows"]["logk"])) <= 0.3 + 1e-6 and row["reward"] == 1.0
|
|
|
|
|
|
def test_a_schedule_too_short_to_feel_it_says_so_and_teaches_nothing(tiny_h3):
|
|
from core import log
|
|
from modules.system.taste import store
|
|
wrap, outer, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
log.new_run()
|
|
_run(outer, lambda: [wrap(lambda *a, **k: x0, x0, _sched(2)[i:i + 1], None, None, None, _to(i, 2),
|
|
latent_shapes=shapes) for i in range(2)])
|
|
assert any("barely acts" in e["message"] for e in log.history())
|
|
assert not (store.ROOT / "fox" / "decisiveness.pending.pt").exists()
|
|
|
|
|
|
def test_learning_without_a_taste_key_is_off_and_says_so(tiny_h3):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from core import log
|
|
from modules.sampling.modifiers.nodes import FunUILoadModifiers
|
|
log.new_run()
|
|
patched, _ = FunUILoadModifiers.execute(tiny_h3.patcher, {"decisiveness": {"enabled": True}}).result
|
|
assert any("needs a Taste key" in e["message"] for e in log.history())
|
|
assert not patched.wrappers.get(WrappersMP.APPLY_MODEL)
|
|
|
|
|
|
def test_a_run_whose_push_never_arrived_teaches_nothing(tiny_h3, monkeypatch):
|
|
import random
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(random, "gauss", lambda *a: 0.2)
|
|
wrap, outer, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
|
|
def twice(): # a second-order sampler: every step called twice
|
|
for i in range(8):
|
|
for _ in range(2):
|
|
wrap(lambda *a, **k: x0, x0, _sched(8)[i:i + 1], None, None, None, _to(i), latent_shapes=shapes)
|
|
|
|
_run(outer, twice)
|
|
assert store.rate("run-1", "liked")["why"]
|
|
|
|
|
|
def test_ratings_from_another_schedule_length_are_left_out_of_the_centre(tiny_h3, monkeypatch):
|
|
import random
|
|
from core import log
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(random, "gauss", lambda *a: 0.0)
|
|
for i in range(3):
|
|
store.capture("fox", "decisiveness", {"logk": torch.tensor(0.3), "steps": torch.tensor(4)}, prompt_id=f"t{i}")
|
|
store.rate(f"t{i}", "liked")
|
|
wrap, outer, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
log.new_run()
|
|
_run(outer, lambda: wrap(lambda *a, **k: x0, x0, _sched(8)[0:1], None, None, None, _to(0), latent_shapes=shapes))
|
|
assert any("from 0 rating(s) at 8 steps" in e["message"] for e in log.history())
|
|
|
|
|
|
def test_on_a_four_step_schedule_the_biggest_push_lands_on_the_last_step_and_still_teaches(tiny_h3, monkeypatch):
|
|
import random
|
|
from modules.system.taste import store
|
|
monkeypatch.setattr(random, "gauss", lambda *a: 0.5) # clamps to the largest k
|
|
wrap, outer, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
sched = torch.tensor([1.0, 0.95, 0.89, 0.80, 0.0])
|
|
to = lambda i: {"sample_sigmas": sched, "sigmas": sched[i:i + 1]}
|
|
_run(outer, lambda: [wrap(lambda *a, **k: x0, x0, sched[i:i + 1], None, None, None, to(i),
|
|
latent_shapes=shapes) for i in range(4)])
|
|
assert store.rate("run-1", "liked")["recorded"] == ["decisiveness"]
|