feat: H3 rating picker becomes a 1-10 scale; REINS learns from rating identity, not reward sign

The category grid assumes axis-specific effects (phrase emphasis,
dynashift...) this project has not proven land differently on H3.
Replaces it with a single scale for H3 pipelines only -- the backend
already turns a bare number into the nearest existing rating profile
(normalize_refiner_v2_rating's legacy-score path), so every consumer
(value functions, phrase emphasis, dynashift, path planner) keeps
working unchanged; only what the picker sends changes.

Also fixes h3_repr_steering: it was splitting liked/disliked by the
sign of the blended scalar reward, which counts near-misses like
Missing action (+0.05) as "liked" even though that reward exists for
the value functions' quality-landscape ascent, not as a liked/disliked
verdict. Now filters by rating KEY (LIKED_KEYS/DISLIKED_KEYS) and
excludes everything ambiguous instead of guessing a side for it.
This commit is contained in:
digital-garbage
2026-09-03 18:22:11 +03:00
parent 6e28f56c92
commit 33038ae700
6 changed files with 109 additions and 40 deletions
+1 -1
View File
@@ -10152,7 +10152,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
import minimax_h3 as _h3
import h3_repr_steering as _rs
if _h3.is_h3_clip(clip):
_rs.commit(refinement_key, float(learning_profile.get("reward", 0.0)))
_rs.commit(refinement_key, learning_profile.get("key", ""))
# DynaShift negative memory: pair the sampler's pending raw latent with this
# rating — promote into the per-key negative bank when the rating marks the run
# a bad outcome (intrusions: awful / wrong_appearance; or the quality-missing
+22 -21
View File
@@ -32,6 +32,17 @@ DEFAULT_BLOCK = 25
MIN_PER_GROUP = 3 # "need 3+ liked generations" -- same floor absolute/taste steering uses.
# Rating KEYS this mechanism will learn from -- not the blended scalar reward every other
# mechanism reads. That scalar is a quality-LANDSCAPE score (Missing action = +0.05: "the
# frame was fine, just no motion" -- a near-miss worth a gentle push in the value functions'
# ascent, not a failure) and reusing it here means a near-miss counts as "liked", diluting a
# direction that needs to be a clean liked/disliked split. Everything not listed -- Wrong
# action, Wrong appearance, Missing action, all the near-misses -- is EXCLUDED, not counted
# toward either side: they are genuinely ambiguous for "was this a good generation", and
# excluding beats guessing.
LIKED_KEYS = {"like", "nailed_it", "loved_it"}
DISLIKED_KEYS = {"awful", "missing_quality", "missing_details_quality", "missing_action_quality"}
def _log():
try:
@@ -128,30 +139,20 @@ def save_pending(refinement_key, descriptor):
"this run's capture is not kept")
def commit(refinement_key, reward):
"""Pairs the pending capture with a rating's reward and appends it to the log. A neutral
/ skipped rating (reward is None) discards the pending capture without logging a row --
same as trajectory_probe: no reward means no learning signal, not a zero one."""
def commit(refinement_key, rating_key):
"""Pairs the pending capture with a rating's KEY (not the blended scalar reward every
other mechanism reads -- see LIKED_KEYS/DISLIKED_KEYS) and appends it to the log if the
key lands in either group. A key that lands in neither (Missing action, Wrong appearance,
every near-miss) discards the pending capture without logging a row -- ambiguous evidence
is excluded, not counted toward the side its reward sign happened to fall on."""
if not refinement_key:
return
data = _load(refinement_key)
pending = data.get("pending")
data["pending"] = None
if pending is None or reward is None:
try:
_save(refinement_key, data)
except OSError as e:
_log().failed("H3 representation steering", "commit", e, "rating not recorded")
return
try:
reward = float(reward)
except (TypeError, ValueError):
try:
_save(refinement_key, data)
except OSError:
pass
return
data["rows"].append({"desc": pending, "reward": reward})
key = str(rating_key or "")
if pending is not None and (key in LIKED_KEYS or key in DISLIKED_KEYS):
data["rows"].append({"desc": pending, "liked": key in LIKED_KEYS})
try:
_save(refinement_key, data)
except OSError as e:
@@ -176,8 +177,8 @@ def direction(refinement_key):
means equally and cancels in the subtraction by construction -- centring only matters
when a vector is compared to itself normalised, which nothing here does."""
data = _load(refinement_key)
liked = [r["desc"] for r in data["rows"] if isinstance(r, dict) and r.get("reward", 0) > 0]
disliked = [r["desc"] for r in data["rows"] if isinstance(r, dict) and r.get("reward", 0) < 0]
liked = [r["desc"] for r in data["rows"] if isinstance(r, dict) and r.get("liked") is True]
disliked = [r["desc"] for r in data["rows"] if isinstance(r, dict) and r.get("liked") is False]
if len(liked) < MIN_PER_GROUP or len(disliked) < MIN_PER_GROUP:
return None, len(liked), len(disliked)
diff = torch.stack(liked).mean(dim=0) - torch.stack(disliked).mean(dim=0)
+39 -8
View File
@@ -44,6 +44,20 @@
{ label: FORGET_LABEL, reward: "none", hint: "Clears the rating — no learning signal." },
];
// 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.`,
};
});
// 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;
@@ -69,7 +83,8 @@
function formatLabel(value) {
const v = String(value || "").trim();
if (!v || v === FORGET_LABEL) return "";
if (v.endsWith("|loved")) return displayName(v.slice(0, -6)) + " ♥";
if (v.endsWith("|loved")) return formatLabel(v.slice(0, -6)) + " ♥";
if (/^\d+$/.test(v)) return `${v}/10`;
return displayName(v);
}
@@ -135,7 +150,7 @@
return wrap;
}
function open(event, currentValue, onPick) {
function open(event, currentValue, onPick, { h3 = false } = {}) {
closePicker();
const picker = document.createElement("div");
@@ -172,25 +187,41 @@
closePicker();
};
for (const cat of CATEGORIES) {
if (h3) {
const section = document.createElement("div");
section.className = "me-rating-section";
section.style.setProperty("--accent", cat.accent);
section.style.setProperty("--accent", "150,150,160");
const sectionLabel = document.createElement("div");
sectionLabel.className = "me-rating-section-label";
sectionLabel.textContent = cat.label;
sectionLabel.textContent = "How close was this? (1 = everything wrong, 10 = exactly right)";
section.append(sectionLabel);
const grid = document.createElement("div");
grid.className = cat.id === "positive" ? "me-rating-grid me-rating-grid-pos" : "me-rating-grid";
for (const r of cat.ratings) grid.append(makeOption(r, cat.accent, handlePick, allOptions));
grid.className = "me-rating-grid";
for (const r of H3_SCALE) grid.append(makeOption(r, "150,150,160", handlePick, allOptions));
section.append(grid);
picker.append(section);
} else {
for (const cat of CATEGORIES) {
const section = document.createElement("div");
section.className = "me-rating-section";
section.style.setProperty("--accent", cat.accent);
const sectionLabel = document.createElement("div");
sectionLabel.className = "me-rating-section-label";
sectionLabel.textContent = cat.label;
section.append(sectionLabel);
const grid = document.createElement("div");
grid.className = cat.id === "positive" ? "me-rating-grid me-rating-grid-pos" : "me-rating-grid";
for (const r of cat.ratings) grid.append(makeOption(r, cat.accent, handlePick, allOptions));
section.append(grid);
picker.append(section);
}
}
const nuclear = document.createElement("div");
nuclear.className = "me-rating-nuclear";
nuclear.style.setProperty("--accent", "140,100,100");
for (const r of NUCLEAR) nuclear.append(makeOption(r, "140,100,100", handlePick, allOptions));
const nuclearRatings = h3 ? [{ label: FORGET_LABEL, reward: "none", hint: "Clears the rating — no learning signal." }] : NUCLEAR;
for (const r of nuclearRatings) nuclear.append(makeOption(r, "140,100,100", handlePick, allOptions));
picker.append(nuclear);
setActive(currentBase, currentLoved);
@@ -0,0 +1,27 @@
// H3's rating scale sends a bare number instead of a category label; formatLabel/buttonLabel
// must render it sensibly since normalize_refiner_v2_rating (conditioning.py) already accepts
// it verbatim. Run: node --test movie_editor/frontend/rating_picker.test.js
const test = require("node:test");
const assert = require("node:assert");
global.window = {};
require("./rating_picker.js");
const Picker = global.window.MovieRatingPicker;
test("a bare scale number renders as N/10", () => {
assert.strictEqual(Picker.formatLabel("7"), "7/10");
assert.strictEqual(Picker.buttonLabel("7"), "★ 7/10");
});
test("a loved scale number keeps the heart", () => {
assert.strictEqual(Picker.formatLabel("9|loved"), "9/10 ♥");
});
test("an existing category label is untouched", () => {
assert.strictEqual(Picker.formatLabel("Perfect"), "Perfect");
});
test("forget and empty still render as nothing / the prompt", () => {
assert.strictEqual(Picker.formatLabel(Picker.FORGET_LABEL), "");
assert.strictEqual(Picker.buttonLabel(""), "Rate scene…");
});
+1 -1
View File
@@ -1807,7 +1807,7 @@
Picker.open(e, raw, (label) => {
const val = label === Picker.FORGET_LABEL ? "" : label;
S.setSceneRating(sc.id, val);
});
}, { h3: !!window.PipelineCaps?.isH3(st) });
};
wrap.append(rlabel);
wrap.append(btn);
+19 -9
View File
@@ -61,10 +61,10 @@ def test_capture_with_no_mask_is_none():
def test_direction_needs_min_per_group_of_each():
for i in range(rs.MIN_PER_GROUP - 1):
rs.save_pending("k", torch.tensor([1.0, 0.0]))
rs.commit("k", 1.0)
rs.commit("k", "like")
for i in range(rs.MIN_PER_GROUP):
rs.save_pending("k", torch.tensor([-1.0, 0.0]))
rs.commit("k", -1.0)
rs.commit("k", "awful")
direction, n_liked, n_disliked = rs.direction("k")
assert direction is None
assert n_liked == rs.MIN_PER_GROUP - 1
@@ -74,10 +74,10 @@ def test_direction_needs_min_per_group_of_each():
def test_direction_points_from_disliked_mean_to_liked_mean():
for _ in range(rs.MIN_PER_GROUP):
rs.save_pending("k", torch.tensor([5.0, 0.0]))
rs.commit("k", 1.0)
rs.commit("k", "like")
for _ in range(rs.MIN_PER_GROUP):
rs.save_pending("k", torch.tensor([-5.0, 0.0]))
rs.commit("k", -1.0)
rs.commit("k", "awful")
direction, n_liked, n_disliked = rs.direction("k")
assert direction is not None
assert n_liked == rs.MIN_PER_GROUP and n_disliked == rs.MIN_PER_GROUP
@@ -90,10 +90,10 @@ def test_shared_content_cancels_without_needing_explicit_centring():
shared = torch.tensor([1000.0, 1000.0])
for _ in range(rs.MIN_PER_GROUP):
rs.save_pending("k", shared + torch.tensor([1.0, 0.0]))
rs.commit("k", 1.0)
rs.commit("k", "nailed_it")
for _ in range(rs.MIN_PER_GROUP):
rs.save_pending("k", shared + torch.tensor([-1.0, 0.0]))
rs.commit("k", -1.0)
rs.commit("k", "missing_quality")
direction, _, _ = rs.direction("k")
assert torch.allclose(direction, torch.tensor([1.0, 0.0]), atol=1e-5)
@@ -105,10 +105,20 @@ def test_pending_is_discarded_on_a_neutral_rating():
assert n_liked == 0 and n_disliked == 0
def test_an_ambiguous_near_miss_is_excluded_from_both_sides():
"""Missing action: reward +0.05 in the shared table, but that scalar is a quality-
landscape score for other mechanisms, not a liked/disliked verdict for this one -- it
must not count as 'liked' just because its reward sign is positive."""
rs.save_pending("k", torch.tensor([1.0, 0.0]))
rs.commit("k", "missing_action")
direction, n_liked, n_disliked = rs.direction("k")
assert n_liked == 0 and n_disliked == 0
def test_pending_overwrites_not_accumulates():
rs.save_pending("k", torch.tensor([1.0, 0.0]))
rs.save_pending("k", torch.tensor([2.0, 0.0])) # a second capture before any rating
rs.commit("k", 1.0)
rs.commit("k", "like")
data = rs._load("k")
assert len(data["rows"]) == 1
assert torch.allclose(data["rows"][0]["desc"], torch.tensor([2.0, 0.0]))
@@ -116,7 +126,7 @@ def test_pending_overwrites_not_accumulates():
def test_clear_all_removes_the_state_file():
rs.save_pending("k", torch.tensor([1.0, 0.0]))
rs.commit("k", 1.0)
rs.commit("k", "like")
rs.clear_all("k")
direction, n_liked, n_disliked = rs.direction("k")
assert direction is None and n_liked == 0 and n_disliked == 0
@@ -124,5 +134,5 @@ def test_clear_all_removes_the_state_file():
def test_no_refinement_key_is_a_safe_no_op():
rs.save_pending("", torch.tensor([1.0])) # must not raise
rs.commit("", 1.0)
rs.commit("", "like")
rs.clear_all("")