diff --git a/conditioning.py b/conditioning.py index 0bd00895..feb749e0 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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 diff --git a/h3_repr_steering.py b/h3_repr_steering.py index 354436ab..501712ab 100644 --- a/h3_repr_steering.py +++ b/h3_repr_steering.py @@ -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) diff --git a/movie_editor/frontend/rating_picker.js b/movie_editor/frontend/rating_picker.js index 8de73e7b..d06b201b 100644 --- a/movie_editor/frontend/rating_picker.js +++ b/movie_editor/frontend/rating_picker.js @@ -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); diff --git a/movie_editor/frontend/rating_picker.test.js b/movie_editor/frontend/rating_picker.test.js new file mode 100644 index 00000000..f880ed64 --- /dev/null +++ b/movie_editor/frontend/rating_picker.test.js @@ -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…"); +}); diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 79d359af..911a3f03 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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); diff --git a/tests/test_h3_repr_steering.py b/tests/test_h3_repr_steering.py index 0355d17c..86932a63 100644 --- a/tests/test_h3_repr_steering.py +++ b/tests/test_h3_repr_steering.py @@ -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("")