diff --git a/conditioning.py b/conditioning.py index 3b130895..ab067d53 100644 --- a/conditioning.py +++ b/conditioning.py @@ -6113,21 +6113,24 @@ V2_RATING_PROFILES = { "Missing action": {"key": "missing_action", "reward": 0.05, "level": 5, "missing_axes": ["action"]}, "Missing quality": {"key": "missing_quality", "reward": -0.30, "level": 4, "missing_axes": ["quality"]}, "Missing details + action": {"key": "missing_details_action", "reward": -0.10, "level": 3, "missing_axes": ["details", "action"]}, - # Wrong-* are prompt-REPAIR signals ("good gen, but the words/identity were off"), not quality - # rewards. `skip_value_function` keeps them out of value-function + Absolute-taste reward - # training (where a 0.0/low reward on a visually-good gen poisons the learned quality landscape - # and the ascent direction → drift/wrong-character). They STILL drive prompt repair and the - # relative direction/category memory via their missing_axes/wrong_axes/wrong_categories. + # Wrong-* are quality-neutral signals ("good gen, but the words/identity were off"), not + # quality rewards. `skip_value_function` keeps them out of value-function + Absolute-taste + # reward training (where a 0.0/low reward on a visually-good gen poisons the learned quality + # landscape and the ascent direction → drift/wrong-character). They STILL drive the relative + # direction/category memory via their missing_axes/wrong_axes. "Wrong details": {"key": "wrong_details", "reward": 0.20, "level": 5, "missing_axes": ["details"], "wrong_axes": ["details"], "skip_value_function": True}, "Wrong action": {"key": "wrong_action", "reward": -0.10, "level": 4, "missing_axes": ["action"], "wrong_axes": ["action"], "skip_value_function": True}, "Wrong action + quality": {"key": "wrong_action_quality", "reward": -0.40, "level": 3, "missing_axes": ["quality"], "wrong_axes": ["action"], "skip_value_function": True}, "Wrong details + action": {"key": "wrong_details_action", "reward": 0.00, "level": 3, "missing_axes": ["details", "action"], "wrong_axes": ["details", "action"], "skip_value_function": True}, + # "Wrong appearance" = character consistency wasn't preserved. Drives the consistency anchor + # (_v2_update_appearance_anchor / _v2_apply_conditioning_memory_impl): pull the next gen toward + # the last good-rated conditioning for this key and repel from the rejected drift. Not a reward + # (skip_value_function) — it's a conditioning nudge, not a quality score. "Wrong appearance": { "key": "wrong_appearance", "reward": 0.0, "level": 4, "missing_axes": [], - "wrong_categories": ["appearance", "subject", "environment"], "skip_value_function": True, }, "Missing details + quality": {"key": "missing_details_quality", "reward": -0.40, "level": 2, "missing_axes": ["details", "quality"]}, @@ -6858,6 +6861,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): seed_output_connected=bool(seed_output_connected), ) self._v2_update_intent_family_memory(target_global, scene_run, profile, iter_num, axis_feedback) + self._v2_update_appearance_anchor(target_global, scene_run, profile, iter_num) self._v2_update_negative_prompt_memory(target_global, scene_run, profile, axis_feedback) self._v2_update_intent_alignment_memory(target_global, scene_run, profile, iter_num, axis_feedback) self._v2_update_conditioning_memory(target_global, scene_run, profile, axis_feedback) @@ -8365,6 +8369,42 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): slot["count"] = 1 return True + def _v2_update_appearance_anchor(self, global_state, previous_run, rating_profile, iter_num): + """Consistency anchor for the "Wrong appearance" rating. global_state is already + per-refinement-key, so these live with that key automatically. + + On a good rating, remember the rated run's conditioning as the "blessed appearance" + (the last good character). On a Wrong-appearance rating, remember the rejected run's + conditioning as the "drift" to repel from. _v2_apply_conditioning_memory_impl pulls the + next gen toward the blessed appearance and away from the drift when the user rated the + previous gen Wrong appearance.""" + if not isinstance(global_state, dict) or not isinstance(previous_run, dict): + return "Appearance anchor: no update." + if rating_profile.get("skip_learning"): + return "Appearance anchor: no update." + conditioning = previous_run.get("conditioning") + if not isinstance(conditioning, dict): + return "Appearance anchor: run carried no conditioning." + key = rating_profile.get("key", "") + is_good = key in {"like", "nailed_it", "loved_it"} or bool(rating_profile.get("loved_modifier")) + if is_good: + existing = global_state.get("appearance_anchor") or {} + global_state["appearance_anchor"] = { + "conditioning": conditioning, + "prompt": self._v2_prompt_key(previous_run.get("prompt", "")), + "count": int(existing.get("count", 0)) + 1, + "last_seen_iter": int(iter_num), + } + return "Appearance anchor: blessed appearance stored." + if key == "wrong_appearance": + global_state["appearance_drift"] = { + "conditioning": conditioning, + "prompt": self._v2_prompt_key(previous_run.get("prompt", "")), + "last_seen_iter": int(iter_num), + } + return "Appearance anchor: drift stored (repel on next gen)." + return "Appearance anchor: no update." + def _v2_update_intent_family_memory(self, global_state, last_run, rating_profile, iter_num, axis_feedback=None): if not isinstance(global_state, dict) or not isinstance(last_run, dict) or rating_profile.get("skip_learning"): return "Intent family: no learning update.", "" @@ -9971,6 +10011,25 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): mixed, family_delta_status = self._v2_apply_intent_family_delta(mixed, intent_family_slot, strength) + # --- Consistency anchor (Wrong appearance) --- + # "Wrong appearance" means the established character drifted. Pull this run gently back + # toward the last good-rated conditioning for this key (the "blessed appearance") and + # repel from the just-rejected drifted look. Gentle fixed strengths; the global 7.5% + # delta cap below bounds the net effect. No-op when nothing is stored or shapes mismatch. + appearance_status = "appearance-anchor idle" + if rating_profile.get("key") == "wrong_appearance": + anchor = global_state.get("appearance_anchor") or {} + drift = global_state.get("appearance_drift") or {} + anchor_actions = [] + if self._v2_shape_compatible(anchor.get("conditioning"), mixed): + mixed = self._v2_apply_conditioning_payload(mixed, anchor.get("conditioning"), 0.05) + anchor_actions.append("pull→blessed") + if self._v2_shape_compatible(drift.get("conditioning"), mixed): + mixed = self._v2_repel_conditioning_payload(mixed, drift.get("conditioning"), 0.04) + anchor_actions.append("repel←drift") + if anchor_actions: + appearance_status = "appearance-anchor: " + " + ".join(anchor_actions) + axis_memory = global_state.get("axis_conditioning_memory", {}) axis_actions = [] missing_axes = set(axis_feedback.get("missing_axes", [])) @@ -10096,6 +10155,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): f" Bad conditioning: {'direction' if bad_count >= 3 else f'lerp fallback ({bad_count}/3 runs)'}\n" f" Axis adjustments: {axis_str}\n" f" {family_delta_status}\n" + f" {appearance_status}\n" f" Total delta clamped to 7.5% of conditioning norm" ) @@ -11610,6 +11670,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): global_state.setdefault("intent_alignment_memory", {}) global_state.setdefault("intent_family_memory", {}) global_state.setdefault("perfect_anchors", {}) + global_state.setdefault("appearance_anchor", {}) + global_state.setdefault("appearance_drift", {}) global_state.setdefault("variant_evidence", {}) global_state.setdefault("intent_preference_phrases", {}) global_state.setdefault("conditioning_deltas", {}) @@ -11711,6 +11773,12 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): int(global_state.get("total_iterations", 0)) + 1, axis_feedback, ) + self._v2_update_appearance_anchor( + global_state, + previous_run, + learning_profile, + int(global_state.get("total_iterations", 0)) + 1, + ) negative_memory_status = self._v2_update_negative_prompt_memory( global_state, previous_run, diff --git a/tests/test_v2_category_alignment.py b/tests/test_v2_category_alignment.py index badfbb0b..5dd62210 100644 --- a/tests/test_v2_category_alignment.py +++ b/tests/test_v2_category_alignment.py @@ -256,7 +256,10 @@ def test_wrong_appearance_rating_is_available(): assert "Wrong appearance" in V2_RATING_LABELS assert profile["key"] == "wrong_appearance" - assert profile["wrong_categories"] == ["appearance", "subject", "environment"] + # Wrong appearance now drives the consistency anchor, not prompt repair (the dead + # wrong_categories field was dropped in Stage 2 Part B). It stays quality-neutral. + assert "wrong_categories" not in profile + assert profile["skip_value_function"] is True def test_scene_builder_user_category_overrides_refiner_classification(): @@ -2067,3 +2070,132 @@ def test_split_by_transitions_uses_provided_scene_seeds(tmp_path): assert [item[1]["funpack_scene_seed"] for item in cond] == [900, 901] assert all(item[1]["funpack_seed_source"] == "successful seed memory" for item in cond) + + +# --- Stage 2 Part B: "Wrong appearance" consistency anchor --- + +def _mean_cosine(a, b): + a = a.reshape(-1, a.shape[-1]).float() + b = b.reshape(-1, b.shape[-1]).float() + a = a / a.norm(dim=-1, keepdim=True).clamp_min(1e-8) + b = b / b.norm(dim=-1, keepdim=True).clamp_min(1e-8) + return float((a * b).sum(dim=-1).mean().item()) + + +def test_appearance_anchor_stores_blessed_on_good_rating(): + refiner = FunPackVideoRefinerV2() + global_state = {} + payload = tensor_to_serializable(torch.randn(1, 6, 8)) + previous_run = {"conditioning": payload, "prompt": "woman in red dress"} + + status = refiner._v2_update_appearance_anchor( + global_state, previous_run, normalize_refiner_v2_rating("Perfect"), 3 + ) + + assert "blessed" in status.lower() + assert global_state["appearance_anchor"]["conditioning"] is payload + assert global_state["appearance_anchor"]["count"] == 1 + # A good rating should NOT populate the drift slot. + assert "appearance_drift" not in global_state + + +def test_appearance_anchor_stores_drift_on_wrong_appearance(): + refiner = FunPackVideoRefinerV2() + global_state = {} + payload = tensor_to_serializable(torch.randn(1, 6, 8)) + previous_run = {"conditioning": payload, "prompt": "woman in red dress"} + + refiner._v2_update_appearance_anchor( + global_state, previous_run, normalize_refiner_v2_rating("Wrong appearance"), 4 + ) + + assert global_state["appearance_drift"]["conditioning"] is payload + # Wrong appearance must not overwrite/seed the blessed anchor. + assert "appearance_anchor" not in global_state + + +def test_good_rating_overwrites_blessed_anchor(): + refiner = FunPackVideoRefinerV2() + global_state = {} + first = tensor_to_serializable(torch.randn(1, 6, 8)) + second = tensor_to_serializable(torch.randn(1, 6, 8)) + + refiner._v2_update_appearance_anchor( + global_state, {"conditioning": first, "prompt": "a"}, normalize_refiner_v2_rating("Perfect"), 1 + ) + refiner._v2_update_appearance_anchor( + global_state, {"conditioning": second, "prompt": "b"}, normalize_refiner_v2_rating("Nailed it"), 2 + ) + + assert global_state["appearance_anchor"]["conditioning"] is second + assert global_state["appearance_anchor"]["count"] == 2 + + +def test_appearance_anchor_skips_when_no_conditioning_or_skip_learning(): + refiner = FunPackVideoRefinerV2() + global_state = {} + # No conditioning payload on the run. + refiner._v2_update_appearance_anchor( + global_state, {"prompt": "x"}, normalize_refiner_v2_rating("Perfect"), 1 + ) + assert "appearance_anchor" not in global_state + # skip_learning rating (e.g. -Just forget it-) never stores. + payload = tensor_to_serializable(torch.randn(1, 6, 8)) + refiner._v2_update_appearance_anchor( + global_state, {"conditioning": payload}, normalize_refiner_v2_rating("-Just forget it-"), 1 + ) + assert "appearance_anchor" not in global_state + + +def test_wrong_appearance_pulls_toward_blessed_and_repels_drift(): + refiner = FunPackVideoRefinerV2() + torch.manual_seed(0) + original = torch.randn(1, 6, 8) + blessed = torch.randn(1, 6, 8) + drift = torch.randn(1, 6, 8) + global_state = { + "appearance_anchor": {"conditioning": tensor_to_serializable(blessed)}, + "appearance_drift": {"conditioning": tensor_to_serializable(drift)}, + } + + refined, status = refiner._v2_apply_conditioning_memory( + original.clone(), global_state, normalize_refiner_v2_rating("Wrong appearance") + ) + + assert "appearance-anchor: pull→blessed + repel←drift" in status + # Output moved toward the blessed appearance and away from the drift (direction). + assert _mean_cosine(refined, blessed) > _mean_cosine(original, blessed) + assert _mean_cosine(refined, drift) < _mean_cosine(original, drift) + # Gentle: per-token norm is preserved and the net change is small. + assert torch.allclose( + refined.norm(dim=-1), original.norm(dim=-1), atol=1e-3 + ) + assert (refined - original).norm() / original.norm() < 0.08 + + +def test_appearance_anchor_idle_without_blessed(): + refiner = FunPackVideoRefinerV2() + torch.manual_seed(1) + original = torch.randn(1, 6, 8) + + refined, status = refiner._v2_apply_conditioning_memory( + original.clone(), {}, normalize_refiner_v2_rating("Wrong appearance") + ) + + assert "appearance-anchor idle" in status + assert torch.allclose(refined, original, atol=1e-4) + + +def test_appearance_anchor_not_applied_on_non_wrong_appearance_rating(): + refiner = FunPackVideoRefinerV2() + torch.manual_seed(2) + original = torch.randn(1, 6, 8) + blessed = torch.randn(1, 6, 8) + global_state = {"appearance_anchor": {"conditioning": tensor_to_serializable(blessed)}} + + # A "Missing details" rating must not trigger the appearance anchor pull. + refined, status = refiner._v2_apply_conditioning_memory( + original.clone(), global_state, normalize_refiner_v2_rating("Missing details") + ) + + assert "appearance-anchor idle" in status