feat: H3 picker goes binary (Liked/Disliked); REINS gets its own capture pause

Picker: h3_repr_steering only ever reads the reward's SIGN now (see
the binary-direction commit), so a 1-10 scale was granularity nothing
downstream uses. Down to two buttons -- reuses the scale's own "10"
and "1" endpoints, zero backend change.

REINS capture toggle: independent of the sampler's h3_repr_steering
widget, which controls whether the learned direction gets APPLIED --
this new per-key flag controls whether it keeps RECORDING at all, so
data collection can be paused without touching the steering setting
or discarding what's already captured. Settings > Refinement & Taste
gets the toggle; save_pending() is the single gate.
This commit is contained in:
digital-garbage
2026-09-03 20:00:01 +03:00
parent cbc0c322a9
commit 19ce43bfb8
7 changed files with 123 additions and 16 deletions
+30 -3
View File
@@ -116,18 +116,43 @@ def capture(hidden_state, video_mask):
def _load(refinement_key):
path = state_path(refinement_key)
if not os.path.exists(path):
return {"rows": [], "pending": None}
return {"rows": [], "pending": None, "enabled": True}
try:
data = torch.load(path, map_location="cpu", weights_only=True)
except Exception: # noqa: BLE001
return {"rows": [], "pending": None}
return {"rows": [], "pending": None, "enabled": True}
if not isinstance(data, dict):
return {"rows": [], "pending": None}
return {"rows": [], "pending": None, "enabled": True}
data.setdefault("rows", [])
data.setdefault("pending", None)
data.setdefault("enabled", True)
return data
def capture_enabled(refinement_key):
"""Whether this key is recording at all -- independent of the sampler's own
h3_repr_steering toggle, which also controls whether the learned direction gets APPLIED.
A user who wants to pause data collection (to test something else without polluting the
log, or because a sweep came back null and they want to think before adding more rows)
still needs the widget on for `block_sweep`/status to mean anything, so the pause lives
here instead of overloading that widget."""
if not refinement_key:
return True
return bool(_load(refinement_key).get("enabled", True))
def set_capture_enabled(refinement_key, enabled):
if not refinement_key:
return
data = _load(refinement_key)
data["enabled"] = bool(enabled)
try:
_save(refinement_key, data)
except OSError as e:
_log().failed("H3 representation steering", "set capture enabled", e,
"the pause/resume did not persist")
def _save(refinement_key, data):
path = state_path(refinement_key)
os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -146,6 +171,8 @@ def save_pending(refinement_key, descriptors):
if not refinement_key or not descriptors:
return
data = _load(refinement_key)
if not data.get("enabled", True):
return
data["pending"] = {int(b): d.detach().float().cpu() for b, d in descriptors.items()}
try:
_save(refinement_key, data)
+1
View File
@@ -200,6 +200,7 @@
// H3 representation steering (Settings ▸ Learning) — per-KEY, unlike the probe above.
reinsStatus: (key) => j("GET", API("/h3_repr_steering") + `?key=${encodeURIComponent(key || "default")}`),
reinsSetEnabled: (key, enabled) => j("POST", API("/h3_repr_steering/enabled"), { key: key || "default", enabled: !!enabled }),
reinsClear: (key) => j("POST", API("/h3_repr_steering/clear"), { key: key || "default" }),
async reinsExport(key) {
const k = key || "default";
+16 -11
View File
@@ -46,21 +46,23 @@
// H3: the category grid above assumes axis-specific effects (phrase emphasis, dynashift,
// ...) this project has not actually proven land differently from one another on H3 — see
// project_reward_model_rework.md. One 1-10 scale instead; the backend already knows how to
// turn a bare number into the nearest existing rating profile (normalize_refiner_v2_rating's
// legacy-score path), so nothing downstream needs to change, only what the picker sends.
const H3_SCALE = Array.from({ length: 10 }, (_, i) => {
const n = i + 1;
return {
label: String(n), display: String(n),
reward: n >= 9 ? "great" : n >= 7 ? "good" : n >= 5 ? "mixed" : n >= 3 ? "bad" : "awful",
hint: n === 10 ? "Exactly what you asked for." : n === 1 ? "Everything wrong." : `${n} of 10.`,
};
});
// project_reward_model_rework.md. A 1-10 scale was tried first (still what the backend
// accepts -- normalize_refiner_v2_rating's legacy-score path), but h3_repr_steering only
// ever reads the SIGN of the resulting reward, never its size (see the 2026-09-04 session:
// weighting by magnitude just let a mediocre middle rating dilute the direction at the
// small sample sizes this actually runs at). A scale whose granularity nothing downstream
// uses is a UI lying about how precise the input needs to be, so it's binary now -- "10"
// and "1" are just the scale's own clean-positive/clean-negative endpoints, already wired,
// nothing to change on the backend.
const H3_SCALE = [
{ label: "10", display: "Liked", reward: "+", hint: "This generation was good." },
{ label: "1", display: "Disliked", reward: "−", hint: "This generation was not." },
];
// canonical label -> display name, for rendering a stored value (scene button, chips).
const DISPLAY_NAMES = {};
for (const cat of CATEGORIES) for (const r of cat.ratings) if (r.display) DISPLAY_NAMES[r.label] = r.display;
for (const r of H3_SCALE) if (r.display) DISPLAY_NAMES[r.label] = r.display;
const displayName = (label) => DISPLAY_NAMES[label] || label;
const NO_LOVED = new Set([
@@ -88,6 +90,9 @@
const v = String(value || "").trim();
if (!v || v === FORGET_LABEL) return "";
if (v.endsWith("|loved")) return formatLabel(v.slice(0, -6)) + " ♥";
if (DISPLAY_NAMES[v]) return DISPLAY_NAMES[v];
// A stray digit from before the H3 picker went binary (2..9) has no display name --
// still render it as a scale value rather than a bare, unlabelled number.
if (/^\d+$/.test(v)) return `${v}/10`;
return displayName(v);
}
@@ -17,6 +17,12 @@ test("a loved scale number keeps the heart", () => {
assert.strictEqual(Picker.formatLabel("9|loved"), "9/10 ♥");
});
test("the H3 picker's binary endpoints render as Liked/Disliked, not N/10", () => {
assert.strictEqual(Picker.formatLabel("10"), "Liked");
assert.strictEqual(Picker.formatLabel("1"), "Disliked");
assert.strictEqual(Picker.buttonLabel("10"), "★ Liked");
});
test("an existing category label is untouched", () => {
assert.strictEqual(Picker.formatLabel("Perfect"), "Perfect");
});
+31 -2
View File
@@ -408,6 +408,26 @@
return tbl;
}
function reinsToggleRow(enabled, onChange) {
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", "Record ratings for this key"));
main.append(el("div", "sw-row-hint",
"On by default. Independent of the sampler's own toggle — that one controls whether "
+ "the learned direction gets APPLIED; this one controls whether it keeps learning at "
+ "all. Turn off to pause the log without losing what's captured, or while testing "
+ "something else you don't want mixed in."));
row.append(main);
const lbl = el("label", "chk es-toggle");
const cb = el("input");
cb.type = "checkbox";
cb.checked = !!enabled;
cb.onchange = () => onChange(cb.checked);
lbl.append(cb, el("span", null, ""));
row.append(lbl);
return row;
}
function reinsRows(key) {
const box = el("div", "sw-stack");
const paint = () => {
@@ -416,12 +436,21 @@
const nLiked = reinsState ? reinsState.n_liked : 0;
const nDisliked = reinsState ? reinsState.n_disliked : 0;
const ready = !!(reinsState && reinsState.ready);
const enabled = !reinsState || reinsState.enabled !== false;
rows.append(reinsToggleRow(enabled, async (on) => {
reinsError = "";
try { reinsState = await window.MovieEditorAPI.reinsSetEnabled(key, on); }
catch (e) { reinsError = String(e.message || e); }
paint();
}));
rows.append(infoRow("Rated generations (this key)",
`${nLiked} liked / ${nDisliked} disliked`, reinsState ? ready : null));
if (reinsState && !ready) {
rows.append(el("div", "sw-hint",
`Needs ${reinsState.min_per_group}+ of each before it steers anything — still `
+ "capturing every generation either way."));
enabled
? `Needs ${reinsState.min_per_group}+ of each before it steers anything — still `
+ "capturing every generation either way."
: "Recording is off — nothing new is being captured for this key."));
}
rows.append(actionRow("Save this key's REINS data",
"Downloads every captured run (all candidate blocks, not just the one that steers) "
+9
View File
@@ -2110,6 +2110,7 @@ if web is not None and PromptServer is not None:
"min_per_group": rs.MIN_PER_GROUP,
"default_block": rs.DEFAULT_BLOCK,
"sweep": {str(b): r for b, r in sweep.items()},
"enabled": rs.capture_enabled(key),
}
@routes.get(UI_PREFIX + "/api/h3_repr_steering")
@@ -2139,6 +2140,14 @@ if web is not None and PromptServer is not None:
body=body, content_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{key}.repr_steer.pt"'})
@routes.post(UI_PREFIX + "/api/h3_repr_steering/enabled")
async def _repr_steer_set_enabled(req):
body = await req.json()
key = str(body.get("key") or "default").strip() or "default"
_repr_steer_module().set_capture_enabled(key, bool(body.get("enabled")))
_rs, state = _repr_steer_state(key)
return web.json_response(state)
@routes.post(UI_PREFIX + "/api/h3_repr_steering/clear")
async def _repr_steer_clear(req):
body = await req.json()
+30
View File
@@ -142,6 +142,36 @@ def test_pending_overwrites_not_accumulates():
assert torch.allclose(data["rows"][0]["desc"][rs.DEFAULT_BLOCK], torch.tensor([2.0, 0.0]))
def test_capture_enabled_defaults_true():
assert rs.capture_enabled("k") is True # no file yet -- still True, not False
def test_disabling_capture_stops_save_pending():
rs.set_capture_enabled("k", False)
rs.save_pending("k", _pend(torch.tensor([1.0, 0.0])))
rs.commit("k", 1.0)
direction, n_pos, n_neg = rs.direction("k")
assert n_pos == 0 and n_neg == 0 # nothing was ever captured to commit
def test_re_enabling_capture_resumes_it():
rs.set_capture_enabled("k", False)
rs.set_capture_enabled("k", True)
assert rs.capture_enabled("k") is True
rs.save_pending("k", _pend(torch.tensor([1.0, 0.0])))
rs.commit("k", 1.0)
data = rs._load("k")
assert len(data["rows"]) == 1
def test_disabling_capture_does_not_discard_existing_rows():
rs.save_pending("k", _pend(torch.tensor([1.0, 0.0])))
rs.commit("k", 1.0)
rs.set_capture_enabled("k", False)
data = rs._load("k")
assert len(data["rows"]) == 1
def test_clear_all_removes_the_state_file():
rs.save_pending("k", _pend(torch.tensor([1.0, 0.0])))
rs.commit("k", 1.0)