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:
+1
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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…");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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("")
|
||||
|
||||
Reference in New Issue
Block a user