Files
SolitaryThinker db866dfb04 [misc] v2: simplify docstrings/comments + drop deleted-design-doc citations
Sweep all 296 v2 modules: simplify verbose docstrings/comments and remove 376 dangling
"(design_vN §X)" citations to the now-deleted design docs (v2/README.md is the source of
truth). Comment/docstring-only — AST-verified code-identical; the CPU suite holds at 237
passed / 5 pre-existing. Also applies yapf + ruff --fix auto-fixes (import ordering,
forward-ref annotation de-quoting under `from __future__ import annotations`; behavior-
neutral, suite-confirmed) and adds the legitimate domain terms mot/clen/te to the codespell
ignore-list. Remaining ruff (24) + mypy (68 no-untyped-call) findings are pre-existing v2
debt, untouched here.
2026-06-19 20:54:59 +00:00

178 lines
8.5 KiB
Python

"""UniRL/PromptRL joint LM+generator RL — the design stress test (arXiv 2510.17937).
These tests assert the *design holds*: a joint two-expert RL recipe fits inside the existing
Card/Loop/Program + training-plane vocabulary with no new primitive. Specifically:
* the unified card is two SEPARATE experts (llm + transformer) on two loops — the topological
opposite of the Cosmos3 MoT card (one shared module on two loops), same vocabulary;
* the serve path is unchanged ODE; SDE-with-logprob capture is gated on ``sde_rollout``;
* likelihood-BASED C2: a per-step log-prob identity (the other half of NFT's likelihood-free C2);
* one reward → two updates (LM token-PG + DiT FlowGRPO-PPO) under one group-relative advantage;
* two WeightSyncPlans version + cache-scope the experts independently;
* prompt-only mode freezes the generator;
* the two-loop serve program still passes the interleave parity gate.
"""
from __future__ import annotations
import numpy as np
from v2._enums import ExecutionProfile, LoopKind
from v2.loop.sampler import flow_sde_step_with_logprob
from v2.recipes import build_unified_engine
from v2.recipes.common import cached_text_encode
from v2.recipes.unified import build_unified_card
from v2.parity import assert_interleave_parity
from v2.request import DiffusionParams, TaskType, make_request
from v2.training.methods import build_unified_rl
from v2.training.rollout import rollout_loop
C2_TOL = 1e-5 # float32 trajectory-storage precision (bf16/fp32 replay buffers in practice)
def _inst(eng):
return eng._registry["unirl-qwenflux"][0]
def _rollout(inst, prompt, *, sde, seed=7, steps=4):
emb = cached_text_encode(inst, prompt)
neg = cached_text_encode(inst, "")
req = make_request(TaskType.T2V, inst.card.model_id, prompt,
diffusion=DiffusionParams(num_steps=steps, seed=seed, guidance_scale=1.0,
sde_rollout=sde))
return rollout_loop(inst, "diffusion_denoise", req,
slots={"text_embeds": emb, "neg_text_embeds": neg},
profile=ExecutionProfile.ROLLOUT), emb
# --- card / program shape --------------------------------------------------------- #
def test_unified_card_validates_and_serves():
eng = build_unified_engine()
req = make_request(TaskType.T2V, "unirl-qwenflux", "a red fox in snow")
res = eng.run(req)
assert res.artifacts["video"].frames is not None
assert np.asarray(res.artifacts["video"].frames).ndim == 4
def test_two_separate_experts_not_a_shared_mot():
"""UniRL keeps the refiner and generator as DISTINCT experts — the two loops bind different
components. (Contrast Cosmos3, where ar_decode + diffusion_denoise bind the SAME transformer.)"""
card = build_unified_card()
assert card.loops["ar_decode"].kind == LoopKind.AR_DECODE
assert card.loops["diffusion_denoise"].kind == LoopKind.DIFFUSION_DENOISE
assert card.loops["ar_decode"].shared_weight_components == ["llm"]
assert card.loops["diffusion_denoise"].shared_weight_components == ["transformer"]
eng = build_unified_engine()
inst = _inst(eng)
assert inst.component("llm") is not inst.component("transformer") # genuinely separate weights
# --- serve ODE vs rollout SDE (gated) --------------------------------------------- #
def test_serve_is_ode_rollout_is_sde():
eng = build_unified_engine()
inst = _inst(eng)
res_ode, _ = _rollout(inst, "a fox", sde=False)
res_sde, _ = _rollout(inst, "a fox", sde=True)
assert all("sde_logprob" not in r for r in res_ode.behavior) # serve sampler: no log-probs
sde_steps = [r for r in res_sde.behavior if "sde_logprob" in r]
assert sde_steps, "SDE rollout must capture per-step log-probs"
for r in sde_steps: # the PPO slice is complete
assert {"sde_logprob", "prev", "sample", "sigma_t", "sigma_next"} <= set(r)
def test_sde_rollout_is_deterministic_given_seed():
eng = build_unified_engine()
inst = _inst(eng)
a, _ = _rollout(inst, "a fox", sde=True, seed=123)
b, _ = _rollout(inst, "a fox", sde=True, seed=123)
assert np.array_equal(a.outputs["latents"], b.outputs["latents"]) # seeded ⇒ bit-identical
assert [r["sde_logprob"] for r in a.behavior if "sde_logprob" in r] == \
[r["sde_logprob"] for r in b.behavior if "sde_logprob" in r]
def test_c2_likelihood_identity_at_rollout_weights():
"""Likelihood-BASED C2: recomputing each step's log-prob under the UNCHANGED rollout weights
reproduces the captured log-prob (⇒ PPO ratio == 1). This is the parity gate this method
lives on, the counterpart to NFT's likelihood-FREE C2."""
eng = build_unified_engine()
inst = _inst(eng)
res, emb = _rollout(inst, "a fox", sde=True, seed=7)
dit = inst.component("transformer")
max_diff = 0.0
for r in res.behavior:
if "sde_logprob" not in r:
continue
v = dit(r["prev"], emb, r["sigma_t"])
_, logp, _, _ = flow_sde_step_with_logprob(r["prev"], v, r["sigma_t"], r["sigma_next"],
prev_sample=r["sample"])
max_diff = max(max_diff, abs(logp - r["sde_logprob"]))
assert max_diff < C2_TOL, f"C2 likelihood identity broken: {max_diff:.2e}"
# --- joint vs prompt-only RL ------------------------------------------------------ #
def _train(joint, target, iters=40, **kw):
m = build_unified_rl(build_unified_card(), joint=joint, target_action=target,
num_samples_per_prompt=4, num_skip_refinement=1, **kw)
batch = {"prompts": ["a fox", "blue sky", "a city at night"], "seeds": [1, 2, 3]}
p_start = float(m.llm._probs()[target])
last = None
for it in range(iters):
_, last = m.managed_train_step(batch, it)
return m, p_start, float(m.llm._probs()[target]), last
def test_joint_rl_updates_both_experts():
m, p0, p1, last = _train(joint=True, target=3)
# 1) the LM learned to pick the reward-favored refinement action
assert p1 > p0 + 0.2, f"LM did not learn the target action: {p0:.3f} -> {p1:.3f}"
# 2) the generator moved (FlowGRPO PPO produced real grads)
assert last["grad_norm/transformer"] > 0.0
assert "dit_pg_loss" in last and np.isfinite(last["dit_pg_loss"])
# 3) PPO ratio is near 1 (small on-policy steps), KL finite
assert 0.5 < last["ppo_ratio_mean"] < 2.0
assert np.isfinite(last["kl_div_loss"])
# 4) two experts versioned INDEPENDENTLY; the frozen text-encoder cache is NOT invalidated
inst = m.student
assert last["llm_weights_version"] != last["transformer_weights_version"]
assert inst.version_of("text_encoder") == "v0" # cache scope preserved
assert inst.version_of("llm") != "v0" and inst.version_of("transformer") != "v0"
def test_prompt_only_freezes_the_generator():
card = build_unified_card()
m = build_unified_rl(card, joint=False, target_action=5,
num_samples_per_prompt=4, num_skip_refinement=1)
w0 = m.dit.w_x.copy()
batch = {"prompts": ["a fox", "blue sky", "a city"], "seeds": [1, 2, 3]}
p0 = float(m.llm._probs()[5])
last = None
for it in range(40):
_, last = m.managed_train_step(batch, it)
p1 = float(m.llm._probs()[5])
assert p1 > p0 + 0.2, "prompt-only: LM still must learn"
assert np.array_equal(w0, m.dit.w_x), "prompt-only: generator weights must be frozen"
assert last["transformer_weights_version"] == "frozen"
assert "dit_pg_loss" not in last
def test_one_reward_drives_both_via_one_advantage():
"""The structural claim: a single reward per sample → one group-relative advantage → BOTH a
token-PG (LM) and a FlowGRPO-PPO (DiT) update. Joint moves the DiT; prompt-only does not —
yet the LM learns in both, from the same advantage."""
_, _, p1_joint, _ = _train(joint=True, target=2)
_, _, p1_prompt, _ = _train(joint=False, target=2)
assert p1_joint > 0.4 and p1_prompt > 0.4 # LM learns either way
# --- the two-loop serve program still passes the core parity gate ----------------- #
def test_unified_serve_interleave_parity():
eng = build_unified_engine()
reqs = [make_request(TaskType.T2V, "unirl-qwenflux", p,
diffusion=DiffusionParams(num_steps=4, seed=s))
for p, s in [("alpha", 11), ("beta", 22), ("alpha", 11)]]
divs = assert_interleave_parity(eng, reqs)
assert not divs, f"unified two-loop program failed interleave parity: {divs}"