Skip to content

hy_worldplay: Clean Forcing drift corrector (training recipe + content-keyed deploy) - #396

Open
wenqingw-nv wants to merge 1 commit into
NVIDIA:mainfrom
wenqingw-nv:clean-forcing-hy-worldplay
Open

hy_worldplay: Clean Forcing drift corrector (training recipe + content-keyed deploy)#396
wenqingw-nv wants to merge 1 commit into
NVIDIA:mainfrom
wenqingw-nv:clean-forcing-hy-worldplay

Conversation

@wenqingw-nv

@wenqingw-nv wenqingw-nv commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Long autoregressive HY-WorldPlay rollouts drift: each chunk conditions on self-generated history and small errors compound into saturation/contrast runaway. This PR adds a trained drift corrector (Clean Forcing), the zero-real-data recipe that produced it, and a content-keyed deploy hook. drift_corrector=None (default) is byte-identical to current behavior.

Contents

  • hy_worldplay/_drift_corrector.py + two runner-config fields (drift_corrector, drift_corrector_gain) — content-keyed per job: static (locked-off camera) trajectories run untouched base weights (static scenes measure negative drift on this host — correction there is pure artifact cost); commanded-motion trajectories apply the LoRA at alpha*(t) × gain per denoise step.
  • integrations/hy_worldplay/drift_correction/ — full recipe, zero real videos: strafe-loop pair construction, step-0 alpha*(t) systematicity gate (go/no-go + the deploy gain profile), v1 counterfactual-teacher training, v2 DAgger + drift-contraction, gain-sweep eval, drift/dynamics/progression/seam scoring, static-scene demo suite. README documents the ~2.5 GPU-day reproduction.
  • tests/test_drift_corrector.py (ci_cpu, 6 tests) — LoRA wrapper is a strict identity until a checkpoint is loaded and gated on; content-keyed selection triggers on action labels.

Docs

Measured (704×1280, 24-chunk ≈19 s rollouts, held-out trajectories, GB300)

config MUSIQ ↑ Δ-drift ↓ dynamic degree
base 68.0 +1.70 19.3
shipped alpha*(t) × 0.5 70.4 −48% 20.3 (≈ base)
flat gain 1.0 72.1 −133% (negative drift) 11.7 (−40%)

Shipped config cuts the drift metric ~50% with no dynamics loss and no added chunk-boundary pulse; commanded trajectories keep advancing (owner-verified side-by-sides). The method/reproduction details: drift_correction/README.md (quick start)

