vajeeda's picture
feat(phase9): platform reward divergence β€” PlatformRegistry, R9 PlatformPacing, R1/R2 platform-aware, 20 tests PASS, gate PASS
998d987
Raw
History Blame Contribute Delete
12.5 kB
"""Phase 9 tests β€” Multi-Platform Reward Divergence."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from viral_script_engine.platforms.platform_spec import PlatformRegistry, PlatformSpec
from viral_script_engine.rewards.r1_hook_strength import HookStrengthReward
from viral_script_engine.rewards.r2_coherence import CoherenceReward
from viral_script_engine.rewards.r9_platform_pacing import PlatformPacingReward
# ── PlatformRegistry ──────────────────────────────────────────────────────────
class TestPlatformRegistry:
def setup_method(self):
self.reg = PlatformRegistry()
def test_get_reels(self):
spec = self.reg.get("Reels")
assert isinstance(spec, PlatformSpec)
assert spec.platform == "Reels"
assert spec.hook_window_seconds == 3
assert spec.max_script_length_words == 180
assert spec.pacing_norm == "fast"
def test_get_shorts(self):
spec = self.reg.get("Shorts")
assert spec.hook_window_seconds == 2
assert spec.max_script_length_words == 120
assert spec.hook_length_words == 10
assert spec.pacing_norm == "very_fast"
def test_get_feed(self):
spec = self.reg.get("Feed")
assert spec.hook_window_seconds == 5
assert spec.max_script_length_words == 300
assert spec.hook_length_words == 25
assert spec.pacing_norm == "moderate"
def test_get_tiktok(self):
spec = self.reg.get("TikTok")
assert spec.hook_window_seconds == 2
assert spec.max_script_length_words == 150
def test_unknown_platform_raises(self):
with pytest.raises(ValueError, match="Unknown platform"):
self.reg.get("Instagram")
def test_all_platforms_have_required_fields(self):
for platform in ["Reels", "Shorts", "Feed", "TikTok"]:
spec = self.reg.get(platform)
assert spec.hook_length_words > 0
assert spec.optimal_script_length_words > 0
assert spec.cta_position != ""
# ── R1 platform-aware hook scoring ───────────────────────────────────────────
# A hook that has 20 words β€” within Reels (15) is a big overrun,
# but Feed allows 25 so it's comfortably within spec.
_LONG_HOOK_SCRIPT = (
"Why does your phone battery lie to you every single morning when you check it? "
"Because manufacturers hide the real numbers. "
"Charge to eighty percent, never below twenty. "
"You will get two extra years. Follow for more."
)
# A very short hook β€” first 3 sentences are 8 words total, well within Reels (15) limit.
_SHORT_HOOK_SCRIPT = (
"Battery lies. Charge to eighty. Never below twenty. "
"Phone manufacturers hide the real numbers to make you charge more often. "
"Subscribe for the full battery myth-busting series."
)
class TestR1PlatformAware:
def setup_method(self):
self.r1 = HookStrengthReward()
def test_long_hook_scores_lower_on_shorts_than_feed(self):
shorts_score = self.r1.score(_LONG_HOOK_SCRIPT, platform="Shorts").score
feed_score = self.r1.score(_LONG_HOOK_SCRIPT, platform="Feed").score
assert feed_score >= shorts_score, (
f"Feed ({feed_score:.3f}) should be >= Shorts ({shorts_score:.3f}) for a 20-word hook"
)
def test_short_hook_passes_length_fit_on_reels(self):
result = self.r1.score(_SHORT_HOOK_SCRIPT, platform="Reels")
assert result.check_details.get("length_fit") is True
def test_platform_param_defaults_to_reels(self):
r_default = self.r1.score(_SHORT_HOOK_SCRIPT)
r_reels = self.r1.score(_SHORT_HOOK_SCRIPT, platform="Reels")
assert abs(r_default.score - r_reels.score) < 1e-6
# ── R2 length penalty ─────────────────────────────────────────────────────────
_SHORT_SCRIPT = "Why is your battery lying? Charge to 80. Never below 20. Subscribe."
_LONG_SCRIPT = " ".join(["This is a filler sentence that adds many words."] * 30)
class TestR2LengthPenalty:
def setup_method(self):
self.r2 = CoherenceReward()
def test_length_penalty_applied_when_over_shorts_max(self):
# _LONG_SCRIPT far exceeds Shorts max (120 words)
score_shorts = self.r2.score(_SHORT_SCRIPT, _LONG_SCRIPT, platform="Shorts").score
score_feed = self.r2.score(_SHORT_SCRIPT, _LONG_SCRIPT, platform="Feed").score
# Feed allows 300 words β€” less penalty than Shorts (120 words max)
assert score_feed >= score_shorts
def test_no_penalty_when_within_limit(self):
within_limit = " ".join(["short word"] * 60) # 120 words, within Shorts limit
result = self.r2.score(_SHORT_SCRIPT, within_limit, platform="Shorts")
assert result.score >= 0.0
def test_penalty_capped_at_0_3(self):
# Use same-vocabulary rewrites so base semantic score stays constant.
# Only the length penalty differs; the cap of 0.3 limits the score delta.
base = " ".join(["word"] * 50)
just_over = " ".join(["word"] * 125) # 4% over Shorts max (120)
way_over = " ".join(["word"] * 5000) # 40x over Shorts max
r_just_over = self.r2.score(base, just_over, platform="Shorts")
r_way_over = self.r2.score(base, way_over, platform="Shorts")
# Both have same base similarity; penalty capped at 0.3 β†’ score delta ≀ 0.3
assert r_just_over.score - r_way_over.score <= 0.31
# ── R9 PlatformPacingReward ───────────────────────────────────────────────────
# Fast-paced script: short sentences in hook
_FAST_SCRIPT = (
"Your phone lies. Battery is fake. Charge to eighty. "
"Manufacturers hide the real numbers so you charge more often. "
"The fix is simple: never go above eighty, never below twenty. "
"Do this for two weeks. You get two extra years. Subscribe."
)
# Slow-paced script: long meandering hook sentence
_SLOW_SCRIPT = (
"So I wanted to start by talking about something that I think is really quite interesting "
"and important that most people don't really think about when they're using their phone on a "
"daily basis, which is the way that battery life is actually calculated and displayed to you. "
"The numbers are not real. Charge to eighty. "
"Subscribe for more."
)
class TestR9PlatformPacing:
def setup_method(self):
self.r9 = PlatformPacingReward()
def test_fast_script_scores_higher_on_reels_than_slow(self):
fast = self.r9.score(_FAST_SCRIPT, platform="Reels").score
slow = self.r9.score(_SLOW_SCRIPT, platform="Reels").score
assert fast > slow, f"fast ({fast:.3f}) should beat slow ({slow:.3f}) on Reels"
def test_same_script_scores_differently_on_reels_vs_feed(self):
# Use _FAST_SCRIPT which has short hook sentences (~3 words each).
# Reels threshold=12 β†’ pacing_score=1.0; Feed threshold=18 β†’ pacing_score=1.0 too,
# but ratio differs because optimal_hook_ratio changes between platforms.
reels = self.r9.score(_FAST_SCRIPT, platform="Reels")
feed = self.r9.score(_FAST_SCRIPT, platform="Feed")
# At least one sub-score must differ (pacing, ratio, or cta threshold)
differs = (
reels.pacing_score != feed.pacing_score
or reels.ratio_score != feed.ratio_score
or reels.cta_score != feed.cta_score
)
assert differs, (
f"No R9 sub-score differed between Reels and Feed: "
f"pacing={reels.pacing_score}/{feed.pacing_score}, "
f"ratio={reels.ratio_score}/{feed.ratio_score}, "
f"cta={reels.cta_score}/{feed.cta_score}"
)
def test_cta_position_correct_for_reels(self):
# A script where >90% of words come before the CTA should score 1.0 on cta_score
body = " ".join(["content word"] * 20)
cta = "Follow for more tips."
script = f"Your phone lies. {body} {cta}"
result = self.r9.score(script, platform="Reels")
assert result.cta_score in (0.5, 1.0)
def test_cta_position_correct_for_shorts(self):
result = self.r9.score(_FAST_SCRIPT, platform="Shorts")
assert 0.0 <= result.score <= 1.0
assert result.platform == "Shorts"
def test_scores_in_valid_range(self):
for platform in ["Reels", "Shorts", "Feed", "TikTok"]:
result = self.r9.score(_FAST_SCRIPT, platform=platform)
assert 0.0 <= result.score <= 1.0
assert 0.0 <= result.pacing_score <= 1.0
assert 0.0 <= result.ratio_score <= 1.0
assert result.cta_score in (0.5, 1.0)
def test_cross_platform_divergence_proof(self):
"""Key proof: same script produces different R9 scores across platforms."""
scores = {p: self.r9.score(_FAST_SCRIPT, platform=p).score
for p in ["Reels", "Shorts", "Feed"]}
unique_scores = len(set(round(s, 3) for s in scores.values()))
assert unique_scores > 1, f"All platforms returned identical R9 score: {scores}"
# ── env.step() passes platform to reward functions ───────────────────────────
class TestEnvPlatformWiring:
def test_env_r9_fires_in_step(self):
"""env.step() must include r9_platform_pacing in reward components."""
from unittest.mock import patch, MagicMock
from viral_script_engine.environment.env import ViralScriptEnv
scripts_path = str(
Path(__file__).parent.parent / "data" / "test_scripts" / "scripts.json"
)
cultural_kb_path = str(
Path(__file__).parent.parent / "data" / "cultural_kb.json"
)
env = ViralScriptEnv(
scripts_path=scripts_path,
cultural_kb_path=cultural_kb_path,
difficulty="easy",
use_escalation=False,
use_anti_gaming=False,
)
env.reset()
current_text = env._state.current_script
mock_critique = MagicMock()
mock_critique.claims = []
mock_critique.overall_severity = "low"
mock_defender_out = MagicMock()
mock_defender_out.core_strength = "test"
mock_defender_out.core_strength_quote = "test"
mock_defender_out.defense_argument = "test"
mock_defender_out.flagged_critic_claims = []
mock_defender_out.regional_voice_elements = []
mock_defender_out.model_dump.return_value = {}
mock_rewrite = MagicMock()
mock_rewrite.rewritten_script = current_text
mock_rewrite.diff = ""
with patch.object(env.critic, "critique", return_value=mock_critique), \
patch.object(env.defender, "defend", return_value=mock_defender_out), \
patch.object(env.rewriter, "rewrite", return_value=mock_rewrite):
action = {
"action_type": "hook_rewrite",
"target_section": "hook",
"instruction": "Make the hook stronger.",
"critique_claim_id": "C1",
"reasoning": "test",
}
_, _, _, _, info = env.step(action)
rc = info["reward_components"]
assert "r9_platform_pacing" in rc
assert rc["r9_platform_pacing"] is not None
assert 0.0 <= rc["r9_platform_pacing"] <= 1.0
def test_env_stores_current_platform_on_reset(self):
from viral_script_engine.environment.env import ViralScriptEnv
scripts_path = str(
Path(__file__).parent.parent / "data" / "test_scripts" / "scripts.json"
)
cultural_kb_path = str(
Path(__file__).parent.parent / "data" / "cultural_kb.json"
)
env = ViralScriptEnv(
scripts_path=scripts_path,
cultural_kb_path=cultural_kb_path,
difficulty="easy",
use_escalation=False,
)
env.reset()
assert env._current_platform in ["Reels", "Shorts", "Feed", "TikTok"]