Deploy notes

  • Per-step alpha*(t) gating cannot merge into one weight set → motion jobs run the LoRA unfused (~0.3% extra matmul); static jobs run the base at zero overhead.
  • v2 LoRA checkpoint (~59 MB) hosting: currently a fork release asset (https://github.com/wenqingw-nv/flashdreams-wq/releases/tag/clean-forcing-hy-v2); release asset here vs HF — maintainer preference welcome.
  • Correctors encode a checkpoint-specific error map: re-instantiation on a new base checkpoint is ~1 GPU-day with the included recipe (prompts only).

Inference overhead (GB300, same rollout protocol as Measured; 3 runs, steady-state mean ± std)

config per-chunk (ms) end-to-end 24 chunks (s) effective fps peak VRAM
base 1265.9 ± 34.9 28.87 ± 0.72 13.2 47.41 GB
shipped corrgate050 (pre-merged) 1268.8 ± 1.5 28.98 ± 0.03 13.1 52.69 GB
  • +0.22% per-chunk (+2.8 ms). The deploy hook pre-merges alpha*(t) × gain into one cached copy of the target projection weights per distinct gate value at load (3 sets, +8.1 GiB) and swaps the set per solver step, driven CPU-side from the denoising schedule — the corrected forward issues the same kernels as base: no LoRA matmuls, no GPU timestep readback. Overhead is therefore GPU-independent (base runtime on any card).
  • Output equivalence vs the runtime fp32 delta path: first-chunk latent diff ~1.2% relative (bf16 weight-merge rounding); on the seeded 24-chunk equivalence pair MUSIQ 70.2 vs 70.5 and Δ-drift 0.89 vs 0.73 — parity; side-by-side frame stacks are visually equivalent.
  • DRIFT_CORRECTOR_UNFUSED=1 selects the previous runtime fp32 delta path (1461 ± 69 ms/chunk, +12%).
  • Static (locked-off camera) jobs run the untouched base weights per the content-keyed ship rule — zero overhead, no module surgery.

Video Comparison

Left: FlashDreams-HY (base), Right: w/ Clean Forcing LoRA drift corrector (better details/color preservation across time):

sbs_base_LEFT_vs_corrgate050_RIGHT_p0_i0_s5042mix.mp4
fsbs_base_LEFT_vs_corrgate050_RIGHT_p0_i0_s5042.mp4
sbs_base_LEFT_vs_corrgate050_RIGHT_p0_i0_s5042.mp4

VBench Ablation:

config subj cons bg cons aesthetic imaging motion smooth dyn degree mean
base 86.4 88.6 65.3 77.6 91.4 100.0 84.9
corr050 92.0 92.9 67.1 77.0 91.8 100.0 86.8
corrgate 94.6 94.2 68.3 77.2 93.9 100.0 88.1
corrgate050 (shipped) 88.7 90.2 64.5 76.5 93.0 100.0 85.5

@copy-pr-bot

copy-pr-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a Clean Forcing drift corrector for HY-WorldPlay autoregressive rollouts: a rank-16 LoRA trained on self-generated strafe-loop pairs (zero real video) that reduces long-rollout drift ~50% with no dynamics loss. It ships with a content-keyed deploy hook (static trajectories run untouched base weights; motion jobs apply the LoRA at alpha*(t) × gain per denoise step), the full training recipe, and six CPU unit tests.

  • New default pre-merged path (_drift_corrector.py): at load time, one merged weight set per distinct alpha*(t) value is cached; the per-step gate swaps lin.weight.data instead of running extra LoRA matmuls, yielding zero added GPU ops at inference versus the prior unfused path (now an opt-in fallback via DRIFT_CORRECTOR_UNFUSED=1).
  • Training recipe (drift_correction/): strafe-loop pair construction (build_pairs.py), step-0 systematicity gates, v1 counterfactual-teacher and v2 DAgger training, gain-sweep eval, and scoring scripts — all reproducing the shipped v2 checkpoint in ~2.5 GPU-days.
  • Runner integration (runner.py): two new config fields (drift_corrector, drift_corrector_gain) with None default preserving byte-identical base behavior.

Confidence Score: 4/5

Safe to merge for single-run use cases (the shipped production path); re-use of the same runner across multiple run() calls produces silently wrong (double-corrected) outputs on the pre-merged path.

The pre-merged path introduced in this PR (now the default) calls _premerge_weight_sets inside run() with no re-entry guard. After the first run, each target linear's weight.data has been swapped to a merged tensor. A second run() call reads those merged values as the base weights and adds the LoRA delta again, producing W_base + gain*(ctx_alpha + alpha)*B@A instead of W_base + gain*alpha*B@A. The output is silently doubly-corrected with no error raised. The same re-invocation also wraps scheduler.sample and finalize_kv_cache a second time. This is harmless for the typical single-rollout deploy path but would corrupt repeated runs on the same runner instance.

Files Needing Attention: integrations/hy_worldplay/hy_worldplay/_drift_corrector.py — the pre-merged weight-swap path and its interaction with repeated run() calls; integrations/hy_worldplay/hy_worldplay/runner.py — the call site inside run() that lacks a re-application guard.

Important Files Changed

Filename Overview
integrations/hy_worldplay/hy_worldplay/_drift_corrector.py Adds a pre-merged LoRA deploy hook (the new default) plus the unfused fallback. The pre-merged path swaps lin.weight.data per solver step, which silently double-corrects on a second run() call because _premerge_weight_sets reads the already-swapped weights. Also retains weights_only=False (flagged in previous thread).
integrations/hy_worldplay/hy_worldplay/runner.py Adds drift_corrector / drift_corrector_gain fields to the runner config and calls maybe_apply_drift_corrector inside run(). No re-entry guard; repeated run() calls re-apply the corrector on each invocation (see the corrector file finding).
integrations/hy_worldplay/tests/test_drift_corrector.py 6 CPU unit tests covering identity-at-zero-scale, zero-B-init, scaled delta, LoRA wrapping, content-keyed selection, gate lookup, load-order consistency, pre-merged math correctness, and base-weight immutability. No test for re-invocation / double-correction path.
integrations/hy_worldplay/drift_correction/_lora.py Training-side LoRA utilities (LoRALinear, apply_lora, save_lora, load_lora). Uses weights_only=False in load_lora (flagged in previous outside-diff comment).
integrations/hy_worldplay/drift_correction/_rollout.py Rollout capture and counterfactual x0-probe utilities for training. load_rollout uses weights_only=False. GATE_ALPHA definition duplicates the deploy-side dict (flagged in previous thread).
integrations/hy_worldplay/drift_correction/_train_attn.py Grad-friendly functional dual-branch attention for training (replaces the inference-only KV-cache path). Includes a differentiable RoPE re-implementation and process-wide patch. Well-documented and logically consistent.
integrations/hy_worldplay/drift_correction/build_pairs.py Strafe-loop rollout pair construction (resumable, env-var-configurable). Correctly constructs the multi-lap pose string and saves patchified latents as clip files.

Sequence Diagram

sequenceDiagram
    participant Runner
    participant DriftCorrector as maybe_apply_drift_corrector
    participant Scheduler as scheduler.sample (gated)
    participant Transformer as transformer.predict_flow
    participant Finalizer as finalize_kv_cache (gated)

    Runner->>DriftCorrector: called inside run() [no re-entry guard]
    DriftCorrector->>DriftCorrector: is_static_trajectory(pose)?
    alt static trajectory
        DriftCorrector-->>Runner: base (static trajectory) — no-op
    else motion trajectory
        DriftCorrector->>DriftCorrector: _target_linears(network)
        DriftCorrector->>DriftCorrector: _premerge_weight_sets(linears, sd, gain) reads lin.weight.data — BUG on 2nd run()
        DriftCorrector->>Scheduler: wrap scheduler.sample → gated_sample
        DriftCorrector->>Finalizer: wrap finalize_kv_cache → gated_finalize
        DriftCorrector-->>Runner: corrected (pre-merged N weight sets)
    end

    loop AR chunks
        Runner->>Scheduler: scheduler.sample(initial_noise, predict_flow, rng)
        loop solver steps
            Scheduler->>Scheduler: "_swap(step_alphas[i]) — lin.weight.data = merged_w"
            Scheduler->>Transformer: predict_flow(noisy, timestep)
            Transformer-->>Scheduler: flow estimate
        end
        Scheduler-->>Runner: x0 chunk
        Runner->>Finalizer: finalize_kv_cache(...)
        Finalizer->>Finalizer: _swap(ctx_alpha)
        Finalizer-->>Runner: done
    end
Loading

Reviews (5): Last reviewed commit: "hy_worldplay: add Clean Forcing drift co..." | Re-trigger Greptile

network = network._orig_mod
params = _apply_lora(network)

sd = torch.load(checkpoint, map_location="cpu", weights_only=False)["lora"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security weights_only=False allows arbitrary code execution

torch.load(..., weights_only=False) uses Python's pickle deserializer, which can execute arbitrary code embedded in the file. checkpoint is a user-controlled path via HyWorldPlayWanI2VRunnerConfig.drift_corrector, so a replaced or tampered .pt file would run attacker code on load. The save_lora format stores only {"lora": {int: Tensor}} — all primitive Python types and tensors — so weights_only=True loads it without issue.

Comment on lines +29 to +33
GATE_ALPHA = {1000.0: 0.81, 960.0: 0.53, 888.8889: 0.53, 727.2728: 0.58}
"""Unbiased alpha*(t) from the step-0 systematicity gate (drift_correction's
``outputs/gate/gate_faithful.json``): the systematic fraction of the
drift-induced error at each of the distilled solver's timesteps. The
shipped config deploys the LoRA at ``alpha*(t) * gain`` per denoise step."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate GATE_ALPHA dict — deploy and training scripts diverge silently

GATE_ALPHA is defined with identical values in both this file and drift_correction/_rollout.py. If a future gate rerun changes the alpha*(t) profile, only one copy is likely to be updated, causing the deployed per-step gain profile to silently mismatch the training configuration.

Suggested change
GATE_ALPHA = {1000.0: 0.81, 960.0: 0.53, 888.8889: 0.53, 727.2728: 0.58}
"""Unbiased alpha*(t) from the step-0 systematicity gate (drift_correction's
``outputs/gate/gate_faithful.json``): the systematic fraction of the
drift-induced error at each of the distilled solver's timesteps. The
shipped config deploys the LoRA at ``alpha*(t) * gain`` per denoise step."""
GATE_ALPHA = {1000.0: 0.81, 960.0: 0.53, 888.8889: 0.53, 727.2728: 0.58}
"""Unbiased alpha*(t) from the step-0 systematicity gate (drift_correction's
``outputs/gate/gate_faithful.json``): the systematic fraction of the
drift-induced error at each of the distilled solver's timesteps. The
shipped config deploys the LoRA at ``alpha*(t) * gain`` per denoise step.
NOTE: This dict is also defined in ``drift_correction/_rollout.py``. Both copies
must be kept in sync; prefer sourcing from a single shared location."""

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@wenqingw-nv
wenqingw-nv force-pushed the clean-forcing-hy-worldplay branch from 58702aa to d91feb2 Compare July 22, 2026 06:22
@wenqingw-nv

Copy link
Copy Markdown
Collaborator Author

Checkpoint availability: the trained v2 corrector LoRA (59 MB) is published as a release asset on the contributing fork for review/testing: https://github.com/wenqingw-nv/flashdreams-wq/releases/tag/clean-forcing-hy-v2 — happy to rehome it (release asset here / HF / internal artifact store) per maintainer preference. Note the deploy dial is inference-time: this single checkpoint serves all configurations (flat gain, alpha*(t) gate, gate x gain); the shipped setting is alpha*(t) x 0.5 with content-keyed selection.

@wenqingw-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test d91feb2

@wenqingw-nv
wenqingw-nv force-pushed the clean-forcing-hy-worldplay branch from d91feb2 to ada922d Compare July 22, 2026 07:36
Comment on lines +356 to +362
if cfg.drift_corrector is not None:
from hy_worldplay._drift_corrector import maybe_apply_drift_corrector

mode = maybe_apply_drift_corrector(
self, cfg.drift_corrector, cfg.drift_corrector_gain
)
logger.info(f"[{cfg.runner_name}] drift corrector: {mode}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Drift corrector re-applied on every run() call, stale gain overrides the new one

maybe_apply_drift_corrector installs a gated_pf closure that captures gain from the call site. Each subsequent run() wraps the previous gated_pf in a new one: the outermost closure (latest call's gain) sets the scale, then returns via orig_pf, which is the previous gated_pf — which overrides the scale again with an older captured gain before calling the real predict_flow. If drift_corrector_gain is mutated between runs (e.g. runner.config.drift_corrector_gain = 0.3 after a first run at 0.7), the network executes every subsequent step at the first-call gain (0.7), not the current one, with no error raised.

Moving the application into __init__ (or adding a _drift_corrector_applied guard) would prevent the stacking entirely.

@wenqingw-nv
wenqingw-nv enabled auto-merge July 22, 2026 08:00
@wenqingw-nv

Copy link
Copy Markdown
Collaborator Author

Domain scope note: the shipped corrector was trained on urban/outdoor content without people; person-heavy or stylized domains may need a pairs refresh (recipe included, ~1 GPU-day). We validated the refresh path: a 200-step continue-train with ~10% person/scene-diverse pairs improves the walking-person stress scene (drift −47%, best seam profile) while keeping — in fact improving — the original commanded-motion profile (bridge drift cut −58%). Checkpoint to follow with the hosting decision.

@wenqingw-nv

Copy link
Copy Markdown
Collaborator Author

Checkpoint update: the shipped LoRA is now v2c2 — a content-diversified refresh (200-step continue-train, 10% person/scene-diverse pairs) that improves BOTH axes: bridge drift cut −58% (was −48%) and the walking-person stress scene fixed (drift −47%, owner-verified side-by-sides). Release asset: https://github.com/wenqingw-nv/flashdreams-wq/releases/tag/clean-forcing-hy-v2c2 (md5 1de6023c...; loads via the identical deploy path, no code change). The earlier v2 asset remains available for reproducing the PR's tables.

…ntent-keyed deploy)

Adds a trained drift corrector for the HY-WorldPlay WAN-5B integration:
a frozen-base LoRA (r16 q/k/v/o, ~0.3% params) trained from the model's
own strafe-loop rollouts with a counterfactual clean-history teacher --
zero real videos end-to-end.

Deploy: HyWorldPlayWanI2VRunnerConfig.drift_corrector (checkpoint path)
+ drift_corrector_gain. Selection is content-keyed per job: static
(locked-off camera) poses run untouched base weights; commanded-motion
poses apply the LoRA at alpha*(t) x gain per denoise step, where
alpha*(t) is the measured systematic fraction of the drift-induced
error at each solver timestep. The hook pre-merges each distinct
alpha*(t) x gain into its own cached copy of the target projection
weights at load (3 sets, +8.1 GiB) and swaps the set per solver step,
driven CPU-side from the denoising schedule -- the corrected forward
issues the same kernels as base, measured +0.22% per chunk.
DRIFT_CORRECTOR_UNFUSED=1 selects the runtime fp32 A/B-delta path
instead.

Measured on 24-chunk (~19 s) rollouts: -48% delta-drift vs base with
dynamics ~= base and no added seam pulse; the base's late-horizon
saturation runaway is eliminated. Training/eval scripts and the step-0
diagnostic live under integrations/hy_worldplay/drift_correction/ with
a README covering the method and the full reproduction recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Wenqing Wang <wenqingw@nvidia.com>
@wenqingw-nv
wenqingw-nv force-pushed the clean-forcing-hy-worldplay branch from 8afdaf1 to a57b507 Compare July 28, 2026 21:09
Comment on lines +230 to +276
linears = _target_linears(network)
assert len(sd) == 2 * len(linears), (
f"corrector checkpoint has {len(sd)} LoRA tensors but the network "
f"exposes {2 * len(linears)}; rank or target mismatch."
)
weight_sets, added_bytes = _premerge_weight_sets(linears, sd, gain)
current: list[float | None] = [None]

def _swap(alpha: float) -> None:
if alpha != current[0]:
for lin, w in zip(linears, weight_sets[alpha]):
lin.weight.data = w
current[0] = alpha

# Drive the gate CPU-side. The Euler scheduler makes exactly one
# ``predict_flow`` call per solver step in a Python loop, so each
# step's alpha resolves from the load-time schedule by call index —
# reading the timestep tensor back per step (the unfused path's
# ``float(timestep.max())``) would stall the CPU launch queue every
# solver step.
scheduler = runner.pipeline.diffusion_model.scheduler
n_steps = scheduler.config.num_inference_steps
step_alphas = [_nearest_alpha(t) for t in scheduler.timesteps.tolist()[:n_steps]]
ctx_alpha = _nearest_alpha(
float(runner.pipeline.diffusion_model.config.context_noise)
)
orig_sample = scheduler.sample

def gated_sample(initial_noise, predict_flow, rng=None):
calls = [0]

def pf(noisy, timestep):
assert calls[0] < len(step_alphas), "predict_flow calls > solver steps"
_swap(step_alphas[calls[0]])
calls[0] += 1
return predict_flow(noisy, timestep)

return orig_sample(initial_noise=initial_noise, predict_flow=pf, rng=rng)

scheduler.sample = gated_sample
orig_finalize = transformer.finalize_kv_cache

def gated_finalize(*args, **kwargs):
_swap(ctx_alpha)
return orig_finalize(*args, **kwargs)

transformer.finalize_kv_cache = gated_finalize

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pre-merged path silently double-corrects on every subsequent run() call

maybe_apply_drift_corrector is invoked at the top of run() with no re-entry guard. On the first call, _premerge_weight_sets correctly merges W_base + gain * alpha * B @ A and stores those tensors. _swap then does lin.weight.data = w, so after the AR loop finishes each linear's weight.data points at one of those merged tensors (the one for ctx_alpha).

On the second run(), _target_linears returns the same nn.Linear objects, but now lin.weight.detach() reads the already-merged value. _premerge_weight_sets then computes:

W_merged_prev + gain * alpha * B @ A = W_base + gain * (ctx_alpha + alpha) * B @ A

The correction is applied twice — the network runs with 2× the intended delta on every step, silently, with no error raised. The wrapped scheduler.sample and finalize_kv_cache also stack a second closure layer on each re-invocation.

The same guard recommended in the previous thread for the unfused path (applying in __init__, or tracking _drift_corrector_applied as a runner attribute) would also prevent the double-correction here, since it would stop _premerge_weight_sets from reading the already-swapped weights.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant