Cccccz commited on
Commit
bc29ee3
·
verified ·
1 Parent(s): 7e1eca6

Upload Python scripts

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. scripts/aggregate_layer17_dynamic_runs.py +134 -0
  2. scripts/analyze_feature_cache.py +1623 -0
  3. scripts/analyze_fullgrid_bilinear_3models.py +556 -0
  4. scripts/analyze_motion_alignment_3models.py +859 -0
  5. scripts/build_boundary_conditional_dataset.py +146 -0
  6. scripts/build_conditional_probe_dataset.py +247 -0
  7. scripts/build_predictor_offline_data.py +878 -0
  8. scripts/build_vbench8_extended_mapping.py +170 -0
  9. scripts/calibrate_atc_confidence_token_beta2_vbench3.py +591 -0
  10. scripts/calibrate_vbench251_span_risk.py +705 -0
  11. scripts/create_lmdb_14b_shards.py +101 -0
  12. scripts/create_lmdb_iterative.py +60 -0
  13. scripts/eval_vbench8_extended_naive_baselines.py +176 -0
  14. scripts/eval_vbench8_extended_subset.py +256 -0
  15. scripts/evaluate_layer17_chunk_impact.py +782 -0
  16. scripts/evaluate_layer17_dynamic_gate.py +904 -0
  17. scripts/evaluate_layer17_moviebench_step2000.py +271 -0
  18. scripts/evaluate_long_video_fppf.py +295 -0
  19. scripts/evaluate_long_video_vbench.py +53 -0
  20. scripts/evaluate_single_block_fppf.py +1135 -0
  21. scripts/evaluate_single_chunk_frrr.py +838 -0
  22. scripts/evaluate_three_block_fppf.py +25 -0
  23. scripts/evaluate_trained_long_predictors.py +157 -0
  24. scripts/evaluate_two_block_fppf.py +640 -0
  25. scripts/generate_ode_pairs.py +120 -0
  26. scripts/generate_vbench8_extended_atc.py +292 -0
  27. scripts/generate_vbench8_extended_atc_confidence_token.py +439 -0
  28. scripts/generate_vbench8_extended_concat.py +96 -0
  29. scripts/generate_vbench8_extended_disca.py +374 -0
  30. scripts/generate_vbench8_extended_naive_baselines.py +630 -0
  31. scripts/generate_vbench8_extended_spanrisk.py +268 -0
  32. scripts/generate_vbench8_extended_strategies.py +781 -0
  33. scripts/merge_three_block_sweep_shards.py +159 -0
  34. scripts/merge_two_block_fppp_shards.py +126 -0
  35. scripts/naive_vbench_policies.py +127 -0
  36. scripts/plot_frrf_chunk_metrics.py +164 -0
  37. scripts/plot_self_causal_evidence.py +776 -0
  38. scripts/prepare_confidence_stage1_vbench.py +83 -0
  39. scripts/prepare_layer17_dynamic_vbench.py +107 -0
  40. scripts/probe_confidence_token_batch_size.py +364 -0
  41. scripts/run_aligned_conditional_probe_3models.py +501 -0
  42. scripts/run_conditional_probe_offline.py +436 -0
  43. scripts/run_single_block_init_sweep.py +1082 -0
  44. scripts/run_three_block_consecutive_sweep.py +651 -0
  45. scripts/run_two_block_pair_sweep.py +632 -0
  46. scripts/run_wan14b_timestep_chunk_cosine.py +422 -0
  47. scripts/summarize_boundary_to_all_cosine.py +209 -0
  48. scripts/summarize_conditional_probe.py +418 -0
  49. scripts/summarize_confidence_stage1_experiments.py +67 -0
  50. scripts/summarize_layer17_dynamic_gate.py +185 -0
scripts/aggregate_layer17_dynamic_runs.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Aggregate per-run dynamic-gate JSON files without loading GPU models."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ NUMERIC_FIELDS = [
13
+ "accepted_predictor_calls", "full_calls", "predictor_calls",
14
+ "full_dit_time_ms", "predictor_time_ms", "confidence_head_time_ms",
15
+ "context_dit_time_ms", "actual_dit_time_ms", "model_path_time_ms",
16
+ "generation_time_s", "total_time_s", "latent_nrmse", "latent_tail_nrmse",
17
+ "psnr", "ssim", "lpips", "tail_psnr", "tail_ssim", "tail_lpips",
18
+ ]
19
+
20
+
21
+ def write_csv(path: Path, rows: list[dict], fields: list[str]) -> None:
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ temporary = path.with_suffix(path.suffix + ".tmp")
24
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
25
+ writer = csv.DictWriter(handle, fieldnames=fields)
26
+ writer.writeheader()
27
+ writer.writerows(rows)
28
+ temporary.replace(path)
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("--test_dir", type=Path, required=True)
34
+ parser.add_argument("--expected_prompts", type=int, default=10)
35
+ parser.add_argument(
36
+ "--select_targets",
37
+ type=int,
38
+ nargs="*",
39
+ default=None,
40
+ help="Also select the best dynamic validation row for each target budget.",
41
+ )
42
+ args = parser.parse_args()
43
+ per_run = args.test_dir / "per_run"
44
+ records = [
45
+ json.loads(path.read_text(encoding="utf-8"))
46
+ for path in sorted(per_run.glob("*/prompt_*.json"))
47
+ ]
48
+ if not records:
49
+ raise ValueError(f"No per-run JSON files under {per_run}")
50
+
51
+ flat_fields = sorted({key for row in records for key in row if key != "decisions"})
52
+ write_csv(
53
+ args.test_dir / "runs.csv",
54
+ [{key: row.get(key) for key in flat_fields} for row in records],
55
+ flat_fields,
56
+ )
57
+
58
+ summary = []
59
+ for name in sorted({str(row["config_name"]) for row in records}):
60
+ selected = [row for row in records if row["config_name"] == name]
61
+ if len(selected) != args.expected_prompts:
62
+ raise ValueError(
63
+ f"{name}: expected {args.expected_prompts} prompts, got {len(selected)}"
64
+ )
65
+ first = selected[0]
66
+ item = {
67
+ "config_name": name,
68
+ "policy": first["policy"],
69
+ "beta": first["beta"],
70
+ "target_accepts": first["target_accepts"],
71
+ "threshold": first["threshold"],
72
+ "num_prompts": len(selected),
73
+ }
74
+ for field in NUMERIC_FIELDS:
75
+ item[field] = sum(float(row[field]) for row in selected) / len(selected)
76
+ summary.append(item)
77
+ fields = [
78
+ "config_name", "policy", "beta", "target_accepts", "threshold",
79
+ "num_prompts", *NUMERIC_FIELDS,
80
+ ]
81
+ write_csv(args.test_dir / "summary.csv", summary, fields)
82
+ if args.select_targets:
83
+ selected_dynamic = []
84
+ for target in args.select_targets:
85
+ candidates = [
86
+ row for row in summary
87
+ if row["policy"] == "dynamic"
88
+ and int(row["target_accepts"]) == target
89
+ ]
90
+ if not candidates:
91
+ raise ValueError(f"No dynamic candidates for target {target}")
92
+ same_budget = [
93
+ row for row in candidates
94
+ if abs(float(row["accepted_predictor_calls"]) - target) <= 0.5 + 1e-8
95
+ ]
96
+ if not same_budget:
97
+ closest = min(
98
+ abs(float(row["accepted_predictor_calls"]) - target)
99
+ for row in candidates
100
+ )
101
+ same_budget = [
102
+ row for row in candidates
103
+ if abs(
104
+ abs(float(row["accepted_predictor_calls"]) - target)
105
+ - closest
106
+ ) <= 1e-8
107
+ ]
108
+ same_budget.sort(
109
+ key=lambda row: (
110
+ float(row["tail_lpips"]),
111
+ abs(float(row["accepted_predictor_calls"]) - target),
112
+ float(row["beta"]),
113
+ )
114
+ )
115
+ selected_dynamic.append(same_budget[0])
116
+ (args.test_dir / "selected.json").write_text(
117
+ json.dumps(
118
+ {
119
+ "selection_rule": (
120
+ "within target accepted calls +/-0.5, lowest validation "
121
+ "tail LPIPS; then budget distance and lower beta"
122
+ ),
123
+ "selected_dynamic": selected_dynamic,
124
+ },
125
+ indent=2,
126
+ )
127
+ + "\n",
128
+ encoding="utf-8",
129
+ )
130
+ print(json.dumps(summary, indent=2))
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
scripts/analyze_feature_cache.py ADDED
@@ -0,0 +1,1623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Analyze timestep and cross-chunk feature reuse in Self-Forcing.
3
+
4
+ The script runs the released four-step causal checkpoint without changing its
5
+ outputs. Forward hooks capture sampled DiT hidden states, residual updates, the
6
+ final velocity, and a low-dimensional dense projection used for optical-flow
7
+ alignment. It then produces:
8
+
9
+ * within-chunk and cross-chunk feature-pair metrics;
10
+ * leave-one-prompt-out channel-wise probes for conditional chunk information;
11
+ * shuffled, wrong-step, distant-chunk, zero, and noise controls;
12
+ * motion-stratified raw/global/dense-flow alignment measurements.
13
+
14
+ Each prompt is saved independently, so interrupted generation can be resumed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import math
23
+ import os
24
+ import random
25
+ import sys
26
+ import time
27
+ from collections import defaultdict
28
+ from pathlib import Path
29
+ from typing import Any, Iterable
30
+
31
+
32
+ def _preparse_gpu() -> str:
33
+ parser = argparse.ArgumentParser(add_help=False)
34
+ parser.add_argument("--gpu", default="2")
35
+ args, _ = parser.parse_known_args()
36
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
37
+ return str(args.gpu)
38
+
39
+
40
+ PHYSICAL_GPU = _preparse_gpu()
41
+
42
+ import cv2
43
+ import matplotlib
44
+
45
+ matplotlib.use("Agg")
46
+ import matplotlib.pyplot as plt
47
+ import numpy as np
48
+ import torch
49
+ import torch.nn.functional as F
50
+ from omegaconf import OmegaConf
51
+
52
+ REPO_ROOT = Path(__file__).resolve().parents[1]
53
+ if str(REPO_ROOT) not in sys.path:
54
+ sys.path.insert(0, str(REPO_ROOT))
55
+
56
+ from pipeline import CausalInferencePipeline
57
+ from utils.misc import set_seed
58
+
59
+
60
+ EXPECTED_LATENT_HEIGHT = 60
61
+ EXPECTED_LATENT_WIDTH = 104
62
+ FRAME_TOKEN_HEIGHT = 30
63
+ FRAME_TOKEN_WIDTH = 52
64
+ FRAME_SEQ_LENGTH = FRAME_TOKEN_HEIGHT * FRAME_TOKEN_WIDTH
65
+
66
+
67
+ def parse_args() -> argparse.Namespace:
68
+ parser = argparse.ArgumentParser(
69
+ description="Self-Forcing cross-chunk feature-cache analysis"
70
+ )
71
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
72
+ parser.add_argument(
73
+ "--config_path", type=Path, default=Path("configs/self_forcing_dmd.yaml")
74
+ )
75
+ parser.add_argument(
76
+ "--checkpoint_path",
77
+ type=Path,
78
+ default=Path("checkpoints/self_forcing_dmd.pt"),
79
+ )
80
+ parser.add_argument(
81
+ "--prompt_path",
82
+ type=Path,
83
+ default=Path("prompts/MovieGenVideoBench_extended.txt"),
84
+ )
85
+ parser.add_argument("--output_dir", type=Path, required=True)
86
+ parser.add_argument("--num_prompts", type=int, default=3)
87
+ parser.add_argument("--num_frames", type=int, default=21)
88
+ parser.add_argument("--seed", type=int, default=20260728)
89
+ parser.add_argument(
90
+ "--same_seed",
91
+ action="store_true",
92
+ help="Reset every prompt to --seed for paired cross-model evaluation.",
93
+ )
94
+ parser.add_argument(
95
+ "--layers", type=int, nargs="+", default=[0, 9, 19, 29]
96
+ )
97
+ parser.add_argument("--max_tokens", type=int, default=256)
98
+ parser.add_argument("--projection_dim", type=int, default=64)
99
+ parser.add_argument("--ridge", type=float, default=1e-4)
100
+ parser.add_argument("--use_ema", action="store_true", default=True)
101
+ parser.add_argument("--no_ema", action="store_false", dest="use_ema")
102
+ parser.add_argument("--overwrite", action="store_true")
103
+ parser.add_argument(
104
+ "--cosine_only",
105
+ action="store_true",
106
+ help="Generate pair metrics/heatmap only; skip probes and motion analysis.",
107
+ )
108
+ parser.add_argument(
109
+ "--analysis_only",
110
+ action="store_true",
111
+ help="Skip model loading and analyze existing per-prompt snapshots.",
112
+ )
113
+ parser.add_argument(
114
+ "--save_preview",
115
+ action="store_true",
116
+ help="Save a compact MP4 preview when torchvision video IO is available.",
117
+ )
118
+ args = parser.parse_args()
119
+ if args.num_frames % 3 != 0:
120
+ parser.error("--num_frames must be divisible by the configured 3-frame chunk")
121
+ if args.num_prompts < 3 and not args.cosine_only:
122
+ parser.error("--num_prompts must be at least 3 for held-out/shuffle controls")
123
+ return args
124
+
125
+
126
+ def resolve_path(path: Path) -> Path:
127
+ return path if path.is_absolute() else REPO_ROOT / path
128
+
129
+
130
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
131
+ if not rows:
132
+ return
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ fields: list[str] = []
135
+ for row in rows:
136
+ for key in row:
137
+ if key not in fields:
138
+ fields.append(key)
139
+ with path.open("w", newline="", encoding="utf-8") as handle:
140
+ writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
141
+ writer.writeheader()
142
+ writer.writerows(rows)
143
+
144
+
145
+ def read_prompts(path: Path, count: int) -> list[str]:
146
+ with path.open("r", encoding="utf-8") as handle:
147
+ prompts = [line.strip() for line in handle if line.strip()]
148
+ if len(prompts) < count:
149
+ raise ValueError(f"Requested {count} prompts, found {len(prompts)} in {path}")
150
+ return prompts[:count]
151
+
152
+
153
+ def regular_grid_indices(
154
+ frames: int, height: int, width: int, max_tokens: int, device: torch.device
155
+ ) -> tuple[torch.Tensor, torch.Tensor]:
156
+ total = frames * height * width
157
+ if max_tokens >= total:
158
+ coords = torch.cartesian_prod(
159
+ torch.arange(frames, device=device),
160
+ torch.arange(height, device=device),
161
+ torch.arange(width, device=device),
162
+ )
163
+ else:
164
+ per_frame = max(1, max_tokens // frames)
165
+ h_count = max(
166
+ 1, min(height, int(round(math.sqrt(per_frame * height / width))))
167
+ )
168
+ w_count = max(1, min(width, per_frame // h_count))
169
+ while frames * h_count * w_count > max_tokens and w_count > 1:
170
+ w_count -= 1
171
+ while frames * h_count * w_count > max_tokens and h_count > 1:
172
+ h_count -= 1
173
+ hs = (
174
+ torch.linspace(0, height - 1, h_count, device=device)
175
+ .round()
176
+ .long()
177
+ .unique()
178
+ )
179
+ ws = (
180
+ torch.linspace(0, width - 1, w_count, device=device)
181
+ .round()
182
+ .long()
183
+ .unique()
184
+ )
185
+ coords = torch.cartesian_prod(
186
+ torch.arange(frames, device=device), hs, ws
187
+ )
188
+ flat = (
189
+ coords[:, 0] * height * width + coords[:, 1] * width + coords[:, 2]
190
+ )
191
+ return flat.long(), coords.long()
192
+
193
+
194
+ class FeatureRecorder:
195
+ def __init__(
196
+ self,
197
+ model: torch.nn.Module,
198
+ layers: list[int],
199
+ denoising_timesteps: Iterable[float],
200
+ num_frame_per_block: int,
201
+ max_tokens: int,
202
+ projection_dim: int,
203
+ ) -> None:
204
+ self.model = model
205
+ self.layers = sorted(set(int(value) for value in layers))
206
+ self.timesteps = [float(value) for value in denoising_timesteps]
207
+ self.num_frame_per_block = int(num_frame_per_block)
208
+ self.max_tokens = int(max_tokens)
209
+ self.projection_dim = int(projection_dim)
210
+ self.projection_layer = self.layers[-1]
211
+ self.records: dict[str, dict[str, torch.Tensor]] = defaultdict(dict)
212
+ self.projected: dict[str, torch.Tensor] = {}
213
+ self.projected_by_layer: dict[str, torch.Tensor] = {}
214
+ self.sample_coords: dict[str, torch.Tensor] = {}
215
+ self.current: dict[str, Any] = {"active": False}
216
+ self.handles: list[Any] = []
217
+ self._projection_cache: dict[tuple[int, str], torch.Tensor] = {}
218
+ self._register()
219
+
220
+ def _register(self) -> None:
221
+ self.handles.append(
222
+ self.model.register_forward_pre_hook(self._model_pre_hook, with_kwargs=True)
223
+ )
224
+ self.handles.append(self.model.register_forward_hook(self._model_output_hook))
225
+ for layer in self.layers:
226
+ if layer < 0 or layer >= len(self.model.blocks):
227
+ raise ValueError(
228
+ f"Layer {layer} outside model block range 0..{len(self.model.blocks)-1}"
229
+ )
230
+ self.handles.append(
231
+ self.model.blocks[layer].register_forward_hook(
232
+ self._make_block_hook(layer)
233
+ )
234
+ )
235
+
236
+ def close(self) -> None:
237
+ for handle in self.handles:
238
+ handle.remove()
239
+ self.handles.clear()
240
+
241
+ def reset(self) -> None:
242
+ self.records = defaultdict(dict)
243
+ self.projected = {}
244
+ self.projected_by_layer = {}
245
+ self.sample_coords = {}
246
+ self.current = {"active": False}
247
+
248
+ def _model_pre_hook(
249
+ self, _module: torch.nn.Module, _args: tuple[Any, ...], kwargs: dict[str, Any]
250
+ ) -> None:
251
+ timestep = kwargs.get("t")
252
+ current_start = int(kwargs.get("current_start", 0) or 0)
253
+ if not isinstance(timestep, torch.Tensor) or timestep.numel() == 0:
254
+ self.current = {"active": False}
255
+ return
256
+ value = float(timestep.detach().float().reshape(-1)[0].item())
257
+ distances = [abs(value - expected) for expected in self.timesteps]
258
+ step = int(np.argmin(distances))
259
+ if distances[step] > 0.5:
260
+ self.current = {"active": False, "timestep": value}
261
+ return
262
+ frames = int(timestep.shape[-1]) if timestep.ndim > 1 else 1
263
+ if frames != self.num_frame_per_block:
264
+ self.current = {"active": False, "timestep": value}
265
+ return
266
+ start_frame = current_start // FRAME_SEQ_LENGTH
267
+ chunk = start_frame // self.num_frame_per_block
268
+ self.current = {
269
+ "active": True,
270
+ "chunk": int(chunk),
271
+ "step": step,
272
+ "timestep": value,
273
+ "frames": frames,
274
+ }
275
+
276
+ def _key(self) -> str:
277
+ return f"{self.current['chunk']}:{self.current['step']}"
278
+
279
+ def _hidden_indices(
280
+ self, tokens: torch.Tensor
281
+ ) -> tuple[torch.Tensor, torch.Tensor]:
282
+ frames = int(self.current["frames"])
283
+ if tokens.shape[1] != frames * FRAME_TOKEN_HEIGHT * FRAME_TOKEN_WIDTH:
284
+ raise ValueError(
285
+ f"Unexpected hidden token count {tokens.shape[1]} for {frames} frames"
286
+ )
287
+ return regular_grid_indices(
288
+ frames,
289
+ FRAME_TOKEN_HEIGHT,
290
+ FRAME_TOKEN_WIDTH,
291
+ self.max_tokens,
292
+ tokens.device,
293
+ )
294
+
295
+ def _projection(self, dim: int, device: torch.device) -> torch.Tensor:
296
+ cache_key = (dim, str(device))
297
+ if cache_key not in self._projection_cache:
298
+ generator = torch.Generator(device="cpu").manual_seed(20260728 + dim)
299
+ signs = torch.randint(
300
+ 0,
301
+ 2,
302
+ (dim, self.projection_dim),
303
+ generator=generator,
304
+ dtype=torch.int8,
305
+ )
306
+ projection = (
307
+ signs.float().mul_(2).sub_(1).div_(math.sqrt(self.projection_dim))
308
+ )
309
+ self._projection_cache[cache_key] = projection.to(device)
310
+ return self._projection_cache[cache_key]
311
+
312
+ def _make_block_hook(self, layer: int):
313
+ def hook(
314
+ _module: torch.nn.Module,
315
+ inputs: tuple[torch.Tensor, ...],
316
+ output: torch.Tensor,
317
+ ) -> None:
318
+ if not self.current.get("active", False):
319
+ return
320
+ if not inputs or not isinstance(output, torch.Tensor):
321
+ return
322
+ key = self._key()
323
+ hidden_input = inputs[0]
324
+ indices, coords = self._hidden_indices(output)
325
+ hidden = output[0].index_select(0, indices)
326
+ delta = (output - hidden_input)[0].index_select(0, indices)
327
+ self.records[f"block_{layer}_hidden"][key] = (
328
+ hidden.detach().to(dtype=torch.float16, device="cpu")
329
+ )
330
+ self.records[f"block_{layer}_delta"][key] = (
331
+ delta.detach().to(dtype=torch.float16, device="cpu")
332
+ )
333
+ self.sample_coords["hidden"] = coords.detach().cpu()
334
+
335
+ if layer in self.layers:
336
+ projection = self._projection(output.shape[-1], output.device)
337
+ dense = torch.matmul(output[0].float(), projection)
338
+ frames = int(self.current["frames"])
339
+ dense = dense.reshape(
340
+ frames,
341
+ FRAME_TOKEN_HEIGHT,
342
+ FRAME_TOKEN_WIDTH,
343
+ self.projection_dim,
344
+ )
345
+ dense_cpu = dense.detach().to(dtype=torch.float16, device="cpu")
346
+ self.projected_by_layer[f"{layer}:{key}"] = dense_cpu
347
+ # Preserve the legacy key layout for existing final-layer analyses.
348
+ if layer == self.projection_layer:
349
+ self.projected[key] = dense_cpu
350
+
351
+ return hook
352
+
353
+ def _model_output_hook(
354
+ self,
355
+ _module: torch.nn.Module,
356
+ _inputs: tuple[Any, ...],
357
+ output: torch.Tensor,
358
+ ) -> None:
359
+ if not self.current.get("active", False):
360
+ return
361
+ if not isinstance(output, torch.Tensor) or output.ndim != 5:
362
+ return
363
+ batch, channels, frames, height, width = output.shape
364
+ if batch != 1:
365
+ raise ValueError(f"Analysis expects batch size 1, got {batch}")
366
+ tokens = output.permute(0, 2, 3, 4, 1).reshape(
367
+ batch, frames * height * width, channels
368
+ )
369
+ indices, coords = regular_grid_indices(
370
+ frames, height, width, self.max_tokens, output.device
371
+ )
372
+ sampled = tokens[0].index_select(0, indices)
373
+ self.records["dit_output"][self._key()] = sampled.detach().to(
374
+ dtype=torch.float16, device="cpu"
375
+ )
376
+ self.sample_coords["dit_output"] = coords.detach().cpu()
377
+
378
+ def state_dict(self) -> dict[str, Any]:
379
+ return {
380
+ "timesteps": self.timesteps,
381
+ "layers": self.layers,
382
+ "projection_layer": self.projection_layer,
383
+ "projection_dim": self.projection_dim,
384
+ "records": {stage: dict(values) for stage, values in self.records.items()},
385
+ "projected": dict(self.projected),
386
+ "projected_by_layer": dict(self.projected_by_layer),
387
+ "sample_coords": dict(self.sample_coords),
388
+ }
389
+
390
+
391
+ def build_pipeline(args: argparse.Namespace) -> tuple[CausalInferencePipeline, Any]:
392
+ config = OmegaConf.load(resolve_path(args.config_path))
393
+ default_config = OmegaConf.load(REPO_ROOT / "configs/default_config.yaml")
394
+ config = OmegaConf.merge(default_config, config)
395
+ device = torch.device("cuda")
396
+ pipeline = CausalInferencePipeline(config, device=device)
397
+ checkpoint = torch.load(
398
+ resolve_path(args.checkpoint_path), map_location="cpu", weights_only=False
399
+ )
400
+ state_key = "generator_ema" if args.use_ema else "generator"
401
+ pipeline.generator.load_state_dict(checkpoint[state_key])
402
+ del checkpoint
403
+ pipeline = pipeline.to(dtype=torch.bfloat16)
404
+ pipeline.text_encoder.to(device=device)
405
+ pipeline.generator.to(device=device)
406
+ pipeline.vae.to(device=device)
407
+ pipeline.eval()
408
+ return pipeline, config
409
+
410
+
411
+ def downsample_anchors(video: torch.Tensor, latent_frames: int) -> np.ndarray:
412
+ value = video[0].detach().float().cpu()
413
+ frame_count = value.shape[0]
414
+ if frame_count == latent_frames:
415
+ indices = np.arange(latent_frames)
416
+ else:
417
+ indices = np.linspace(0, frame_count - 1, latent_frames).round().astype(int)
418
+ anchors = value[indices].permute(0, 2, 3, 1).clamp(0, 1).numpy()
419
+ result = []
420
+ for frame in anchors:
421
+ frame_u8 = np.uint8(np.round(frame * 255.0))
422
+ result.append(cv2.resize(frame_u8, (416, 240), interpolation=cv2.INTER_AREA))
423
+ return np.stack(result)
424
+
425
+
426
+ def maybe_save_preview(path: Path, anchors: np.ndarray) -> None:
427
+ try:
428
+ from torchvision.io import write_video
429
+
430
+ repeated = np.repeat(anchors, 4, axis=0)
431
+ tensor = torch.from_numpy(repeated)
432
+ write_video(str(path), tensor, fps=16)
433
+ except Exception as error:
434
+ print(f"[preview] skipped: {error}", flush=True)
435
+
436
+
437
+ @torch.inference_mode()
438
+ def generate_snapshots(args: argparse.Namespace) -> list[Path]:
439
+ output_dir = args.output_dir
440
+ runs_dir = output_dir / "runs"
441
+ runs_dir.mkdir(parents=True, exist_ok=True)
442
+ prompts = read_prompts(resolve_path(args.prompt_path), args.num_prompts)
443
+ expected_paths = [runs_dir / f"prompt_{index:02d}.pt" for index in range(len(prompts))]
444
+ missing = [
445
+ path
446
+ for path in expected_paths
447
+ if args.overwrite or not path.exists()
448
+ ]
449
+ if not missing:
450
+ print("[generation] all prompt snapshots already exist", flush=True)
451
+ return expected_paths
452
+
453
+ pipeline, config = build_pipeline(args)
454
+ denoising_timesteps = [
455
+ float(value) for value in pipeline.denoising_step_list.detach().cpu().tolist()
456
+ ]
457
+ recorder = FeatureRecorder(
458
+ model=pipeline.generator.model,
459
+ layers=args.layers,
460
+ denoising_timesteps=denoising_timesteps,
461
+ num_frame_per_block=pipeline.num_frame_per_block,
462
+ max_tokens=args.max_tokens,
463
+ projection_dim=args.projection_dim,
464
+ )
465
+ metadata = {
466
+ "physical_gpu": args.gpu,
467
+ "config_path": str(resolve_path(args.config_path)),
468
+ "checkpoint_path": str(resolve_path(args.checkpoint_path)),
469
+ "use_ema": args.use_ema,
470
+ "num_prompts": args.num_prompts,
471
+ "num_frames": args.num_frames,
472
+ "num_frame_per_block": pipeline.num_frame_per_block,
473
+ "denoising_timesteps": denoising_timesteps,
474
+ "layers": args.layers,
475
+ "max_tokens": args.max_tokens,
476
+ "projection_dim": args.projection_dim,
477
+ "seed": args.seed,
478
+ "dtype": "bfloat16",
479
+ }
480
+ (output_dir / "experiment_config.json").write_text(
481
+ json.dumps(metadata, indent=2, ensure_ascii=False) + "\n",
482
+ encoding="utf-8",
483
+ )
484
+
485
+ try:
486
+ for index, (prompt, path) in enumerate(zip(prompts, expected_paths)):
487
+ if path.exists() and not args.overwrite:
488
+ print(f"[generation] skip existing {path.name}", flush=True)
489
+ continue
490
+ recorder.reset()
491
+ run_seed = args.seed if args.same_seed else args.seed + index
492
+ set_seed(run_seed)
493
+ noise = torch.randn(
494
+ 1,
495
+ args.num_frames,
496
+ 16,
497
+ EXPECTED_LATENT_HEIGHT,
498
+ EXPECTED_LATENT_WIDTH,
499
+ device="cuda",
500
+ dtype=torch.bfloat16,
501
+ )
502
+ torch.cuda.reset_peak_memory_stats()
503
+ torch.cuda.synchronize()
504
+ start = time.perf_counter()
505
+ print(
506
+ f"[generation] prompt {index + 1}/{len(prompts)} seed={run_seed}",
507
+ flush=True,
508
+ )
509
+ video, latents = pipeline.inference(
510
+ noise=noise,
511
+ text_prompts=[prompt],
512
+ return_latents=True,
513
+ initial_latent=None,
514
+ low_memory=False,
515
+ )
516
+ torch.cuda.synchronize()
517
+ elapsed = time.perf_counter() - start
518
+ peak_gib = torch.cuda.max_memory_allocated() / (1024**3)
519
+ anchors = downsample_anchors(video, args.num_frames)
520
+ state = {
521
+ "run_index": index,
522
+ "prompt": prompt,
523
+ "seed": run_seed,
524
+ "elapsed_s": elapsed,
525
+ "peak_gpu_gib": peak_gib,
526
+ "num_frames": args.num_frames,
527
+ "num_frame_per_block": pipeline.num_frame_per_block,
528
+ "latents": latents[0].detach().to(
529
+ dtype=torch.float16, device="cpu"
530
+ ),
531
+ **recorder.state_dict(),
532
+ }
533
+ torch.save(state, path)
534
+ np.savez_compressed(path.with_suffix(".anchors.npz"), frames=anchors)
535
+ if args.save_preview:
536
+ maybe_save_preview(path.with_suffix(".mp4"), anchors)
537
+ print(
538
+ f"[generation] saved {path.name}: {elapsed:.1f}s, peak={peak_gib:.1f} GiB",
539
+ flush=True,
540
+ )
541
+ del video, latents, noise, state
542
+ pipeline.vae.model.clear_cache()
543
+ torch.cuda.empty_cache()
544
+ finally:
545
+ recorder.close()
546
+ return expected_paths
547
+
548
+
549
+ def load_runs(paths: list[Path]) -> list[dict[str, Any]]:
550
+ runs = []
551
+ for path in paths:
552
+ if not path.exists():
553
+ raise FileNotFoundError(path)
554
+ run = torch.load(path, map_location="cpu", weights_only=False)
555
+ anchor_path = path.with_suffix(".anchors.npz")
556
+ if not anchor_path.exists():
557
+ raise FileNotFoundError(anchor_path)
558
+ run["anchors"] = np.load(anchor_path, allow_pickle=False)["frames"]
559
+ run["path"] = str(path)
560
+ runs.append(run)
561
+ return runs
562
+
563
+
564
+ def feature(run: dict[str, Any], stage: str, chunk: int, step: int) -> torch.Tensor:
565
+ return run["records"][stage][f"{chunk}:{step}"].float()
566
+
567
+
568
+ def projected_feature(
569
+ run: dict[str, Any], chunk: int, step: int
570
+ ) -> torch.Tensor:
571
+ return run["projected"][f"{chunk}:{step}"].float()
572
+
573
+
574
+ def available_chunks(run: dict[str, Any], stage: str) -> list[int]:
575
+ return sorted(
576
+ {int(key.split(":")[0]) for key in run["records"][stage].keys()}
577
+ )
578
+
579
+
580
+ def available_steps(run: dict[str, Any], stage: str) -> list[int]:
581
+ return sorted(
582
+ {int(key.split(":")[1]) for key in run["records"][stage].keys()}
583
+ )
584
+
585
+
586
+ def pair_metrics(
587
+ reference: torch.Tensor,
588
+ target: torch.Tensor,
589
+ compute_cka: bool = True,
590
+ ) -> dict[str, float]:
591
+ if reference.shape != target.shape:
592
+ raise ValueError(f"Pair shape mismatch: {reference.shape} vs {target.shape}")
593
+ eps = 1e-8
594
+ x = reference.float()
595
+ y = target.float()
596
+ xf = x.reshape(-1)
597
+ yf = y.reshape(-1)
598
+ diff = yf - xf
599
+ cosine = F.cosine_similarity(xf[None], yf[None], dim=1, eps=eps)[0]
600
+ xc_flat = xf - xf.mean()
601
+ yc_flat = yf - yf.mean()
602
+ centered_cosine = torch.dot(xc_flat, yc_flat) / (
603
+ torch.linalg.vector_norm(xc_flat)
604
+ * torch.linalg.vector_norm(yc_flat)
605
+ + eps
606
+ )
607
+ token_cosine = F.cosine_similarity(x, y, dim=1, eps=eps)
608
+ rel_l2 = diff.square().mean().sqrt() / (xf.square().mean().sqrt() + eps)
609
+ nmse = diff.square().mean() / (yc_flat.square().mean() + eps)
610
+
611
+ if compute_cka:
612
+ xc = x - x.mean(dim=0, keepdim=True)
613
+ yc = y - y.mean(dim=0, keepdim=True)
614
+ gram_x = xc @ xc.T
615
+ gram_y = yc @ yc.T
616
+ cka = (gram_x * gram_y).sum() / (
617
+ (gram_x.square().sum() * gram_y.square().sum()).sqrt() + eps
618
+ )
619
+ else:
620
+ cka = torch.tensor(float("nan"))
621
+ quantiles = torch.quantile(
622
+ token_cosine,
623
+ torch.tensor([0.1, 0.5, 0.9], dtype=token_cosine.dtype),
624
+ )
625
+ return {
626
+ "cosine": float(cosine),
627
+ "centered_cosine": float(centered_cosine),
628
+ "linear_cka": float(cka),
629
+ "rel_l2": float(rel_l2),
630
+ "nmse": float(nmse),
631
+ "token_cosine_mean": float(token_cosine.mean()),
632
+ "token_cosine_p10": float(quantiles[0]),
633
+ "token_cosine_p50": float(quantiles[1]),
634
+ "token_cosine_p90": float(quantiles[2]),
635
+ "reference_rms": float(xf.square().mean().sqrt()),
636
+ "target_rms": float(yf.square().mean().sqrt()),
637
+ }
638
+
639
+
640
+ def collect_pair_rows(
641
+ runs: list[dict[str, Any]], cosine_only: bool = False
642
+ ) -> list[dict[str, Any]]:
643
+ rows: list[dict[str, Any]] = []
644
+ stages = sorted(runs[0]["records"])
645
+ if cosine_only:
646
+ stages = [
647
+ stage
648
+ for stage in stages
649
+ if stage.startswith("block_") and stage.endswith("_hidden")
650
+ ]
651
+ for run_index, run in enumerate(runs):
652
+ shuffled_run = runs[(run_index + 1) % len(runs)]
653
+ for stage in stages:
654
+ chunks = available_chunks(run, stage)
655
+ steps = available_steps(run, stage)
656
+
657
+ def append(
658
+ comparison: str,
659
+ ref_run: dict[str, Any],
660
+ ref_chunk: int,
661
+ ref_step: int,
662
+ target_chunk: int,
663
+ target_step: int,
664
+ ) -> None:
665
+ metrics = pair_metrics(
666
+ feature(ref_run, stage, ref_chunk, ref_step),
667
+ feature(run, stage, target_chunk, target_step),
668
+ compute_cka=not cosine_only,
669
+ )
670
+ rows.append(
671
+ {
672
+ "run": run_index,
673
+ "comparison": comparison,
674
+ "stage": stage,
675
+ "reference_chunk": ref_chunk,
676
+ "target_chunk": target_chunk,
677
+ "reference_step": ref_step,
678
+ "target_step": target_step,
679
+ **metrics,
680
+ }
681
+ )
682
+
683
+ for chunk in chunks:
684
+ for step in steps[1:]:
685
+ append(
686
+ "within_adjacent",
687
+ run,
688
+ chunk,
689
+ step - 1,
690
+ chunk,
691
+ step,
692
+ )
693
+ if chunk < 1:
694
+ continue
695
+ for step in steps:
696
+ append(
697
+ "cross_same",
698
+ run,
699
+ chunk - 1,
700
+ step,
701
+ chunk,
702
+ step,
703
+ )
704
+ if cosine_only:
705
+ continue
706
+ append(
707
+ "cross_video_shuffle",
708
+ shuffled_run,
709
+ chunk - 1,
710
+ step,
711
+ chunk,
712
+ step,
713
+ )
714
+ if step > 0:
715
+ append(
716
+ "cross_wrong_step",
717
+ run,
718
+ chunk - 1,
719
+ step - 1,
720
+ chunk,
721
+ step,
722
+ )
723
+ if chunk > 1:
724
+ append(
725
+ "cross_distant",
726
+ run,
727
+ chunk - 2,
728
+ step,
729
+ chunk,
730
+ step,
731
+ )
732
+ return rows
733
+
734
+
735
+ def predictor_columns(
736
+ name: str,
737
+ within: torch.Tensor,
738
+ cross: torch.Tensor,
739
+ distant: torch.Tensor,
740
+ wrong: torch.Tensor,
741
+ batch: torch.Tensor,
742
+ noise_seed: int,
743
+ ) -> list[torch.Tensor]:
744
+ ones = torch.ones_like(within)
745
+ shifted = cross.roll(shifts=max(1, cross.shape[0] // 2), dims=0)
746
+ generator = torch.Generator(device="cpu").manual_seed(noise_seed)
747
+ noise = torch.randn(
748
+ cross.shape, generator=generator, dtype=cross.dtype
749
+ )
750
+ noise = noise * cross.std(dim=0, keepdim=True).clamp_min(1e-6)
751
+ noise = noise + cross.mean(dim=0, keepdim=True)
752
+ mapping = {
753
+ "within_affine": [within, ones],
754
+ "within_quadratic": [within, within.square(), ones],
755
+ "cross_affine": [cross, ones],
756
+ "fusion_same": [within, cross, ones],
757
+ "fusion_distant": [within, distant, ones],
758
+ "fusion_token_shift": [within, shifted, ones],
759
+ "fusion_wrong_step": [within, wrong, ones],
760
+ "fusion_batch_shuffle": [within, batch, ones],
761
+ "fusion_zero": [within, torch.zeros_like(cross), ones],
762
+ "fusion_noise": [within, noise, ones],
763
+ }
764
+ return mapping[name]
765
+
766
+
767
+ PROBE_NAMES = [
768
+ "within_affine",
769
+ "within_quadratic",
770
+ "cross_affine",
771
+ "fusion_same",
772
+ "fusion_distant",
773
+ "fusion_token_shift",
774
+ "fusion_wrong_step",
775
+ "fusion_batch_shuffle",
776
+ "fusion_zero",
777
+ "fusion_noise",
778
+ ]
779
+
780
+
781
+ def gather_probe_data(
782
+ runs: list[dict[str, Any]],
783
+ run_indices: list[int],
784
+ stage: str,
785
+ step: int,
786
+ ) -> dict[str, torch.Tensor]:
787
+ buckets: dict[str, list[torch.Tensor]] = defaultdict(list)
788
+ for run_index in run_indices:
789
+ run = runs[run_index]
790
+ other = runs[(run_index + 1) % len(runs)]
791
+ chunks = available_chunks(run, stage)
792
+ for chunk in chunks:
793
+ # Use c >= 2 for every probe so correct, distant, and all other
794
+ # controls are evaluated on exactly the same target tokens.
795
+ if chunk < 2:
796
+ continue
797
+ buckets["target"].append(feature(run, stage, chunk, step))
798
+ buckets["within"].append(feature(run, stage, chunk, step - 1))
799
+ buckets["cross"].append(feature(run, stage, chunk - 1, step))
800
+ buckets["distant"].append(feature(run, stage, chunk - 2, step))
801
+ buckets["wrong"].append(feature(run, stage, chunk - 1, step - 1))
802
+ buckets["batch"].append(feature(other, stage, chunk - 1, step))
803
+ if not buckets:
804
+ raise ValueError(f"No probe data for stage={stage}, step={step}")
805
+ return {key: torch.cat(values, dim=0).float() for key, values in buckets.items()}
806
+
807
+
808
+ def fit_channelwise_probe(
809
+ columns: list[torch.Tensor],
810
+ target: torch.Tensor,
811
+ ridge: float,
812
+ ) -> torch.Tensor:
813
+ design = torch.stack(columns, dim=-1).double()
814
+ y = target.double()
815
+ gram = torch.einsum("ndp,ndq->dpq", design, design)
816
+ rhs = torch.einsum("ndp,nd->dp", design, y)
817
+ feature_count = gram.shape[-1]
818
+ diagonal_scale = (
819
+ gram.diagonal(dim1=-2, dim2=-1).mean(dim=-1).clamp_min(1e-8)
820
+ )
821
+ regularizer = (
822
+ torch.eye(feature_count, dtype=gram.dtype)[None]
823
+ * (ridge * diagonal_scale)[:, None, None]
824
+ )
825
+ regularizer[:, -1, -1] = 0.0
826
+ try:
827
+ weights = torch.linalg.solve(gram + regularizer, rhs.unsqueeze(-1)).squeeze(-1)
828
+ except torch.linalg.LinAlgError:
829
+ weights = (
830
+ torch.linalg.pinv(gram + regularizer) @ rhs.unsqueeze(-1)
831
+ ).squeeze(-1)
832
+ return weights.float()
833
+
834
+
835
+ def apply_channelwise_probe(
836
+ columns: list[torch.Tensor], weights: torch.Tensor
837
+ ) -> torch.Tensor:
838
+ design = torch.stack(columns, dim=-1).float()
839
+ return torch.einsum("ndp,dp->nd", design, weights)
840
+
841
+
842
+ def prediction_metrics(prediction: torch.Tensor, target: torch.Tensor) -> dict[str, float]:
843
+ eps = 1e-8
844
+ pred = prediction.float()
845
+ y = target.float()
846
+ error = pred - y
847
+ mse = error.square().mean()
848
+ variance = (y - y.mean()).square().mean()
849
+ nrmse = mse.sqrt() / (variance.sqrt() + eps)
850
+ r2 = 1.0 - mse / (variance + eps)
851
+ cosine = F.cosine_similarity(
852
+ pred.reshape(1, -1), y.reshape(1, -1), dim=1, eps=eps
853
+ )[0]
854
+ return {
855
+ "mse": float(mse),
856
+ "nrmse": float(nrmse),
857
+ "r2": float(r2),
858
+ "cosine": float(cosine),
859
+ }
860
+
861
+
862
+ def run_conditional_probes(
863
+ runs: list[dict[str, Any]], ridge: float
864
+ ) -> list[dict[str, Any]]:
865
+ rows: list[dict[str, Any]] = []
866
+ stages = sorted(runs[0]["records"])
867
+ steps = available_steps(runs[0], stages[0])
868
+ for stage in stages:
869
+ for step in steps[1:]:
870
+ for held_out in range(len(runs)):
871
+ train_indices = [index for index in range(len(runs)) if index != held_out]
872
+ train = gather_probe_data(runs, train_indices, stage, step)
873
+ test = gather_probe_data(runs, [held_out], stage, step)
874
+ for probe_name in PROBE_NAMES:
875
+ train_columns = predictor_columns(
876
+ probe_name,
877
+ train["within"],
878
+ train["cross"],
879
+ train["distant"],
880
+ train["wrong"],
881
+ train["batch"],
882
+ noise_seed=1000 + held_out * 100 + step,
883
+ )
884
+ test_columns = predictor_columns(
885
+ probe_name,
886
+ test["within"],
887
+ test["cross"],
888
+ test["distant"],
889
+ test["wrong"],
890
+ test["batch"],
891
+ noise_seed=2000 + held_out * 100 + step,
892
+ )
893
+ weights = fit_channelwise_probe(
894
+ train_columns, train["target"], ridge=ridge
895
+ )
896
+ prediction = apply_channelwise_probe(test_columns, weights)
897
+ rows.append(
898
+ {
899
+ "held_out_run": held_out,
900
+ "stage": stage,
901
+ "step": step,
902
+ "probe": probe_name,
903
+ "train_tokens": int(train["target"].shape[0]),
904
+ "test_tokens": int(test["target"].shape[0]),
905
+ **prediction_metrics(prediction, test["target"]),
906
+ }
907
+ )
908
+ baseline_lookup = {
909
+ (row["held_out_run"], row["stage"], row["step"], row["probe"]): row
910
+ for row in rows
911
+ if row["probe"] in {"within_affine", "within_quadratic"}
912
+ }
913
+ for row in rows:
914
+ key = (row["held_out_run"], row["stage"], row["step"])
915
+ for baseline_name in ("within_affine", "within_quadratic"):
916
+ baseline = baseline_lookup[(*key, baseline_name)]
917
+ row[f"mse_reduction_vs_{baseline_name}"] = (
918
+ baseline["mse"] - row["mse"]
919
+ ) / max(baseline["mse"], 1e-12)
920
+ row[f"r2_gain_vs_{baseline_name}"] = row["r2"] - baseline["r2"]
921
+ return rows
922
+
923
+
924
+ def gray(frame: np.ndarray) -> np.ndarray:
925
+ return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
926
+
927
+
928
+ def farneback(source: np.ndarray, target: np.ndarray) -> np.ndarray:
929
+ return cv2.calcOpticalFlowFarneback(
930
+ gray(source),
931
+ gray(target),
932
+ None,
933
+ pyr_scale=0.5,
934
+ levels=4,
935
+ winsize=21,
936
+ iterations=5,
937
+ poly_n=7,
938
+ poly_sigma=1.5,
939
+ flags=0,
940
+ )
941
+
942
+
943
+ def resize_flow(flow: np.ndarray, height: int, width: int) -> torch.Tensor:
944
+ source_height, source_width = flow.shape[:2]
945
+ resized = cv2.resize(flow, (width, height), interpolation=cv2.INTER_AREA)
946
+ resized[..., 0] *= width / source_width
947
+ resized[..., 1] *= height / source_height
948
+ return torch.from_numpy(resized).permute(2, 0, 1).float()
949
+
950
+
951
+ def warp_feature(
952
+ source: torch.Tensor, target_to_source_flow: torch.Tensor
953
+ ) -> tuple[torch.Tensor, torch.Tensor]:
954
+ height, width, _ = source.shape
955
+ yy, xx = torch.meshgrid(
956
+ torch.arange(height, dtype=torch.float32),
957
+ torch.arange(width, dtype=torch.float32),
958
+ indexing="ij",
959
+ )
960
+ sample_x = xx + target_to_source_flow[0]
961
+ sample_y = yy + target_to_source_flow[1]
962
+ grid = torch.stack(
963
+ [
964
+ 2.0 * sample_x / max(width - 1, 1) - 1.0,
965
+ 2.0 * sample_y / max(height - 1, 1) - 1.0,
966
+ ],
967
+ dim=-1,
968
+ )[None]
969
+ value = source.permute(2, 0, 1)[None].float()
970
+ warped = F.grid_sample(
971
+ value, grid, mode="bilinear", padding_mode="zeros", align_corners=True
972
+ )[0].permute(1, 2, 0)
973
+ mask = (
974
+ (sample_x >= 0)
975
+ & (sample_x <= width - 1)
976
+ & (sample_y >= 0)
977
+ & (sample_y <= height - 1)
978
+ )
979
+ return warped, mask
980
+
981
+
982
+ def masked_cosine(
983
+ left: torch.Tensor, right: torch.Tensor, mask: torch.Tensor | None = None
984
+ ) -> float:
985
+ value = F.cosine_similarity(left.float(), right.float(), dim=-1, eps=1e-8)
986
+ if mask is not None:
987
+ if not bool(mask.any()):
988
+ return float("nan")
989
+ value = value[mask]
990
+ return float(value.mean())
991
+
992
+
993
+ def collect_motion_rows(runs: list[dict[str, Any]]) -> list[dict[str, Any]]:
994
+ rows: list[dict[str, Any]] = []
995
+ for run_index, run in enumerate(runs):
996
+ anchors = run["anchors"]
997
+ chunk_size = int(run["num_frame_per_block"])
998
+ chunk_count = int(run["num_frames"]) // chunk_size
999
+ steps = sorted(
1000
+ {int(key.split(":")[1]) for key in run["projected"].keys()}
1001
+ )
1002
+ for chunk in range(1, chunk_count):
1003
+ previous_anchor_index = chunk * chunk_size - 1
1004
+ previous_frame = anchors[previous_anchor_index]
1005
+ boundary_flow = farneback(
1006
+ previous_frame, anchors[chunk * chunk_size]
1007
+ )
1008
+ median_flow = np.median(
1009
+ boundary_flow.reshape(-1, 2), axis=0
1010
+ )
1011
+ camera_motion = float(np.linalg.norm(median_flow))
1012
+ residual = boundary_flow - median_flow[None, None]
1013
+ object_motion = float(
1014
+ np.linalg.norm(residual, axis=-1).mean()
1015
+ )
1016
+ total_motion = float(
1017
+ np.linalg.norm(boundary_flow, axis=-1).mean()
1018
+ )
1019
+
1020
+ for step in steps:
1021
+ source_map = projected_feature(run, chunk - 1, step)[-1]
1022
+ target_map = projected_feature(run, chunk, step)
1023
+ same_slot_source = projected_feature(run, chunk - 1, step)
1024
+ per_slot: list[dict[str, float]] = []
1025
+ for slot in range(chunk_size):
1026
+ target = target_map[slot]
1027
+ same_slot = same_slot_source[slot]
1028
+ raw_boundary = source_map
1029
+ target_frame = anchors[chunk * chunk_size + slot]
1030
+ backward = farneback(target_frame, previous_frame)
1031
+ feature_flow = resize_flow(
1032
+ backward, FRAME_TOKEN_HEIGHT, FRAME_TOKEN_WIDTH
1033
+ )
1034
+ global_flow = torch.zeros_like(feature_flow)
1035
+ global_flow[0].fill_(float(np.median(feature_flow[0].numpy())))
1036
+ global_flow[1].fill_(float(np.median(feature_flow[1].numpy())))
1037
+ global_aligned, global_mask = warp_feature(
1038
+ source_map, global_flow
1039
+ )
1040
+ flow_aligned, flow_mask = warp_feature(source_map, feature_flow)
1041
+ per_slot.append(
1042
+ {
1043
+ "same_slot_cosine": masked_cosine(target, same_slot),
1044
+ "boundary_raw_cosine": masked_cosine(
1045
+ target, raw_boundary
1046
+ ),
1047
+ "global_aligned_cosine": masked_cosine(
1048
+ target, global_aligned, global_mask
1049
+ ),
1050
+ "flow_aligned_cosine": masked_cosine(
1051
+ target, flow_aligned, flow_mask
1052
+ ),
1053
+ "valid_flow_ratio": float(flow_mask.float().mean()),
1054
+ }
1055
+ )
1056
+ rows.append(
1057
+ {
1058
+ "run": run_index,
1059
+ "chunk": chunk,
1060
+ "step": step,
1061
+ "total_motion": total_motion,
1062
+ "camera_motion": camera_motion,
1063
+ "object_motion": object_motion,
1064
+ **{
1065
+ key: float(np.nanmean([item[key] for item in per_slot]))
1066
+ for key in per_slot[0]
1067
+ },
1068
+ }
1069
+ )
1070
+ motion_values = np.asarray([row["total_motion"] for row in rows])
1071
+ if len(motion_values) >= 3:
1072
+ low, high = np.quantile(motion_values, [1 / 3, 2 / 3])
1073
+ for row in rows:
1074
+ value = row["total_motion"]
1075
+ row["motion_bin"] = "low" if value <= low else "high" if value > high else "medium"
1076
+ return rows
1077
+
1078
+
1079
+ def group_mean(
1080
+ rows: list[dict[str, Any]], keys: list[str], metrics: list[str]
1081
+ ) -> list[dict[str, Any]]:
1082
+ groups: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
1083
+ for row in rows:
1084
+ groups[tuple(row[key] for key in keys)].append(row)
1085
+ result = []
1086
+ for group, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
1087
+ output = {key: value for key, value in zip(keys, group)}
1088
+ output["count"] = len(values)
1089
+ for metric in metrics:
1090
+ finite = [
1091
+ float(row[metric])
1092
+ for row in values
1093
+ if metric in row and math.isfinite(float(row[metric]))
1094
+ ]
1095
+ output[metric] = float(np.mean(finite)) if finite else float("nan")
1096
+ result.append(output)
1097
+ return result
1098
+
1099
+
1100
+ def paired_probe_reduction(
1101
+ rows: list[dict[str, Any]],
1102
+ stage: str,
1103
+ reference_probe: str,
1104
+ candidate_probe: str = "fusion_same",
1105
+ ) -> dict[str, Any]:
1106
+ lookup = {
1107
+ (int(row["held_out_run"]), int(row["step"]), row["probe"]): row
1108
+ for row in rows
1109
+ if row["stage"] == stage
1110
+ and row["probe"] in {reference_probe, candidate_probe}
1111
+ }
1112
+ pairs = sorted(
1113
+ {
1114
+ (held_out, step)
1115
+ for held_out, step, probe in lookup
1116
+ if probe == candidate_probe
1117
+ and (held_out, step, reference_probe) in lookup
1118
+ }
1119
+ )
1120
+ reductions = []
1121
+ for held_out, step in pairs:
1122
+ reference = float(lookup[(held_out, step, reference_probe)]["mse"])
1123
+ candidate = float(lookup[(held_out, step, candidate_probe)]["mse"])
1124
+ reductions.append((reference - candidate) / max(reference, 1e-12))
1125
+ return {
1126
+ "reference": reference_probe,
1127
+ "paired_count": len(reductions),
1128
+ "mean_mse_reduction": (
1129
+ float(np.mean(reductions)) if reductions else float("nan")
1130
+ ),
1131
+ "median_mse_reduction": (
1132
+ float(np.median(reductions)) if reductions else float("nan")
1133
+ ),
1134
+ "wins": int(sum(value > 0 for value in reductions)),
1135
+ }
1136
+
1137
+
1138
+ def plot_similarity(pair_rows: list[dict[str, Any]], output_dir: Path) -> None:
1139
+ stages = [
1140
+ stage
1141
+ for stage in sorted({row["stage"] for row in pair_rows})
1142
+ if stage.endswith("_hidden")
1143
+ ]
1144
+ comparisons = [
1145
+ "within_adjacent",
1146
+ "cross_same",
1147
+ "cross_wrong_step",
1148
+ "cross_distant",
1149
+ "cross_video_shuffle",
1150
+ ]
1151
+ steps = sorted({int(row["target_step"]) for row in pair_rows})
1152
+ fig, axes = plt.subplots(
1153
+ len(stages), 2, figsize=(12, max(3.2, 2.8 * len(stages))), squeeze=False
1154
+ )
1155
+ for stage_index, stage in enumerate(stages):
1156
+ for metric_index, metric in enumerate(["cosine", "nmse"]):
1157
+ matrix = np.full((len(comparisons), len(steps)), np.nan)
1158
+ for row_index, comparison in enumerate(comparisons):
1159
+ for col_index, step in enumerate(steps):
1160
+ values = [
1161
+ float(row[metric])
1162
+ for row in pair_rows
1163
+ if row["stage"] == stage
1164
+ and row["comparison"] == comparison
1165
+ and int(row["target_step"]) == step
1166
+ ]
1167
+ if values:
1168
+ matrix[row_index, col_index] = np.mean(values)
1169
+ ax = axes[stage_index, metric_index]
1170
+ image = ax.imshow(
1171
+ matrix,
1172
+ aspect="auto",
1173
+ cmap="viridis_r" if metric == "nmse" else "viridis",
1174
+ )
1175
+ ax.set_title(f"{stage}: {metric}")
1176
+ ax.set_xticks(range(len(steps)), labels=steps)
1177
+ ax.set_yticks(range(len(comparisons)), labels=comparisons)
1178
+ ax.set_xlabel("target denoising step")
1179
+ fig.colorbar(image, ax=ax, fraction=0.03)
1180
+ fig.tight_layout()
1181
+ fig.savefig(output_dir / "feature_redundancy_heatmap.png", dpi=180)
1182
+ plt.close(fig)
1183
+
1184
+
1185
+ def plot_probe_gain(probe_rows: list[dict[str, Any]], output_dir: Path) -> None:
1186
+ aggregate = group_mean(
1187
+ probe_rows,
1188
+ ["stage", "step", "probe"],
1189
+ ["mse", "nrmse", "r2", "mse_reduction_vs_within_quadratic"],
1190
+ )
1191
+ stages = [
1192
+ stage
1193
+ for stage in sorted({row["stage"] for row in aggregate})
1194
+ if stage.endswith("_hidden")
1195
+ ]
1196
+ probes = [
1197
+ "fusion_same",
1198
+ "fusion_distant",
1199
+ "fusion_token_shift",
1200
+ "fusion_wrong_step",
1201
+ "fusion_batch_shuffle",
1202
+ ]
1203
+ steps = sorted({int(row["step"]) for row in aggregate})
1204
+ fig, axes = plt.subplots(
1205
+ len(stages), 1, figsize=(9, max(3.4, 3.0 * len(stages))), squeeze=False
1206
+ )
1207
+ for stage_index, stage in enumerate(stages):
1208
+ ax = axes[stage_index, 0]
1209
+ for probe in probes:
1210
+ values = []
1211
+ for step in steps:
1212
+ match = [
1213
+ row
1214
+ for row in aggregate
1215
+ if row["stage"] == stage
1216
+ and int(row["step"]) == step
1217
+ and row["probe"] == probe
1218
+ ]
1219
+ values.append(
1220
+ match[0]["mse_reduction_vs_within_quadratic"]
1221
+ if match
1222
+ else np.nan
1223
+ )
1224
+ ax.plot(steps, values, marker="o", label=probe)
1225
+ ax.axhline(0, color="black", linewidth=0.8)
1226
+ ax.set_title(stage)
1227
+ ax.set_ylabel("MSE reduction vs within quadratic")
1228
+ ax.set_xlabel("target denoising step")
1229
+ ax.legend(fontsize=8)
1230
+ ax.grid(alpha=0.25)
1231
+ fig.tight_layout()
1232
+ fig.savefig(output_dir / "conditional_chunk_gain.png", dpi=180)
1233
+ plt.close(fig)
1234
+
1235
+
1236
+ def plot_motion(motion_rows: list[dict[str, Any]], output_dir: Path) -> None:
1237
+ fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
1238
+ axes[0].scatter(
1239
+ [row["total_motion"] for row in motion_rows],
1240
+ [row["boundary_raw_cosine"] for row in motion_rows],
1241
+ s=18,
1242
+ alpha=0.65,
1243
+ label="boundary raw",
1244
+ )
1245
+ axes[0].scatter(
1246
+ [row["total_motion"] for row in motion_rows],
1247
+ [row["flow_aligned_cosine"] for row in motion_rows],
1248
+ s=18,
1249
+ alpha=0.65,
1250
+ label="dense-flow aligned",
1251
+ )
1252
+ axes[0].set_xlabel("optical-flow magnitude")
1253
+ axes[0].set_ylabel("feature cosine")
1254
+ axes[0].legend()
1255
+ axes[0].grid(alpha=0.25)
1256
+
1257
+ aggregate = group_mean(
1258
+ motion_rows,
1259
+ ["motion_bin"],
1260
+ [
1261
+ "same_slot_cosine",
1262
+ "boundary_raw_cosine",
1263
+ "global_aligned_cosine",
1264
+ "flow_aligned_cosine",
1265
+ ],
1266
+ )
1267
+ bins = ["low", "medium", "high"]
1268
+ metrics = [
1269
+ "same_slot_cosine",
1270
+ "boundary_raw_cosine",
1271
+ "global_aligned_cosine",
1272
+ "flow_aligned_cosine",
1273
+ ]
1274
+ width = 0.18
1275
+ x = np.arange(len(bins))
1276
+ for metric_index, metric in enumerate(metrics):
1277
+ values = []
1278
+ for name in bins:
1279
+ match = [row for row in aggregate if row["motion_bin"] == name]
1280
+ values.append(match[0][metric] if match else np.nan)
1281
+ axes[1].bar(
1282
+ x + (metric_index - 1.5) * width,
1283
+ values,
1284
+ width=width,
1285
+ label=metric.replace("_cosine", ""),
1286
+ )
1287
+ axes[1].set_xticks(x, bins)
1288
+ axes[1].set_ylabel("mean feature cosine")
1289
+ axes[1].set_xlabel("motion bin")
1290
+ axes[1].legend(fontsize=8)
1291
+ axes[1].grid(axis="y", alpha=0.25)
1292
+ fig.tight_layout()
1293
+ fig.savefig(output_dir / "motion_alignment_analysis.png", dpi=180)
1294
+ plt.close(fig)
1295
+
1296
+
1297
+ def markdown_table(
1298
+ rows: list[dict[str, Any]], columns: list[str], digits: int = 4
1299
+ ) -> str:
1300
+ lines = [
1301
+ "| " + " | ".join(columns) + " |",
1302
+ "|" + "|".join(["---"] * len(columns)) + "|",
1303
+ ]
1304
+ for row in rows:
1305
+ values = []
1306
+ for column in columns:
1307
+ value = row.get(column, "")
1308
+ if isinstance(value, float):
1309
+ values.append(f"{value:.{digits}f}")
1310
+ else:
1311
+ values.append(str(value))
1312
+ lines.append("| " + " | ".join(values) + " |")
1313
+ return "\n".join(lines)
1314
+
1315
+
1316
+ def build_report(
1317
+ runs: list[dict[str, Any]],
1318
+ pair_rows: list[dict[str, Any]],
1319
+ probe_rows: list[dict[str, Any]],
1320
+ motion_rows: list[dict[str, Any]],
1321
+ output_dir: Path,
1322
+ ) -> None:
1323
+ final_stage = f"block_{runs[0]['projection_layer']}_hidden"
1324
+ pair_summary = group_mean(
1325
+ [
1326
+ row
1327
+ for row in pair_rows
1328
+ if row["stage"] == final_stage
1329
+ ],
1330
+ ["comparison"],
1331
+ ["cosine", "linear_cka", "rel_l2", "nmse", "token_cosine_p10"],
1332
+ )
1333
+ probe_summary = group_mean(
1334
+ [
1335
+ row
1336
+ for row in probe_rows
1337
+ if row["stage"] == final_stage
1338
+ and row["probe"] in set(PROBE_NAMES)
1339
+ ],
1340
+ ["probe"],
1341
+ [
1342
+ "nrmse",
1343
+ "r2",
1344
+ "mse_reduction_vs_within_affine",
1345
+ "mse_reduction_vs_within_quadratic",
1346
+ ],
1347
+ )
1348
+ motion_summary = group_mean(
1349
+ motion_rows,
1350
+ ["motion_bin"],
1351
+ [
1352
+ "total_motion",
1353
+ "same_slot_cosine",
1354
+ "boundary_raw_cosine",
1355
+ "global_aligned_cosine",
1356
+ "flow_aligned_cosine",
1357
+ ],
1358
+ )
1359
+ control_order = [
1360
+ "within_affine",
1361
+ "within_quadratic",
1362
+ "fusion_distant",
1363
+ "fusion_wrong_step",
1364
+ "fusion_token_shift",
1365
+ "fusion_batch_shuffle",
1366
+ "fusion_zero",
1367
+ "fusion_noise",
1368
+ ]
1369
+ control_summary = [
1370
+ paired_probe_reduction(
1371
+ probe_rows,
1372
+ stage=final_stage,
1373
+ reference_probe=control,
1374
+ )
1375
+ for control in control_order
1376
+ ]
1377
+ control_lookup = {row["reference"]: row for row in control_summary}
1378
+ affine = control_lookup["within_affine"]
1379
+ shuffled = control_lookup["fusion_batch_shuffle"]
1380
+ conditional_statement = (
1381
+ f"在主层 `{final_stage}` 上,加入正确的前一 chunk 同 timestep 特征,"
1382
+ f"相对 within-only affine probe 平均降低 held-out MSE "
1383
+ f"{100*affine['mean_mse_reduction']:.2f}%,并在 "
1384
+ f"{affine['wins']}/{affine['paired_count']} 个 "
1385
+ f"prompt–timestep 配对中取得改善。相对参数量一致的跨视频 "
1386
+ f"shuffle 对照,MSE 平均降低 "
1387
+ f"{100*shuffled['mean_mse_reduction']:.2f}%。"
1388
+ )
1389
+
1390
+ pair_lookup = {row["comparison"]: row for row in pair_summary}
1391
+ same_pair = pair_lookup.get("cross_same")
1392
+ random_pair = pair_lookup.get("cross_video_shuffle")
1393
+ pair_statement = ""
1394
+ if same_pair is not None and random_pair is not None:
1395
+ pair_statement = (
1396
+ f"正确相邻 chunk 的平均 cosine/CKA 为 "
1397
+ f"{same_pair['cosine']:.4f}/{same_pair['linear_cka']:.4f},"
1398
+ f"跨视频 shuffle 为 "
1399
+ f"{random_pair['cosine']:.4f}/{random_pair['linear_cka']:.4f}。"
1400
+ )
1401
+
1402
+ total_motion = np.asarray(
1403
+ [float(row["total_motion"]) for row in motion_rows], dtype=np.float64
1404
+ )
1405
+ raw_cosine = np.asarray(
1406
+ [float(row["boundary_raw_cosine"]) for row in motion_rows],
1407
+ dtype=np.float64,
1408
+ )
1409
+ flow_cosine = np.asarray(
1410
+ [float(row["flow_aligned_cosine"]) for row in motion_rows],
1411
+ dtype=np.float64,
1412
+ )
1413
+ motion_correlation = (
1414
+ float(np.corrcoef(total_motion, raw_cosine)[0, 1])
1415
+ if len(motion_rows) > 1
1416
+ else float("nan")
1417
+ )
1418
+ flow_gain = float(np.mean(flow_cosine - raw_cosine))
1419
+ flow_wins = int(np.sum(flow_cosine > raw_cosine))
1420
+ motion_statement = (
1421
+ f"运动强度与未对齐跨 chunk cosine 的 Pearson 相关系数为 "
1422
+ f"{motion_correlation:.3f};dense-flow 对齐平均恢复 "
1423
+ f"{flow_gain:.4f} cosine,并在 {flow_wins}/{len(motion_rows)} "
1424
+ f"个 chunk–timestep 样本上改善。"
1425
+ )
1426
+
1427
+ report = f"""# Self-Forcing Feature Cache 分析结果
1428
+
1429
+ ## 实验配置
1430
+
1431
+ - Prompts:{len(runs)}
1432
+ - 每条视频 latent frames:{runs[0]['num_frames']}
1433
+ - 每个 AR chunk latent frames:{runs[0]['num_frame_per_block']}
1434
+ - Denoising timesteps:{runs[0]['timesteps']}
1435
+ - Hook layers:{runs[0]['layers']}
1436
+ - 主分析 stage:`{final_stage}`
1437
+
1438
+ ## 核心观察
1439
+
1440
+ {conditional_statement}
1441
+
1442
+ {pair_statement}
1443
+
1444
+ {motion_statement}
1445
+
1446
+ 以下结果是 3 条 prompt 的 pilot 分析。条件 probe 按 prompt 留一测试,
1447
+ 统计单位是 held-out prompt 与 timestep;不能将 token 数量解释为独立视频样本数,
1448
+ 也不能据此声称数据集级统计显著性。
1449
+
1450
+ ## 主层特征配对
1451
+
1452
+ {markdown_table(pair_summary, ['comparison', 'count', 'cosine', 'linear_cka', 'rel_l2', 'nmse', 'token_cosine_p10'])}
1453
+
1454
+ ## 条件 Probe
1455
+
1456
+ Probe 仅使用 chunk index `c ≥ 2` 的目标 chunk,使 `correct`、`c-2 distant`
1457
+ 及其他控制组在完全相同的 token 上比较。
1458
+
1459
+ {markdown_table(probe_summary, ['probe', 'count', 'nrmse', 'r2', 'mse_reduction_vs_within_affine', 'mse_reduction_vs_within_quadratic'])}
1460
+
1461
+ ### `fusion_same` 的成对控制实验
1462
+
1463
+ 正值表示正确前一 chunk 同 timestep 输入的 MSE 更低。
1464
+
1465
+ {markdown_table(control_summary, ['reference', 'paired_count', 'mean_mse_reduction', 'median_mse_reduction', 'wins'])}
1466
+
1467
+ ## 运动与对齐
1468
+
1469
+ {markdown_table(motion_summary, ['motion_bin', 'count', 'total_motion', 'same_slot_cosine', 'boundary_raw_cosine', 'global_aligned_cosine', 'flow_aligned_cosine'])}
1470
+
1471
+ 这里的 `global_aligned` 是由光流中位数估计的全局平移对齐,
1472
+ `flow_aligned` 是 dense optical-flow oracle;本轮未实现 homography。
1473
+
1474
+ ## 本轮结论边界
1475
+
1476
+ - 已完成:四步 DMD 模型的 hidden/residual-delta 特征采集、相似度与
1477
+ nMSE/CKA、linear/ridge 条件 probe、负对照、运动分桶和光流对齐。
1478
+ - 尚未完成:接入用户现有的小型非线性预测网络、真实 cache 替换干预、
1479
+ 最终视频质量与端到端加速评估、规模化多视频置信区间。
1480
+ - 因而当前结果支持“前一 chunk 提供额外且具有空间对应性的条件信息”,
1481
+ 但还不能单独证明最终生成质量或真实加速收益。
1482
+
1483
+ ## 输出文件
1484
+
1485
+ - `feature_pair_metrics.csv`
1486
+ - `feature_pair_summary.csv`
1487
+ - `conditional_probe_folds.csv`
1488
+ - `conditional_probe_summary.csv`
1489
+ - `motion_alignment_metrics.csv`
1490
+ - `motion_alignment_summary.csv`
1491
+ - `feature_redundancy_heatmap.png`
1492
+ - `conditional_chunk_gain.png`
1493
+ - `motion_alignment_analysis.png`
1494
+ """
1495
+ (output_dir / "REPORT.md").write_text(report, encoding="utf-8")
1496
+
1497
+
1498
+ def analyze(args: argparse.Namespace, paths: list[Path]) -> None:
1499
+ print("[analysis] loading snapshots", flush=True)
1500
+ runs = load_runs(paths)
1501
+ pair_rows = collect_pair_rows(runs, cosine_only=args.cosine_only)
1502
+ if args.cosine_only:
1503
+ pair_summary = group_mean(
1504
+ pair_rows,
1505
+ ["comparison", "stage", "target_step"],
1506
+ [
1507
+ "cosine",
1508
+ "centered_cosine",
1509
+ "linear_cka",
1510
+ "rel_l2",
1511
+ "nmse",
1512
+ "token_cosine_mean",
1513
+ "token_cosine_p10",
1514
+ "token_cosine_p50",
1515
+ "token_cosine_p90",
1516
+ ],
1517
+ )
1518
+ write_csv(args.output_dir / "feature_pair_metrics.csv", pair_rows)
1519
+ write_csv(args.output_dir / "feature_pair_summary.csv", pair_summary)
1520
+ plot_similarity(pair_rows, args.output_dir)
1521
+ summary = {
1522
+ "runs": len(runs),
1523
+ "pair_rows": len(pair_rows),
1524
+ "cosine_only": True,
1525
+ }
1526
+ (args.output_dir / "summary.json").write_text(
1527
+ json.dumps(summary, indent=2) + "\n", encoding="utf-8"
1528
+ )
1529
+ print(f"[analysis] cosine-only complete: {args.output_dir}", flush=True)
1530
+ return
1531
+ probe_rows = run_conditional_probes(runs, ridge=args.ridge)
1532
+ motion_rows = collect_motion_rows(runs)
1533
+
1534
+ pair_summary = group_mean(
1535
+ pair_rows,
1536
+ ["comparison", "stage", "target_step"],
1537
+ [
1538
+ "cosine",
1539
+ "centered_cosine",
1540
+ "linear_cka",
1541
+ "rel_l2",
1542
+ "nmse",
1543
+ "token_cosine_mean",
1544
+ "token_cosine_p10",
1545
+ "token_cosine_p50",
1546
+ "token_cosine_p90",
1547
+ ],
1548
+ )
1549
+ probe_summary = group_mean(
1550
+ probe_rows,
1551
+ ["stage", "step", "probe"],
1552
+ [
1553
+ "mse",
1554
+ "nrmse",
1555
+ "r2",
1556
+ "cosine",
1557
+ "mse_reduction_vs_within_affine",
1558
+ "mse_reduction_vs_within_quadratic",
1559
+ "r2_gain_vs_within_affine",
1560
+ "r2_gain_vs_within_quadratic",
1561
+ ],
1562
+ )
1563
+ motion_summary = group_mean(
1564
+ motion_rows,
1565
+ ["motion_bin", "step"],
1566
+ [
1567
+ "total_motion",
1568
+ "camera_motion",
1569
+ "object_motion",
1570
+ "same_slot_cosine",
1571
+ "boundary_raw_cosine",
1572
+ "global_aligned_cosine",
1573
+ "flow_aligned_cosine",
1574
+ "valid_flow_ratio",
1575
+ ],
1576
+ )
1577
+
1578
+ write_csv(args.output_dir / "feature_pair_metrics.csv", pair_rows)
1579
+ write_csv(args.output_dir / "feature_pair_summary.csv", pair_summary)
1580
+ write_csv(args.output_dir / "conditional_probe_folds.csv", probe_rows)
1581
+ write_csv(args.output_dir / "conditional_probe_summary.csv", probe_summary)
1582
+ write_csv(args.output_dir / "motion_alignment_metrics.csv", motion_rows)
1583
+ write_csv(args.output_dir / "motion_alignment_summary.csv", motion_summary)
1584
+ plot_similarity(pair_rows, args.output_dir)
1585
+ plot_probe_gain(probe_rows, args.output_dir)
1586
+ plot_motion(motion_rows, args.output_dir)
1587
+ build_report(runs, pair_rows, probe_rows, motion_rows, args.output_dir)
1588
+ summary = {
1589
+ "runs": len(runs),
1590
+ "pair_rows": len(pair_rows),
1591
+ "probe_rows": len(probe_rows),
1592
+ "motion_rows": len(motion_rows),
1593
+ "report": str(args.output_dir / "REPORT.md"),
1594
+ }
1595
+ (args.output_dir / "summary.json").write_text(
1596
+ json.dumps(summary, indent=2) + "\n", encoding="utf-8"
1597
+ )
1598
+ print(f"[analysis] complete: {args.output_dir / 'REPORT.md'}", flush=True)
1599
+
1600
+
1601
+ def main() -> None:
1602
+ args = parse_args()
1603
+ args.config_path = resolve_path(args.config_path)
1604
+ args.checkpoint_path = resolve_path(args.checkpoint_path)
1605
+ args.prompt_path = resolve_path(args.prompt_path)
1606
+ args.output_dir = resolve_path(args.output_dir)
1607
+ args.output_dir.mkdir(parents=True, exist_ok=True)
1608
+ random.seed(args.seed)
1609
+ np.random.seed(args.seed)
1610
+ torch.manual_seed(args.seed)
1611
+ torch.set_grad_enabled(False)
1612
+
1613
+ run_paths = [
1614
+ args.output_dir / "runs" / f"prompt_{index:02d}.pt"
1615
+ for index in range(args.num_prompts)
1616
+ ]
1617
+ if not args.analysis_only:
1618
+ run_paths = generate_snapshots(args)
1619
+ analyze(args, run_paths)
1620
+
1621
+
1622
+ if __name__ == "__main__":
1623
+ main()
scripts/analyze_fullgrid_bilinear_3models.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Full-grid bilinear motion alignment for the three four-step backbones.
3
+
4
+ This intentionally follows the original Self-Forcing pilot: compare every
5
+ current-chunk temporal slot with the previous chunk's boundary feature map and
6
+ warp the source map with continuous target-to-source flow using bilinear
7
+ ``grid_sample``. Correct, global, negated, and spatially shuffled flow fields
8
+ are evaluated with identical interpolation and in-bounds masking.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import csv
15
+ import json
16
+ import math
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+ from typing import Any, Iterable
20
+
21
+ import cv2
22
+ import matplotlib
23
+
24
+ matplotlib.use("Agg")
25
+ import matplotlib.pyplot as plt
26
+ import numpy as np
27
+ import torch
28
+ import torch.nn.functional as F
29
+
30
+
31
+ GRID_H, GRID_W = 30, 52
32
+ PROJECTION_SEED_BASE = 20260728
33
+
34
+
35
+ def parse_args() -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser()
37
+ parser.add_argument("--self_root", type=Path, required=True)
38
+ parser.add_argument("--causal_root", type=Path, required=True)
39
+ parser.add_argument("--hy_root", type=Path, required=True)
40
+ parser.add_argument("--hy_right_root", type=Path, required=True)
41
+ parser.add_argument("--output_root", type=Path, required=True)
42
+ parser.add_argument("--projection_dim", type=int, default=64)
43
+ parser.add_argument("--projection_device", default="cpu")
44
+ parser.add_argument("--overwrite_projection_cache", action="store_true")
45
+ return parser.parse_args()
46
+
47
+
48
+ def mean(values: Iterable[float]) -> float:
49
+ values = [float(value) for value in values if np.isfinite(value)]
50
+ return float(np.mean(values)) if values else float("nan")
51
+
52
+
53
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
54
+ if not rows:
55
+ return
56
+ fields: list[str] = []
57
+ for row in rows:
58
+ for key in row:
59
+ if key not in fields:
60
+ fields.append(key)
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ with path.open("w", newline="", encoding="utf-8") as handle:
63
+ writer = csv.DictWriter(handle, fieldnames=fields)
64
+ writer.writeheader()
65
+ writer.writerows(rows)
66
+
67
+
68
+ def projection_matrix(dim: int, output_dim: int, device: torch.device) -> torch.Tensor:
69
+ generator = torch.Generator(device="cpu").manual_seed(PROJECTION_SEED_BASE + dim)
70
+ signs = torch.randint(0, 2, (dim, output_dim), generator=generator, dtype=torch.int8)
71
+ return signs.float().mul_(2).sub_(1).div_(math.sqrt(output_dim)).to(device)
72
+
73
+
74
+ def farneback(source: np.ndarray, target: np.ndarray) -> np.ndarray:
75
+ def gray(frame: np.ndarray) -> np.ndarray:
76
+ if frame.dtype != np.uint8:
77
+ frame = np.uint8(np.clip(np.round(frame * 255.0), 0, 255))
78
+ return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
79
+
80
+ return cv2.calcOpticalFlowFarneback(
81
+ gray(source),
82
+ gray(target),
83
+ None,
84
+ pyr_scale=0.5,
85
+ levels=4,
86
+ winsize=21,
87
+ iterations=5,
88
+ poly_n=7,
89
+ poly_sigma=1.5,
90
+ flags=0,
91
+ )
92
+
93
+
94
+ def resize_flow(flow: np.ndarray) -> torch.Tensor:
95
+ source_h, source_w = flow.shape[:2]
96
+ resized = cv2.resize(flow, (GRID_W, GRID_H), interpolation=cv2.INTER_AREA)
97
+ resized[..., 0] *= GRID_W / source_w
98
+ resized[..., 1] *= GRID_H / source_h
99
+ return torch.from_numpy(resized).permute(2, 0, 1).float()
100
+
101
+
102
+ def warp(source: torch.Tensor, flow: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
103
+ yy, xx = torch.meshgrid(
104
+ torch.arange(GRID_H, dtype=torch.float32),
105
+ torch.arange(GRID_W, dtype=torch.float32),
106
+ indexing="ij",
107
+ )
108
+ sample_x = xx + flow[0]
109
+ sample_y = yy + flow[1]
110
+ grid = torch.stack(
111
+ [
112
+ 2.0 * sample_x / (GRID_W - 1) - 1.0,
113
+ 2.0 * sample_y / (GRID_H - 1) - 1.0,
114
+ ],
115
+ dim=-1,
116
+ )[None]
117
+ value = source.permute(2, 0, 1)[None].float()
118
+ warped = F.grid_sample(
119
+ value,
120
+ grid,
121
+ mode="bilinear",
122
+ padding_mode="zeros",
123
+ align_corners=True,
124
+ )[0].permute(1, 2, 0)
125
+ mask = (
126
+ (sample_x >= 0)
127
+ & (sample_x <= GRID_W - 1)
128
+ & (sample_y >= 0)
129
+ & (sample_y <= GRID_H - 1)
130
+ )
131
+ return warped, mask
132
+
133
+
134
+ def cosine(target: torch.Tensor, source: torch.Tensor, mask: torch.Tensor | None = None) -> float:
135
+ values = F.cosine_similarity(target.float(), source.float(), dim=-1, eps=1e-8)
136
+ if mask is not None:
137
+ values = values[mask]
138
+ return float(values.mean()) if values.numel() else float("nan")
139
+
140
+
141
+ class GridRun:
142
+ def __init__(
143
+ self,
144
+ model: str,
145
+ action: str,
146
+ prompt_id: int,
147
+ anchors: np.ndarray,
148
+ chunk_size: int,
149
+ features: dict[tuple[int, int], torch.Tensor],
150
+ source: Path,
151
+ ):
152
+ self.model = model
153
+ self.action = action
154
+ self.prompt_id = int(prompt_id)
155
+ self.anchors = anchors.astype(np.uint8)
156
+ self.chunk_size = int(chunk_size)
157
+ self.features = features
158
+ self.source = source
159
+ self.chunks = max(chunk for chunk, _ in features) + 1
160
+ self.steps = sorted({step for _, step in features})
161
+
162
+
163
+ def load_self_runs(root: Path) -> list[GridRun]:
164
+ runs: list[GridRun] = []
165
+ for path in sorted((root / "runs").glob("prompt_*.pt")):
166
+ state = torch.load(path, map_location="cpu", weights_only=False)
167
+ features = {
168
+ tuple(int(value) for value in key.split(":")): tensor.float()
169
+ for key, tensor in state["projected"].items()
170
+ }
171
+ anchors = np.load(path.with_suffix(".anchors.npz"), allow_pickle=False)["frames"]
172
+ runs.append(
173
+ GridRun(
174
+ "self_forcing",
175
+ "none",
176
+ int(state.get("run_index", len(runs))),
177
+ anchors,
178
+ int(state["num_frame_per_block"]),
179
+ features,
180
+ path,
181
+ )
182
+ )
183
+ del state
184
+ return runs
185
+
186
+
187
+ def load_causal_runs(root: Path) -> list[GridRun]:
188
+ runs: list[GridRun] = []
189
+ for run_dir in sorted((root / "runs").glob("prompt_*")):
190
+ path = run_dir / "feature_snapshots.pt"
191
+ anchor_path = run_dir / "rgb_anchor_frames.npz"
192
+ if not path.exists() or not anchor_path.exists():
193
+ continue
194
+ state = torch.load(path, map_location="cpu", weights_only=False)
195
+ projected = state.get("projected", {})
196
+ if not projected:
197
+ raise ValueError(f"No full-grid projected features in {path}")
198
+ features: dict[tuple[int, int], torch.Tensor] = {}
199
+ for key, tensor in projected.items():
200
+ layer, chunk, step = (int(value) for value in key.split(":"))
201
+ if layer == max(state["layers"]):
202
+ features[(chunk, step)] = tensor.float()
203
+ anchors = np.load(anchor_path, allow_pickle=False)["frames"]
204
+ runs.append(
205
+ GridRun(
206
+ "causal_forcing",
207
+ "none",
208
+ int(state["prompt_id"]),
209
+ anchors,
210
+ 3,
211
+ features,
212
+ path,
213
+ )
214
+ )
215
+ del state
216
+ return runs
217
+
218
+
219
+ def project_hy_run(
220
+ snapshot_path: Path,
221
+ cache_path: Path,
222
+ projection_dim: int,
223
+ device: torch.device,
224
+ overwrite: bool,
225
+ ) -> dict[tuple[int, int], torch.Tensor]:
226
+ if cache_path.exists() and not overwrite:
227
+ state = torch.load(cache_path, map_location="cpu", weights_only=False)
228
+ return {
229
+ tuple(int(value) for value in key.split(":")): tensor.float()
230
+ for key, tensor in state["features"].items()
231
+ }
232
+
233
+ data = np.load(snapshot_path, allow_pickle=False)
234
+ stages = data["stages"].astype(str)
235
+ chunks = data["chunks"].astype(int)
236
+ steps = data["steps"].astype(int)
237
+ coords = data["coords"].astype(int)
238
+ expected_coords = np.stack(
239
+ np.meshgrid(np.arange(4), np.arange(GRID_H), np.arange(GRID_W), indexing="ij"),
240
+ axis=-1,
241
+ ).reshape(-1, 3)
242
+ if coords.shape != expected_coords.shape or not np.array_equal(coords, expected_coords):
243
+ raise ValueError(f"Unexpected HY coordinate order in {snapshot_path}")
244
+ feature_array = data["features"]
245
+ projection = projection_matrix(int(feature_array.shape[-1]), projection_dim, device)
246
+ features: dict[tuple[int, int], torch.Tensor] = {}
247
+ selected = np.flatnonzero(stages == "block_53")
248
+ for position, index in enumerate(selected):
249
+ value = torch.from_numpy(np.asarray(feature_array[index])).to(device=device, dtype=torch.float32)
250
+ value = torch.matmul(value, projection).reshape(4, GRID_H, GRID_W, projection_dim)
251
+ features[(int(chunks[index]), int(steps[index]))] = value.to("cpu", torch.float16)
252
+ if position % 4 == 3:
253
+ print(f"[HY projection] {snapshot_path.parent.name}: {position + 1}/{len(selected)}", flush=True)
254
+ data.close()
255
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
256
+ torch.save(
257
+ {
258
+ "source": str(snapshot_path),
259
+ "projection_dim": projection_dim,
260
+ "features": {f"{chunk}:{step}": value for (chunk, step), value in features.items()},
261
+ },
262
+ cache_path,
263
+ )
264
+ return {key: value.float() for key, value in features.items()}
265
+
266
+
267
+ def load_hy_runs(
268
+ root: Path,
269
+ action: str,
270
+ cache_root: Path,
271
+ projection_dim: int,
272
+ device: torch.device,
273
+ overwrite: bool,
274
+ ) -> list[GridRun]:
275
+ runs: list[GridRun] = []
276
+ for case_dir in sorted((root / "runs").glob("prompt_*")):
277
+ run_dir = case_dir / action
278
+ snapshot = run_dir / "dense_selected_snapshots.npz"
279
+ anchor_path = run_dir / "rgb_anchor_frames.npz"
280
+ if not snapshot.exists() or not anchor_path.exists():
281
+ continue
282
+ prompt_id = int(case_dir.name.split("_")[-1])
283
+ cache_path = cache_root / action / f"prompt_{prompt_id:04d}.pt"
284
+ features = project_hy_run(snapshot, cache_path, projection_dim, device, overwrite)
285
+ anchors = np.load(anchor_path, allow_pickle=False)["frames"]
286
+ runs.append(
287
+ GridRun(
288
+ "hy_worldplay",
289
+ action,
290
+ prompt_id,
291
+ anchors,
292
+ 4,
293
+ features,
294
+ run_dir,
295
+ )
296
+ )
297
+ return runs
298
+
299
+
300
+ def shuffled_flow(flow: torch.Tensor, seed: int) -> torch.Tensor:
301
+ generator = torch.Generator(device="cpu").manual_seed(seed)
302
+ permutation = torch.randperm(GRID_H * GRID_W, generator=generator)
303
+ return flow.reshape(2, -1)[:, permutation].reshape_as(flow)
304
+
305
+
306
+ def collect_rows(runs: list[GridRun]) -> list[dict[str, Any]]:
307
+ rows: list[dict[str, Any]] = []
308
+ for run_index, run in enumerate(runs):
309
+ for chunk in range(1, run.chunks):
310
+ source_frame_index = chunk * run.chunk_size - 1
311
+ source_frame = run.anchors[source_frame_index]
312
+ boundary_flow = farneback(source_frame, run.anchors[chunk * run.chunk_size])
313
+ median = np.median(boundary_flow.reshape(-1, 2), axis=0)
314
+ residual = boundary_flow - median[None, None]
315
+ motion = {
316
+ "total_motion": float(np.linalg.norm(boundary_flow, axis=-1).mean()),
317
+ "camera_motion": float(np.linalg.norm(median)),
318
+ "object_motion": float(np.linalg.norm(residual, axis=-1).mean()),
319
+ }
320
+ for step in run.steps:
321
+ source_map = run.features[(chunk - 1, step)][-1].float()
322
+ target_maps = run.features[(chunk, step)].float()
323
+ slot_rows: list[dict[str, float]] = []
324
+ for slot in range(run.chunk_size):
325
+ target = target_maps[slot]
326
+ target_frame = run.anchors[chunk * run.chunk_size + slot]
327
+ flow = resize_flow(farneback(target_frame, source_frame))
328
+ global_flow = torch.zeros_like(flow)
329
+ global_flow[0].fill_(float(torch.median(flow[0])))
330
+ global_flow[1].fill_(float(torch.median(flow[1])))
331
+ controls = {
332
+ "global": global_flow,
333
+ "flow": flow,
334
+ "negated": -flow,
335
+ "shuffled": shuffled_flow(
336
+ flow,
337
+ seed=(run.prompt_id + 1) * 100000 + chunk * 1000 + slot * 10 + step,
338
+ ),
339
+ }
340
+ values = {"raw_cosine": cosine(target, source_map)}
341
+ valid_ratios = []
342
+ for name, control_flow in controls.items():
343
+ aligned, mask = warp(source_map, control_flow)
344
+ values[f"{name}_aligned_cosine"] = cosine(target, aligned, mask)
345
+ valid_ratios.append(float(mask.float().mean()))
346
+ values["valid_flow_ratio"] = mean(valid_ratios)
347
+ slot_rows.append(values)
348
+ row = {
349
+ "model": run.model,
350
+ "action": run.action,
351
+ "prompt_id": run.prompt_id,
352
+ "chunk": chunk,
353
+ "step": step,
354
+ "source": str(run.source),
355
+ **motion,
356
+ }
357
+ for key in slot_rows[0]:
358
+ row[key] = mean(item[key] for item in slot_rows)
359
+ for name in ("global", "flow", "negated", "shuffled"):
360
+ row[f"{name}_gain"] = row[f"{name}_aligned_cosine"] - row["raw_cosine"]
361
+ row["flow_over_global"] = row["flow_aligned_cosine"] - row["global_aligned_cosine"]
362
+ row["flow_over_shuffled"] = row["flow_aligned_cosine"] - row["shuffled_aligned_cosine"]
363
+ rows.append(row)
364
+ print(f"[analysis] {run.model}/{run.action} prompt {run.prompt_id}: {run_index + 1}/{len(runs)}", flush=True)
365
+ return rows
366
+
367
+
368
+ def add_motion_bins(rows: list[dict[str, Any]]) -> None:
369
+ groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
370
+ for row in rows:
371
+ groups[(row["model"], row["action"])].append(row)
372
+ for values in groups.values():
373
+ low, high = np.quantile([row["total_motion"] for row in values], [1 / 3, 2 / 3])
374
+ for row in values:
375
+ row["motion_bin"] = (
376
+ "low" if row["total_motion"] <= low else "high" if row["total_motion"] > high else "medium"
377
+ )
378
+
379
+
380
+ METRICS = [
381
+ "total_motion",
382
+ "camera_motion",
383
+ "object_motion",
384
+ "raw_cosine",
385
+ "global_aligned_cosine",
386
+ "flow_aligned_cosine",
387
+ "negated_aligned_cosine",
388
+ "shuffled_aligned_cosine",
389
+ "global_gain",
390
+ "flow_gain",
391
+ "negated_gain",
392
+ "shuffled_gain",
393
+ "flow_over_global",
394
+ "flow_over_shuffled",
395
+ "valid_flow_ratio",
396
+ ]
397
+
398
+
399
+ def summarize(rows: list[dict[str, Any]], keys: list[str]) -> list[dict[str, Any]]:
400
+ groups: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
401
+ for row in rows:
402
+ groups[tuple(row[key] for key in keys)].append(row)
403
+ output = []
404
+ for group, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
405
+ item = {key: value for key, value in zip(keys, group)}
406
+ item["count"] = len(values)
407
+ for metric in METRICS:
408
+ item[metric] = mean(row[metric] for row in values)
409
+ item["flow_win_fraction"] = mean(row["flow_gain"] > 0 for row in values)
410
+ item["flow_beats_shuffled_fraction"] = mean(
411
+ row["flow_aligned_cosine"] > row["shuffled_aligned_cosine"] for row in values
412
+ )
413
+ motion = np.asarray([row["total_motion"] for row in values], dtype=np.float64)
414
+ raw = np.asarray([row["raw_cosine"] for row in values], dtype=np.float64)
415
+ item["motion_raw_pearson"] = (
416
+ float(np.corrcoef(motion, raw)[0, 1]) if len(values) >= 3 and np.std(motion) > 0 else float("nan")
417
+ )
418
+ output.append(item)
419
+ return output
420
+
421
+
422
+ def plot(rows: list[dict[str, Any]], output: Path) -> None:
423
+ groups = sorted({(row["model"], row["action"]) for row in rows})
424
+ names = [f"{model}\n{action}" for model, action in groups]
425
+ methods = ["raw_cosine", "global_aligned_cosine", "flow_aligned_cosine", "negated_aligned_cosine", "shuffled_aligned_cosine"]
426
+ labels = ["raw", "global", "correct flow", "negated", "shuffled"]
427
+ fig, axes = plt.subplots(1, 2, figsize=(15, 5.5))
428
+ x = np.arange(len(groups))
429
+ width = 0.15
430
+ for index, (metric, label) in enumerate(zip(methods, labels)):
431
+ values = [mean(row[metric] for row in rows if (row["model"], row["action"]) == group) for group in groups]
432
+ axes[0].bar(x + (index - 2) * width, values, width=width, label=label)
433
+ axes[0].set_xticks(x, names)
434
+ axes[0].set_ylabel("cosine")
435
+ axes[0].set_title("Full-grid bilinear alignment")
436
+ axes[0].legend(fontsize=8)
437
+ for group in groups:
438
+ selected = [row for row in rows if (row["model"], row["action"]) == group]
439
+ axes[1].scatter(
440
+ [row["total_motion"] for row in selected],
441
+ [row["flow_gain"] for row in selected],
442
+ s=14,
443
+ alpha=0.5,
444
+ label="/".join(group),
445
+ )
446
+ axes[1].axhline(0, color="black", linewidth=1)
447
+ axes[1].set_xlabel("motion magnitude")
448
+ axes[1].set_ylabel("correct-flow cosine gain")
449
+ axes[1].set_title("Alignment gain vs motion")
450
+ axes[1].legend(fontsize=7)
451
+ fig.tight_layout()
452
+ fig.savefig(output / "fullgrid_bilinear_alignment.png", dpi=180)
453
+ plt.close(fig)
454
+
455
+
456
+ def markdown_table(rows: list[dict[str, Any]]) -> str:
457
+ columns = [
458
+ "model",
459
+ "action",
460
+ "motion_bin",
461
+ "count",
462
+ "raw_cosine",
463
+ "global_aligned_cosine",
464
+ "flow_aligned_cosine",
465
+ "negated_aligned_cosine",
466
+ "shuffled_aligned_cosine",
467
+ "flow_gain",
468
+ "flow_over_shuffled",
469
+ "flow_win_fraction",
470
+ ]
471
+ lines = ["| " + " | ".join(columns) + " |", "|" + "|".join("---" for _ in columns) + "|"]
472
+ for row in rows:
473
+ cells = []
474
+ for column in columns:
475
+ value = row.get(column, "")
476
+ cells.append(f"{value:.4f}" if isinstance(value, float) and np.isfinite(value) else str(value))
477
+ lines.append("| " + " | ".join(cells) + " |")
478
+ return "\n".join(lines)
479
+
480
+
481
+ def main() -> None:
482
+ args = parse_args()
483
+ output = args.output_root.resolve()
484
+ output.mkdir(parents=True, exist_ok=True)
485
+ device = torch.device(args.projection_device)
486
+ rows: list[dict[str, Any]] = []
487
+
488
+ self_runs = load_self_runs(args.self_root.resolve())
489
+ print(f"[load] Self runs: {len(self_runs)}", flush=True)
490
+ rows.extend(collect_rows(self_runs))
491
+ del self_runs
492
+
493
+ causal_runs = load_causal_runs(args.causal_root.resolve())
494
+ print(f"[load] Causal runs: {len(causal_runs)}", flush=True)
495
+ rows.extend(collect_rows(causal_runs))
496
+ del causal_runs
497
+
498
+ cache_root = output / "projected_cache" / "hy_worldplay"
499
+ for action, root in (
500
+ ("static", args.hy_root.resolve()),
501
+ ("forward", args.hy_root.resolve()),
502
+ ("right", args.hy_right_root.resolve()),
503
+ ):
504
+ runs = load_hy_runs(
505
+ root,
506
+ action,
507
+ cache_root,
508
+ args.projection_dim,
509
+ device,
510
+ args.overwrite_projection_cache,
511
+ )
512
+ print(f"[load] HY {action} runs: {len(runs)}", flush=True)
513
+ rows.extend(collect_rows(runs))
514
+ del runs
515
+ if device.type == "cuda":
516
+ torch.cuda.empty_cache()
517
+
518
+ add_motion_bins(rows)
519
+ overall = summarize(rows, ["model", "action"])
520
+ bins = summarize(rows, ["model", "action", "motion_bin"])
521
+ chunks = summarize(rows, ["model", "action", "chunk"])
522
+ write_csv(output / "fullgrid_bilinear_metrics.csv", rows)
523
+ write_csv(output / "fullgrid_bilinear_summary.csv", overall)
524
+ write_csv(output / "fullgrid_bilinear_motion_bins.csv", bins)
525
+ write_csv(output / "fullgrid_bilinear_chunks.csv", chunks)
526
+ plot(rows, output)
527
+ metadata = {
528
+ "projection_dim": args.projection_dim,
529
+ "projection_seed_base": PROJECTION_SEED_BASE,
530
+ "grid": [GRID_H, GRID_W],
531
+ "flow": "Farneback target-to-source, resized with vector scaling",
532
+ "warp": "bilinear grid_sample, align_corners=True, in-bounds mask",
533
+ "motion_bins": "tertiles computed independently inside each model/action",
534
+ "run_count": len({(row["model"], row["action"], row["prompt_id"]) for row in rows}),
535
+ "row_count": len(rows),
536
+ }
537
+ (output / "analysis_config.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
538
+ report = [
539
+ "# Three-backbone full-grid bilinear alignment",
540
+ "",
541
+ "Primary comparison follows the original Self-Forcing pilot and keeps a complete 30x52 feature grid. Correct flow is evaluated against global, negated, and spatially shuffled flow fields under the same bilinear interpolation.",
542
+ "",
543
+ markdown_table(bins),
544
+ "",
545
+ "A correct-flow gain alone can include interpolation effects. `flow_over_shuffled` and `flow_beats_shuffled_fraction` test whether spatially correct displacement adds value beyond a magnitude-matched interpolating control.",
546
+ "",
547
+ "In the current results, Self-Forcing and Causal-Forcing retain material correct-flow advantages over shuffled flow (0.0040 and 0.0075 cosine overall). HY's static/forward/right advantages are only about 0.0002: its raw-to-warp gain is therefore dominated by bilinear smoothing rather than verified optical-flow correspondence.",
548
+ "",
549
+ "HY has only two target chunk boundaries. Motion buckets are strongly confounded with chunk position and must not be read as a clean low/medium/high causal trend; use the chunk-stratified CSV for diagnosis.",
550
+ ]
551
+ (output / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
552
+ print(f"[complete] {output}: {len(rows)} rows", flush=True)
553
+
554
+
555
+ if __name__ == "__main__":
556
+ main()
scripts/analyze_motion_alignment_3models.py ADDED
@@ -0,0 +1,859 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Motion-stratified and correspondence-aware cache analysis for the three AR4 models.
3
+
4
+ The generation jobs deliberately remain project-native. This file is an offline
5
+ consumer of their saved feature snapshots and RGB anchor frames. It keeps the
6
+ feature comparison at token locations (rather than comparing only a pooled
7
+ vector), estimates RAFT flow with a forward/backward consistency mask, and
8
+ reports raw, global-transform, homography, dense-flow, and (for WorldPlay)
9
+ camera-action rotation alignment.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import csv
16
+ import gc
17
+ import json
18
+ import math
19
+ import os
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Any, Iterable
23
+
24
+ import cv2
25
+ import matplotlib
26
+
27
+ matplotlib.use("Agg")
28
+ import matplotlib.pyplot as plt
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn.functional as F
32
+
33
+
34
+ IMG_W, IMG_H = 416, 240
35
+ ROLE_ORDER = ("early", "middle", "late", "final")
36
+
37
+
38
+ def parse_args() -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser()
40
+ parser.add_argument("--self_root", type=Path, required=True)
41
+ parser.add_argument("--causal_root", type=Path, required=True)
42
+ parser.add_argument("--hy_root", type=Path, required=True)
43
+ parser.add_argument("--hy_right_root", type=Path, default=None)
44
+ parser.add_argument("--output_root", type=Path, required=True)
45
+ parser.add_argument("--flow_device", default="auto")
46
+ parser.add_argument("--flow_backend", choices=("raft", "farneback"), default="raft")
47
+ parser.add_argument("--corr_max_tokens", type=int, default=512)
48
+ parser.add_argument("--overwrite", action="store_true")
49
+ return parser.parse_args()
50
+
51
+
52
+ def finite(value: Any) -> bool:
53
+ try:
54
+ return math.isfinite(float(value))
55
+ except (TypeError, ValueError):
56
+ return False
57
+
58
+
59
+ def mean_or_nan(values: Iterable[float]) -> float:
60
+ values = [float(v) for v in values if finite(v)]
61
+ return float(np.mean(values)) if values else float("nan")
62
+
63
+
64
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
65
+ if not rows:
66
+ return
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ fields: list[str] = []
69
+ for row in rows:
70
+ for key in row:
71
+ if key not in fields:
72
+ fields.append(key)
73
+ with path.open("w", newline="", encoding="utf-8") as handle:
74
+ writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
75
+ writer.writeheader()
76
+ writer.writerows(rows)
77
+
78
+
79
+ def json_dump(path: Path, value: Any) -> None:
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
82
+
83
+
84
+ def _as_numpy(value: Any) -> np.ndarray:
85
+ if isinstance(value, torch.Tensor):
86
+ return value.detach().cpu().numpy()
87
+ return np.asarray(value)
88
+
89
+
90
+ def _key_parts(key: str) -> tuple[int, int]:
91
+ left, right = str(key).split(":")[:2]
92
+ return int(left), int(right)
93
+
94
+
95
+ class FeatureRun:
96
+ """A normalized view of one prompt/action's saved features."""
97
+
98
+ def __init__(
99
+ self,
100
+ model: str,
101
+ action: str,
102
+ prompt_id: int,
103
+ path: Path,
104
+ anchors: np.ndarray,
105
+ chunks: int,
106
+ chunk_size: int,
107
+ stages: dict[tuple[str, int, int], tuple[np.ndarray, np.ndarray]],
108
+ camera: tuple[np.ndarray, np.ndarray] | None = None,
109
+ dense_path: Path | None = None,
110
+ ):
111
+ self.model = model
112
+ self.action = action
113
+ self.prompt_id = int(prompt_id)
114
+ self.path = path
115
+ self.anchors = anchors
116
+ self.chunk_size = int(chunk_size)
117
+ self.chunks = int(chunks)
118
+ self.stages = stages
119
+ self.camera = camera
120
+ self.dense_path = dense_path
121
+ self._dense_features = None
122
+ self._dense_coords = None
123
+ self._dense_stages = None
124
+ self._dense_chunks = None
125
+ self._dense_steps = None
126
+ self.grid_h = 30
127
+ self.grid_w = 52
128
+ for _, (_, coords) in stages.items():
129
+ if len(coords):
130
+ self.grid_h = max(self.grid_h, int(np.max(coords[:, 1])) + 1)
131
+ self.grid_w = max(self.grid_w, int(np.max(coords[:, 2])) + 1)
132
+
133
+ @property
134
+ def stage_names(self) -> list[str]:
135
+ return sorted({key[0] for key in self.stages}, key=stage_sort)
136
+
137
+ def get(self, stage: str, chunk: int, step: int):
138
+ # HY's block_53 dense snapshots are large compressed archives. Keep
139
+ # them lazy so that one run is decompressed only while it is being
140
+ # analyzed, instead of retaining ~1.2 GB per prompt/action in RAM.
141
+ if stage == "block_53" and self.dense_path is not None and self.dense_path.exists():
142
+ if self._dense_features is None:
143
+ data = np.load(self.dense_path, allow_pickle=False)
144
+ # NpzFile lazily decompresses an array on every indexing
145
+ # operation. Materialize each member once per run so the
146
+ # 6.2-GB float16 feature tensor is not decompressed once per
147
+ # chunk/timestep query.
148
+ try:
149
+ self._dense_features = np.asarray(data["features"])
150
+ self._dense_coords = np.asarray(data["coords"], dtype=np.int32)
151
+ self._dense_stages = np.asarray(data["stages"]).astype(str)
152
+ self._dense_chunks = np.asarray(data["chunks"], dtype=np.int32)
153
+ self._dense_steps = np.asarray(data["steps"], dtype=np.int32)
154
+ finally:
155
+ data.close()
156
+ stages = self._dense_stages
157
+ chunks = self._dense_chunks
158
+ steps = self._dense_steps
159
+ matches = np.flatnonzero(
160
+ (stages == stage) & (chunks == int(chunk)) & (steps == int(step))
161
+ )
162
+ if len(matches):
163
+ index = int(matches[0])
164
+ return self._dense_features[index].astype(np.float32), self._dense_coords
165
+ return self.stages.get((stage, int(chunk), int(step)))
166
+
167
+ def release(self):
168
+ if self._dense_features is not None:
169
+ self._dense_features = None
170
+ self._dense_coords = None
171
+ self._dense_stages = None
172
+ self._dense_chunks = None
173
+ self._dense_steps = None
174
+ gc.collect()
175
+
176
+
177
+ def stage_sort(stage: str):
178
+ if stage == "projected":
179
+ return (10000, stage)
180
+ try:
181
+ return (int(stage.split("_")[1]), stage)
182
+ except (IndexError, ValueError):
183
+ return (9000, stage)
184
+
185
+
186
+ def stage_role(run: FeatureRun, stage: str) -> str:
187
+ if stage == "projected":
188
+ return "final"
189
+ block_stages = [name for name in run.stage_names if name.startswith("block_")]
190
+ if stage in block_stages:
191
+ index = block_stages.index(stage)
192
+ return ROLE_ORDER[min(index, len(ROLE_ORDER) - 1)]
193
+ return stage
194
+
195
+
196
+ def _anchor(path: Path) -> np.ndarray:
197
+ data = np.load(path, allow_pickle=False)
198
+ value = data["frames"]
199
+ if value.ndim != 4:
200
+ raise ValueError(f"Expected [T,H,W,3] anchors at {path}, got {value.shape}")
201
+ return value.astype(np.uint8)
202
+
203
+
204
+ def load_self(root: Path) -> list[FeatureRun]:
205
+ result: list[FeatureRun] = []
206
+ for path in sorted((root / "runs").glob("prompt_*.pt")):
207
+ state = torch.load(path, map_location="cpu", weights_only=False)
208
+ anchor_path = path.with_suffix(".anchors.npz")
209
+ if not anchor_path.exists():
210
+ raise FileNotFoundError(anchor_path)
211
+ coords_hidden = _as_numpy(state["sample_coords"]["hidden"]).astype(np.int32)
212
+ stages: dict[tuple[str, int, int], tuple[np.ndarray, np.ndarray]] = {}
213
+ for stage, values in state["records"].items():
214
+ if not stage.endswith("_hidden"):
215
+ continue
216
+ for key, value in values.items():
217
+ chunk, step = _key_parts(key)
218
+ stages[(stage, chunk, step)] = (_as_numpy(value).astype(np.float32), coords_hidden)
219
+ # The native recorder has a compact full-grid random projection for its
220
+ # last block. Keep it as an additional final-stage representation.
221
+ full_coords = np.stack(
222
+ np.meshgrid(np.arange(3), np.arange(30), np.arange(52), indexing="ij"), axis=-1
223
+ ).reshape(-1, 3).astype(np.int32)
224
+ for key, value in state.get("projected", {}).items():
225
+ chunk, step = _key_parts(key)
226
+ array = _as_numpy(value).astype(np.float32).reshape(-1, _as_numpy(value).shape[-1])
227
+ stages[("projected", chunk, step)] = (array, full_coords)
228
+ result.append(
229
+ FeatureRun(
230
+ "self_forcing",
231
+ "none",
232
+ int(state.get("run_index", len(result))),
233
+ path,
234
+ _anchor(anchor_path),
235
+ int(state["num_frames"]) // int(state["num_frame_per_block"]),
236
+ int(state["num_frame_per_block"]),
237
+ stages,
238
+ )
239
+ )
240
+ del state
241
+ if len(result) != 10:
242
+ print(f"[warn] Self-Forcing runs found {len(result)} prompts, expected 10")
243
+ return result
244
+
245
+
246
+ def _flat_indices_to_coords(indices: np.ndarray, frame_count: int = 3) -> np.ndarray:
247
+ indices = indices.astype(np.int64).reshape(-1)
248
+ frame_size = 30 * 52
249
+ return np.stack(
250
+ [indices // frame_size, (indices % frame_size) // 52, indices % 52], axis=1
251
+ ).astype(np.int32)
252
+
253
+
254
+ def load_causal(root: Path) -> list[FeatureRun]:
255
+ result: list[FeatureRun] = []
256
+ for run_dir in sorted((root / "runs").glob("prompt_*")):
257
+ path = run_dir / "feature_snapshots.pt"
258
+ if not path.exists():
259
+ continue
260
+ state = torch.load(path, map_location="cpu", weights_only=False)
261
+ anchor_path = run_dir / "rgb_anchor_frames.npz"
262
+ if not anchor_path.exists():
263
+ raise FileNotFoundError(anchor_path)
264
+ stages: dict[tuple[str, int, int], tuple[np.ndarray, np.ndarray]] = {}
265
+ index_dict = state.get("feature_indices", {})
266
+ for key, value in state["features"].items():
267
+ layer, chunk, step = (int(v) for v in str(key).split(":"))
268
+ indices = index_dict.get(key)
269
+ if indices is None:
270
+ token_count = 3 * 30 * 52
271
+ indices = np.linspace(0, token_count - 1, int(state["max_tokens"])).round().astype(np.int64)
272
+ coords = _flat_indices_to_coords(_as_numpy(indices))
273
+ stages[(f"block_{layer:02d}", chunk, step)] = (_as_numpy(value).astype(np.float32), coords)
274
+ prompt_id = int(state.get("prompt_id", run_dir.name.split("_")[-1]))
275
+ result.append(
276
+ FeatureRun(
277
+ "causal_forcing",
278
+ "none",
279
+ prompt_id,
280
+ path,
281
+ _anchor(anchor_path),
282
+ int(state["num_chunks"]),
283
+ 3,
284
+ stages,
285
+ )
286
+ )
287
+ del state
288
+ if len(result) != 10:
289
+ print(f"[warn] Causal-Forcing runs found {len(result)} prompts, expected 10")
290
+ return result
291
+
292
+
293
+ def _load_hy_action(root: Path, action: str) -> list[FeatureRun]:
294
+ result: list[FeatureRun] = []
295
+ for case_dir in sorted((root / "runs").glob("prompt_*")):
296
+ run_dir = case_dir / action
297
+ if not run_dir.exists():
298
+ continue
299
+ anchor_path = run_dir / "rgb_anchor_frames.npz"
300
+ dense_path = run_dir / "dense_selected_snapshots.npz"
301
+ sampled_path = run_dir / "final_hidden_snapshots.npz"
302
+ # The sampled archive contains all requested layers and is sufficient
303
+ # for the ordinary token metrics. Keep the expensive dense archive
304
+ # only as a lazy source for HY's final block (block_53).
305
+ snapshot_path = sampled_path if sampled_path.exists() else dense_path
306
+ if not anchor_path.exists() or not snapshot_path.exists():
307
+ print(f"[warn] skip incomplete HY run {run_dir}")
308
+ continue
309
+ data = np.load(snapshot_path, allow_pickle=False)
310
+ features = data["features"].astype(np.float32)
311
+ chunks = data["chunks"].astype(int)
312
+ steps = data["steps"].astype(int)
313
+ stages_np = data["stages"].astype(str)
314
+ coords = data["coords"].astype(np.int32)
315
+ grid_shape = tuple(int(v) for v in data["grid_shape"])
316
+ stages: dict[tuple[str, int, int], tuple[np.ndarray, np.ndarray]] = {}
317
+ for index in range(features.shape[0]):
318
+ stages[(stages_np[index], int(chunks[index]), int(steps[index]))] = (
319
+ features[index],
320
+ coords,
321
+ )
322
+ camera = None
323
+ camera_path = run_dir / "camera_trajectory.npz"
324
+ if camera_path.exists():
325
+ camera_data = np.load(camera_path, allow_pickle=False)
326
+ camera = (camera_data["viewmats"].astype(np.float64), camera_data["intrinsics"].astype(np.float64))
327
+ metadata = run_dir / "run_metadata.json"
328
+ prompt_id = int(case_dir.name.split("_")[-1])
329
+ chunks_count = int(json.loads(metadata.read_text()).get("video_length", 45) if metadata.exists() else 45)
330
+ latent_count = (chunks_count - 1) // 4 + 1
331
+ # Prefer the actual snapshot chunk count when available.
332
+ chunks_count = max(1, int(np.max(chunks)) + 1)
333
+ result.append(
334
+ FeatureRun(
335
+ "hy_worldplay",
336
+ action,
337
+ prompt_id,
338
+ run_dir,
339
+ _anchor(anchor_path),
340
+ chunks_count,
341
+ 4,
342
+ stages,
343
+ camera,
344
+ # Keep the cross-model comparison at the same 240-token
345
+ # sampling budget. The dense 6,240-token archive is retained
346
+ # as an optional artifact, but using it for all-pairs matching
347
+ # would make HY's final layer incomparable and unnecessarily
348
+ # expensive (6,240^2 distances per query).
349
+ dense_path=None,
350
+ )
351
+ )
352
+ return result
353
+
354
+
355
+ class FlowEstimator:
356
+ def __init__(self, backend: str, device: str):
357
+ self.backend = backend
358
+ self.device = torch.device("cuda" if device == "auto" and torch.cuda.is_available() else (device if device != "auto" else "cpu"))
359
+ self.model = None
360
+ self.transforms = None
361
+ if backend == "raft":
362
+ try:
363
+ from torchvision.models.optical_flow import Raft_Small_Weights, raft_small
364
+
365
+ weights = Raft_Small_Weights.DEFAULT
366
+ self.model = raft_small(weights=weights, progress=True).eval().to(self.device)
367
+ self.transforms = weights.transforms()
368
+ print(f"[flow] RAFT-small on {self.device}")
369
+ except Exception as error:
370
+ print(f"[flow] RAFT unavailable ({error}); using Farneback")
371
+ self.backend = "farneback"
372
+
373
+ @staticmethod
374
+ def _image(frame: np.ndarray) -> np.ndarray:
375
+ if frame.shape[:2] != (IMG_H, IMG_W):
376
+ return cv2.resize(frame, (IMG_W, IMG_H), interpolation=cv2.INTER_AREA)
377
+ return frame
378
+
379
+ def _farneback(self, first: np.ndarray, second: np.ndarray) -> np.ndarray:
380
+ a = cv2.cvtColor(self._image(first), cv2.COLOR_RGB2GRAY)
381
+ b = cv2.cvtColor(self._image(second), cv2.COLOR_RGB2GRAY)
382
+ return cv2.calcOpticalFlowFarneback(a, b, None, 0.5, 3, 21, 5, 7, 1.5, 0)
383
+
384
+ @torch.inference_mode()
385
+ def pair(self, first: np.ndarray, second: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
386
+ """Return first->second flow, reverse flow, and a mask on first pixels."""
387
+ first = self._image(first)
388
+ second = self._image(second)
389
+ if self.backend != "raft" or self.model is None:
390
+ forward = self._farneback(first, second)
391
+ backward = self._farneback(second, first)
392
+ else:
393
+ x = torch.from_numpy(first).permute(2, 0, 1).float().div_(255).unsqueeze(0).to(self.device)
394
+ y = torch.from_numpy(second).permute(2, 0, 1).float().div_(255).unsqueeze(0).to(self.device)
395
+ x, y = self.transforms(x, y)
396
+ forward = self.model(x, y)[-1][0].permute(1, 2, 0).float().cpu().numpy()
397
+ backward = self.model(y, x)[-1][0].permute(1, 2, 0).float().cpu().numpy()
398
+ # The feature query is made at pixels in ``first`` (the current/target
399
+ # frame), so the consistency mask must also be indexed in that frame.
400
+ # A previous implementation used the reverse flow and a mask indexed
401
+ # in ``second``; that silently inverted the alignment direction.
402
+ h, w = forward.shape[:2]
403
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
404
+ sx = xx + forward[..., 0]
405
+ sy = yy + forward[..., 1]
406
+ sampled_backward = cv2.remap(backward, sx, sy, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
407
+ fb = np.linalg.norm(forward + sampled_backward, axis=-1)
408
+ magnitude = np.linalg.norm(forward, axis=-1)
409
+ in_bounds = (sx >= 0) & (sx < w) & (sy >= 0) & (sy < h)
410
+ # The threshold scales mildly with motion, avoiding an overly strict
411
+ # rejection of the fast camera actions while retaining occlusion masks.
412
+ valid = in_bounds & (fb <= 1.5 + 0.05 * magnitude)
413
+ return forward, backward, valid
414
+
415
+
416
+ def flow_at(flow: np.ndarray, xy: np.ndarray) -> np.ndarray:
417
+ h, w = flow.shape[:2]
418
+ x = np.clip(xy[:, 0], 0, w - 1).astype(np.float32)
419
+ y = np.clip(xy[:, 1], 0, h - 1).astype(np.float32)
420
+ return np.stack(
421
+ [cv2.remap(flow[..., dim], x, y, cv2.INTER_LINEAR).reshape(-1) for dim in range(2)], axis=1
422
+ )
423
+
424
+
425
+ def mask_at(mask: np.ndarray, xy: np.ndarray) -> np.ndarray:
426
+ h, w = mask.shape[:2]
427
+ x = np.clip(xy[:, 0], 0, w - 1).astype(np.float32)
428
+ y = np.clip(xy[:, 1], 0, h - 1).astype(np.float32)
429
+ value = cv2.remap(mask.astype(np.uint8), x, y, cv2.INTER_NEAREST).reshape(-1)
430
+ return value.astype(bool)
431
+
432
+
433
+ def fit_transforms(flow: np.ndarray, valid: np.ndarray):
434
+ h, w = flow.shape[:2]
435
+ yy, xx = np.mgrid[0:h:8, 0:w:8].astype(np.float32)
436
+ points = np.stack([xx.reshape(-1), yy.reshape(-1)], axis=1)
437
+ selected = valid[::8, ::8].reshape(-1)
438
+ points = points[selected]
439
+ if len(points) < 6:
440
+ fallback_y, fallback_x = np.mgrid[0:h:4, 0:w:4]
441
+ points = np.stack([fallback_x.reshape(-1), fallback_y.reshape(-1)], axis=1).astype(np.float32)
442
+ selected_flow = flow[::4, ::4].reshape(-1, 2)
443
+ else:
444
+ selected_flow = flow[::8, ::8].reshape(-1, 2)[selected]
445
+ destinations = points + selected_flow
446
+ affine = None
447
+ homography = None
448
+ if len(points) >= 3:
449
+ affine, _ = cv2.estimateAffine2D(points, destinations, method=cv2.RANSAC, ransacReprojThreshold=3.0)
450
+ if len(points) >= 4:
451
+ homography, _ = cv2.findHomography(points, destinations, cv2.RANSAC, 4.0)
452
+ median = np.median(flow[valid] if bool(np.any(valid)) else flow.reshape(-1, 2), axis=0)
453
+ translation = np.asarray([[1.0, 0.0, median[0]], [0.0, 1.0, median[1]]], dtype=np.float32)
454
+ return translation, affine, homography
455
+
456
+
457
+ def apply_transform(points: np.ndarray, transform: np.ndarray | None) -> np.ndarray | None:
458
+ if transform is None or len(points) == 0:
459
+ return None
460
+ if transform.shape == (2, 3):
461
+ return points @ transform[:, :2].T + transform[:, 2]
462
+ homogeneous = np.concatenate([points, np.ones((len(points), 1), dtype=np.float32)], axis=1)
463
+ mapped = homogeneous @ transform.T
464
+ return mapped[:, :2] / np.clip(mapped[:, 2:3], 1e-6, None)
465
+
466
+
467
+ def nearest_features(source_xy: np.ndarray, source_features: np.ndarray, query_xy: np.ndarray):
468
+ if len(source_xy) == 0 or len(query_xy) == 0:
469
+ return np.empty((0, source_features.shape[-1]), dtype=np.float32), np.zeros(len(query_xy), bool), np.empty(len(query_xy))
470
+ distances = ((query_xy[:, None, :] - source_xy[None, :, :]) ** 2).sum(axis=-1)
471
+ indices = np.argmin(distances, axis=1)
472
+ return source_features[indices], np.ones(len(query_xy), bool), np.sqrt(distances[np.arange(len(indices)), indices])
473
+
474
+
475
+ def cosine_mean(left: np.ndarray, right: np.ndarray, mask: np.ndarray | None = None) -> float:
476
+ if len(left) == 0 or len(right) == 0:
477
+ return float("nan")
478
+ value = np.sum(left * right, axis=-1) / (
479
+ np.linalg.norm(left, axis=-1) * np.linalg.norm(right, axis=-1) + 1e-8
480
+ )
481
+ if mask is not None:
482
+ value = value[mask]
483
+ return mean_or_nan(value)
484
+
485
+
486
+ def select_frame(features: np.ndarray, coords: np.ndarray, frame: int):
487
+ selected = coords[:, 0] == int(frame)
488
+ return features[selected], coords[selected, 1:3][:, ::-1].astype(np.float32)
489
+
490
+
491
+ def token_correspondence(target: np.ndarray, target_xy: np.ndarray, source: np.ndarray, source_xy: np.ndarray, max_tokens: int):
492
+ if len(target) == 0 or len(source) == 0:
493
+ return {"top1_cosine": float("nan"), "top5_cosine": float("nan"), "mnn_fraction": float("nan"), "match_distance": float("nan")}
494
+ def thin(array, xy):
495
+ if len(array) <= max_tokens:
496
+ return array, xy
497
+ idx = np.linspace(0, len(array) - 1, max_tokens).round().astype(int)
498
+ return array[idx], xy[idx]
499
+ target, target_xy = thin(target, target_xy)
500
+ source, source_xy = thin(source, source_xy)
501
+ target_n = target / (np.linalg.norm(target, axis=-1, keepdims=True) + 1e-8)
502
+ source_n = source / (np.linalg.norm(source, axis=-1, keepdims=True) + 1e-8)
503
+ similarity = target_n @ source_n.T
504
+ topk = np.sort(similarity, axis=1)[:, -min(5, similarity.shape[1]):]
505
+ best_source = np.argmax(similarity, axis=1)
506
+ best_target = np.argmax(similarity, axis=0)
507
+ target_ids = np.arange(len(best_source))
508
+ mnn = best_target[best_source] == target_ids
509
+ distance = np.linalg.norm(target_xy - source_xy[best_source], axis=-1)
510
+ return {
511
+ "top1_cosine": float(np.mean(topk[:, -1])),
512
+ "top5_cosine": float(np.mean(topk)),
513
+ "mnn_fraction": float(np.mean(mnn)),
514
+ "match_distance": float(np.mean(distance)),
515
+ }
516
+
517
+
518
+ def camera_rotation_homography(camera, source_index: int, target_index: int, image_shape=(IMG_H, IMG_W)):
519
+ if camera is None:
520
+ return None
521
+ viewmats, intrinsics = camera
522
+ if source_index >= len(viewmats) or target_index >= len(viewmats):
523
+ return None
524
+ source_k = intrinsics[source_index].copy()
525
+ target_k = intrinsics[target_index].copy()
526
+ h, w = image_shape
527
+ for k in (source_k, target_k):
528
+ k[0, 0] *= w
529
+ k[0, 2] *= w
530
+ k[1, 1] *= h
531
+ k[1, 2] *= h
532
+ source_r = viewmats[source_index][:3, :3]
533
+ target_r = viewmats[target_index][:3, :3]
534
+ try:
535
+ return source_k @ source_r @ target_r.T @ np.linalg.inv(target_k)
536
+ except np.linalg.LinAlgError:
537
+ return None
538
+
539
+
540
+ def action_motion(camera, source_index: int, target_index: int):
541
+ if camera is None:
542
+ return {"action_translation": float("nan"), "action_rotation_deg": float("nan")}
543
+ viewmats, _ = camera
544
+ if source_index >= len(viewmats) or target_index >= len(viewmats):
545
+ return {"action_translation": float("nan"), "action_rotation_deg": float("nan")}
546
+ source_to_world = np.linalg.inv(viewmats[source_index])
547
+ target_to_world = np.linalg.inv(viewmats[target_index])
548
+ relative_rotation = source_to_world[:3, :3].T @ target_to_world[:3, :3]
549
+ trace = np.clip((np.trace(relative_rotation) - 1.0) / 2.0, -1.0, 1.0)
550
+ return {
551
+ "action_translation": float(np.linalg.norm(target_to_world[:3, 3] - source_to_world[:3, 3])),
552
+ "action_rotation_deg": float(np.degrees(np.arccos(trace))),
553
+ }
554
+
555
+
556
+ def token_points(run: FeatureRun, coords: np.ndarray) -> np.ndarray:
557
+ # coords are (frame,y,x) in the latent feature grid; convert to pixel
558
+ # centers in the resized RGB canvas, represented as (x,y).
559
+ return np.stack(
560
+ [
561
+ (coords[:, 2].astype(np.float32) + 0.5) * IMG_W / run.grid_w,
562
+ (coords[:, 1].astype(np.float32) + 0.5) * IMG_H / run.grid_h,
563
+ ],
564
+ axis=1,
565
+ )
566
+
567
+
568
+ def alignment_metrics(run: FeatureRun, stage: str, target_pair, source_pair, target_slot: int, flow: np.ndarray, valid_flow: np.ndarray, transforms, action_h, corr_max_tokens: int):
569
+ target_features, target_coords = target_pair
570
+ source_features, source_coords = source_pair
571
+ target_features, target_xy = select_frame(target_features, target_coords, target_slot)
572
+ source_boundary, source_boundary_xy = select_frame(source_features, source_coords, run.chunk_size - 1)
573
+ source_same, source_same_xy = select_frame(source_features, source_coords, min(target_slot, run.chunk_size - 1))
574
+ if len(target_features) == 0 or len(source_boundary) == 0:
575
+ return None
576
+ target_pixels = token_points(run, np.concatenate([np.full((len(target_xy), 1), target_slot), target_xy[:, ::-1]], axis=1).astype(np.int32))
577
+ source_boundary_pixels = token_points(run, np.concatenate([np.full((len(source_boundary_xy), 1), run.chunk_size - 1), source_boundary_xy[:, ::-1]], axis=1).astype(np.int32))
578
+ source_same_pixels = token_points(run, np.concatenate([np.full((len(source_same_xy), 1), min(target_slot, run.chunk_size - 1)), source_same_xy[:, ::-1]], axis=1).astype(np.int32))
579
+ raw_features, _, raw_dist = nearest_features(source_boundary_pixels, source_boundary, target_pixels)
580
+ same_features, _, _ = nearest_features(source_same_pixels, source_same, target_pixels)
581
+ target_flow = flow_at(flow, target_pixels)
582
+ flow_query = target_pixels + target_flow
583
+ flow_mask = mask_at(valid_flow, target_pixels)
584
+ flow_features, _, flow_dist = nearest_features(source_boundary_pixels, source_boundary, flow_query)
585
+ translation, affine, homography = transforms
586
+ def transformed_metric(transform):
587
+ query = apply_transform(target_pixels, transform)
588
+ if query is None:
589
+ return float("nan"), float("nan")
590
+ values, _, distances = nearest_features(source_boundary_pixels, source_boundary, query)
591
+ in_bounds = (query[:, 0] >= 0) & (query[:, 0] < IMG_W) & (query[:, 1] >= 0) & (query[:, 1] < IMG_H)
592
+ return cosine_mean(target_features, values, in_bounds), mean_or_nan(distances[in_bounds])
593
+ trans_cos, trans_dist = transformed_metric(translation)
594
+ affine_cos, affine_dist = transformed_metric(affine)
595
+ homo_cos, homo_dist = transformed_metric(homography)
596
+ action_cos, action_dist = transformed_metric(action_h)
597
+ correspondence = token_correspondence(target_features, target_pixels, source_boundary, source_boundary_pixels, corr_max_tokens)
598
+ return {
599
+ "same_slot_cosine": cosine_mean(target_features, same_features),
600
+ "boundary_raw_cosine": cosine_mean(target_features, raw_features),
601
+ "translation_aligned_cosine": trans_cos,
602
+ "affine_aligned_cosine": affine_cos,
603
+ "homography_aligned_cosine": homo_cos,
604
+ "flow_aligned_cosine": cosine_mean(target_features, flow_features, flow_mask),
605
+ "action_rotation_cosine": action_cos,
606
+ "raw_match_distance": mean_or_nan(raw_dist),
607
+ "flow_match_distance": mean_or_nan(flow_dist[flow_mask]),
608
+ "translation_match_distance": trans_dist,
609
+ "affine_match_distance": affine_dist,
610
+ "homography_match_distance": homo_dist,
611
+ "action_match_distance": action_dist,
612
+ "valid_flow_ratio": float(np.mean(flow_mask)),
613
+ "occlusion_ratio": float(1.0 - np.mean(flow_mask)),
614
+ **correspondence,
615
+ }
616
+
617
+
618
+ def motion_rows(runs: list[FeatureRun], estimator: FlowEstimator, corr_max_tokens: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
619
+ rows: list[dict[str, Any]] = []
620
+ correspondence_rows: list[dict[str, Any]] = []
621
+ flow_cache: dict[tuple[str, int, int, int], tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
622
+ for run in runs:
623
+ for chunk in range(1, run.chunks):
624
+ source_index = (chunk - 1) * run.chunk_size + run.chunk_size - 1
625
+ if source_index >= len(run.anchors):
626
+ continue
627
+ for slot in range(run.chunk_size):
628
+ target_index = chunk * run.chunk_size + slot
629
+ if target_index >= len(run.anchors):
630
+ continue
631
+ cache_key = (str(run.path), chunk, source_index, target_index)
632
+ if cache_key not in flow_cache:
633
+ # The first return is target->source and is therefore the
634
+ # displacement used to query the previous chunk's tokens.
635
+ forward, _, valid = estimator.pair(run.anchors[target_index], run.anchors[source_index])
636
+ flow_cache[cache_key] = (forward, valid, np.asarray([]))
637
+ flow, valid, _ = flow_cache[cache_key]
638
+ flow_mag = np.linalg.norm(flow, axis=-1)
639
+ median = np.median(flow[valid] if bool(np.any(valid)) else flow.reshape(-1, 2), axis=0)
640
+ residual = flow - median[None, None]
641
+ source_img = cv2.resize(run.anchors[source_index], (IMG_W, IMG_H), interpolation=cv2.INTER_AREA)
642
+ target_img = cv2.resize(run.anchors[target_index], (IMG_W, IMG_H), interpolation=cv2.INTER_AREA)
643
+ scene_cut = float(np.mean(np.abs(source_img.astype(np.float32) - target_img.astype(np.float32))) / 255.0)
644
+ action_motion_values = action_motion(run.camera, source_index, target_index)
645
+ action_h = camera_rotation_homography(run.camera, source_index, target_index)
646
+ transforms = fit_transforms(flow, valid)
647
+ stage_names = [stage for stage in run.stage_names if stage.startswith("block_") or stage == "projected"]
648
+ for stage in stage_names:
649
+ common_steps = sorted(
650
+ set(step for st, ch, step in run.stages if st == stage and ch == chunk)
651
+ & set(step for st, ch, step in run.stages if st == stage and ch == chunk - 1)
652
+ )
653
+ for step in common_steps:
654
+ target_pair = run.get(stage, chunk, step)
655
+ source_pair = run.get(stage, chunk - 1, step)
656
+ metrics = alignment_metrics(run, stage, target_pair, source_pair, slot, flow, valid, transforms, action_h, corr_max_tokens)
657
+ if metrics is None:
658
+ continue
659
+ row = {
660
+ "model": run.model,
661
+ "action": run.action,
662
+ "prompt_id": run.prompt_id,
663
+ "chunk": chunk,
664
+ "target_slot": slot,
665
+ "step": step,
666
+ "stage": stage,
667
+ "role": stage_role(run, stage),
668
+ "source_frame": source_index,
669
+ "target_frame": target_index,
670
+ "total_motion": float(np.mean(flow_mag)),
671
+ "camera_motion": float(np.linalg.norm(median)),
672
+ "object_motion": float(np.mean(np.linalg.norm(residual, axis=-1))),
673
+ "scene_cut": scene_cut,
674
+ **action_motion_values,
675
+ **metrics,
676
+ }
677
+ rows.append(row)
678
+ if row["role"] == "final":
679
+ correspondence_rows.append({
680
+ key: row[key]
681
+ for key in ("model", "action", "prompt_id", "chunk", "target_slot", "step", "stage", "role", "total_motion")
682
+ } | {key: metrics[key] for key in ("top1_cosine", "top5_cosine", "mnn_fraction", "match_distance")})
683
+ run.release()
684
+ values = np.asarray([row["total_motion"] for row in rows if finite(row["total_motion"])], dtype=np.float64)
685
+ if len(values) >= 3:
686
+ low, high = np.quantile(values, [1 / 3, 2 / 3])
687
+ for row in rows:
688
+ row["motion_bin"] = "low" if row["total_motion"] <= low else "high" if row["total_motion"] > high else "medium"
689
+ for row in correspondence_rows:
690
+ matches = [item for item in rows if all(item[k] == row[k] for k in ("model", "action", "prompt_id", "chunk", "target_slot", "step", "stage", "role"))]
691
+ row["motion_bin"] = matches[0].get("motion_bin", "unknown") if matches else "unknown"
692
+ metadata = {
693
+ "flow_backend": estimator.backend,
694
+ "flow_device": str(estimator.device),
695
+ "flow_resolution": [IMG_W, IMG_H],
696
+ "forward_backward_consistency": "valid = in-bounds and FB error <= 1.5 + 0.05*|flow|",
697
+ "motion_bin_edges": [float(low), float(high)] if len(values) >= 3 else [],
698
+ }
699
+ return rows, correspondence_rows, metadata
700
+
701
+
702
+ def group_rows(rows: list[dict[str, Any]], keys: list[str], metrics: list[str]) -> list[dict[str, Any]]:
703
+ groups: dict[tuple[Any, ...], list[dict[str, Any]]] = defaultdict(list)
704
+ for row in rows:
705
+ groups[tuple(row.get(key) for key in keys)].append(row)
706
+ output: list[dict[str, Any]] = []
707
+ for group, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
708
+ item = {key: value for key, value in zip(keys, group)}
709
+ item["count"] = len(values)
710
+ for metric in metrics:
711
+ item[metric] = mean_or_nan([row.get(metric) for row in values])
712
+ output.append(item)
713
+ return output
714
+
715
+
716
+ def plot_outputs(rows: list[dict[str, Any]], correspondence: list[dict[str, Any]], output: Path) -> None:
717
+ output.mkdir(parents=True, exist_ok=True)
718
+ if not rows:
719
+ return
720
+ models = sorted({str(row["model"]) for row in rows})
721
+ colors = {model: plt.cm.tab10(index) for index, model in enumerate(models)}
722
+ fig, axes = plt.subplots(2, 2, figsize=(14, 10))
723
+ for model in models:
724
+ selected = [row for row in rows if row["model"] == model]
725
+ axes[0, 0].scatter([row["total_motion"] for row in selected], [row["boundary_raw_cosine"] for row in selected], s=10, alpha=0.45, label=model, color=colors[model])
726
+ axes[0, 0].set(xlabel="flow magnitude", ylabel="raw boundary cosine", title="Motion vs raw cross-chunk similarity")
727
+ axes[0, 0].legend(fontsize=8)
728
+ methods = ["boundary_raw_cosine", "translation_aligned_cosine", "affine_aligned_cosine", "homography_aligned_cosine", "flow_aligned_cosine", "action_rotation_cosine"]
729
+ labels = ["raw", "translation", "affine", "homography", "dense flow", "action rotation"]
730
+ positions = np.arange(len(methods))
731
+ for index, model in enumerate(models):
732
+ values = [mean_or_nan([row.get(method) for row in rows if row["model"] == model]) for method in methods]
733
+ axes[0, 1].plot(positions, values, marker="o", label=model, color=colors[model])
734
+ axes[0, 1].set_xticks(positions, labels, rotation=25, ha="right")
735
+ axes[0, 1].set_ylim(-1, 1)
736
+ axes[0, 1].set_title("Alignment methods")
737
+ axes[0, 1].legend(fontsize=8)
738
+ for index, model in enumerate(models):
739
+ selected = [row for row in rows if row["model"] == model]
740
+ bins = ["low", "medium", "high"]
741
+ raw = [mean_or_nan([row["boundary_raw_cosine"] for row in selected if row.get("motion_bin") == name]) for name in bins]
742
+ flow = [mean_or_nan([row["flow_aligned_cosine"] for row in selected if row.get("motion_bin") == name]) for name in bins]
743
+ axes[1, 0].plot(bins, raw, marker="o", linestyle="--", label=f"{model} raw", color=colors[model], alpha=0.55)
744
+ axes[1, 0].plot(bins, flow, marker="o", label=f"{model} flow", color=colors[model])
745
+ axes[1, 0].set_ylim(-1, 1)
746
+ axes[1, 0].set_title("Motion bins")
747
+ axes[1, 0].set_ylabel("cosine")
748
+ axes[1, 0].legend(fontsize=7)
749
+ if correspondence:
750
+ for model in models:
751
+ selected = [row for row in correspondence if row["model"] == model]
752
+ axes[1, 1].scatter([row["total_motion"] for row in selected], [row["top1_cosine"] for row in selected], s=12, alpha=0.5, label=model, color=colors[model])
753
+ axes[1, 1].set(xlabel="flow magnitude", ylabel="top-1 feature match cosine", title="Token correspondence")
754
+ axes[1, 1].legend(fontsize=8)
755
+ fig.tight_layout()
756
+ fig.savefig(output / "motion_alignment_overview.png", dpi=180)
757
+ plt.close(fig)
758
+
759
+ if correspondence:
760
+ fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
761
+ for model in models:
762
+ selected = [row for row in correspondence if row["model"] == model]
763
+ for axis, metric, title in zip(axes, ("top1_cosine", "mnn_fraction", "match_distance"), ("Top-1 cosine", "MNN fraction", "Match distance")):
764
+ axis.scatter([row["total_motion"] for row in selected], [row[metric] for row in selected], s=12, alpha=0.5, label=model, color=colors[model])
765
+ axis.set_title(title)
766
+ axis.set_xlabel("flow magnitude")
767
+ axes[0].set_ylabel("value")
768
+ axes[0].legend(fontsize=8)
769
+ fig.tight_layout()
770
+ fig.savefig(output / "token_correspondence_summary.png", dpi=180)
771
+ plt.close(fig)
772
+
773
+
774
+ def markdown_table(rows: list[dict[str, Any]], columns: list[str]) -> str:
775
+ if not rows:
776
+ return "_No rows._"
777
+ lines = ["| " + " | ".join(columns) + " |", "|" + "|".join("---" for _ in columns) + "|"]
778
+ for row in rows:
779
+ values = []
780
+ for column in columns:
781
+ value = row.get(column, "")
782
+ values.append(f"{float(value):.4f}" if isinstance(value, (float, np.floating)) and finite(value) else str(value))
783
+ lines.append("| " + " | ".join(values) + " |")
784
+ return "\n".join(lines)
785
+
786
+
787
+ def write_report(output: Path, rows: list[dict[str, Any]], correspondence: list[dict[str, Any]], metadata: dict[str, Any], runs: list[FeatureRun]) -> None:
788
+ stage_summary = group_rows(rows, ["model", "action", "role", "stage"], ["boundary_raw_cosine", "translation_aligned_cosine", "affine_aligned_cosine", "homography_aligned_cosine", "flow_aligned_cosine", "action_rotation_cosine", "total_motion", "valid_flow_ratio"])
789
+ bin_summary = group_rows(rows, ["model", "action", "motion_bin"], ["total_motion", "boundary_raw_cosine", "flow_aligned_cosine", "homography_aligned_cosine", "object_motion", "occlusion_ratio"])
790
+ json_dump(output / "motion_alignment_summary.json", {"metadata": metadata, "stage_summary": stage_summary, "motion_bin_summary": bin_summary, "run_count": len(runs), "row_count": len(rows), "correspondence_count": len(correspondence)})
791
+ report = [
792
+ "# Experiment 3: motion stratification and explicit alignment",
793
+ "",
794
+ f"Runs: {len(runs)} prompt/action runs; metric rows: {len(rows)}; correspondence rows: {len(correspondence)}.",
795
+ "",
796
+ "The raw baseline compares the current chunk token with the previous chunk's boundary frame at the same spatial coordinate. `flow_aligned` uses the target-to-source flow from the backend listed below and a forward/backward consistency mask. Affine and homography are global-transform fits to the valid flow. `action_rotation` is the WorldPlay camera-rotation homography only; it does not claim to model depth-dependent translation.",
797
+ "",
798
+ "## By model/action/stage",
799
+ "",
800
+ markdown_table(stage_summary, ["model", "action", "role", "stage", "count", "boundary_raw_cosine", "translation_aligned_cosine", "affine_aligned_cosine", "homography_aligned_cosine", "flow_aligned_cosine", "action_rotation_cosine"]),
801
+ "",
802
+ "## Motion bins",
803
+ "",
804
+ markdown_table(bin_summary, ["model", "action", "motion_bin", "count", "total_motion", "boundary_raw_cosine", "homography_aligned_cosine", "flow_aligned_cosine", "object_motion", "occlusion_ratio"]),
805
+ "",
806
+ "## Interpretation guardrails",
807
+ "",
808
+ "- A positive dense-flow gain with lower raw cosine supports spatial migration rather than loss of content information.",
809
+ "- Action rotation is a physically informed control for HY; forward translation requires depth and is therefore reported separately rather than treated as an exact warp.",
810
+ "- Correlation and alignment rows are prompt-level repeated measurements; use prompt bootstrap or a mixed-effects model for significance claims.",
811
+ "",
812
+ "## Run settings",
813
+ "",
814
+ "```json",
815
+ json.dumps(metadata, indent=2, ensure_ascii=False),
816
+ "```",
817
+ ]
818
+ (output / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
819
+ write_csv(output / "motion_alignment_metrics.csv", rows)
820
+ write_csv(output / "motion_alignment_summary.csv", stage_summary)
821
+ write_csv(output / "motion_alignment_motion_bins.csv", bin_summary)
822
+ write_csv(output / "token_correspondence.csv", correspondence)
823
+
824
+
825
+ def main() -> None:
826
+ args = parse_args()
827
+ output = args.output_root.resolve()
828
+ output.mkdir(parents=True, exist_ok=True)
829
+ if not args.overwrite and (output / "motion_alignment_metrics.csv").exists():
830
+ print(f"[skip] existing analysis at {output}; pass --overwrite to recompute")
831
+ return
832
+ runs: list[FeatureRun] = []
833
+ runs.extend(load_self(args.self_root.resolve()))
834
+ runs.extend(load_causal(args.causal_root.resolve()))
835
+ runs.extend(_load_hy_action(args.hy_root.resolve(), "forward"))
836
+ runs.extend(_load_hy_action(args.hy_root.resolve(), "static"))
837
+ if args.hy_right_root is not None:
838
+ runs.extend(_load_hy_action(args.hy_right_root.resolve(), "right"))
839
+ if not runs:
840
+ raise RuntimeError("No normalized runs found")
841
+ estimator = FlowEstimator(args.flow_backend, args.flow_device)
842
+ rows, correspondence, metadata = motion_rows(runs, estimator, args.corr_max_tokens)
843
+ metadata.update({
844
+ "models": sorted({run.model for run in runs}),
845
+ "actions": sorted({run.action for run in runs}),
846
+ "prompt_ids": sorted({run.prompt_id for run in runs}),
847
+ "run_descriptions": [
848
+ {"model": run.model, "action": run.action, "prompt_id": run.prompt_id, "chunk_size": run.chunk_size, "chunks": run.chunks, "anchor_frames": len(run.anchors), "stages": run.stage_names}
849
+ for run in runs
850
+ ],
851
+ })
852
+ plot_outputs(rows, correspondence, output)
853
+ write_report(output, rows, correspondence, metadata, runs)
854
+ json_dump(output / "analysis_config.json", metadata)
855
+ print(f"[complete] {output}: {len(runs)} runs, {len(rows)} motion rows, {len(correspondence)} correspondence rows")
856
+
857
+
858
+ if __name__ == "__main__":
859
+ main()
scripts/build_boundary_conditional_dataset.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Assemble the native-feature boundary-to-all conditional-probe dataset.
3
+
4
+ Self-Forcing and HY-static already use structured temporal/spatial sampling and
5
+ are linked read-only. Causal-Forcing is normalized from a fresh recorder run
6
+ whose features are stored as [temporal, spatial, channel].
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import torch
18
+
19
+
20
+ ROLE_MAP = {7: "early", 14: "middle", 22: "late", 29: "final"}
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--self_dir", type=Path, required=True)
26
+ parser.add_argument("--causal_root", type=Path, required=True)
27
+ parser.add_argument("--hy_dir", type=Path, required=True)
28
+ parser.add_argument("--output_root", type=Path, required=True)
29
+ parser.add_argument("--num_prompts", type=int, default=10)
30
+ parser.add_argument("--chunks", type=int, default=4)
31
+ parser.add_argument("--steps", type=int, default=4)
32
+ return parser.parse_args()
33
+
34
+
35
+ def ensure_link(path: Path, target: Path) -> None:
36
+ target = target.resolve()
37
+ if path.is_symlink():
38
+ if path.resolve() != target:
39
+ raise ValueError(f"Existing symlink {path} points to {path.resolve()}, not {target}")
40
+ return
41
+ if path.exists():
42
+ raise FileExistsError(path)
43
+ path.symlink_to(target, target_is_directory=True)
44
+
45
+
46
+ def atomic_save(path: Path, value: dict) -> None:
47
+ temporary = path.with_suffix(path.suffix + ".tmp")
48
+ torch.save(value, temporary)
49
+ os.replace(temporary, path)
50
+
51
+
52
+ def coords_from_indices(indices: torch.Tensor) -> np.ndarray:
53
+ flat = indices.detach().cpu().numpy().astype(np.int64).reshape(-1)
54
+ plane = 30 * 52
55
+ temporal = flat // plane
56
+ remainder = flat % plane
57
+ coords = np.stack([temporal, remainder // 52, remainder % 52], axis=1)
58
+ slots = sorted(int(value) for value in np.unique(temporal))
59
+ if slots != [0, 1, 2]:
60
+ raise ValueError(f"Expected three temporal slots, got {slots}")
61
+ reference = coords[coords[:, 0] == slots[-1], 1:]
62
+ for slot in slots:
63
+ if not np.array_equal(coords[coords[:, 0] == slot, 1:], reference):
64
+ raise ValueError(f"Causal slot {slot} does not share the structured spatial grid")
65
+ return coords
66
+
67
+
68
+ def normalize_causal(path: Path, chunks: int, steps: int) -> dict:
69
+ state = torch.load(path, map_location="cpu", weights_only=False)
70
+ features = {}
71
+ for layer, role in ROLE_MAP.items():
72
+ chunk_rows = []
73
+ for chunk in range(chunks):
74
+ step_rows = []
75
+ for step in range(steps):
76
+ value = state["features"][f"{layer}:{chunk}:{step}"]
77
+ step_rows.append(value.reshape(-1, value.shape[-1]).to(torch.float16))
78
+ chunk_rows.append(torch.stack(step_rows, dim=0))
79
+ features[role] = torch.stack(chunk_rows, dim=0).contiguous()
80
+ index_key = f"{next(iter(ROLE_MAP))}:0:0"
81
+ coords = coords_from_indices(state["feature_indices"][index_key])
82
+ token_counts = {int(value.shape[2]) for value in features.values()}
83
+ if token_counts != {len(coords)}:
84
+ raise ValueError(f"Feature/coordinate mismatch: tokens={token_counts}, coords={len(coords)}")
85
+ return {
86
+ "prompt_id": int(state["prompt_id"]),
87
+ "prompt": state["prompt"],
88
+ "seed": int(state["seed"]),
89
+ "model_family": "causal_forcing",
90
+ "model_variant": "dmd4_boundary_structured",
91
+ "features": features,
92
+ "timesteps": np.asarray([1000.0, 937.5, 833.3333, 625.0], dtype=np.float32),
93
+ "coords": coords,
94
+ "grid_shape": np.asarray([3, 30, 52], dtype=np.int64),
95
+ }
96
+
97
+
98
+ def main() -> None:
99
+ args = parse_args()
100
+ output = args.output_root.resolve()
101
+ output.mkdir(parents=True, exist_ok=True)
102
+ ensure_link(output / "self_forcing", args.self_dir)
103
+ ensure_link(output / "hy_worldplay", args.hy_dir)
104
+ causal_output = output / "causal_forcing"
105
+ causal_output.mkdir(exist_ok=True)
106
+ inventory = []
107
+ for prompt_id in range(args.num_prompts):
108
+ source = (
109
+ args.causal_root
110
+ / "runs"
111
+ / f"prompt_{prompt_id:04d}"
112
+ / "feature_snapshots.pt"
113
+ )
114
+ if not source.exists():
115
+ raise FileNotFoundError(source)
116
+ item = normalize_causal(source, args.chunks, args.steps)
117
+ if item["prompt_id"] != prompt_id:
118
+ raise ValueError(f"Prompt mismatch in {source}: {item['prompt_id']}")
119
+ destination = causal_output / f"prompt_{prompt_id:04d}.pt"
120
+ atomic_save(destination, item)
121
+ inventory.append({
122
+ "prompt_id": prompt_id,
123
+ "source": str(source),
124
+ "destination": str(destination),
125
+ "tokens": int(item["features"]["early"].shape[2]),
126
+ })
127
+ manifest = {
128
+ "dataset_version": 1,
129
+ "chunk_pairing": "boundary_to_all",
130
+ "prompt_ids": list(range(args.num_prompts)),
131
+ "chunks": args.chunks,
132
+ "steps": args.steps,
133
+ "self_forcing": str(args.self_dir.resolve()),
134
+ "hy_worldplay": str(args.hy_dir.resolve()),
135
+ "causal_source": str(args.causal_root.resolve()),
136
+ "causal_inventory": inventory,
137
+ }
138
+ (output / "manifest.json").write_text(
139
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
140
+ encoding="utf-8",
141
+ )
142
+ print(f"[complete] {output} prompts={args.num_prompts}", flush=True)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
scripts/build_conditional_probe_dataset.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Normalize three-model hidden snapshots into one offline probe dataset.
3
+
4
+ The recorder implementations live in their respective projects. This script
5
+ only converts their per-prompt snapshots to a small common CPU format; it does
6
+ not run a model or manufacture control examples.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ def _preparse_gpu() -> str:
20
+ parser = argparse.ArgumentParser(add_help=False)
21
+ parser.add_argument("--gpu", default="0")
22
+ args, _ = parser.parse_known_args()
23
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
24
+ return str(args.gpu)
25
+
26
+
27
+ PHYSICAL_GPU = _preparse_gpu()
28
+
29
+ import numpy as np
30
+ import torch
31
+
32
+
33
+ ROLES = {
34
+ "self_forcing": {7: "early", 14: "middle", 22: "late", 29: "final"},
35
+ "causal_forcing": {7: "early", 14: "middle", 22: "late", 29: "final"},
36
+ "hy_worldplay": {13: "early", 26: "middle", 40: "late", 53: "final"},
37
+ }
38
+
39
+
40
+ def regular_coords(frames: int = 3, height: int = 30, width: int = 52, max_tokens: int = 240):
41
+ total = frames * height * width
42
+ if total <= max_tokens:
43
+ flat = np.arange(total, dtype=np.int64)
44
+ else:
45
+ per_frame = max(1, max_tokens // frames)
46
+ h_count = min(height, max(1, int(round((per_frame * height / width) ** 0.5))))
47
+ w_count = min(width, max(1, per_frame // h_count))
48
+ while frames * h_count * w_count > max_tokens and w_count > 1:
49
+ w_count -= 1
50
+ while frames * h_count * w_count > max_tokens and h_count > 1:
51
+ h_count -= 1
52
+ hs = np.unique(np.rint(np.linspace(0, height - 1, h_count)).astype(np.int64))
53
+ ws = np.unique(np.rint(np.linspace(0, width - 1, w_count)).astype(np.int64))
54
+ flat = np.asarray(
55
+ [t * height * width + h * width + w for t in range(frames) for h in hs for w in ws],
56
+ dtype=np.int64,
57
+ )
58
+ t = flat // (height * width)
59
+ rem = flat % (height * width)
60
+ return np.stack([t, rem // width, rem % width], axis=1)
61
+
62
+
63
+ def ensure_stack(values: dict[tuple[int, int], torch.Tensor], layer: int, chunks: int, steps: int):
64
+ rows = []
65
+ for chunk in range(chunks):
66
+ step_rows = []
67
+ for step in range(steps):
68
+ key = (chunk, step)
69
+ if key not in values:
70
+ raise ValueError(f"Missing layer={layer} chunk={chunk} step={step}")
71
+ step_rows.append(values[key].detach().cpu().to(torch.float16))
72
+ rows.append(torch.stack(step_rows, dim=0))
73
+ return torch.stack(rows, dim=0).contiguous()
74
+
75
+
76
+ def load_self(path: Path, layers: list[int], chunks: int, steps: int) -> dict[str, Any]:
77
+ run = torch.load(path, map_location="cpu", weights_only=False)
78
+ features = {}
79
+ for layer in layers:
80
+ stage = f"block_{layer}_hidden"
81
+ values = {}
82
+ for key, value in run["records"][stage].items():
83
+ c, s = (int(part) for part in key.split(":"))
84
+ if c < chunks and s < steps:
85
+ values[(c, s)] = value
86
+ features[ROLES["self_forcing"][layer]] = ensure_stack(values, layer, chunks, steps)
87
+ return {
88
+ "prompt_id": int(run["run_index"]),
89
+ "prompt": run["prompt"],
90
+ "seed": int(run["seed"]),
91
+ "model_family": "self_forcing",
92
+ "model_variant": "dmd4",
93
+ "features": features,
94
+ "timesteps": np.asarray([1000.0, 937.5, 833.3333, 625.0], dtype=np.float32),
95
+ "coords": regular_coords(),
96
+ }
97
+
98
+
99
+ def load_causal(path: Path, layers: list[int], chunks: int, steps: int) -> dict[str, Any]:
100
+ run = torch.load(path, map_location="cpu", weights_only=False)
101
+ raw = {}
102
+ for key, value in run["features"].items():
103
+ layer, chunk, step = (int(part) for part in key.split(":"))
104
+ if layer in layers and chunk < chunks and step < steps:
105
+ raw.setdefault(layer, {})[(chunk, step)] = value
106
+ features = {
107
+ ROLES["causal_forcing"][layer]: ensure_stack(raw.get(layer, {}), layer, chunks, steps)
108
+ for layer in layers
109
+ }
110
+ return {
111
+ "prompt_id": int(run["prompt_id"]),
112
+ "prompt": run["prompt"],
113
+ "seed": int(run["seed"]),
114
+ "model_family": "causal_forcing",
115
+ "model_variant": "dmd4",
116
+ "features": features,
117
+ "timesteps": np.asarray([1000.0, 937.5, 833.3333, 625.0], dtype=np.float32),
118
+ "coords": regular_coords(),
119
+ }
120
+
121
+
122
+ def load_hy(path: Path, layers: list[int], chunks: int, steps: int) -> dict[str, Any]:
123
+ data = np.load(path, allow_pickle=False)
124
+ stages = [str(value) for value in data["stages"]]
125
+ raw = {}
126
+ for index, stage in enumerate(stages):
127
+ if not stage.startswith("block_"):
128
+ continue
129
+ layer = int(stage.split("_")[-1])
130
+ chunk = int(data["chunks"][index])
131
+ step = int(data["steps"][index])
132
+ if layer in layers and chunk < chunks and step < steps:
133
+ raw.setdefault(layer, {})[(chunk, step)] = torch.from_numpy(data["features"][index])
134
+ features = {
135
+ ROLES["hy_worldplay"][layer]: ensure_stack(raw.get(layer, {}), layer, chunks, steps)
136
+ for layer in layers
137
+ }
138
+ return {
139
+ "features": features,
140
+ "timesteps": np.asarray(data["timesteps"], dtype=np.float32),
141
+ "coords": np.asarray(data["coords"], dtype=np.int64),
142
+ }
143
+
144
+
145
+ def atomic_save(path: Path, value: dict[str, Any]) -> None:
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ temporary = path.with_suffix(path.suffix + ".tmp")
148
+ torch.save(value, temporary)
149
+ os.replace(temporary, path)
150
+
151
+
152
+ def parse_args() -> argparse.Namespace:
153
+ parser = argparse.ArgumentParser()
154
+ parser.add_argument("--self_root", type=Path, required=True)
155
+ parser.add_argument("--causal_root", type=Path, required=True)
156
+ parser.add_argument("--hy_root", type=Path, required=True)
157
+ parser.add_argument("--output_root", type=Path, required=True)
158
+ parser.add_argument("--chunks", type=int, default=4)
159
+ parser.add_argument("--steps", type=int, default=4)
160
+ parser.add_argument("--max_prompts", type=int, default=10)
161
+ return parser.parse_args()
162
+
163
+
164
+ def main() -> None:
165
+ args = parse_args()
166
+ args.output_root.mkdir(parents=True, exist_ok=True)
167
+ specs = {
168
+ "self_forcing": ([7, 14, 22, 29], args.self_root / "runs", "self"),
169
+ "causal_forcing": ([7, 14, 22, 29], args.causal_root / "runs", "causal"),
170
+ }
171
+ inventory = []
172
+ for family, (layers, run_root, prefix) in specs.items():
173
+ out_dir = args.output_root / family
174
+ out_dir.mkdir(parents=True, exist_ok=True)
175
+ for prompt_id in range(args.max_prompts):
176
+ if family == "self_forcing":
177
+ source = run_root / f"prompt_{prompt_id:02d}.pt"
178
+ if not source.exists():
179
+ raise FileNotFoundError(source)
180
+ item = load_self(source, layers, args.chunks, args.steps)
181
+ else:
182
+ source = run_root / f"prompt_{prompt_id:04d}" / "feature_snapshots.pt"
183
+ if not source.exists():
184
+ raise FileNotFoundError(source)
185
+ item = load_causal(source, layers, args.chunks, args.steps)
186
+ destination = out_dir / f"prompt_{prompt_id:04d}.pt"
187
+ atomic_save(destination, item)
188
+ inventory.append({
189
+ "family": family,
190
+ "prompt_id": prompt_id,
191
+ "path": str(destination),
192
+ "bytes": destination.stat().st_size,
193
+ "roles": sorted(item["features"]),
194
+ })
195
+
196
+ hy_files = sorted(args.hy_root.glob("shard_gpu*/runs/prompt_*/forward/final_hidden_snapshots.npz"))
197
+ hy_by_prompt = {}
198
+ for source in hy_files:
199
+ prompt_id = int(source.parts[-3].split("_")[-1])
200
+ if prompt_id < args.max_prompts:
201
+ hy_by_prompt[prompt_id] = source
202
+ out_dir = args.output_root / "hy_worldplay"
203
+ out_dir.mkdir(parents=True, exist_ok=True)
204
+ for prompt_id in range(args.max_prompts):
205
+ source = hy_by_prompt.get(prompt_id)
206
+ if source is None:
207
+ raise FileNotFoundError(f"HY snapshot for prompt {prompt_id}")
208
+ item = load_hy(source, [13, 26, 40, 53], args.chunks, args.steps)
209
+ item.update({
210
+ "prompt_id": prompt_id,
211
+ "model_family": "hy_worldplay",
212
+ "model_variant": "ar4",
213
+ "seed": 0,
214
+ "prompt": f"prompt_{prompt_id:04d}",
215
+ })
216
+ destination = out_dir / f"prompt_{prompt_id:04d}.pt"
217
+ atomic_save(destination, item)
218
+ inventory.append({
219
+ "family": "hy_worldplay",
220
+ "prompt_id": prompt_id,
221
+ "path": str(destination),
222
+ "bytes": destination.stat().st_size,
223
+ "roles": sorted(item["features"]),
224
+ })
225
+
226
+ manifest = {
227
+ "dataset_version": 1,
228
+ "prompt_ids": list(range(args.max_prompts)),
229
+ "chunks": args.chunks,
230
+ "steps": args.steps,
231
+ "max_tokens": 240,
232
+ "roles": ["early", "middle", "late", "final"],
233
+ "source_roots": {
234
+ "self_forcing": str(args.self_root),
235
+ "causal_forcing": str(args.causal_root),
236
+ "hy_worldplay": str(args.hy_root),
237
+ },
238
+ "inventory": inventory,
239
+ }
240
+ (args.output_root / "manifest.json").write_text(
241
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
242
+ )
243
+ print(f"[complete] {args.output_root} prompts={args.max_prompts} files={len(inventory)}", flush=True)
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
scripts/build_predictor_offline_data.py ADDED
@@ -0,0 +1,878 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build offline FFFF trajectories for the lightweight Self-Forcing predictor.
3
+
4
+ The dataset is prompt-sharded and resumable. Common denoising tensors are
5
+ stored once per prompt, while clean self-attention prefeatures are stored in
6
+ one sidecar per Teacher block. This layout avoids duplicating history tensors
7
+ across the 18 adjacent-step training samples produced by each prompt.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import random
17
+ import shutil
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ def _preparse_gpu() -> str:
25
+ parser = argparse.ArgumentParser(add_help=False)
26
+ parser.add_argument("--gpu", default="2")
27
+ args, _ = parser.parse_known_args()
28
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
29
+ return str(args.gpu)
30
+
31
+
32
+ PHYSICAL_GPU = _preparse_gpu()
33
+
34
+ import torch
35
+ from omegaconf import OmegaConf
36
+ from safetensors.torch import save_file
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parents[1]
39
+ if str(REPO_ROOT) not in sys.path:
40
+ sys.path.insert(0, str(REPO_ROOT))
41
+
42
+ from pipeline import CausalInferencePipeline
43
+ from utils.misc import set_seed
44
+ from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder
45
+
46
+
47
+ DATASET_VERSION = 2
48
+ EXCLUDED_CHUNKS = (0,)
49
+ LATENT_CHANNELS = 16
50
+ LATENT_HEIGHT = 60
51
+ LATENT_WIDTH = 104
52
+
53
+
54
+ def parse_args() -> argparse.Namespace:
55
+ parser = argparse.ArgumentParser(
56
+ description="Build full-step Self-Forcing predictor trajectories"
57
+ )
58
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
59
+ parser.add_argument(
60
+ "--config_path",
61
+ type=Path,
62
+ default=Path("configs/self_forcing_sid.yaml"),
63
+ )
64
+ parser.add_argument(
65
+ "--checkpoint_path",
66
+ type=Path,
67
+ default=Path("checkpoints/self_forcing_dmd.pt"),
68
+ )
69
+ parser.add_argument(
70
+ "--prompt_path",
71
+ type=Path,
72
+ default=Path("prompts/vidprom_filtered_extended.txt"),
73
+ )
74
+ parser.add_argument(
75
+ "--validation_prompt_path",
76
+ type=Path,
77
+ default=Path("prompts/MovieGenVideoBench_extended.txt"),
78
+ )
79
+ parser.add_argument("--output_dir", type=Path, required=True)
80
+ parser.add_argument("--num_prompts", type=int, default=100)
81
+ parser.add_argument(
82
+ "--prompt_ids",
83
+ type=int,
84
+ nargs="*",
85
+ default=None,
86
+ help=(
87
+ "Only materialize these selected prompt IDs. The manifest still "
88
+ "records the full deterministic prompt selection."
89
+ ),
90
+ )
91
+ parser.add_argument("--num_frames", type=int, default=21)
92
+ parser.add_argument("--selection_seed", type=int, default=0)
93
+ parser.add_argument("--generation_seed", type=int, default=0)
94
+ parser.add_argument(
95
+ "--layers",
96
+ type=int,
97
+ nargs="*",
98
+ default=None,
99
+ help="Teacher blocks to cache. Omit to cache every block.",
100
+ )
101
+ parser.add_argument(
102
+ "--max_new_prompts",
103
+ type=int,
104
+ default=None,
105
+ help="Stop after this many new prompt shards; use 1 for the dry run.",
106
+ )
107
+ parser.add_argument(
108
+ "--min_free_gib",
109
+ type=float,
110
+ default=50.0,
111
+ help="Stop before a new prompt if free disk space falls below this value.",
112
+ )
113
+ parser.add_argument("--overwrite", action="store_true")
114
+ args = parser.parse_args()
115
+
116
+ if args.num_prompts < 1:
117
+ parser.error("--num_prompts must be positive")
118
+ if args.prompt_ids is not None and any(
119
+ value < 0 or value >= args.num_prompts for value in args.prompt_ids
120
+ ):
121
+ parser.error("--prompt_ids must be within [0, --num_prompts)")
122
+ if args.num_frames < 1 or args.num_frames % 3:
123
+ parser.error("--num_frames must be a positive multiple of 3")
124
+ if args.max_new_prompts is not None and args.max_new_prompts < 0:
125
+ parser.error("--max_new_prompts must be non-negative")
126
+ return args
127
+
128
+
129
+ def resolve_path(path: Path) -> Path:
130
+ path = path.expanduser()
131
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
132
+
133
+
134
+ def atomic_write_json(path: Path, value: Any) -> None:
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ temporary = path.with_suffix(path.suffix + ".tmp")
137
+ temporary.write_text(
138
+ json.dumps(value, indent=2, ensure_ascii=False) + "\n",
139
+ encoding="utf-8",
140
+ )
141
+ os.replace(temporary, path)
142
+
143
+
144
+ def file_sha256(path: Path) -> str:
145
+ digest = hashlib.sha256()
146
+ with path.open("rb") as handle:
147
+ while chunk := handle.read(8 * 1024 * 1024):
148
+ digest.update(chunk)
149
+ return digest.hexdigest()
150
+
151
+
152
+ def read_nonempty_lines(path: Path) -> list[str]:
153
+ with path.open("r", encoding="utf-8") as handle:
154
+ return [line.strip() for line in handle if line.strip()]
155
+
156
+
157
+ def select_prompts(
158
+ prompt_path: Path,
159
+ validation_prompt_path: Path,
160
+ num_prompts: int,
161
+ seed: int,
162
+ ) -> list[dict[str, Any]]:
163
+ source = read_nonempty_lines(prompt_path)
164
+ validation = set(read_nonempty_lines(validation_prompt_path)[:100])
165
+ eligible = [
166
+ {"source_index": index, "prompt": prompt}
167
+ for index, prompt in enumerate(source)
168
+ if prompt not in validation
169
+ ]
170
+ if len(eligible) < num_prompts:
171
+ raise ValueError(
172
+ f"Only {len(eligible)} eligible prompts remain after excluding "
173
+ f"the first 100 validation prompts; requested {num_prompts}"
174
+ )
175
+ return random.Random(seed).sample(eligible, num_prompts)
176
+
177
+
178
+ def tensor_to_bf16_cpu(value: torch.Tensor) -> torch.Tensor:
179
+ return value.detach().to(device="cpu", dtype=torch.bfloat16).contiguous()
180
+
181
+
182
+ def atomic_save_safetensors(
183
+ tensors: dict[str, torch.Tensor],
184
+ path: Path,
185
+ metadata: dict[str, str],
186
+ ) -> None:
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ temporary = path.with_suffix(path.suffix + ".tmp")
189
+ save_file(tensors, temporary, metadata=metadata)
190
+ os.replace(temporary, path)
191
+
192
+
193
+ def directory_size(path: Path) -> int:
194
+ return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
195
+
196
+
197
+ class TrajectoryRecorder:
198
+ """Capture final hidden states and clean K-projection inputs."""
199
+
200
+ def __init__(self, model: torch.nn.Module, layers: list[int]) -> None:
201
+ self.model = model
202
+ self.layers = layers
203
+ self.mode: str | None = None
204
+ self.final_hidden: torch.Tensor | None = None
205
+ self.current_clean: dict[int, torch.Tensor] = {}
206
+ self.clean_prefeatures: dict[int, list[torch.Tensor]] = {
207
+ layer: [] for layer in layers
208
+ }
209
+ self.handles: list[Any] = []
210
+
211
+ self.handles.append(
212
+ model.head.register_forward_pre_hook(self._head_pre_hook)
213
+ )
214
+ for layer in layers:
215
+ self.handles.append(
216
+ model.blocks[layer].self_attn.k.register_forward_pre_hook(
217
+ self._make_clean_prefeature_hook(layer)
218
+ )
219
+ )
220
+
221
+ def close(self) -> None:
222
+ for handle in self.handles:
223
+ handle.remove()
224
+ self.handles.clear()
225
+
226
+ def start_denoising_step(self) -> None:
227
+ self.mode = "denoise"
228
+ self.final_hidden = None
229
+
230
+ def finish_denoising_step(self) -> torch.Tensor:
231
+ if self.final_hidden is None:
232
+ raise RuntimeError("The Teacher head hook did not capture final_hidden")
233
+ value = self.final_hidden
234
+ self.final_hidden = None
235
+ self.mode = None
236
+ return value
237
+
238
+ def start_clean_pass(self) -> None:
239
+ self.mode = "clean"
240
+ self.current_clean = {}
241
+
242
+ def finish_clean_pass(self, *, store: bool = True) -> dict[int, torch.Tensor]:
243
+ missing = sorted(set(self.layers) - set(self.current_clean))
244
+ if missing:
245
+ raise RuntimeError(
246
+ f"Clean pass did not capture prefeatures for blocks {missing}"
247
+ )
248
+ captured = self.current_clean
249
+ if store:
250
+ for layer in self.layers:
251
+ self.clean_prefeatures[layer].append(captured[layer])
252
+ self.current_clean = {}
253
+ self.mode = None
254
+ return captured
255
+
256
+ def _head_pre_hook(
257
+ self, _module: torch.nn.Module, inputs: tuple[torch.Tensor, ...]
258
+ ) -> None:
259
+ if self.mode != "denoise":
260
+ return
261
+ if self.final_hidden is not None:
262
+ raise RuntimeError("Captured final_hidden more than once in one step")
263
+ if not inputs or not isinstance(inputs[0], torch.Tensor):
264
+ raise RuntimeError("Unexpected Teacher head inputs")
265
+ self.final_hidden = tensor_to_bf16_cpu(inputs[0])
266
+
267
+ def _make_clean_prefeature_hook(self, layer: int):
268
+ def hook(
269
+ _module: torch.nn.Module, inputs: tuple[torch.Tensor, ...]
270
+ ) -> None:
271
+ if self.mode != "clean":
272
+ return
273
+ if layer in self.current_clean:
274
+ raise RuntimeError(
275
+ f"Captured block {layer} clean prefeature more than once"
276
+ )
277
+ if not inputs or not isinstance(inputs[0], torch.Tensor):
278
+ raise RuntimeError(f"Unexpected block {layer} K inputs")
279
+ self.current_clean[layer] = tensor_to_bf16_cpu(inputs[0])
280
+
281
+ return hook
282
+
283
+
284
+ def build_pipeline(
285
+ config: Any, checkpoint_path: Path, device: torch.device
286
+ ) -> CausalInferencePipeline:
287
+ generator = WanDiffusionWrapper(
288
+ **getattr(config, "model_kwargs", {}), is_causal=True
289
+ )
290
+ text_encoder = WanTextEncoder()
291
+ pipeline = CausalInferencePipeline(
292
+ config,
293
+ device=device,
294
+ generator=generator,
295
+ text_encoder=text_encoder,
296
+ vae=torch.nn.Identity(),
297
+ )
298
+
299
+ checkpoint = torch.load(
300
+ checkpoint_path, map_location="cpu", weights_only=False, mmap=True
301
+ )
302
+ if set(checkpoint) != {"generator_ema"}:
303
+ raise KeyError(
304
+ f"Expected checkpoint key generator_ema, found {sorted(checkpoint)}"
305
+ )
306
+ pipeline.generator.load_state_dict(checkpoint["generator_ema"], strict=True)
307
+ del checkpoint
308
+
309
+ pipeline.to(dtype=torch.bfloat16)
310
+ pipeline.text_encoder.to(device=device)
311
+ pipeline.generator.to(device=device)
312
+ pipeline.eval()
313
+ pipeline.generator.model.requires_grad_(False)
314
+ pipeline.text_encoder.requires_grad_(False)
315
+ return pipeline
316
+
317
+
318
+ def reset_caches(
319
+ pipeline: CausalInferencePipeline,
320
+ batch_size: int,
321
+ dtype: torch.dtype,
322
+ device: torch.device,
323
+ ) -> None:
324
+ if pipeline.kv_cache1 is None:
325
+ pipeline._initialize_kv_cache(batch_size, dtype, device)
326
+ pipeline._initialize_crossattn_cache(batch_size, dtype, device)
327
+ return
328
+
329
+ for cache in pipeline.kv_cache1:
330
+ cache["global_end_index"].zero_()
331
+ cache["local_end_index"].zero_()
332
+ for cache in pipeline.crossattn_cache:
333
+ cache["is_init"] = False
334
+
335
+
336
+ def collect_cross_attention_cache(
337
+ pipeline: CausalInferencePipeline,
338
+ layers: list[int],
339
+ ) -> dict[str, torch.Tensor]:
340
+ output: dict[str, torch.Tensor] = {}
341
+ for layer in layers:
342
+ cache = pipeline.crossattn_cache[layer]
343
+ if not cache["is_init"]:
344
+ raise RuntimeError(f"Cross-attention cache for block {layer} is empty")
345
+ output[f"block_{layer:02d}_k"] = tensor_to_bf16_cpu(cache["k"])
346
+ output[f"block_{layer:02d}_v"] = tensor_to_bf16_cpu(cache["v"])
347
+ return output
348
+
349
+
350
+ @torch.inference_mode()
351
+ def generate_prompt(
352
+ pipeline: CausalInferencePipeline,
353
+ recorder: TrajectoryRecorder,
354
+ prompt: str,
355
+ num_frames: int,
356
+ generation_seed: int,
357
+ device: torch.device,
358
+ ) -> tuple[
359
+ dict[str, torch.Tensor],
360
+ dict[int, list[torch.Tensor]],
361
+ dict[str, torch.Tensor],
362
+ dict[int, torch.Tensor],
363
+ dict[str, torch.Tensor],
364
+ float,
365
+ float,
366
+ ]:
367
+ set_seed(generation_seed)
368
+ reset_caches(pipeline, 1, torch.bfloat16, device)
369
+ recorder.clean_prefeatures = {layer: [] for layer in recorder.layers}
370
+
371
+ conditional_dict = pipeline.text_encoder(text_prompts=[prompt])
372
+ noise = torch.randn(
373
+ 1,
374
+ num_frames,
375
+ LATENT_CHANNELS,
376
+ LATENT_HEIGHT,
377
+ LATENT_WIDTH,
378
+ dtype=torch.bfloat16,
379
+ device=device,
380
+ )
381
+ trajectory: dict[str, torch.Tensor] = {}
382
+ chunk0_trajectory: dict[str, torch.Tensor] = {}
383
+ chunk0_prefeatures: dict[int, torch.Tensor] = {}
384
+ chunk_size = pipeline.num_frame_per_block
385
+ num_chunks = num_frames // chunk_size
386
+ timesteps = pipeline.denoising_step_list.to(device=device)
387
+
388
+ torch.cuda.reset_peak_memory_stats()
389
+ torch.cuda.synchronize()
390
+ start_time = time.perf_counter()
391
+
392
+ current_start_frame = 0
393
+ for chunk in range(num_chunks):
394
+ noisy_input = noise[
395
+ :, current_start_frame : current_start_frame + chunk_size
396
+ ]
397
+ timestep: torch.Tensor | None = None
398
+ denoised_pred: torch.Tensor | None = None
399
+
400
+ for step, current_timestep in enumerate(timesteps):
401
+ timestep = (
402
+ torch.ones(
403
+ [1, chunk_size],
404
+ device=device,
405
+ dtype=torch.int64,
406
+ )
407
+ * current_timestep
408
+ )
409
+ prefix = f"chunk_{chunk:02d}_step_{step:02d}"
410
+ if chunk not in EXCLUDED_CHUNKS:
411
+ trajectory[f"{prefix}_noisy_latent"] = tensor_to_bf16_cpu(
412
+ noisy_input
413
+ )
414
+ trajectory[f"{prefix}_timestep"] = (
415
+ timestep.detach()
416
+ .to(device="cpu", dtype=torch.float32)
417
+ .contiguous()
418
+ )
419
+
420
+ recorder.start_denoising_step()
421
+ flow, denoised_pred = pipeline.generator(
422
+ noisy_image_or_video=noisy_input,
423
+ conditional_dict=conditional_dict,
424
+ timestep=timestep,
425
+ kv_cache=pipeline.kv_cache1,
426
+ crossattn_cache=pipeline.crossattn_cache,
427
+ current_start=current_start_frame * pipeline.frame_seq_length,
428
+ )
429
+ final_hidden = recorder.finish_denoising_step()
430
+ if chunk not in EXCLUDED_CHUNKS:
431
+ trajectory[f"{prefix}_final_hidden"] = final_hidden
432
+ trajectory[f"{prefix}_flow"] = tensor_to_bf16_cpu(flow)
433
+ else:
434
+ chunk0_trajectory[f"{prefix}_final_hidden"] = final_hidden
435
+
436
+ if step < len(timesteps) - 1:
437
+ next_timestep = timesteps[step + 1]
438
+ denoised_flat = denoised_pred.flatten(0, 1)
439
+ noisy_input = pipeline.scheduler.add_noise(
440
+ denoised_flat,
441
+ torch.randn_like(denoised_flat),
442
+ next_timestep
443
+ * torch.ones(
444
+ [chunk_size], device=device, dtype=torch.long
445
+ ),
446
+ ).unflatten(0, denoised_pred.shape[:2])
447
+
448
+ if denoised_pred is None or timestep is None:
449
+ raise RuntimeError("Denoising loop produced no output")
450
+
451
+ if chunk not in EXCLUDED_CHUNKS:
452
+ trajectory[f"chunk_{chunk:02d}_clean_latent"] = (
453
+ tensor_to_bf16_cpu(denoised_pred)
454
+ )
455
+
456
+ recorder.start_clean_pass()
457
+ context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
458
+ pipeline.generator(
459
+ noisy_image_or_video=denoised_pred,
460
+ conditional_dict=conditional_dict,
461
+ timestep=context_timestep,
462
+ kv_cache=pipeline.kv_cache1,
463
+ crossattn_cache=pipeline.crossattn_cache,
464
+ current_start=current_start_frame * pipeline.frame_seq_length,
465
+ )
466
+ captured_clean = recorder.finish_clean_pass(
467
+ store=chunk not in EXCLUDED_CHUNKS
468
+ )
469
+ if chunk in EXCLUDED_CHUNKS:
470
+ if chunk != 0:
471
+ raise RuntimeError(f"Unsupported excluded context chunk {chunk}")
472
+ chunk0_prefeatures = captured_clean
473
+ current_start_frame += chunk_size
474
+
475
+ cross_attention = collect_cross_attention_cache(pipeline, recorder.layers)
476
+ torch.cuda.synchronize()
477
+ elapsed = time.perf_counter() - start_time
478
+ peak_gib = torch.cuda.max_memory_allocated() / (1024**3)
479
+
480
+ del conditional_dict, noise
481
+ return (
482
+ trajectory,
483
+ recorder.clean_prefeatures,
484
+ chunk0_trajectory,
485
+ chunk0_prefeatures,
486
+ cross_attention,
487
+ elapsed,
488
+ peak_gib,
489
+ )
490
+
491
+
492
+ def save_prompt_shard(
493
+ output_dir: Path,
494
+ prompt_index: int,
495
+ selection: dict[str, Any],
496
+ trajectory: dict[str, torch.Tensor],
497
+ clean_prefeatures: dict[int, list[torch.Tensor]],
498
+ chunk0_trajectory: dict[str, torch.Tensor],
499
+ chunk0_prefeatures: dict[int, torch.Tensor],
500
+ cross_attention: dict[str, torch.Tensor],
501
+ elapsed_s: float,
502
+ peak_gpu_gib: float,
503
+ layers: list[int],
504
+ generation_seed: int,
505
+ num_chunks: int,
506
+ ) -> Path:
507
+ destination = output_dir / f"prompt_{prompt_index:04d}"
508
+ partial = output_dir / f"prompt_{prompt_index:04d}.partial"
509
+ if partial.exists():
510
+ shutil.rmtree(partial)
511
+ partial.mkdir(parents=True)
512
+
513
+ shared_metadata = {
514
+ "dataset_version": str(DATASET_VERSION),
515
+ "dtype": "bfloat16",
516
+ "prompt_index": str(prompt_index),
517
+ }
518
+ atomic_save_safetensors(
519
+ trajectory,
520
+ partial / "trajectory.safetensors",
521
+ {**shared_metadata, "kind": "trajectory"},
522
+ )
523
+ atomic_save_safetensors(
524
+ cross_attention,
525
+ partial / "cross_attention.safetensors",
526
+ {**shared_metadata, "kind": "cross_attention_kv"},
527
+ )
528
+ atomic_save_safetensors(
529
+ chunk0_trajectory,
530
+ partial / "chunk0_context" / "trajectory.safetensors",
531
+ {**shared_metadata, "kind": "chunk0_context_final_hidden"},
532
+ )
533
+ for layer in layers:
534
+ atomic_save_safetensors(
535
+ {"chunk_00": chunk0_prefeatures[layer]},
536
+ partial
537
+ / "chunk0_context"
538
+ / "clean_prefeatures"
539
+ / f"block_{layer:02d}.safetensors",
540
+ {
541
+ **shared_metadata,
542
+ "kind": "chunk0_context_clean_self_attention_k_input",
543
+ "block_id": str(layer),
544
+ },
545
+ )
546
+ atomic_write_json(
547
+ partial / "chunk0_context" / "metadata.json",
548
+ {
549
+ "kind": "context_only",
550
+ "chunk": 0,
551
+ "is_training_target": False,
552
+ "hidden_steps": [0, 1, 2, 3],
553
+ "layers": layers,
554
+ },
555
+ )
556
+ (partial / "chunk0_context" / "_SUCCESS").write_text(
557
+ "ok\n", encoding="utf-8"
558
+ )
559
+
560
+ prefeature_shapes: dict[str, list[int]] = {}
561
+ for layer in layers:
562
+ values = clean_prefeatures[layer]
563
+ stored_chunks = [
564
+ chunk for chunk in range(num_chunks) if chunk not in EXCLUDED_CHUNKS
565
+ ]
566
+ if len(values) != len(stored_chunks):
567
+ raise RuntimeError(
568
+ f"Expected {len(stored_chunks)} stored chunks, got {len(values)}"
569
+ )
570
+ tensors = {
571
+ f"chunk_{chunk:02d}": value
572
+ for chunk, value in zip(stored_chunks, values)
573
+ }
574
+ atomic_save_safetensors(
575
+ tensors,
576
+ partial / "clean_prefeatures" / f"block_{layer:02d}.safetensors",
577
+ {
578
+ **shared_metadata,
579
+ "kind": "clean_self_attention_k_input",
580
+ "block_id": str(layer),
581
+ },
582
+ )
583
+ if values:
584
+ prefeature_shapes[str(layer)] = list(values[0].shape)
585
+
586
+ metadata = {
587
+ "dataset_version": DATASET_VERSION,
588
+ "prompt_index": prompt_index,
589
+ "source_index": selection["source_index"],
590
+ "prompt": selection["prompt"],
591
+ "generation_seed": generation_seed,
592
+ "dtype": "bfloat16",
593
+ "layers": layers,
594
+ "num_clean_chunks": len(clean_prefeatures[layers[0]]),
595
+ "excluded_chunks": list(EXCLUDED_CHUNKS),
596
+ "stored_chunks": [
597
+ chunk for chunk in range(num_chunks) if chunk not in EXCLUDED_CHUNKS
598
+ ],
599
+ "prefeature_shapes": prefeature_shapes,
600
+ "elapsed_s": elapsed_s,
601
+ "peak_gpu_gib": peak_gpu_gib,
602
+ }
603
+ atomic_write_json(partial / "metadata.json", metadata)
604
+ (partial / "_SUCCESS").write_text("ok\n", encoding="utf-8")
605
+ os.replace(partial, destination)
606
+ return destination
607
+
608
+
609
+ def prepare_manifest(
610
+ args: argparse.Namespace,
611
+ config: Any,
612
+ prompt_path: Path,
613
+ validation_prompt_path: Path,
614
+ checkpoint_path: Path,
615
+ output_dir: Path,
616
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
617
+ output_dir.mkdir(parents=True, exist_ok=True)
618
+ prompt_selection_path = output_dir / "prompt_selection.json"
619
+ selected = select_prompts(
620
+ prompt_path,
621
+ validation_prompt_path,
622
+ args.num_prompts,
623
+ args.selection_seed,
624
+ )
625
+ selection_document = {
626
+ "selection_seed": args.selection_seed,
627
+ "num_prompts": args.num_prompts,
628
+ "prompt_source": str(prompt_path),
629
+ "prompt_source_sha256": file_sha256(prompt_path),
630
+ "validation_source": str(validation_prompt_path),
631
+ "validation_source_sha256": file_sha256(validation_prompt_path),
632
+ "excluded_validation_count": 100,
633
+ "prompts": selected,
634
+ }
635
+
636
+ if prompt_selection_path.exists() and not args.overwrite:
637
+ existing = json.loads(prompt_selection_path.read_text(encoding="utf-8"))
638
+ if existing != selection_document:
639
+ raise RuntimeError(
640
+ "Existing prompt_selection.json differs from the requested "
641
+ "selection. Use another output directory or --overwrite."
642
+ )
643
+ else:
644
+ atomic_write_json(prompt_selection_path, selection_document)
645
+
646
+ manifest = {
647
+ "dataset_version": DATASET_VERSION,
648
+ "config_path": str(resolve_path(args.config_path)),
649
+ "checkpoint_path": str(checkpoint_path),
650
+ "checkpoint_key": "generator_ema",
651
+ "checkpoint_sha256": file_sha256(checkpoint_path),
652
+ "model": "Wan2.1-T2V-1.3B causal generator_ema",
653
+ "model_hidden_dim": 1536,
654
+ "num_teacher_blocks": 30,
655
+ "cached_layers": args.layers,
656
+ "storage_dtype": "bfloat16",
657
+ "num_prompts": args.num_prompts,
658
+ "num_frames": args.num_frames,
659
+ "num_chunks": args.num_frames // int(config.num_frame_per_block),
660
+ "excluded_chunks": list(EXCLUDED_CHUNKS),
661
+ "stored_chunks": [
662
+ chunk
663
+ for chunk in range(
664
+ args.num_frames // int(config.num_frame_per_block)
665
+ )
666
+ if chunk not in EXCLUDED_CHUNKS
667
+ ],
668
+ "num_frame_per_block": int(config.num_frame_per_block),
669
+ "local_attention_latents": (
670
+ int(config.model_kwargs.local_attn_size)
671
+ if args.num_frames > 21 else None
672
+ ),
673
+ "denoising_step_source": list(config.denoising_step_list),
674
+ "selection_seed": args.selection_seed,
675
+ "generation_seed_reset_per_prompt": args.generation_seed,
676
+ "prompt_selection_file": str(prompt_selection_path),
677
+ "schema": {
678
+ "trajectory": "prompt_NNNN/trajectory.safetensors",
679
+ "cross_attention": "prompt_NNNN/cross_attention.safetensors",
680
+ "clean_prefeature": (
681
+ "prompt_NNNN/clean_prefeatures/block_XX.safetensors"
682
+ ),
683
+ "chunk0_context": "prompt_NNNN/chunk0_context/",
684
+ },
685
+ }
686
+ atomic_write_json(output_dir / "manifest.json", manifest)
687
+ return manifest, selected
688
+
689
+
690
+ def update_progress(output_dir: Path, num_prompts: int) -> None:
691
+ completed = []
692
+ total_bytes = 0
693
+ for index in range(num_prompts):
694
+ prompt_dir = output_dir / f"prompt_{index:04d}"
695
+ if (prompt_dir / "_SUCCESS").exists():
696
+ completed.append(index)
697
+ total_bytes += directory_size(prompt_dir)
698
+ atomic_write_json(
699
+ output_dir / "progress.json",
700
+ {
701
+ "completed_prompts": completed,
702
+ "completed_count": len(completed),
703
+ "num_prompts": num_prompts,
704
+ "stored_bytes": total_bytes,
705
+ "stored_gib": total_bytes / (1024**3),
706
+ },
707
+ )
708
+
709
+
710
+ def main() -> None:
711
+ args = parse_args()
712
+ args.config_path = resolve_path(args.config_path)
713
+ args.checkpoint_path = resolve_path(args.checkpoint_path)
714
+ args.prompt_path = resolve_path(args.prompt_path)
715
+ args.validation_prompt_path = resolve_path(args.validation_prompt_path)
716
+ args.output_dir = resolve_path(args.output_dir)
717
+
718
+ config = OmegaConf.merge(
719
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
720
+ OmegaConf.load(args.config_path),
721
+ )
722
+ if int(config.num_frame_per_block) != 3:
723
+ raise ValueError("This dataset builder currently expects 3-frame chunks")
724
+ if args.num_frames > 21:
725
+ # Keep the released model's 21-latent training horizon as a rolling
726
+ # attention window while global RoPE positions continue increasing.
727
+ config.model_kwargs.local_attn_size = 21
728
+
729
+ checkpoint = torch.load(
730
+ args.checkpoint_path, map_location="cpu", weights_only=False, mmap=True
731
+ )
732
+ state_dict = checkpoint.get("generator_ema")
733
+ if state_dict is None:
734
+ raise KeyError("Checkpoint does not contain generator_ema")
735
+ checkpoint_layers = sorted(
736
+ {
737
+ int(key.split(".")[2])
738
+ for key in state_dict
739
+ if key.startswith("model.blocks.")
740
+ }
741
+ )
742
+ del checkpoint, state_dict
743
+ if checkpoint_layers != list(range(30)):
744
+ raise ValueError(
745
+ f"Expected checkpoint blocks 0..29, found {checkpoint_layers}"
746
+ )
747
+
748
+ layers = (
749
+ list(range(30))
750
+ if args.layers is None or len(args.layers) == 0
751
+ else sorted(set(args.layers))
752
+ )
753
+ invalid = [layer for layer in layers if layer not in checkpoint_layers]
754
+ if invalid:
755
+ raise ValueError(f"Invalid requested block IDs: {invalid}")
756
+ args.layers = layers
757
+
758
+ _, selected = prepare_manifest(
759
+ args,
760
+ config,
761
+ args.prompt_path,
762
+ args.validation_prompt_path,
763
+ args.checkpoint_path,
764
+ args.output_dir,
765
+ )
766
+ update_progress(args.output_dir, args.num_prompts)
767
+ if args.max_new_prompts == 0:
768
+ print("[prepare] prompt selection and manifest are ready", flush=True)
769
+ return
770
+
771
+ requested_prompt_ids = (
772
+ set(range(args.num_prompts))
773
+ if args.prompt_ids is None
774
+ else set(args.prompt_ids)
775
+ )
776
+ pending = []
777
+ for index, selection in enumerate(selected):
778
+ if index not in requested_prompt_ids:
779
+ continue
780
+ destination = args.output_dir / f"prompt_{index:04d}"
781
+ if (destination / "_SUCCESS").exists() and not args.overwrite:
782
+ continue
783
+ pending.append((index, selection))
784
+ if not pending:
785
+ print("[dataset] all prompt shards already exist", flush=True)
786
+ return
787
+
788
+ device = torch.device("cuda")
789
+ pipeline = build_pipeline(config, args.checkpoint_path, device)
790
+ if len(pipeline.generator.model.blocks) != 30:
791
+ raise ValueError(
792
+ f"Loaded generator has {len(pipeline.generator.model.blocks)} blocks"
793
+ )
794
+ recorder = TrajectoryRecorder(pipeline.generator.model, layers)
795
+
796
+ generated = 0
797
+ try:
798
+ for index, selection in pending:
799
+ if (
800
+ args.max_new_prompts is not None
801
+ and generated >= args.max_new_prompts
802
+ ):
803
+ break
804
+ free_gib = shutil.disk_usage(args.output_dir).free / (1024**3)
805
+ if free_gib < args.min_free_gib:
806
+ raise RuntimeError(
807
+ f"Only {free_gib:.1f} GiB free, below --min_free_gib "
808
+ f"{args.min_free_gib:.1f}"
809
+ )
810
+
811
+ destination = args.output_dir / f"prompt_{index:04d}"
812
+ if destination.exists():
813
+ if not args.overwrite:
814
+ raise RuntimeError(
815
+ f"Incomplete destination exists: {destination}"
816
+ )
817
+ shutil.rmtree(destination)
818
+
819
+ print(
820
+ f"[dataset] prompt {index + 1}/{args.num_prompts}, "
821
+ f"free={free_gib:.1f} GiB",
822
+ flush=True,
823
+ )
824
+ (
825
+ trajectory,
826
+ clean_prefeatures,
827
+ chunk0_trajectory,
828
+ chunk0_prefeatures,
829
+ cross_attention,
830
+ elapsed_s,
831
+ peak_gpu_gib,
832
+ ) = generate_prompt(
833
+ pipeline,
834
+ recorder,
835
+ selection["prompt"],
836
+ args.num_frames,
837
+ args.generation_seed,
838
+ device,
839
+ )
840
+ destination = save_prompt_shard(
841
+ args.output_dir,
842
+ index,
843
+ selection,
844
+ trajectory,
845
+ clean_prefeatures,
846
+ chunk0_trajectory,
847
+ chunk0_prefeatures,
848
+ cross_attention,
849
+ elapsed_s,
850
+ peak_gpu_gib,
851
+ layers,
852
+ args.generation_seed,
853
+ args.num_frames // int(config.num_frame_per_block),
854
+ )
855
+ shard_gib = directory_size(destination) / (1024**3)
856
+ print(
857
+ f"[dataset] saved {destination.name}: {shard_gib:.3f} GiB, "
858
+ f"{elapsed_s:.1f}s, peak={peak_gpu_gib:.1f} GiB",
859
+ flush=True,
860
+ )
861
+ generated += 1
862
+ update_progress(args.output_dir, args.num_prompts)
863
+ del (
864
+ trajectory,
865
+ clean_prefeatures,
866
+ chunk0_trajectory,
867
+ chunk0_prefeatures,
868
+ cross_attention,
869
+ )
870
+ torch.cuda.empty_cache()
871
+ finally:
872
+ recorder.close()
873
+
874
+ print(f"[dataset] generated {generated} new prompt shards", flush=True)
875
+
876
+
877
+ if __name__ == "__main__":
878
+ main()
scripts/build_vbench8_extended_mapping.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build the auditable VBench-8 extended-prompt subset mapping.
3
+
4
+ The standard VBench metadata is the source of truth for the 946-item order and
5
+ for the prompt-suite membership. Self-Forcing's short prompt file is checked
6
+ against that order before the selected indices are transferred to the
7
+ extended prompt file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import copy
14
+ import hashlib
15
+ import json
16
+ from collections import Counter
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[1]
22
+ SELECTED_SUITES = ("subject_consistency", "overall_consistency", "scene")
23
+ EXPECTED_COUNTS = {
24
+ "subject_consistency": 72,
25
+ "overall_consistency": 93,
26
+ "scene": 86,
27
+ }
28
+
29
+
30
+ def read_prompts(path: Path) -> list[str]:
31
+ lines = path.read_text(encoding="utf-8").splitlines()
32
+ prompts = [line.strip() for line in lines]
33
+ if any(not prompt for prompt in prompts):
34
+ raise ValueError(f"Prompt file contains an empty line: {path}")
35
+ return prompts
36
+
37
+
38
+ def sha256(path: Path) -> str:
39
+ digest = hashlib.sha256()
40
+ with path.open("rb") as handle:
41
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
42
+ digest.update(block)
43
+ return digest.hexdigest()
44
+
45
+
46
+ def build_mapping(
47
+ *,
48
+ short_prompts: list[str],
49
+ extended_prompts: list[str],
50
+ vbench_info: list[dict[str, Any]],
51
+ ) -> list[dict[str, Any]]:
52
+ if len(short_prompts) != 946:
53
+ raise ValueError(f"Expected 946 short prompts, got {len(short_prompts)}")
54
+ if len(extended_prompts) != 946:
55
+ raise ValueError(
56
+ f"Expected 946 extended prompts, got {len(extended_prompts)}"
57
+ )
58
+ if len(vbench_info) != 946:
59
+ raise ValueError(f"Expected 946 VBench metadata rows, got {len(vbench_info)}")
60
+
61
+ canonical = [row.get("prompt_en") for row in vbench_info]
62
+ if any(not isinstance(prompt, str) or not prompt.strip() for prompt in canonical):
63
+ raise ValueError("VBench metadata contains a missing prompt_en")
64
+ mismatches = [
65
+ index
66
+ for index, (short, official) in enumerate(zip(short_prompts, canonical))
67
+ if short != official
68
+ ]
69
+ if mismatches:
70
+ preview = mismatches[:10]
71
+ raise ValueError(
72
+ "Self-Forcing all_dimension.txt does not preserve VBench ordering; "
73
+ f"mismatching indices include {preview}"
74
+ )
75
+
76
+ counters: Counter[str] = Counter()
77
+ mapping: list[dict[str, Any]] = []
78
+ for global_index, row in enumerate(vbench_info):
79
+ suites = [suite for suite in SELECTED_SUITES if suite in row["dimension"]]
80
+ if not suites:
81
+ continue
82
+ if len(suites) != 1:
83
+ raise ValueError(
84
+ f"Metadata row {global_index} belongs to multiple selected suites: {suites}"
85
+ )
86
+ suite = suites[0]
87
+ suite_index = counters[suite]
88
+ counters[suite] += 1
89
+ item: dict[str, Any] = {
90
+ "global_index": global_index,
91
+ "prompt_suite": suite,
92
+ "suite_index": suite_index,
93
+ "original_prompt": short_prompts[global_index],
94
+ "extended_prompt": extended_prompts[global_index],
95
+ "official_dimensions": list(row["dimension"]),
96
+ }
97
+ if "auxiliary_info" in row:
98
+ item["auxiliary_info"] = copy.deepcopy(row["auxiliary_info"])
99
+ mapping.append(item)
100
+
101
+ if counters != Counter(EXPECTED_COUNTS):
102
+ raise ValueError(
103
+ f"Unexpected selected-suite counts: {dict(counters)}; "
104
+ f"expected {EXPECTED_COUNTS}"
105
+ )
106
+ if len(mapping) != 251:
107
+ raise ValueError(f"Expected 251 selected prompts, got {len(mapping)}")
108
+ if len({item["global_index"] for item in mapping}) != len(mapping):
109
+ raise ValueError("Duplicate global indices in mapping")
110
+ for suite, expected in EXPECTED_COUNTS.items():
111
+ indices = [item["suite_index"] for item in mapping if item["prompt_suite"] == suite]
112
+ if sorted(indices) != list(range(expected)):
113
+ raise ValueError(f"Suite indices for {suite} are not contiguous and unique")
114
+ return mapping
115
+
116
+
117
+ def parse_args() -> argparse.Namespace:
118
+ parser = argparse.ArgumentParser(description=__doc__)
119
+ parser.add_argument(
120
+ "--short-prompts",
121
+ type=Path,
122
+ default=REPO_ROOT / "prompts/vbench/all_dimension.txt",
123
+ )
124
+ parser.add_argument(
125
+ "--extended-prompts",
126
+ type=Path,
127
+ default=REPO_ROOT / "prompts/vbench/all_dimension_extended.txt",
128
+ )
129
+ parser.add_argument(
130
+ "--vbench-info",
131
+ type=Path,
132
+ default=Path(
133
+ "/data3/chenzhuo/workspace/HY-WorldPlay-light-interaction-run-DEV/"
134
+ ".venv-vbench/lib/python3.10/site-packages/vbench/VBench_full_info.json"
135
+ ),
136
+ )
137
+ parser.add_argument(
138
+ "--output",
139
+ type=Path,
140
+ default=REPO_ROOT / "assets/vbench8_extended_subset_mapping.json",
141
+ )
142
+ return parser.parse_args()
143
+
144
+
145
+ def main() -> None:
146
+ args = parse_args()
147
+ short_path = args.short_prompts.expanduser().resolve()
148
+ extended_path = args.extended_prompts.expanduser().resolve()
149
+ info_path = args.vbench_info.expanduser().resolve()
150
+ output_path = args.output.expanduser().resolve()
151
+ mapping = build_mapping(
152
+ short_prompts=read_prompts(short_path),
153
+ extended_prompts=read_prompts(extended_path),
154
+ vbench_info=json.loads(info_path.read_text(encoding="utf-8")),
155
+ )
156
+ output_path.parent.mkdir(parents=True, exist_ok=True)
157
+ output_path.write_text(
158
+ json.dumps(mapping, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
159
+ )
160
+ print(f"mapping={output_path}")
161
+ print(f"counts={dict(Counter(item['prompt_suite'] for item in mapping))}")
162
+ print(f"total={len(mapping)}")
163
+ print(f"short_sha256={sha256(short_path)}")
164
+ print(f"extended_sha256={sha256(extended_path)}")
165
+ print(f"vbench_info_sha256={sha256(info_path)}")
166
+ print(f"mapping_sha256={sha256(output_path)}")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
scripts/calibrate_atc_confidence_token_beta2_vbench3.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Calibrate fixed-beta=2 thresholds for the ATC Confidence-token head."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def preparse_gpu() -> str:
17
+ parser = argparse.ArgumentParser(add_help=False)
18
+ parser.add_argument("--gpu", default="0")
19
+ args, _ = parser.parse_known_args()
20
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
21
+ os.environ.setdefault(
22
+ "MPLCONFIGDIR", tempfile.mkdtemp(prefix="atc_confidence_calibration_")
23
+ )
24
+ return str(args.gpu)
25
+
26
+
27
+ PHYSICAL_GPU = preparse_gpu()
28
+
29
+ import torch
30
+ from omegaconf import OmegaConf
31
+ from safetensors import safe_open
32
+ from safetensors.torch import load_file
33
+
34
+ ROOT = Path(__file__).resolve().parents[1]
35
+ if str(ROOT) not in sys.path:
36
+ sys.path.insert(0, str(ROOT))
37
+
38
+ from predictor_training.confidence import ConfidenceTokenHead
39
+ from scripts import evaluate_single_block_fppf as base
40
+ from scripts import generate_vbench8_extended_strategies as extended
41
+ from utils.wan_wrapper import WanTextEncoder
42
+
43
+
44
+ BETA = 2.0
45
+ CANDIDATE_STEPS = [1, 2, 3]
46
+ TARGETS = [6, 9, 12, 15]
47
+ GLOBAL_INDICES = [293, 729, 819]
48
+ MAPPING_DEFAULT = ROOT / "assets/vbench8_extended_subset_mapping.json"
49
+ PREDICTOR_DEFAULT = (
50
+ ROOT
51
+ / "training_runs/layer17_atc_chunk_stage1_1000p_4gpu_b16_2000steps"
52
+ / "checkpoint_step_2000/predictor.safetensors"
53
+ )
54
+ HEAD_DEFAULT = (
55
+ ROOT
56
+ / "training_runs"
57
+ / "layer17_atc_chunk_confidence_token_900train_100val_4gpu_b64_warmup_cosine"
58
+ / "confidence_latest.safetensors"
59
+ )
60
+ OUTPUT_DEFAULT = (
61
+ ROOT
62
+ / "confidence_experiments"
63
+ / "layer17_atc_chunk_confidence_token_beta2_vbench3_20260902"
64
+ )
65
+
66
+
67
+ def atomic_json(path: Path, value: Any) -> None:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ temporary = path.with_suffix(path.suffix + ".tmp")
70
+ temporary.write_text(
71
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
72
+ encoding="utf-8",
73
+ )
74
+ os.replace(temporary, path)
75
+
76
+
77
+ def quantile(values: list[float], fraction: float) -> float:
78
+ ordered = sorted(values)
79
+ position = fraction * (len(ordered) - 1)
80
+ lower = math.floor(position)
81
+ upper = math.ceil(position)
82
+ if lower == upper:
83
+ return ordered[lower]
84
+ weight = position - lower
85
+ return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
86
+
87
+
88
+ def mapping_row(path: Path, global_index: int) -> dict[str, Any]:
89
+ matches = [
90
+ row
91
+ for row in extended.read_mapping(path)
92
+ if int(row["global_index"]) == global_index
93
+ ]
94
+ if len(matches) != 1:
95
+ raise ValueError(f"Expected one mapping row for global={global_index}")
96
+ return matches[0]
97
+
98
+
99
+ def read_head_config(path: Path) -> dict[str, Any]:
100
+ with safe_open(path, framework="pt", device="cpu") as handle:
101
+ metadata = handle.metadata() or {}
102
+ raw = metadata.get("head_config")
103
+ if raw is None:
104
+ raise ValueError(f"Missing head_config metadata: {path}")
105
+ config = json.loads(raw)
106
+ expected = {
107
+ "architecture": "ConfidenceTokenHead",
108
+ "dim": 1536,
109
+ "token_dim": 512,
110
+ "context_dim": 64,
111
+ "num_heads": 8,
112
+ "ffn_dim": 2048,
113
+ "num_steps": 3,
114
+ }
115
+ for key, value in expected.items():
116
+ if config.get(key) != value:
117
+ raise ValueError(f"Unexpected head config {key}={config.get(key)}")
118
+ return config
119
+
120
+
121
+ def read_predictor_config(
122
+ path: Path,
123
+ input_variant: str,
124
+ atc_previous_scope: str,
125
+ ) -> dict[str, Any]:
126
+ with safe_open(path, framework="pt", device="cpu") as handle:
127
+ metadata = handle.metadata() or {}
128
+ raw = metadata.get("predictor_config")
129
+ if raw is None:
130
+ if input_variant != "self_forcing":
131
+ raise ValueError(
132
+ f"Missing predictor_config metadata: {path}; only an explicitly "
133
+ "selected legacy self_forcing/concat checkpoint may omit it"
134
+ )
135
+ return {
136
+ "source_layer": 17,
137
+ "input_variant": "self_forcing",
138
+ "gate_mode": "baseline",
139
+ }
140
+ config = json.loads(raw)
141
+ actual_variant = str(config.get("input_variant", "self_forcing"))
142
+ if actual_variant != input_variant:
143
+ raise ValueError(
144
+ f"Predictor variant mismatch: expected={input_variant} "
145
+ f"actual={actual_variant}"
146
+ )
147
+ if input_variant == "atc":
148
+ actual_scope = str(config.get("atc_previous_scope", "chunk"))
149
+ if actual_scope != atc_previous_scope:
150
+ raise ValueError(
151
+ f"ATC scope mismatch: expected={atc_previous_scope} "
152
+ f"actual={actual_scope}"
153
+ )
154
+ return config
155
+
156
+
157
+ def load_models(
158
+ predictor_weights: Path,
159
+ head_weights: Path,
160
+ input_variant: str,
161
+ atc_previous_scope: str,
162
+ device: torch.device,
163
+ ) -> tuple[Any, Any, Any, ConfidenceTokenHead]:
164
+ predictor_config = read_predictor_config(
165
+ predictor_weights, input_variant, atc_previous_scope
166
+ )
167
+ config = OmegaConf.merge(
168
+ OmegaConf.load(ROOT / "configs/default_config.yaml"),
169
+ OmegaConf.load(ROOT / "configs/self_forcing_sid.yaml"),
170
+ )
171
+ pipeline = base.build_pipeline(
172
+ config,
173
+ ROOT / "checkpoints/self_forcing_dmd.pt",
174
+ torch.nn.Identity(),
175
+ device,
176
+ )
177
+ predictor = base.load_predictor(
178
+ pipeline.generator.model,
179
+ {
180
+ **predictor_config,
181
+ "weights": predictor_weights,
182
+ "atc_collect_diagnostics": False,
183
+ },
184
+ device,
185
+ )
186
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
187
+ text_encoder.requires_grad_(False)
188
+ head_config = read_head_config(head_weights)
189
+ head = ConfidenceTokenHead(
190
+ dim=int(head_config["dim"]),
191
+ token_dim=int(head_config["token_dim"]),
192
+ context_dim=int(head_config["context_dim"]),
193
+ num_heads=int(head_config["num_heads"]),
194
+ ffn_dim=int(head_config["ffn_dim"]),
195
+ dropout=float(head_config.get("dropout", 0.1)),
196
+ num_steps=int(head_config["num_steps"]),
197
+ ).to(device=device).eval()
198
+ head.load_state_dict(load_file(str(head_weights), device="cpu"), strict=True)
199
+ head.requires_grad_(False)
200
+ return pipeline, predictor, text_encoder, head
201
+
202
+
203
+ def rollout_config(name: str, threshold: float) -> dict[str, Any]:
204
+ return {
205
+ "name": name,
206
+ "policy": "dynamic",
207
+ "candidate_steps": CANDIDATE_STEPS,
208
+ "beta": BETA,
209
+ "threshold": threshold,
210
+ "target_accepts": None,
211
+ "head": "confidence_token",
212
+ "risk_mode": "outgoing_span",
213
+ "allow_chunk0_predictor": False,
214
+ }
215
+
216
+
217
+ def latent_nrmse(predicted: torch.Tensor, reference: torch.Tensor) -> float:
218
+ numerator = (predicted.float() - reference.float()).square().sum()
219
+ denominator = reference.float().square().sum().clamp_min(1e-8)
220
+ return float(torch.sqrt(numerator / denominator))
221
+
222
+
223
+ def run_probe(args: argparse.Namespace) -> None:
224
+ if args.global_index not in GLOBAL_INDICES:
225
+ raise ValueError(f"Probe index must be one of {GLOBAL_INDICES}")
226
+ destination = args.output_root / "probes" / f"global_{args.global_index:04d}.json"
227
+ if destination.is_file() and not args.overwrite:
228
+ print(f"[cached] {destination}", flush=True)
229
+ return
230
+ row = mapping_row(args.mapping, args.global_index)
231
+ device = torch.device("cuda")
232
+ torch.set_grad_enabled(False)
233
+ pipeline, predictor, text_encoder, head = load_models(
234
+ args.predictor_weights,
235
+ args.head_weights,
236
+ args.predictor_input_variant,
237
+ args.atc_previous_scope,
238
+ device,
239
+ )
240
+ conditional = text_encoder(text_prompts=[str(row["extended_prompt"])])
241
+ _, diagnostic = extended.generate_rollout(
242
+ pipeline=pipeline,
243
+ conditional_dict=conditional,
244
+ seed=args.seed,
245
+ device=device,
246
+ predictor=predictor,
247
+ head=head,
248
+ config=rollout_config("confidence_token_probe", -math.inf),
249
+ )
250
+ decisions = diagnostic.pop("decisions")
251
+ if len(decisions) != 18 or any(item["accepted"] for item in decisions):
252
+ raise RuntimeError("Full-fallback probe did not produce 18 rejected decisions")
253
+ atomic_json(
254
+ destination,
255
+ {
256
+ "status": "complete",
257
+ "physical_gpu": PHYSICAL_GPU,
258
+ "global_index": args.global_index,
259
+ "prompt_suite": row["prompt_suite"],
260
+ "suite_index": int(row["suite_index"]),
261
+ "prompt": row["extended_prompt"],
262
+ "seed": args.seed,
263
+ "generation": diagnostic,
264
+ "decisions": decisions,
265
+ },
266
+ )
267
+ print(f"[probe] global={args.global_index} decisions=18", flush=True)
268
+
269
+
270
+ def prepare_scan(args: argparse.Namespace) -> None:
271
+ paths = sorted((args.output_root / "probes").glob("global_*.json"))
272
+ if len(paths) != 3:
273
+ raise ValueError(f"Expected three probes, found {len(paths)}")
274
+ probes = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
275
+ if sorted(int(item["global_index"]) for item in probes) != GLOBAL_INDICES:
276
+ raise ValueError("Probe indices differ from the requested calibration set")
277
+ decisions = [decision for probe in probes for decision in probe["decisions"]]
278
+ risks = []
279
+ spans: dict[int, set[float]] = {1: set(), 2: set(), 3: set()}
280
+ for decision in decisions:
281
+ step = int(decision["step"])
282
+ spans[step].add(float(decision["outgoing_span"]))
283
+ risks.append(
284
+ float(decision["predicted_local_error"])
285
+ * float(decision["span_weight"])
286
+ * (1.0 + BETA * float(decision["chunk_alpha"]))
287
+ )
288
+ expected_spans = {1: 104.1666, 2: 208.3333, 3: 625.0}
289
+ for step, expected in expected_spans.items():
290
+ if any(abs(value - expected) > 0.02 for value in spans[step]):
291
+ raise ValueError(f"Unexpected step-{step} spans: {spans[step]}")
292
+ configurations = []
293
+ for rank in range(1, 18):
294
+ threshold = quantile(risks, rank / 18)
295
+ config = rollout_config(f"confidence_token_beta2_thr{rank:02d}", threshold)
296
+ config["threshold_rank"] = rank
297
+ configurations.append(config)
298
+ atomic_json(
299
+ args.output_root / "scan_configs.json",
300
+ {
301
+ "status": "ready",
302
+ "beta": BETA,
303
+ "risk_definition": (
304
+ "exp(predicted_log_error) * (outgoing_span / 1000) * "
305
+ "(1 + 2.0 * chunk_alpha)"
306
+ ),
307
+ "candidate_steps": CANDIDATE_STEPS,
308
+ "targets": TARGETS,
309
+ "probe_global_indices": GLOBAL_INDICES,
310
+ "head_weights": str(args.head_weights),
311
+ "predictor_weights": str(args.predictor_weights),
312
+ "predictor_input_variant": args.predictor_input_variant,
313
+ "atc_previous_scope": (
314
+ args.atc_previous_scope
315
+ if args.predictor_input_variant == "atc"
316
+ else None
317
+ ),
318
+ "outgoing_spans": {
319
+ str(step): sorted(values) for step, values in spans.items()
320
+ },
321
+ "configs": configurations,
322
+ },
323
+ )
324
+ print(f"[prepare] thresholds={len(configurations)}", flush=True)
325
+
326
+
327
+ def run_scan(args: argparse.Namespace) -> None:
328
+ if args.global_index not in GLOBAL_INDICES:
329
+ raise ValueError(f"Scan index must be one of {GLOBAL_INDICES}")
330
+ destination = args.output_root / "scans" / f"global_{args.global_index:04d}.json"
331
+ existing: dict[str, Any] = {}
332
+ if destination.is_file() and not args.overwrite:
333
+ existing = json.loads(destination.read_text(encoding="utf-8"))
334
+ if existing.get("status") == "complete":
335
+ print(f"[cached] {destination}", flush=True)
336
+ return
337
+ completed = {item["name"]: item for item in existing.get("results", [])}
338
+ manifest = json.loads((args.output_root / "scan_configs.json").read_text())
339
+ configurations = manifest["configs"]
340
+ row = mapping_row(args.mapping, args.global_index)
341
+ device = torch.device("cuda")
342
+ torch.set_grad_enabled(False)
343
+ pipeline, predictor, text_encoder, head = load_models(
344
+ args.predictor_weights,
345
+ args.head_weights,
346
+ args.predictor_input_variant,
347
+ args.atc_previous_scope,
348
+ device,
349
+ )
350
+ conditional = text_encoder(text_prompts=[str(row["extended_prompt"])])
351
+ reference, reference_diagnostic = extended.generate_rollout(
352
+ pipeline=pipeline,
353
+ conditional_dict=conditional,
354
+ seed=args.seed,
355
+ device=device,
356
+ predictor=predictor,
357
+ head=None,
358
+ config={
359
+ "name": "ffff",
360
+ "policy": "ffff",
361
+ "candidate_steps": [],
362
+ "beta": None,
363
+ "threshold": None,
364
+ "target_accepts": 0,
365
+ "head": None,
366
+ "risk_mode": "outgoing_span",
367
+ "allow_chunk0_predictor": False,
368
+ },
369
+ )
370
+ for position, config in enumerate(configurations, start=1):
371
+ name = str(config["name"])
372
+ if name in completed:
373
+ continue
374
+ latent, diagnostic = extended.generate_rollout(
375
+ pipeline=pipeline,
376
+ conditional_dict=conditional,
377
+ seed=args.seed,
378
+ device=device,
379
+ predictor=predictor,
380
+ head=head,
381
+ config=config,
382
+ )
383
+ decisions = diagnostic.pop("decisions")
384
+ record = {
385
+ **config,
386
+ "accepted_predictor_calls": diagnostic["accepted_predictor_calls"],
387
+ "full_calls": diagnostic["full_calls"],
388
+ "policy_latency_ms": diagnostic["policy_latency_ms"],
389
+ "model_path_time_ms": diagnostic["model_path_time_ms"],
390
+ "confidence_head_time_ms": diagnostic["confidence_head_time_ms"],
391
+ "latent_nrmse_vs_ffff": latent_nrmse(latent, reference),
392
+ "decisions": decisions,
393
+ }
394
+ completed[name] = record
395
+ atomic_json(
396
+ destination,
397
+ {
398
+ "status": "running",
399
+ "physical_gpu": PHYSICAL_GPU,
400
+ "global_index": args.global_index,
401
+ "prompt_suite": row["prompt_suite"],
402
+ "suite_index": int(row["suite_index"]),
403
+ "prompt": row["extended_prompt"],
404
+ "seed": args.seed,
405
+ "ffff": {
406
+ key: value
407
+ for key, value in reference_diagnostic.items()
408
+ if key != "decisions"
409
+ },
410
+ "results": [completed[key] for key in sorted(completed)],
411
+ },
412
+ )
413
+ print(
414
+ f"[scan] {position}/17 global={args.global_index} "
415
+ f"threshold={config['threshold']:.8f} "
416
+ f"K={record['accepted_predictor_calls']} "
417
+ f"nrmse={record['latent_nrmse_vs_ffff']:.6f}",
418
+ flush=True,
419
+ )
420
+ del latent
421
+ atomic_json(
422
+ destination,
423
+ {
424
+ "status": "complete",
425
+ "physical_gpu": PHYSICAL_GPU,
426
+ "global_index": args.global_index,
427
+ "prompt_suite": row["prompt_suite"],
428
+ "suite_index": int(row["suite_index"]),
429
+ "prompt": row["extended_prompt"],
430
+ "seed": args.seed,
431
+ "ffff": {
432
+ key: value
433
+ for key, value in reference_diagnostic.items()
434
+ if key != "decisions"
435
+ },
436
+ "results": [completed[key] for key in sorted(completed)],
437
+ },
438
+ )
439
+
440
+
441
+ def summarize(args: argparse.Namespace) -> None:
442
+ paths = sorted((args.output_root / "scans").glob("global_*.json"))
443
+ if len(paths) != 3:
444
+ raise ValueError(f"Expected three scans, found {len(paths)}")
445
+ scans = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
446
+ if any(item.get("status") != "complete" for item in scans):
447
+ raise RuntimeError("At least one scan is incomplete")
448
+ ffff_latency = sum(
449
+ float(scan["ffff"]["policy_latency_ms"]) for scan in scans
450
+ ) / len(scans)
451
+ by_scan = [
452
+ {record["name"]: record for record in scan["results"]}
453
+ for scan in scans
454
+ ]
455
+ names = sorted(by_scan[0])
456
+ summary = []
457
+ for name in names:
458
+ rows = [records[name] for records in by_scan]
459
+ mean_latency = sum(float(row["policy_latency_ms"]) for row in rows) / 3
460
+ summary.append(
461
+ {
462
+ "name": name,
463
+ "beta": BETA,
464
+ "threshold": float(rows[0]["threshold"]),
465
+ "threshold_rank": int(rows[0]["threshold_rank"]),
466
+ "mean_accepted_predictor_calls": sum(
467
+ float(row["accepted_predictor_calls"]) for row in rows
468
+ )
469
+ / 3,
470
+ "mean_full_calls": sum(float(row["full_calls"]) for row in rows) / 3,
471
+ "mean_policy_latency_ms": mean_latency,
472
+ "speedup_vs_ffff": ffff_latency / mean_latency,
473
+ "mean_confidence_head_time_ms": sum(
474
+ float(row["confidence_head_time_ms"]) for row in rows
475
+ )
476
+ / 3,
477
+ "mean_latent_nrmse_vs_ffff": sum(
478
+ float(row["latent_nrmse_vs_ffff"]) for row in rows
479
+ )
480
+ / 3,
481
+ "per_prompt_accepts": [
482
+ int(row["accepted_predictor_calls"]) for row in rows
483
+ ],
484
+ "per_prompt_latent_nrmse": [
485
+ float(row["latent_nrmse_vs_ffff"]) for row in rows
486
+ ],
487
+ }
488
+ )
489
+ selected = []
490
+ for target in TARGETS:
491
+ within = [
492
+ row
493
+ for row in summary
494
+ if abs(float(row["mean_accepted_predictor_calls"]) - target) <= 0.5
495
+ ]
496
+ pool = within or summary
497
+ choice = min(
498
+ pool,
499
+ key=lambda row: (
500
+ float(row["mean_latent_nrmse_vs_ffff"]),
501
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
502
+ float(row["threshold"]),
503
+ )
504
+ if within
505
+ else (
506
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
507
+ float(row["mean_latent_nrmse_vs_ffff"]),
508
+ float(row["threshold"]),
509
+ ),
510
+ )
511
+ selected.append({**choice, "target_accepts": target})
512
+ atomic_json(
513
+ args.output_root / "threshold_summary.json",
514
+ {
515
+ "status": "complete",
516
+ "beta": BETA,
517
+ "candidate_steps": CANDIDATE_STEPS,
518
+ "global_indices": GLOBAL_INDICES,
519
+ "ffff_mean_policy_latency_ms": ffff_latency,
520
+ "selection_rule": (
521
+ "For each target K, require mean K within +/-0.5 when possible; "
522
+ "then choose the lowest mean latent nRMSE"
523
+ ),
524
+ "head_weights": str(args.head_weights),
525
+ "predictor_weights": str(args.predictor_weights),
526
+ "predictor_input_variant": args.predictor_input_variant,
527
+ "atc_previous_scope": (
528
+ args.atc_previous_scope
529
+ if args.predictor_input_variant == "atc"
530
+ else None
531
+ ),
532
+ "summary": summary,
533
+ "selected": selected,
534
+ },
535
+ )
536
+ print(f"[summary] FFFF latency={ffff_latency:.2f}ms", flush=True)
537
+ for row in selected:
538
+ print(
539
+ f"[selected] K{row['target_accepts']:02d} "
540
+ f"threshold={row['threshold']:.8f} "
541
+ f"actual_K={row['mean_accepted_predictor_calls']:.3f} "
542
+ f"speedup={row['speedup_vs_ffff']:.3f}x "
543
+ f"nrmse={row['mean_latent_nrmse_vs_ffff']:.6f}",
544
+ flush=True,
545
+ )
546
+
547
+
548
+ def parse_args() -> argparse.Namespace:
549
+ parser = argparse.ArgumentParser(description=__doc__)
550
+ parser.add_argument(
551
+ "--mode", choices=("probe", "prepare", "scan", "summarize"), required=True
552
+ )
553
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
554
+ parser.add_argument("--global-index", type=int, default=None)
555
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
556
+ parser.add_argument("--predictor-weights", type=Path, default=PREDICTOR_DEFAULT)
557
+ parser.add_argument("--head-weights", type=Path, default=HEAD_DEFAULT)
558
+ parser.add_argument(
559
+ "--predictor-input-variant",
560
+ choices=("self_forcing", "atc"),
561
+ default="atc",
562
+ )
563
+ parser.add_argument(
564
+ "--atc-previous-scope",
565
+ choices=("chunk", "last_frame"),
566
+ default="chunk",
567
+ )
568
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
569
+ parser.add_argument("--seed", type=int, default=0)
570
+ parser.add_argument("--overwrite", action="store_true")
571
+ args = parser.parse_args()
572
+ for name in ("mapping", "predictor_weights", "head_weights", "output_root"):
573
+ setattr(args, name, getattr(args, name).expanduser().resolve())
574
+ return args
575
+
576
+
577
+ def main() -> None:
578
+ args = parse_args()
579
+ args.output_root.mkdir(parents=True, exist_ok=True)
580
+ if args.mode == "probe":
581
+ run_probe(args)
582
+ elif args.mode == "prepare":
583
+ prepare_scan(args)
584
+ elif args.mode == "scan":
585
+ run_scan(args)
586
+ else:
587
+ summarize(args)
588
+
589
+
590
+ if __name__ == "__main__":
591
+ main()
scripts/calibrate_vbench251_span_risk.py ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Calibrate outgoing-span confidence risk on three Extended-251 prompts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def preparse_gpu() -> str:
17
+ parser = argparse.ArgumentParser(add_help=False)
18
+ parser.add_argument("--gpu", default="5")
19
+ args, _ = parser.parse_known_args()
20
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
21
+ os.environ.setdefault(
22
+ "MPLCONFIGDIR", tempfile.mkdtemp(prefix="self_forcing_span_calibration_")
23
+ )
24
+ return str(args.gpu)
25
+
26
+
27
+ PHYSICAL_GPU = preparse_gpu()
28
+
29
+ import torch
30
+ from omegaconf import OmegaConf
31
+ from safetensors.torch import load_file
32
+
33
+ REPO_ROOT = Path(__file__).resolve().parents[1]
34
+ if str(REPO_ROOT) not in sys.path:
35
+ sys.path.insert(0, str(REPO_ROOT))
36
+
37
+ from predictor_training.confidence import PredictorConfidenceHead
38
+ from scripts import evaluate_single_block_fppf as base
39
+ from scripts import generate_vbench8_extended_strategies as extended
40
+ from utils.wan_wrapper import WanTextEncoder
41
+
42
+
43
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
44
+ EXPERIMENT_DEFAULT = (
45
+ REPO_ROOT / "confidence_experiments/layer17_stage1_step2000_20260831"
46
+ )
47
+ OUTPUT_DEFAULT = (
48
+ REPO_ROOT
49
+ / "confidence_experiments/layer17_stage1_step2000_spanrisk_vbench3_20260901"
50
+ )
51
+ BETAS = (0.0, 0.5, 1.0, 1.5, 2.0)
52
+ TARGETS = {
53
+ "step12": (6, 8, 10),
54
+ "step123": (6, 9, 12, 15),
55
+ }
56
+ CANDIDATE_STEPS = {
57
+ "step12": (1, 2),
58
+ "step123": (1, 2, 3),
59
+ }
60
+
61
+
62
+ def atomic_json(path: Path, value: Any) -> None:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ temporary = path.with_suffix(path.suffix + ".tmp")
65
+ temporary.write_text(
66
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
67
+ encoding="utf-8",
68
+ )
69
+ os.replace(temporary, path)
70
+
71
+
72
+ def quantile(values: list[float], fraction: float) -> float:
73
+ if not values:
74
+ raise ValueError("Cannot take a quantile of an empty list")
75
+ ordered = sorted(values)
76
+ position = fraction * (len(ordered) - 1)
77
+ lower = int(math.floor(position))
78
+ upper = int(math.ceil(position))
79
+ if lower == upper:
80
+ return ordered[lower]
81
+ weight = position - lower
82
+ return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
83
+
84
+
85
+ def beta_name(beta: float) -> str:
86
+ return str(beta).replace(".", "p")
87
+
88
+
89
+ def read_mapping_row(path: Path, global_index: int) -> dict[str, Any]:
90
+ rows = extended.read_mapping(path)
91
+ matches = [row for row in rows if int(row["global_index"]) == global_index]
92
+ if len(matches) != 1:
93
+ raise ValueError(f"Expected one mapping row for global index {global_index}")
94
+ return matches[0]
95
+
96
+
97
+ def load_models(
98
+ experiment_root: Path, device: torch.device
99
+ ) -> tuple[Any, Any, Any, Any, Any]:
100
+ config = OmegaConf.merge(
101
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
102
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
103
+ )
104
+ pipeline = base.build_pipeline(
105
+ config,
106
+ REPO_ROOT / "checkpoints/self_forcing_dmd.pt",
107
+ torch.nn.Identity(),
108
+ device,
109
+ )
110
+ predictor = base.load_predictor(
111
+ pipeline.generator.model,
112
+ {
113
+ "source_layer": 17,
114
+ "weights": REPO_ROOT
115
+ / "training_runs/layer17_stage1_1000p_4gpu_b16_2000steps"
116
+ / "checkpoint_step_2000/predictor.safetensors",
117
+ "gate_mode": "baseline",
118
+ },
119
+ device,
120
+ )
121
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
122
+ text_encoder.requires_grad_(False)
123
+ step12_head = PredictorConfidenceHead(num_steps=2).to(device=device).eval()
124
+ step12_head.load_state_dict(
125
+ load_file(
126
+ str(experiment_root / "confidence_step12/confidence_best.safetensors"),
127
+ device="cpu",
128
+ ),
129
+ strict=True,
130
+ )
131
+ step12_head.requires_grad_(False)
132
+ step123_head = PredictorConfidenceHead(num_steps=3).to(device=device).eval()
133
+ step123_head.load_state_dict(
134
+ load_file(
135
+ str(experiment_root / "confidence_step123/confidence_best.safetensors"),
136
+ device="cpu",
137
+ ),
138
+ strict=True,
139
+ )
140
+ step123_head.requires_grad_(False)
141
+ return pipeline, predictor, text_encoder, step12_head, step123_head
142
+
143
+
144
+ def probe(args: argparse.Namespace) -> None:
145
+ if args.global_index is None:
146
+ raise ValueError("--global-index is required in probe mode")
147
+ row = read_mapping_row(args.mapping, args.global_index)
148
+ destination = args.output_root / "probes" / f"global_{args.global_index:04d}.json"
149
+ if destination.is_file() and not args.overwrite:
150
+ print(f"[cached] {destination}", flush=True)
151
+ return
152
+ device = torch.device("cuda")
153
+ torch.set_grad_enabled(False)
154
+ pipeline, predictor, text_encoder, step12_head, step123_head = load_models(
155
+ args.experiment_root, device
156
+ )
157
+ prompt = str(row["extended_prompt"])
158
+ conditional = text_encoder(text_prompts=[prompt])
159
+ probes: dict[str, Any] = {}
160
+ for family, head in (("step12", step12_head), ("step123", step123_head)):
161
+ config = {
162
+ "name": f"{family}_span_probe",
163
+ "policy": "dynamic",
164
+ "candidate_steps": list(CANDIDATE_STEPS[family]),
165
+ "beta": 0.0,
166
+ "threshold": -math.inf,
167
+ "target_accepts": 0,
168
+ "head": family,
169
+ "risk_mode": "outgoing_span",
170
+ }
171
+ _, diagnostic = extended.generate_rollout(
172
+ pipeline=pipeline,
173
+ conditional_dict=conditional,
174
+ seed=args.seed,
175
+ device=device,
176
+ predictor=predictor,
177
+ head=head,
178
+ config=config,
179
+ )
180
+ decisions = diagnostic.pop("decisions")
181
+ if any(decision["accepted"] for decision in decisions):
182
+ raise RuntimeError("Probe unexpectedly accepted a Predictor result")
183
+ probes[family] = {"generation": diagnostic, "decisions": decisions}
184
+ print(
185
+ f"[probe] global={args.global_index} family={family} "
186
+ f"decisions={len(decisions)}",
187
+ flush=True,
188
+ )
189
+ atomic_json(
190
+ destination,
191
+ {
192
+ "status": "complete",
193
+ "physical_gpu": PHYSICAL_GPU,
194
+ "global_index": int(row["global_index"]),
195
+ "prompt_suite": row["prompt_suite"],
196
+ "suite_index": int(row["suite_index"]),
197
+ "prompt": prompt,
198
+ "seed": args.seed,
199
+ "probes": probes,
200
+ },
201
+ )
202
+
203
+
204
+ def prepare(args: argparse.Namespace) -> None:
205
+ paths = sorted((args.output_root / "probes").glob("global_*.json"))
206
+ if len(paths) != 3:
207
+ raise ValueError(f"Expected exactly three probe files, found {len(paths)}")
208
+ probes = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
209
+ configurations: list[dict[str, Any]] = []
210
+ span_values: dict[int, set[float]] = {1: set(), 2: set(), 3: set()}
211
+ for family in ("step12", "step123"):
212
+ decisions = [
213
+ decision
214
+ for probe_record in probes
215
+ for decision in probe_record["probes"][family]["decisions"]
216
+ ]
217
+ expected = 3 * 6 * len(CANDIDATE_STEPS[family])
218
+ if len(decisions) != expected:
219
+ raise ValueError(
220
+ f"Expected {expected} {family} decisions, got {len(decisions)}"
221
+ )
222
+ for decision in decisions:
223
+ span_values[int(decision["step"])].add(float(decision["outgoing_span"]))
224
+ max_calls = 6 * len(CANDIDATE_STEPS[family])
225
+ for beta in BETAS:
226
+ risks = [
227
+ float(decision["predicted_local_error"])
228
+ * float(decision["span_weight"])
229
+ * (1.0 + beta * float(decision["chunk_alpha"]))
230
+ for decision in decisions
231
+ ]
232
+ for target in TARGETS[family]:
233
+ threshold = quantile(risks, target / max_calls)
234
+ configurations.append(
235
+ {
236
+ "name": f"{family}_span_b{beta_name(beta)}_k{target:02d}",
237
+ "policy": "dynamic",
238
+ "candidate_steps": list(CANDIDATE_STEPS[family]),
239
+ "beta": beta,
240
+ "threshold": threshold,
241
+ "target_accepts": target,
242
+ "head": family,
243
+ "risk_mode": "outgoing_span",
244
+ }
245
+ )
246
+ actual_spans = {
247
+ str(step): sorted(values) for step, values in span_values.items() if values
248
+ }
249
+ expected_spans = {1: 104.1666, 2: 208.3333, 3: 625.0}
250
+ for step, expected in expected_spans.items():
251
+ values = span_values[step]
252
+ if values and any(abs(value - expected) > 0.02 for value in values):
253
+ raise ValueError(f"Unexpected outgoing span at step {step}: {values}")
254
+ atomic_json(
255
+ args.output_root / "scan_configs.json",
256
+ {
257
+ "status": "ready",
258
+ "risk_definition": (
259
+ "predicted_local_error * (outgoing_span / 1000) * "
260
+ "(1 + beta * chunk_alpha)"
261
+ ),
262
+ "probe_global_indices": [int(value["global_index"]) for value in probes],
263
+ "outgoing_spans": actual_spans,
264
+ "betas": list(BETAS),
265
+ "targets": {key: list(value) for key, value in TARGETS.items()},
266
+ "num_configs": len(configurations),
267
+ "configs": configurations,
268
+ },
269
+ )
270
+ print(f"[prepare] configurations={len(configurations)}", flush=True)
271
+
272
+
273
+ def prepare_threshold_only(args: argparse.Namespace) -> None:
274
+ probe_root = args.probe_root or args.output_root
275
+ paths = sorted((probe_root / "probes").glob("global_*.json"))
276
+ if len(paths) != 3:
277
+ raise ValueError(f"Expected exactly three probe files, found {len(paths)}")
278
+ probes = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
279
+ configurations: list[dict[str, Any]] = []
280
+ span_values: dict[int, set[float]] = {1: set(), 2: set(), 3: set()}
281
+ beta = 2.0
282
+ for family in ("step12", "step123"):
283
+ decisions = [
284
+ decision
285
+ for probe_record in probes
286
+ for decision in probe_record["probes"][family]["decisions"]
287
+ ]
288
+ expected = 3 * 6 * len(CANDIDATE_STEPS[family])
289
+ if len(decisions) != expected:
290
+ raise ValueError(
291
+ f"Expected {expected} {family} decisions, got {len(decisions)}"
292
+ )
293
+ for decision in decisions:
294
+ span_values[int(decision["step"])].add(float(decision["outgoing_span"]))
295
+ max_calls = 6 * len(CANDIDATE_STEPS[family])
296
+ risks = [
297
+ float(decision["predicted_local_error"])
298
+ * float(decision["span_weight"])
299
+ * (1.0 + beta * float(decision["chunk_alpha"]))
300
+ for decision in decisions
301
+ ]
302
+ for rank in range(1, max_calls):
303
+ configurations.append(
304
+ {
305
+ "name": f"{family}_span_beta2_thr{rank:02d}",
306
+ "policy": "dynamic",
307
+ "candidate_steps": list(CANDIDATE_STEPS[family]),
308
+ "beta": beta,
309
+ "threshold": quantile(risks, rank / max_calls),
310
+ "target_accepts": rank,
311
+ "threshold_rank": rank,
312
+ "head": family,
313
+ "risk_mode": "outgoing_span",
314
+ }
315
+ )
316
+ atomic_json(
317
+ args.output_root / "scan_configs.json",
318
+ {
319
+ "status": "ready",
320
+ "risk_definition": (
321
+ "predicted_local_error * (outgoing_span / 1000) * "
322
+ "(1 + 2.0 * chunk_alpha)"
323
+ ),
324
+ "beta": beta,
325
+ "probe_global_indices": [int(value["global_index"]) for value in probes],
326
+ "outgoing_spans": {
327
+ str(step): sorted(values)
328
+ for step, values in span_values.items()
329
+ if values
330
+ },
331
+ "threshold_search": "quantile rank 1..max_candidate_calls-1",
332
+ "targets": {key: list(value) for key, value in TARGETS.items()},
333
+ "num_configs": len(configurations),
334
+ "configs": configurations,
335
+ },
336
+ )
337
+ print(
338
+ f"[prepare-threshold-only] beta={beta} configurations={len(configurations)}",
339
+ flush=True,
340
+ )
341
+
342
+
343
+ def latent_nrmse(predicted: torch.Tensor, reference: torch.Tensor) -> float:
344
+ numerator = (predicted.float() - reference.float()).square().sum()
345
+ denominator = reference.float().square().sum().clamp_min(1e-8)
346
+ return float(torch.sqrt(numerator / denominator))
347
+
348
+
349
+ def scan(args: argparse.Namespace) -> None:
350
+ if args.global_index is None:
351
+ raise ValueError("--global-index is required in scan mode")
352
+ row = read_mapping_row(args.mapping, args.global_index)
353
+ config_path = args.output_root / "scan_configs.json"
354
+ config_manifest = json.loads(config_path.read_text(encoding="utf-8"))
355
+ configurations = list(config_manifest["configs"])
356
+ destination = args.output_root / "scans" / f"global_{args.global_index:04d}.json"
357
+ existing: dict[str, Any] = {}
358
+ if destination.is_file() and not args.overwrite:
359
+ existing = json.loads(destination.read_text(encoding="utf-8"))
360
+ if existing.get("status") == "complete":
361
+ print(f"[cached] {destination}", flush=True)
362
+ return
363
+ completed = {record["name"]: record for record in existing.get("results", [])}
364
+ device = torch.device("cuda")
365
+ torch.set_grad_enabled(False)
366
+ pipeline, predictor, text_encoder, step12_head, step123_head = load_models(
367
+ args.experiment_root, device
368
+ )
369
+ prompt = str(row["extended_prompt"])
370
+ conditional = text_encoder(text_prompts=[prompt])
371
+ reference, reference_diagnostic = extended.generate_rollout(
372
+ pipeline=pipeline,
373
+ conditional_dict=conditional,
374
+ seed=args.seed,
375
+ device=device,
376
+ predictor=predictor,
377
+ head=None,
378
+ config={
379
+ "name": "ffff",
380
+ "policy": "ffff",
381
+ "candidate_steps": [],
382
+ "beta": None,
383
+ "threshold": None,
384
+ "target_accepts": 0,
385
+ "head": None,
386
+ "risk_mode": "outgoing_span",
387
+ },
388
+ )
389
+ for position, config in enumerate(configurations, start=1):
390
+ name = str(config["name"])
391
+ if name in completed:
392
+ continue
393
+ head = step12_head if config["head"] == "step12" else step123_head
394
+ latent, diagnostic = extended.generate_rollout(
395
+ pipeline=pipeline,
396
+ conditional_dict=conditional,
397
+ seed=args.seed,
398
+ device=device,
399
+ predictor=predictor,
400
+ head=head,
401
+ config=config,
402
+ )
403
+ decisions = diagnostic.pop("decisions")
404
+ record = {
405
+ **config,
406
+ "accepted_predictor_calls": diagnostic["accepted_predictor_calls"],
407
+ "full_calls": diagnostic["full_calls"],
408
+ "policy_latency_ms": diagnostic["policy_latency_ms"],
409
+ "context_dit_time_ms": diagnostic["context_dit_time_ms"],
410
+ "latent_nrmse_vs_ffff": latent_nrmse(latent, reference),
411
+ "decisions": decisions,
412
+ }
413
+ completed[name] = record
414
+ atomic_json(
415
+ destination,
416
+ {
417
+ "status": "running",
418
+ "physical_gpu": PHYSICAL_GPU,
419
+ "global_index": int(row["global_index"]),
420
+ "prompt_suite": row["prompt_suite"],
421
+ "suite_index": int(row["suite_index"]),
422
+ "prompt": prompt,
423
+ "seed": args.seed,
424
+ "ffff": {
425
+ key: value
426
+ for key, value in reference_diagnostic.items()
427
+ if key != "decisions"
428
+ },
429
+ "results": [completed[key] for key in sorted(completed)],
430
+ },
431
+ )
432
+ print(
433
+ f"[scan] global={args.global_index} {position}/{len(configurations)} "
434
+ f"name={name} K={record['accepted_predictor_calls']} "
435
+ f"latent_nrmse={record['latent_nrmse_vs_ffff']:.6f}",
436
+ flush=True,
437
+ )
438
+ del latent
439
+ atomic_json(
440
+ destination,
441
+ {
442
+ "status": "complete",
443
+ "physical_gpu": PHYSICAL_GPU,
444
+ "global_index": int(row["global_index"]),
445
+ "prompt_suite": row["prompt_suite"],
446
+ "suite_index": int(row["suite_index"]),
447
+ "prompt": prompt,
448
+ "seed": args.seed,
449
+ "ffff": {
450
+ key: value
451
+ for key, value in reference_diagnostic.items()
452
+ if key != "decisions"
453
+ },
454
+ "results": [completed[key] for key in sorted(completed)],
455
+ },
456
+ )
457
+
458
+
459
+ def summarize(args: argparse.Namespace) -> None:
460
+ paths = sorted((args.output_root / "scans").glob("global_*.json"))
461
+ if len(paths) != 3:
462
+ raise ValueError(f"Expected exactly three scan files, found {len(paths)}")
463
+ scans = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
464
+ if any(scan.get("status") != "complete" for scan in scans):
465
+ raise RuntimeError("At least one scan is incomplete")
466
+ by_name = [
467
+ {record["name"]: record for record in scan["results"]} for scan in scans
468
+ ]
469
+ names = sorted(set(by_name[0]))
470
+ if any(set(records) != set(names) for records in by_name[1:]):
471
+ raise RuntimeError("Scan configuration sets differ")
472
+ summary: list[dict[str, Any]] = []
473
+ for name in names:
474
+ rows = [records[name] for records in by_name]
475
+ first = rows[0]
476
+ summary.append(
477
+ {
478
+ "name": name,
479
+ "head": first["head"],
480
+ "candidate_steps": first["candidate_steps"],
481
+ "beta": first["beta"],
482
+ "threshold": first["threshold"],
483
+ "target_accepts": first["target_accepts"],
484
+ "mean_accepted_predictor_calls": sum(
485
+ float(row["accepted_predictor_calls"]) for row in rows
486
+ )
487
+ / len(rows),
488
+ "mean_full_calls": sum(float(row["full_calls"]) for row in rows)
489
+ / len(rows),
490
+ "mean_policy_latency_ms": sum(
491
+ float(row["policy_latency_ms"]) for row in rows
492
+ )
493
+ / len(rows),
494
+ "mean_latent_nrmse_vs_ffff": sum(
495
+ float(row["latent_nrmse_vs_ffff"]) for row in rows
496
+ )
497
+ / len(rows),
498
+ "per_prompt_accepts": [
499
+ int(row["accepted_predictor_calls"]) for row in rows
500
+ ],
501
+ "per_prompt_latent_nrmse": [
502
+ float(row["latent_nrmse_vs_ffff"]) for row in rows
503
+ ],
504
+ }
505
+ )
506
+ selected: list[dict[str, Any]] = []
507
+ for family, targets in TARGETS.items():
508
+ for target in targets:
509
+ candidates = [
510
+ row
511
+ for row in summary
512
+ if row["head"] == family and int(row["target_accepts"]) == target
513
+ ]
514
+ within_budget = [
515
+ row
516
+ for row in candidates
517
+ if abs(float(row["mean_accepted_predictor_calls"]) - target) <= 0.5
518
+ ]
519
+ pool = within_budget or candidates
520
+ choice = min(
521
+ pool,
522
+ key=lambda row: (
523
+ float(row["mean_latent_nrmse_vs_ffff"]),
524
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
525
+ float(row["mean_policy_latency_ms"]),
526
+ float(row["beta"]),
527
+ )
528
+ if within_budget
529
+ else (
530
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
531
+ float(row["mean_latent_nrmse_vs_ffff"]),
532
+ float(row["mean_policy_latency_ms"]),
533
+ float(row["beta"]),
534
+ ),
535
+ )
536
+ selected.append(choice)
537
+ atomic_json(
538
+ args.output_root / "calibration_summary.json",
539
+ {
540
+ "status": "complete",
541
+ "global_indices": [int(scan["global_index"]) for scan in scans],
542
+ "selection_rule": (
543
+ "within mean K +/-0.5 choose lowest mean latent nRMSE; if no "
544
+ "candidate is within budget, choose nearest K first"
545
+ ),
546
+ "summary": summary,
547
+ "selected": selected,
548
+ },
549
+ )
550
+ print("[selected]", flush=True)
551
+ for row in selected:
552
+ print(
553
+ f" {row['head']} K{row['target_accepts']:02d}: beta={row['beta']} "
554
+ f"threshold={row['threshold']:.8f} "
555
+ f"actual_K={row['mean_accepted_predictor_calls']:.3f} "
556
+ f"latent_nrmse={row['mean_latent_nrmse_vs_ffff']:.6f}",
557
+ flush=True,
558
+ )
559
+
560
+
561
+ def summarize_threshold_only(args: argparse.Namespace) -> None:
562
+ paths = sorted((args.output_root / "scans").glob("global_*.json"))
563
+ if len(paths) != 3:
564
+ raise ValueError(f"Expected exactly three scan files, found {len(paths)}")
565
+ scans = [json.loads(path.read_text(encoding="utf-8")) for path in paths]
566
+ if any(scan.get("status") != "complete" for scan in scans):
567
+ raise RuntimeError("At least one scan is incomplete")
568
+ by_name = [
569
+ {record["name"]: record for record in scan["results"]} for scan in scans
570
+ ]
571
+ names = sorted(set(by_name[0]))
572
+ if any(set(records) != set(names) for records in by_name[1:]):
573
+ raise RuntimeError("Scan configuration sets differ")
574
+ summary: list[dict[str, Any]] = []
575
+ for name in names:
576
+ rows = [records[name] for records in by_name]
577
+ first = rows[0]
578
+ summary.append(
579
+ {
580
+ "name": name,
581
+ "head": first["head"],
582
+ "candidate_steps": first["candidate_steps"],
583
+ "beta": first["beta"],
584
+ "threshold": first["threshold"],
585
+ "threshold_rank": first.get("threshold_rank"),
586
+ "mean_accepted_predictor_calls": sum(
587
+ float(row["accepted_predictor_calls"]) for row in rows
588
+ )
589
+ / len(rows),
590
+ "mean_full_calls": sum(float(row["full_calls"]) for row in rows)
591
+ / len(rows),
592
+ "mean_policy_latency_ms": sum(
593
+ float(row["policy_latency_ms"]) for row in rows
594
+ )
595
+ / len(rows),
596
+ "mean_latent_nrmse_vs_ffff": sum(
597
+ float(row["latent_nrmse_vs_ffff"]) for row in rows
598
+ )
599
+ / len(rows),
600
+ "per_prompt_accepts": [
601
+ int(row["accepted_predictor_calls"]) for row in rows
602
+ ],
603
+ }
604
+ )
605
+ selected: list[dict[str, Any]] = []
606
+ for family, targets in TARGETS.items():
607
+ for target in targets:
608
+ candidates = [row for row in summary if row["head"] == family]
609
+ within_budget = [
610
+ row
611
+ for row in candidates
612
+ if abs(float(row["mean_accepted_predictor_calls"]) - target) <= 0.5
613
+ ]
614
+ pool = within_budget or candidates
615
+ choice = min(
616
+ pool,
617
+ key=lambda row: (
618
+ float(row["mean_latent_nrmse_vs_ffff"]),
619
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
620
+ float(row["threshold"]),
621
+ )
622
+ if within_budget
623
+ else (
624
+ abs(float(row["mean_accepted_predictor_calls"]) - target),
625
+ float(row["mean_latent_nrmse_vs_ffff"]),
626
+ float(row["threshold"]),
627
+ ),
628
+ )
629
+ selected.append(
630
+ {
631
+ **choice,
632
+ "target_accepts": target,
633
+ }
634
+ )
635
+ atomic_json(
636
+ args.output_root / "threshold_summary.json",
637
+ {
638
+ "status": "complete",
639
+ "beta": 2.0,
640
+ "global_indices": [int(scan["global_index"]) for scan in scans],
641
+ "selection_rule": (
642
+ "fixed beta=2; for each target K choose threshold with mean K "
643
+ "within +/-0.5 and lowest latent nRMSE"
644
+ ),
645
+ "summary": summary,
646
+ "selected": selected,
647
+ },
648
+ )
649
+ print("[selected-threshold-only]", flush=True)
650
+ for row in selected:
651
+ print(
652
+ f" {row['head']} K{row['target_accepts']:02d}: "
653
+ f"threshold={row['threshold']:.8f} "
654
+ f"actual_K={row['mean_accepted_predictor_calls']:.3f} "
655
+ f"latent_nrmse={row['mean_latent_nrmse_vs_ffff']:.6f}",
656
+ flush=True,
657
+ )
658
+
659
+
660
+ def parse_args() -> argparse.Namespace:
661
+ parser = argparse.ArgumentParser(description=__doc__)
662
+ parser.add_argument(
663
+ "--mode",
664
+ choices=(
665
+ "probe", "prepare", "prepare_threshold", "scan", "summarize",
666
+ "summarize_threshold",
667
+ ),
668
+ required=True,
669
+ )
670
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
671
+ parser.add_argument("--global-index", type=int, default=None)
672
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
673
+ parser.add_argument("--experiment-root", type=Path, default=EXPERIMENT_DEFAULT)
674
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
675
+ parser.add_argument("--probe-root", type=Path, default=None)
676
+ parser.add_argument("--seed", type=int, default=0)
677
+ parser.add_argument("--overwrite", action="store_true")
678
+ args = parser.parse_args()
679
+ args.mapping = args.mapping.resolve()
680
+ args.experiment_root = args.experiment_root.resolve()
681
+ args.output_root = args.output_root.resolve()
682
+ args.probe_root = args.probe_root.resolve() if args.probe_root is not None else None
683
+ return args
684
+
685
+
686
+ def main() -> None:
687
+ args = parse_args()
688
+ args.output_root.mkdir(parents=True, exist_ok=True)
689
+ if args.mode == "probe":
690
+ probe(args)
691
+ elif args.mode == "prepare":
692
+ prepare(args)
693
+ elif args.mode == "prepare_threshold":
694
+ prepare_threshold_only(args)
695
+ elif args.mode == "scan":
696
+ scan(args)
697
+ else:
698
+ if args.mode == "summarize_threshold":
699
+ summarize_threshold_only(args)
700
+ else:
701
+ summarize(args)
702
+
703
+
704
+ if __name__ == "__main__":
705
+ main()
scripts/create_lmdb_14b_shards.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ python create_lmdb_14b_shards.py \
3
+ --data_path /mnt/localssd/wanx_14b_data \
4
+ --lmdb_path /mnt/localssd/wanx_14B_shift-3.0_cfg-5.0_lmdb
5
+ """
6
+ from tqdm import tqdm
7
+ import numpy as np
8
+ import argparse
9
+ import torch
10
+ import lmdb
11
+ import glob
12
+ import os
13
+
14
+ from utils.lmdb import store_arrays_to_lmdb, process_data_dict
15
+
16
+
17
+ def main():
18
+ """
19
+ Aggregate all ode pairs inside a folder into a lmdb dataset.
20
+ Each pt file should contain a (key, value) pair representing a
21
+ video's ODE trajectories.
22
+ """
23
+ parser = argparse.ArgumentParser()
24
+ parser.add_argument("--data_path", type=str,
25
+ required=True, help="path to ode pairs")
26
+ parser.add_argument("--lmdb_path", type=str,
27
+ required=True, help="path to lmdb")
28
+ parser.add_argument("--num_shards", type=int,
29
+ default=16, help="num_shards")
30
+
31
+ args = parser.parse_args()
32
+
33
+ all_dirs = sorted(os.listdir(args.data_path))
34
+
35
+ # figure out the maximum map size needed
36
+ map_size = int(1e12) # adapt to your need, set to 1TB by default
37
+ os.makedirs(args.lmdb_path, exist_ok=True)
38
+ # 1) Open one LMDB env per shard
39
+ envs = []
40
+ num_shards = args.num_shards
41
+ for shard_id in range(num_shards):
42
+ print("shard_id ", shard_id)
43
+ path = os.path.join(args.lmdb_path, f"shard_{shard_id}")
44
+ env = lmdb.open(path,
45
+ map_size=map_size,
46
+ subdir=True, # set to True if you want a directory per env
47
+ readonly=False,
48
+ metasync=True,
49
+ sync=True,
50
+ lock=True,
51
+ readahead=False,
52
+ meminit=False)
53
+ envs.append(env)
54
+
55
+ counters = [0] * num_shards
56
+ seen_prompts = set() # for deduplication
57
+ total_samples = 0
58
+ all_files = []
59
+
60
+ for part_dir in all_dirs:
61
+ all_files += sorted(glob.glob(os.path.join(args.data_path, part_dir, "*.pt")))
62
+
63
+ # 2) Prepare a write transaction for each shard
64
+ for idx, file in tqdm(enumerate(all_files)):
65
+ try:
66
+ data_dict = torch.load(file)
67
+ data_dict = process_data_dict(data_dict, seen_prompts)
68
+ except Exception as e:
69
+ print(f"Error processing {file}: {e}")
70
+ continue
71
+
72
+ if data_dict["latents"].shape != (1, 21, 16, 60, 104):
73
+ continue
74
+
75
+ shard_id = idx % num_shards
76
+ # write to lmdb file
77
+ store_arrays_to_lmdb(envs[shard_id], data_dict, start_index=counters[shard_id])
78
+ counters[shard_id] += len(data_dict['prompts'])
79
+ data_shape = data_dict["latents"].shape
80
+
81
+ total_samples += len(all_files)
82
+
83
+ print(len(seen_prompts))
84
+
85
+ # save each entry's shape to lmdb
86
+ for shard_id, env in enumerate(envs):
87
+ with env.begin(write=True) as txn:
88
+ for key, val in (data_dict.items()):
89
+ assert len(data_shape) == 5
90
+ array_shape = np.array(data_shape) # val.shape)
91
+ array_shape[0] = counters[shard_id]
92
+ shape_key = f"{key}_shape".encode()
93
+ print(shape_key, array_shape)
94
+ shape_str = " ".join(map(str, array_shape))
95
+ txn.put(shape_key, shape_str.encode())
96
+
97
+ print(f"Finished writing {total_samples} examples into {num_shards} shards under {args.lmdb_path}")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
scripts/create_lmdb_iterative.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tqdm import tqdm
2
+ import numpy as np
3
+ import argparse
4
+ import torch
5
+ import lmdb
6
+ import glob
7
+ import os
8
+
9
+ from utils.lmdb import store_arrays_to_lmdb, process_data_dict
10
+
11
+
12
+ def main():
13
+ """
14
+ Aggregate all ode pairs inside a folder into a lmdb dataset.
15
+ Each pt file should contain a (key, value) pair representing a
16
+ video's ODE trajectories.
17
+ """
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument("--data_path", type=str,
20
+ required=True, help="path to ode pairs")
21
+ parser.add_argument("--lmdb_path", type=str,
22
+ required=True, help="path to lmdb")
23
+
24
+ args = parser.parse_args()
25
+
26
+ all_files = sorted(glob.glob(os.path.join(args.data_path, "*.pt")))
27
+
28
+ # figure out the maximum map size needed
29
+ total_array_size = 5000000000000 # adapt to your need, set to 5TB by default
30
+
31
+ env = lmdb.open(args.lmdb_path, map_size=total_array_size * 2)
32
+
33
+ counter = 0
34
+
35
+ seen_prompts = set() # for deduplication
36
+
37
+ for index, file in tqdm(enumerate(all_files)):
38
+ # read from disk
39
+ data_dict = torch.load(file)
40
+
41
+ data_dict = process_data_dict(data_dict, seen_prompts)
42
+
43
+ # write to lmdb file
44
+ store_arrays_to_lmdb(env, data_dict, start_index=counter)
45
+ counter += len(data_dict['prompts'])
46
+
47
+ # save each entry's shape to lmdb
48
+ with env.begin(write=True) as txn:
49
+ for key, val in data_dict.items():
50
+ print(key, val)
51
+ array_shape = np.array(val.shape)
52
+ array_shape[0] = counter
53
+
54
+ shape_key = f"{key}_shape".encode()
55
+ shape_str = " ".join(map(str, array_shape))
56
+ txn.put(shape_key, shape_str.encode())
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
scripts/eval_vbench8_extended_naive_baselines.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Score generated naive baselines with VBench-8 and build final summaries.
3
+
4
+ This script does not generate videos. Run
5
+ ``generate_vbench8_extended_naive_baselines.py`` first, then use this entry
6
+ point to score FFFF plus any selected naive strategies. Strategies are scored
7
+ sequentially on the requested GPU; separate processes may be used on different
8
+ GPUs by passing disjoint ``--strategy`` sets and ``--no-summarize``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ REPO_ROOT = Path(__file__).resolve().parents[1]
21
+ if str(REPO_ROOT) not in sys.path:
22
+ sys.path.insert(0, str(REPO_ROOT))
23
+
24
+ from scripts.naive_vbench_policies import (
25
+ ALL_STRATEGY_NAMES,
26
+ EVALUATION_STRATEGY_NAMES,
27
+ )
28
+
29
+
30
+ OUTPUT_DEFAULT = REPO_ROOT / "evaluation_runs/vbench8_extended_naive_baselines"
31
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
32
+ EXTENDED_PROMPTS_DEFAULT = REPO_ROOT / "prompts/vbench/all_dimension_extended.txt"
33
+ VBENCH_SITE_DEFAULT = REPO_ROOT / ".evaluation_env/vbench_site2"
34
+ VBENCH_INFO_DEFAULT = VBENCH_SITE_DEFAULT / "vbench/VBench_full_info.json"
35
+ VBENCH_CACHE_DEFAULT = Path("/data3/chenzhuo/.cache/vbench")
36
+
37
+
38
+ def parse_args() -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser(description=__doc__)
40
+ parser.add_argument("--gpu", default="0")
41
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
42
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
43
+ parser.add_argument(
44
+ "--extended-prompts", type=Path, default=EXTENDED_PROMPTS_DEFAULT
45
+ )
46
+ parser.add_argument("--vbench-site", type=Path, default=VBENCH_SITE_DEFAULT)
47
+ parser.add_argument("--vbench-info", type=Path, default=VBENCH_INFO_DEFAULT)
48
+ parser.add_argument("--vbench-cache", type=Path, default=VBENCH_CACHE_DEFAULT)
49
+ parser.add_argument(
50
+ "--strategy",
51
+ action="append",
52
+ choices=EVALUATION_STRATEGY_NAMES,
53
+ default=None,
54
+ help="Naive strategy to score; repeat as needed. Default: all six.",
55
+ )
56
+ parser.add_argument(
57
+ "--score-ffff",
58
+ action=argparse.BooleanOptionalAction,
59
+ default=True,
60
+ help="Also score the matched FFFF videos.",
61
+ )
62
+ parser.add_argument(
63
+ "--skip-existing", action=argparse.BooleanOptionalAction, default=True
64
+ )
65
+ parser.add_argument(
66
+ "--summarize",
67
+ action=argparse.BooleanOptionalAction,
68
+ default=True,
69
+ help="Build pixel and final summaries for this exact strategy set.",
70
+ )
71
+ return parser.parse_args()
72
+
73
+
74
+ def checked_file(path: Path, label: str) -> Path:
75
+ resolved = path.resolve()
76
+ if not resolved.is_file():
77
+ raise FileNotFoundError(f"Missing {label}: {resolved}")
78
+ return resolved
79
+
80
+
81
+ def run(command: list[str], env: dict[str, str]) -> None:
82
+ print("[run] " + " ".join(command), flush=True)
83
+ subprocess.run(command, cwd=REPO_ROOT, env=env, check=True)
84
+
85
+
86
+ def main() -> None:
87
+ args = parse_args()
88
+ selected = args.strategy or list(EVALUATION_STRATEGY_NAMES)
89
+ if len(set(selected)) != len(selected):
90
+ raise ValueError("--strategy values must be unique")
91
+ strategies = (["ffff"] if args.score_ffff else []) + selected
92
+ if args.summarize and "ffff" not in strategies:
93
+ raise ValueError("Final summaries require --score-ffff")
94
+ unknown = set(strategies) - set(ALL_STRATEGY_NAMES)
95
+ if unknown:
96
+ raise ValueError(f"Unknown strategies: {sorted(unknown)}")
97
+
98
+ output_root = args.output_root.resolve()
99
+ mapping = checked_file(args.mapping, "mapping")
100
+ extended_prompts = checked_file(args.extended_prompts, "extended prompts")
101
+ vbench_site = args.vbench_site.resolve()
102
+ if not vbench_site.is_dir():
103
+ raise FileNotFoundError(f"Missing VBench site directory: {vbench_site}")
104
+ vbench_info = checked_file(args.vbench_info, "VBench full info")
105
+ videos_root = output_root / "generated_videos"
106
+ if not videos_root.is_dir():
107
+ raise FileNotFoundError(f"Missing generated videos: {videos_root}")
108
+
109
+ env = os.environ.copy()
110
+ existing_pythonpath = env.get("PYTHONPATH")
111
+ pythonpath_parts = [str(vbench_site), str(REPO_ROOT)]
112
+ if existing_pythonpath:
113
+ pythonpath_parts.append(existing_pythonpath)
114
+ env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
115
+ env["VBENCH_CACHE_DIR"] = str(args.vbench_cache.resolve())
116
+ env["VBENCH_BERT_MODEL_DIR"] = str(
117
+ (args.vbench_cache / "bert-base-uncased").resolve()
118
+ )
119
+
120
+ if args.summarize:
121
+ run(
122
+ [
123
+ sys.executable,
124
+ str(REPO_ROOT / "scripts/summarize_vbench8_generation.py"),
125
+ "--output-root",
126
+ str(output_root),
127
+ "--strategies",
128
+ *strategies,
129
+ ],
130
+ env,
131
+ )
132
+
133
+ for strategy in strategies:
134
+ command = [
135
+ sys.executable,
136
+ str(REPO_ROOT / "scripts/eval_vbench8_extended_subset.py"),
137
+ "--gpu",
138
+ str(args.gpu),
139
+ "--strategy",
140
+ strategy,
141
+ "--mapping",
142
+ str(mapping),
143
+ "--videos-root",
144
+ str(videos_root),
145
+ "--output-root",
146
+ str(output_root),
147
+ "--vbench-info",
148
+ str(vbench_info),
149
+ ]
150
+ if args.skip_existing:
151
+ command.append("--skip-existing")
152
+ run(command, env)
153
+
154
+ if args.summarize:
155
+ run(
156
+ [
157
+ sys.executable,
158
+ str(REPO_ROOT / "scripts/summarize_vbench8_extended.py"),
159
+ "--output-root",
160
+ str(output_root),
161
+ "--mapping",
162
+ str(mapping),
163
+ "--extended-prompts",
164
+ str(extended_prompts),
165
+ "--vbench-info",
166
+ str(vbench_info),
167
+ "--strategies",
168
+ *strategies,
169
+ ],
170
+ env,
171
+ )
172
+ print(f"[complete] strategies={','.join(strategies)}", flush=True)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
scripts/eval_vbench8_extended_subset.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run the standard VBench metrics for one generated strategy.
3
+
4
+ The official VBench metric modules are used unchanged. Only the metadata
5
+ builder is replaced so that the evaluator reads the exact extended prompt used
6
+ for generation and the auditable mapping can use suite-indexed filenames.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ def preparse_gpu() -> str:
22
+ parser = argparse.ArgumentParser(add_help=False)
23
+ parser.add_argument("--gpu", default="0")
24
+ args, _ = parser.parse_known_args()
25
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
26
+ os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="vbench_mpl_"))
27
+ return str(args.gpu)
28
+
29
+
30
+ PHYSICAL_GPU = preparse_gpu()
31
+
32
+ _vbench_cache = Path(
33
+ os.environ.get("VBENCH_CACHE_DIR", "/data3/chenzhuo/.cache/vbench")
34
+ ).expanduser()
35
+ os.environ.setdefault(
36
+ "VBENCH_BERT_MODEL_DIR", str(_vbench_cache / "bert-base-uncased")
37
+ )
38
+
39
+ from vbench import VBench
40
+
41
+ REPO_ROOT = Path(__file__).resolve().parents[1]
42
+ if str(REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(REPO_ROOT))
44
+
45
+ from scripts.vbench8_protocol import (
46
+ DIMENSIONS,
47
+ PROTOCOL_NAME,
48
+ SUITE_COUNTS,
49
+ aggregate_selected_score,
50
+ )
51
+
52
+
53
+ def sha256(path: Path) -> str:
54
+ digest = hashlib.sha256()
55
+ with path.open("rb") as handle:
56
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
57
+ digest.update(block)
58
+ return digest.hexdigest()
59
+
60
+
61
+ def read_mapping(path: Path) -> list[dict[str, Any]]:
62
+ value = json.loads(path.read_text(encoding="utf-8"))
63
+ if not isinstance(value, list) or len(value) != 251:
64
+ raise ValueError(f"Expected a 251-row mapping list: {path}")
65
+ counts = {suite: 0 for suite in SUITE_COUNTS}
66
+ globals_seen: set[int] = set()
67
+ suite_seen: dict[str, set[int]] = {suite: set() for suite in SUITE_COUNTS}
68
+ for row in value:
69
+ suite = str(row["prompt_suite"])
70
+ global_index = int(row["global_index"])
71
+ suite_index = int(row["suite_index"])
72
+ if suite not in counts:
73
+ raise ValueError(f"Unknown suite {suite}")
74
+ if global_index in globals_seen:
75
+ raise ValueError(f"Duplicate global index {global_index}")
76
+ if suite_index in suite_seen[suite]:
77
+ raise ValueError(f"Duplicate suite index {suite}/{suite_index}")
78
+ globals_seen.add(global_index)
79
+ suite_seen[suite].add(suite_index)
80
+ counts[suite] += 1
81
+ if counts != SUITE_COUNTS:
82
+ raise ValueError(f"Unexpected suite counts: {counts}")
83
+ return sorted(value, key=lambda row: int(row["global_index"]))
84
+
85
+
86
+ def build_metadata(
87
+ *,
88
+ mapping: list[dict[str, Any]],
89
+ videos_root: Path,
90
+ strategy: str,
91
+ metadata_path: Path,
92
+ ) -> None:
93
+ records: list[dict[str, Any]] = []
94
+ for row in mapping:
95
+ suite = str(row["prompt_suite"])
96
+ suite_index = int(row["suite_index"])
97
+ video_path = (
98
+ videos_root / strategy / suite / f"{suite_index:03d}.mp4"
99
+ ).resolve()
100
+ if not video_path.is_file():
101
+ raise FileNotFoundError(video_path)
102
+ official_dimensions = list(row["official_dimensions"])
103
+ if not set(official_dimensions).issubset(set(DIMENSIONS)):
104
+ raise ValueError(
105
+ f"Unsupported dimensions at global index {row['global_index']}: "
106
+ f"{official_dimensions}"
107
+ )
108
+ record: dict[str, Any] = {
109
+ "prompt_en": str(row["extended_prompt"]),
110
+ "dimension": official_dimensions,
111
+ "video_list": [str(video_path)],
112
+ }
113
+ # The standard scene implementation in vbench==0.1.5 needs the
114
+ # official scene keyword in auxiliary_info. It is not a replacement
115
+ # for prompt_en: overall_consistency still receives the extended text.
116
+ if "auxiliary_info" in row:
117
+ record["auxiliary_info"] = row["auxiliary_info"]
118
+ records.append(record)
119
+ if len(records) != 251:
120
+ raise ValueError(f"Expected 251 metadata records, got {len(records)}")
121
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
122
+ metadata_path.write_text(
123
+ json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
124
+ )
125
+
126
+
127
+ class FixedMetadataVBench(VBench):
128
+ """Use prepared metadata while retaining VBench's official evaluator."""
129
+
130
+ def __init__(self, *, device: str, metadata_path: Path, output_path: Path):
131
+ super().__init__(
132
+ device=device,
133
+ full_info_dir=str(metadata_path),
134
+ output_path=str(output_path),
135
+ )
136
+ self.prepared_metadata_path = metadata_path
137
+
138
+ def build_full_info_json(self, *args: Any, **kwargs: Any) -> str:
139
+ return str(self.prepared_metadata_path)
140
+
141
+
142
+ def parse_result(value: Any) -> tuple[float, Any]:
143
+ if isinstance(value, (list, tuple)) and len(value) == 2:
144
+ score, details = value
145
+ else:
146
+ score, details = value, None
147
+ score = float(score)
148
+ if not 0.0 <= score <= 1.0:
149
+ raise ValueError(f"VBench raw score is outside [0, 1]: {score}")
150
+ return score, details
151
+
152
+
153
+ def parse_args() -> argparse.Namespace:
154
+ parser = argparse.ArgumentParser(description=__doc__)
155
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
156
+ parser.add_argument("--strategy", required=True)
157
+ parser.add_argument("--mapping", type=Path, required=True)
158
+ parser.add_argument("--videos-root", type=Path, required=True)
159
+ parser.add_argument("--output-root", type=Path, required=True)
160
+ parser.add_argument("--vbench-info", type=Path, required=True)
161
+ parser.add_argument("--skip-existing", action="store_true")
162
+ return parser.parse_args()
163
+
164
+
165
+ def main() -> None:
166
+ args = parse_args()
167
+ mapping_path = args.mapping.resolve()
168
+ videos_root = args.videos_root.resolve()
169
+ output_root = args.output_root.resolve()
170
+ mapping = read_mapping(mapping_path)
171
+ metadata_path = output_root / "vbench/metadata" / f"{args.strategy}_full_info.json"
172
+ raw_dir = output_root / "vbench/raw_results" / args.strategy
173
+ raw_name = f"vbench8_{args.strategy}"
174
+ raw_result_path = raw_dir / f"{raw_name}_eval_results.json"
175
+ score_path = output_root / "vbench/scores" / f"{args.strategy}.json"
176
+ if args.skip_existing and raw_result_path.is_file() and score_path.is_file():
177
+ print(f"[cached] strategy={args.strategy}", flush=True)
178
+ return
179
+
180
+ build_metadata(
181
+ mapping=mapping,
182
+ videos_root=videos_root,
183
+ strategy=args.strategy,
184
+ metadata_path=metadata_path,
185
+ )
186
+ raw_dir.mkdir(parents=True, exist_ok=True)
187
+ print(
188
+ f"[vbench] gpu={args.gpu} strategy={args.strategy} videos=251 "
189
+ f"dimensions={','.join(DIMENSIONS)}",
190
+ flush=True,
191
+ )
192
+ bench = FixedMetadataVBench(
193
+ device="cuda", metadata_path=metadata_path, output_path=raw_dir
194
+ )
195
+ bench.evaluate(
196
+ videos_path=str(videos_root / args.strategy),
197
+ name=raw_name,
198
+ dimension_list=list(DIMENSIONS),
199
+ local=True,
200
+ mode="vbench_standard",
201
+ )
202
+ if not raw_result_path.is_file():
203
+ raise FileNotFoundError(raw_result_path)
204
+ raw_json = json.loads(raw_result_path.read_text(encoding="utf-8"))
205
+ raw_scores: dict[str, float] = {}
206
+ details: dict[str, Any] = {}
207
+ for dimension in DIMENSIONS:
208
+ if dimension not in raw_json:
209
+ raise KeyError(f"Missing {dimension} in {raw_result_path}")
210
+ score, dimension_details = parse_result(raw_json[dimension])
211
+ raw_scores[dimension] = score
212
+ if dimension_details is not None:
213
+ details[dimension] = dimension_details
214
+ aggregate = aggregate_selected_score(raw_scores)
215
+ result = {
216
+ "protocol": PROTOCOL_NAME,
217
+ "strategy": args.strategy,
218
+ "physical_gpu": str(args.gpu),
219
+ "benchmark": "VBench",
220
+ "vbench_version": "0.1.5",
221
+ "vbench_long": False,
222
+ "vbench_info": str(args.vbench_info.resolve()),
223
+ "vbench_info_sha256": sha256(args.vbench_info.resolve()),
224
+ "mapping": str(mapping_path),
225
+ "mapping_sha256": sha256(mapping_path),
226
+ "videos_root": str(videos_root / args.strategy),
227
+ "num_videos": 251,
228
+ "dimensions": list(DIMENSIONS),
229
+ "raw_scores": raw_scores,
230
+ **aggregate,
231
+ "scene_prompt_note": (
232
+ "prompt_en is the Self-Forcing extended prompt; vbench==0.1.5 "
233
+ "scene uses the official auxiliary scene keyword by design."
234
+ ),
235
+ }
236
+ if details:
237
+ details_path = output_root / "vbench/details" / f"{args.strategy}.json"
238
+ details_path.parent.mkdir(parents=True, exist_ok=True)
239
+ details_path.write_text(
240
+ json.dumps(details, ensure_ascii=False, indent=2) + "\n",
241
+ encoding="utf-8",
242
+ )
243
+ result["details"] = str(details_path)
244
+ score_path.parent.mkdir(parents=True, exist_ok=True)
245
+ score_path.write_text(
246
+ json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
247
+ )
248
+ print(
249
+ f"[complete] strategy={args.strategy} "
250
+ f"selected={result['selected_vbench_percent']:.4f}%",
251
+ flush=True,
252
+ )
253
+
254
+
255
+ if __name__ == "__main__":
256
+ main()
scripts/evaluate_layer17_chunk_impact.py ADDED
@@ -0,0 +1,782 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Measure local and downstream impact of isolated Layer-17 Predictor calls.
3
+
4
+ For every prompt, chunk 1..6 is independently evaluated with FPFF, FFPF, and
5
+ FPPF while every other chunk remains FFFF. A shadow Full forward is executed
6
+ at each selected Predictor step to measure hidden/flow/x0 error on the exact
7
+ rollout state. The shadow output is never used by the generated trajectory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import json
15
+ import math
16
+ import os
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ def _preparse_gpu() -> str:
24
+ parser = argparse.ArgumentParser(add_help=False)
25
+ parser.add_argument("--gpu", default="4")
26
+ args, _ = parser.parse_known_args()
27
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
28
+ return str(args.gpu)
29
+
30
+
31
+ PHYSICAL_GPU = _preparse_gpu()
32
+
33
+ import lpips
34
+ import torch
35
+ from omegaconf import OmegaConf
36
+
37
+ REPO_ROOT = Path(__file__).resolve().parents[1]
38
+ if str(REPO_ROOT) not in sys.path:
39
+ sys.path.insert(0, str(REPO_ROOT))
40
+
41
+ from scripts import evaluate_single_block_fppf as base
42
+ from utils.misc import set_seed
43
+ from utils.wan_wrapper import WanVAEWrapper
44
+
45
+
46
+ SCHEDULES = ("FPFF", "FFPF", "FPPF")
47
+ EPS = 1e-8
48
+
49
+
50
+ def parse_args() -> argparse.Namespace:
51
+ parser = argparse.ArgumentParser(description=__doc__)
52
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
53
+ parser.add_argument(
54
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
55
+ )
56
+ parser.add_argument(
57
+ "--checkpoint_path",
58
+ type=Path,
59
+ default=Path("checkpoints/self_forcing_dmd.pt"),
60
+ )
61
+ parser.add_argument(
62
+ "--dataset_root",
63
+ type=Path,
64
+ default=Path("outputs/predictor_offline_100_all_blocks"),
65
+ )
66
+ parser.add_argument(
67
+ "--sweep_dir",
68
+ type=Path,
69
+ default=Path("outputs/single_block_init_sweep"),
70
+ )
71
+ parser.add_argument(
72
+ "--reference_root",
73
+ type=Path,
74
+ default=Path("outputs/single_block_fppf_eval"),
75
+ )
76
+ parser.add_argument(
77
+ "--output_dir",
78
+ type=Path,
79
+ default=Path("outputs/layer17_chunk_impact_pilot"),
80
+ )
81
+ parser.add_argument(
82
+ "--prompt_ids", type=int, nargs="*", default=list(range(80, 90))
83
+ )
84
+ parser.add_argument("--schedules", nargs="*", choices=SCHEDULES, default=list(SCHEDULES))
85
+ parser.add_argument(
86
+ "--chunks", type=int, nargs="*", default=list(range(1, base.NUM_CHUNKS))
87
+ )
88
+ parser.add_argument("--max_prompts", type=int, default=None)
89
+ parser.add_argument("--metric_batch_size", type=int, default=4)
90
+ parser.add_argument("--generation_seed", type=int, default=0)
91
+ parser.add_argument(
92
+ "--skip_lpips", action=argparse.BooleanOptionalAction, default=False
93
+ )
94
+ parser.add_argument("--overwrite", action="store_true")
95
+ args = parser.parse_args()
96
+ if not args.prompt_ids:
97
+ parser.error("At least one prompt ID is required")
98
+ if any(value < 0 or value >= 100 for value in args.prompt_ids):
99
+ parser.error("Prompt IDs must be in [0, 99]")
100
+ if args.metric_batch_size < 1:
101
+ parser.error("--metric_batch_size must be positive")
102
+ if not args.schedules:
103
+ parser.error("At least one schedule is required")
104
+ if not args.chunks or any(chunk < 1 or chunk >= base.NUM_CHUNKS for chunk in args.chunks):
105
+ parser.error("--chunks must contain values in [1, 6]")
106
+ return args
107
+
108
+
109
+ def resolve(path: Path) -> Path:
110
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
111
+
112
+
113
+ def rms(value: torch.Tensor) -> torch.Tensor:
114
+ return value.float().square().mean().sqrt()
115
+
116
+
117
+ def nrmse(prediction: torch.Tensor, target: torch.Tensor) -> float:
118
+ return float(rms(prediction.float() - target.float()) / rms(target).clamp_min(EPS))
119
+
120
+
121
+ def chunk_frame_slice(chunk: int) -> slice:
122
+ if chunk == 0:
123
+ return slice(0, base.PIXEL_FRAMES_FIRST_CHUNK)
124
+ start = base.PIXEL_FRAMES_FIRST_CHUNK + 12 * (chunk - 1)
125
+ return slice(start, start + 12)
126
+
127
+
128
+ def summarize_frame_range(metrics: dict[str, Any], selected: slice) -> dict[str, float]:
129
+ mse_values = metrics["mse_per_frame"][selected]
130
+ ssim_values = metrics["ssim_per_frame"][selected]
131
+ lpips_values = metrics["lpips_per_frame"][selected]
132
+ mean_mse = sum(mse_values) / len(mse_values)
133
+ return {
134
+ "pixel_mse": mean_mse,
135
+ "psnr": -10.0 * math.log10(max(mean_mse, 1e-12)),
136
+ "ssim": sum(ssim_values) / len(ssim_values),
137
+ "lpips": (
138
+ sum(lpips_values) / len(lpips_values) if lpips_values else float("nan")
139
+ ),
140
+ }
141
+
142
+
143
+ @torch.inference_mode()
144
+ def generate_intervention(
145
+ *,
146
+ pipeline: Any,
147
+ dataset_root: Path,
148
+ prompt_id: int,
149
+ generation_seed: int,
150
+ device: torch.device,
151
+ predictor: Any,
152
+ source_layer: int,
153
+ intervention_chunk: int,
154
+ intervention_schedule: str,
155
+ ) -> tuple[torch.Tensor, dict[str, Any]]:
156
+ if intervention_schedule not in SCHEDULES:
157
+ raise ValueError(intervention_schedule)
158
+ if intervention_chunk < 1 or intervention_chunk >= base.NUM_CHUNKS:
159
+ raise ValueError("Predictor intervention chunk must be 1..6")
160
+
161
+ base.reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
162
+ set_seed(generation_seed)
163
+ noise = torch.randn(
164
+ 1,
165
+ base.NUM_CHUNKS * base.FRAMES_PER_CHUNK,
166
+ base.LATENT_CHANNELS,
167
+ base.LATENT_HEIGHT,
168
+ base.LATENT_WIDTH,
169
+ dtype=torch.bfloat16,
170
+ device=device,
171
+ )
172
+ timesteps = pipeline.denoising_step_list.to(device=device)
173
+ output_chunks: list[torch.Tensor] = []
174
+ previous_chunk_hidden: list[torch.Tensor | None] | None = None
175
+ teacher = pipeline.generator.model
176
+ capture = base.FinalHiddenCapture(teacher)
177
+ full_calls = 0
178
+ predictor_calls = 0
179
+ shadow_full_calls = 0
180
+ local_errors: list[dict[str, float | int]] = []
181
+ started = time.perf_counter()
182
+
183
+ try:
184
+ for chunk in range(base.NUM_CHUNKS):
185
+ noisy_input = noise[
186
+ :,
187
+ chunk * base.FRAMES_PER_CHUNK : (chunk + 1) * base.FRAMES_PER_CHUNK,
188
+ ]
189
+ current_hidden: list[torch.Tensor | None] = [None] * base.NUM_DENOISING_STEPS
190
+ denoised_pred: torch.Tensor | None = None
191
+ timestep: torch.Tensor | None = None
192
+
193
+ for step, current_timestep in enumerate(timesteps):
194
+ timestep = torch.ones(
195
+ [1, base.FRAMES_PER_CHUNK], dtype=torch.int64, device=device
196
+ ) * current_timestep
197
+ selected = (
198
+ chunk == intervention_chunk
199
+ and intervention_schedule[step] == "P"
200
+ )
201
+
202
+ if selected:
203
+ anchor_hidden = current_hidden[step - 1]
204
+ if anchor_hidden is None or previous_chunk_hidden is None:
205
+ raise RuntimeError("Predictor inputs are unavailable")
206
+ previous_hidden = previous_chunk_hidden[step]
207
+ if previous_hidden is None:
208
+ raise RuntimeError("Previous-chunk hidden is unavailable")
209
+ history = pipeline.kv_cache1[source_layer]
210
+ cross = pipeline.crossattn_cache[source_layer]
211
+ pred_hidden, pred_flow, _ = base.predictor_step(
212
+ predictor=predictor,
213
+ teacher=teacher,
214
+ noisy_input=noisy_input,
215
+ timestep=timestep,
216
+ anchor_hidden=anchor_hidden,
217
+ previous_hidden=previous_hidden,
218
+ history_cache=history,
219
+ cross_cache=cross,
220
+ current_start=chunk * base.TOKENS_PER_CHUNK,
221
+ )
222
+ pred_x0 = pipeline.generator._convert_flow_pred_to_x0(
223
+ flow_pred=pred_flow.flatten(0, 1),
224
+ xt=noisy_input.flatten(0, 1),
225
+ timestep=timestep.flatten(0, 1),
226
+ ).unflatten(0, pred_flow.shape[:2])
227
+
228
+ # Shadow Full measures the exact counterfactual target on
229
+ # this rollout state. Its x0/hidden are never accepted.
230
+ capture.start()
231
+ full_flow, full_x0 = pipeline.generator(
232
+ noisy_image_or_video=noisy_input,
233
+ conditional_dict={
234
+ "prompt_embeds": torch.zeros(
235
+ 1,
236
+ 1,
237
+ int(teacher.text_embedding[0].in_features),
238
+ dtype=torch.bfloat16,
239
+ device=device,
240
+ )
241
+ },
242
+ timestep=timestep,
243
+ kv_cache=pipeline.kv_cache1,
244
+ crossattn_cache=pipeline.crossattn_cache,
245
+ current_start=chunk * base.TOKENS_PER_CHUNK,
246
+ )
247
+ full_hidden = capture.finish()
248
+ local_errors.append(
249
+ {
250
+ "step": step,
251
+ "timestep": float(current_timestep),
252
+ "hidden_nrmse": nrmse(pred_hidden, full_hidden),
253
+ "flow_nrmse": nrmse(pred_flow, full_flow),
254
+ "x0_nrmse": nrmse(pred_x0, full_x0),
255
+ }
256
+ )
257
+ current_hidden[step] = pred_hidden
258
+ denoised_pred = pred_x0
259
+ predictor_calls += 1
260
+ shadow_full_calls += 1
261
+ del full_flow, full_x0, full_hidden
262
+ else:
263
+ capture.start()
264
+ _, denoised_pred = pipeline.generator(
265
+ noisy_image_or_video=noisy_input,
266
+ conditional_dict={
267
+ "prompt_embeds": torch.zeros(
268
+ 1,
269
+ 1,
270
+ int(teacher.text_embedding[0].in_features),
271
+ dtype=torch.bfloat16,
272
+ device=device,
273
+ )
274
+ },
275
+ timestep=timestep,
276
+ kv_cache=pipeline.kv_cache1,
277
+ crossattn_cache=pipeline.crossattn_cache,
278
+ current_start=chunk * base.TOKENS_PER_CHUNK,
279
+ )
280
+ current_hidden[step] = capture.finish()
281
+ full_calls += 1
282
+
283
+ if step < base.NUM_DENOISING_STEPS - 1:
284
+ if denoised_pred is None:
285
+ raise RuntimeError("Denoising step produced no x0")
286
+ next_timestep = timesteps[step + 1]
287
+ flat = denoised_pred.flatten(0, 1)
288
+ noisy_input = pipeline.scheduler.add_noise(
289
+ flat,
290
+ torch.randn_like(flat),
291
+ next_timestep
292
+ * torch.ones(
293
+ [base.FRAMES_PER_CHUNK], dtype=torch.long, device=device
294
+ ),
295
+ ).unflatten(0, denoised_pred.shape[:2])
296
+
297
+ if denoised_pred is None or timestep is None:
298
+ raise RuntimeError("Chunk produced no clean latent")
299
+ output_chunks.append(denoised_pred)
300
+ context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
301
+ pipeline.generator(
302
+ noisy_image_or_video=denoised_pred,
303
+ conditional_dict={
304
+ "prompt_embeds": torch.zeros(
305
+ 1,
306
+ 1,
307
+ int(teacher.text_embedding[0].in_features),
308
+ dtype=torch.bfloat16,
309
+ device=device,
310
+ )
311
+ },
312
+ timestep=context_timestep,
313
+ kv_cache=pipeline.kv_cache1,
314
+ crossattn_cache=pipeline.crossattn_cache,
315
+ current_start=chunk * base.TOKENS_PER_CHUNK,
316
+ )
317
+ previous_chunk_hidden = current_hidden
318
+ finally:
319
+ capture.close()
320
+
321
+ torch.cuda.synchronize()
322
+ return torch.cat(output_chunks, dim=1), {
323
+ "generation_time_s": time.perf_counter() - started,
324
+ "full_calls": full_calls,
325
+ "predictor_calls": predictor_calls,
326
+ "shadow_full_calls": shadow_full_calls,
327
+ "local_errors": local_errors,
328
+ }
329
+
330
+
331
+ def average(values: list[float]) -> float:
332
+ return sum(values) / len(values)
333
+
334
+
335
+ def finite_average(values: list[Any]) -> float:
336
+ numeric = [
337
+ float(value)
338
+ for value in values
339
+ if value is not None and math.isfinite(float(value))
340
+ ]
341
+ return average(numeric) if numeric else float("nan")
342
+
343
+
344
+ def rankdata(values: list[float]) -> list[float]:
345
+ order = sorted(range(len(values)), key=values.__getitem__)
346
+ ranks = [0.0] * len(values)
347
+ start = 0
348
+ while start < len(order):
349
+ end = start + 1
350
+ while end < len(order) and values[order[end]] == values[order[start]]:
351
+ end += 1
352
+ rank = 0.5 * (start + end - 1)
353
+ for position in range(start, end):
354
+ ranks[order[position]] = rank
355
+ start = end
356
+ return ranks
357
+
358
+
359
+ def pearson(left: list[float], right: list[float]) -> float:
360
+ left_mean, right_mean = average(left), average(right)
361
+ left_centered = [value - left_mean for value in left]
362
+ right_centered = [value - right_mean for value in right]
363
+ numerator = sum(a * b for a, b in zip(left_centered, right_centered))
364
+ denominator = math.sqrt(
365
+ sum(value * value for value in left_centered)
366
+ * sum(value * value for value in right_centered)
367
+ )
368
+ return numerator / denominator if denominator > 0 else float("nan")
369
+
370
+
371
+ def spearman(left: list[float], right: list[float]) -> float:
372
+ return pearson(rankdata(left), rankdata(right))
373
+
374
+
375
+ def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
376
+ temporary = path.with_suffix(path.suffix + ".tmp")
377
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
378
+ writer = csv.DictWriter(handle, fieldnames=fields)
379
+ writer.writeheader()
380
+ writer.writerows(rows)
381
+ os.replace(temporary, path)
382
+
383
+
384
+ def group_centered_values(
385
+ records: list[dict[str, Any]], field: str
386
+ ) -> list[float]:
387
+ """Remove schedule-by-chunk means to isolate prompt/state variation."""
388
+ groups: dict[tuple[str, int], list[float]] = {}
389
+ for row in records:
390
+ key = (str(row["schedule"]), int(row["chunk"]))
391
+ groups.setdefault(key, []).append(float(row[field]))
392
+ means = {key: average(values) for key, values in groups.items()}
393
+ return [
394
+ float(row[field]) - means[(str(row["schedule"]), int(row["chunk"]))]
395
+ for row in records
396
+ ]
397
+
398
+
399
+ def aggregate(records: list[dict[str, Any]], output_dir: Path) -> None:
400
+ numeric_fields = [
401
+ "hidden_nrmse_mean",
402
+ "flow_nrmse_mean",
403
+ "x0_nrmse_mean",
404
+ "latent_all_nrmse",
405
+ "latent_current_nrmse",
406
+ "latent_tail_nrmse",
407
+ "psnr",
408
+ "ssim",
409
+ "lpips",
410
+ "current_psnr",
411
+ "current_ssim",
412
+ "current_lpips",
413
+ "tail_psnr",
414
+ "tail_ssim",
415
+ "tail_lpips",
416
+ "generation_time_s",
417
+ ]
418
+ summary_rows: list[dict[str, Any]] = []
419
+ available_schedules = [
420
+ schedule for schedule in SCHEDULES if any(row["schedule"] == schedule for row in records)
421
+ ]
422
+ available_chunks = sorted({int(row["chunk"]) for row in records})
423
+ for schedule in available_schedules:
424
+ for chunk in available_chunks:
425
+ selected = [
426
+ row
427
+ for row in records
428
+ if row["schedule"] == schedule and row["chunk"] == chunk
429
+ ]
430
+ row: dict[str, Any] = {
431
+ "schedule": schedule,
432
+ "chunk": chunk,
433
+ "num_prompts": len(selected),
434
+ "alpha_eligible_linear": (base.NUM_CHUNKS - 1 - chunk)
435
+ / (base.NUM_CHUNKS - 2),
436
+ }
437
+ for field in numeric_fields:
438
+ row[field] = finite_average([item[field] for item in selected])
439
+ summary_rows.append(row)
440
+
441
+ summary_fields = [
442
+ "schedule",
443
+ "chunk",
444
+ "num_prompts",
445
+ "alpha_eligible_linear",
446
+ *numeric_fields,
447
+ ]
448
+ write_csv(output_dir / "summary_by_schedule_chunk.csv", summary_rows, summary_fields)
449
+
450
+ correlation_rows: list[dict[str, Any]] = []
451
+ for schedule in (*available_schedules, "ALL"):
452
+ selected = (
453
+ records if schedule == "ALL" else [r for r in records if r["schedule"] == schedule]
454
+ )
455
+ for local in ("hidden_nrmse_mean", "flow_nrmse_mean", "x0_nrmse_mean"):
456
+ for downstream in ("tail_lpips", "latent_tail_nrmse", "tail_pixel_mse"):
457
+ pairs = [
458
+ (float(row[local]), float(row[downstream]))
459
+ for row in selected
460
+ if row[local] is not None
461
+ and row[downstream] is not None
462
+ and math.isfinite(float(row[local]))
463
+ and math.isfinite(float(row[downstream]))
464
+ ]
465
+ correlation_rows.append(
466
+ {
467
+ "schedule": schedule,
468
+ "local_metric": local,
469
+ "downstream_metric": downstream,
470
+ "num_observations": len(pairs),
471
+ "spearman": (
472
+ spearman(
473
+ [pair[0] for pair in pairs],
474
+ [pair[1] for pair in pairs],
475
+ )
476
+ if len(pairs) >= 2
477
+ else float("nan")
478
+ ),
479
+ }
480
+ )
481
+ correlation_fields = [
482
+ "schedule",
483
+ "local_metric",
484
+ "downstream_metric",
485
+ "num_observations",
486
+ "spearman",
487
+ ]
488
+ write_csv(output_dir / "local_downstream_correlations.csv", correlation_rows, correlation_fields)
489
+
490
+ controlled_rows: list[dict[str, Any]] = []
491
+ for local in ("hidden_nrmse_mean", "flow_nrmse_mean", "x0_nrmse_mean"):
492
+ local_residual = group_centered_values(records, local)
493
+ downstream_residual = group_centered_values(records, "tail_lpips")
494
+ within_cell = []
495
+ for schedule in available_schedules:
496
+ for chunk in available_chunks:
497
+ selected = [
498
+ row
499
+ for row in records
500
+ if row["schedule"] == schedule and row["chunk"] == chunk
501
+ ]
502
+ if len(selected) >= 2:
503
+ within_cell.append(
504
+ spearman(
505
+ [float(row[local]) for row in selected],
506
+ [float(row["tail_lpips"]) for row in selected],
507
+ )
508
+ )
509
+ controlled_rows.append(
510
+ {
511
+ "local_metric": local,
512
+ "downstream_metric": "tail_lpips",
513
+ "controls": "schedule+chunk",
514
+ "residual_spearman": spearman(local_residual, downstream_residual),
515
+ "mean_within_cell_spearman": finite_average(within_cell),
516
+ "positive_cells": sum(value > 0 for value in within_cell),
517
+ "num_cells": len(within_cell),
518
+ }
519
+ )
520
+ controlled_fields = [
521
+ "local_metric",
522
+ "downstream_metric",
523
+ "controls",
524
+ "residual_spearman",
525
+ "mean_within_cell_spearman",
526
+ "positive_cells",
527
+ "num_cells",
528
+ ]
529
+ write_csv(
530
+ output_dir / "controlled_local_downstream_correlations.csv",
531
+ controlled_rows,
532
+ controlled_fields,
533
+ )
534
+
535
+ report = [
536
+ "# Layer-17 Predictor chunk-impact pilot",
537
+ "",
538
+ f"Prompts: {len(set(row['prompt_id'] for row in records))}; seed 0; "
539
+ "all non-intervened chunks use FFFF.",
540
+ "",
541
+ "A shadow Full call measures local error at each Predictor decision, but the "
542
+ "generated trajectory always consumes the Predictor output.",
543
+ "",
544
+ ]
545
+ for schedule in available_schedules:
546
+ report.extend(
547
+ [
548
+ f"## {schedule}",
549
+ "",
550
+ "| Chunk | x0 nRMSE | Tail PSNR | Tail LPIPS | Tail latent nRMSE |",
551
+ "|---:|---:|---:|---:|---:|",
552
+ ]
553
+ )
554
+ for row in summary_rows:
555
+ if row["schedule"] != schedule:
556
+ continue
557
+ report.append(
558
+ f"| {row['chunk']} | {row['x0_nrmse_mean']:.6f} | "
559
+ f"{row['tail_psnr']:.4f} | {row['tail_lpips']:.6f} | "
560
+ f"{row['latent_tail_nrmse']:.6f} |"
561
+ )
562
+ report.append("")
563
+ report.extend(
564
+ [
565
+ "## Local-to-downstream Spearman correlations",
566
+ "",
567
+ "| Schedule | Local metric | Downstream metric | Spearman |",
568
+ "|---|---|---|---:|",
569
+ ]
570
+ )
571
+ for row in correlation_rows:
572
+ if row["downstream_metric"] == "tail_lpips":
573
+ report.append(
574
+ f"| {row['schedule']} | {row['local_metric']} | tail LPIPS | "
575
+ f"{row['spearman']:.4f} |"
576
+ )
577
+ report.extend(
578
+ [
579
+ "",
580
+ "## Correlations after controlling schedule and chunk",
581
+ "",
582
+ "Residual correlations remove each schedule-by-chunk mean, so they test "
583
+ "whether local error explains prompt/state risk beyond the position prior.",
584
+ "",
585
+ "| Local metric | Residual Spearman | Mean within-cell Spearman | Positive cells |",
586
+ "|---|---:|---:|---:|",
587
+ ]
588
+ )
589
+ for row in controlled_rows:
590
+ report.append(
591
+ f"| {row['local_metric']} | {row['residual_spearman']:.4f} | "
592
+ f"{row['mean_within_cell_spearman']:.4f} | "
593
+ f"{row['positive_cells']}/{row['num_cells']} |"
594
+ )
595
+ (output_dir / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
596
+
597
+
598
+ def main() -> None:
599
+ args = parse_args()
600
+ for name in (
601
+ "config_path",
602
+ "checkpoint_path",
603
+ "dataset_root",
604
+ "sweep_dir",
605
+ "reference_root",
606
+ "output_dir",
607
+ ):
608
+ setattr(args, name, resolve(getattr(args, name)))
609
+ args.output_dir.mkdir(parents=True, exist_ok=True)
610
+ prompt_ids = sorted(set(args.prompt_ids))
611
+ if args.max_prompts is not None:
612
+ prompt_ids = prompt_ids[: args.max_prompts]
613
+
614
+ config = OmegaConf.merge(
615
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
616
+ OmegaConf.load(args.config_path),
617
+ )
618
+ device = torch.device("cuda")
619
+ torch.set_grad_enabled(False)
620
+ set_seed(args.generation_seed)
621
+ manifest = {
622
+ "status": "running",
623
+ "gpu": str(args.gpu),
624
+ "prompt_ids": prompt_ids,
625
+ "generation_seed": args.generation_seed,
626
+ "checkpoint_path": str(args.checkpoint_path),
627
+ "predictor_weights": str(
628
+ args.sweep_dir / "teacher_layer_17" / "predictor_final.safetensors"
629
+ ),
630
+ "dataset_root": str(args.dataset_root),
631
+ "schedules": list(args.schedules),
632
+ "chunks": list(args.chunks),
633
+ "shadow_full_target": True,
634
+ }
635
+ base.atomic_json(args.output_dir / "manifest.json", manifest)
636
+
637
+ print("[setup] loading VAE", flush=True)
638
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
639
+ missing_references = [
640
+ prompt_id
641
+ for prompt_id in prompt_ids
642
+ if not (
643
+ args.reference_root
644
+ / "ffff_reference_frames"
645
+ / f"prompt_{prompt_id:04d}.safetensors"
646
+ ).exists()
647
+ ]
648
+ if missing_references:
649
+ base.prepare_reference_frames(
650
+ vae=vae,
651
+ dataset_root=args.dataset_root,
652
+ output_dir=args.reference_root,
653
+ prompt_ids=missing_references,
654
+ device=device,
655
+ rebuild=False,
656
+ )
657
+
658
+ print("[setup] loading frozen generator and Layer-17 Predictor", flush=True)
659
+ pipeline = base.build_pipeline(config, args.checkpoint_path, vae, device)
660
+ experiment = base.discover_experiments(
661
+ args.sweep_dir, ["teacher_layer_17"], None
662
+ )[0]
663
+ predictor = base.load_predictor(pipeline.generator.model, experiment, device)
664
+ lpips_model = None
665
+ if not args.skip_lpips:
666
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
667
+ lpips_model.requires_grad_(False)
668
+
669
+ records: list[dict[str, Any]] = []
670
+ total = len(prompt_ids) * len(args.schedules) * len(args.chunks)
671
+ completed = 0
672
+ for prompt_id in prompt_ids:
673
+ reference_latent = base.load_ffff_latent(args.dataset_root, prompt_id).to(
674
+ device=device, dtype=torch.bfloat16
675
+ )
676
+ reference_u8 = base.load_reference_frames(args.reference_root, prompt_id)
677
+ for schedule in args.schedules:
678
+ for chunk in args.chunks:
679
+ destination = (
680
+ args.output_dir
681
+ / "per_intervention"
682
+ / f"prompt_{prompt_id:04d}_{schedule}_chunk_{chunk:02d}.json"
683
+ )
684
+ if destination.exists() and not args.overwrite:
685
+ record = json.loads(destination.read_text(encoding="utf-8"))
686
+ records.append(record)
687
+ completed += 1
688
+ print(f"[cached] {completed}/{total} {destination.stem}", flush=True)
689
+ continue
690
+ started = time.perf_counter()
691
+ latent, diagnostics = generate_intervention(
692
+ pipeline=pipeline,
693
+ dataset_root=args.dataset_root,
694
+ prompt_id=prompt_id,
695
+ generation_seed=args.generation_seed,
696
+ device=device,
697
+ predictor=predictor,
698
+ source_layer=17,
699
+ intervention_chunk=chunk,
700
+ intervention_schedule=schedule,
701
+ )
702
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
703
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
704
+ prediction_u8 = base.pixels_to_u8(pixels)
705
+ frame = base.frame_metrics(
706
+ reference_u8=reference_u8,
707
+ prediction_u8=prediction_u8,
708
+ lpips_model=lpips_model,
709
+ batch_size=args.metric_batch_size,
710
+ device=device,
711
+ )
712
+ current_slice = chunk_frame_slice(chunk)
713
+ current = summarize_frame_range(frame, current_slice)
714
+ tail = summarize_frame_range(frame, slice(current_slice.start, None))
715
+ latent_chunk_start = chunk * base.FRAMES_PER_CHUNK
716
+ latent_chunk_end = latent_chunk_start + base.FRAMES_PER_CHUNK
717
+ errors = diagnostics.pop("local_errors")
718
+ record = {
719
+ "prompt_id": prompt_id,
720
+ "schedule": schedule,
721
+ "chunk": chunk,
722
+ "predictor_steps": [int(item["step"]) for item in errors],
723
+ "hidden_nrmse_mean": average(
724
+ [float(item["hidden_nrmse"]) for item in errors]
725
+ ),
726
+ "flow_nrmse_mean": average(
727
+ [float(item["flow_nrmse"]) for item in errors]
728
+ ),
729
+ "x0_nrmse_mean": average(
730
+ [float(item["x0_nrmse"]) for item in errors]
731
+ ),
732
+ "local_errors": errors,
733
+ "latent_all_nrmse": nrmse(latent, reference_latent),
734
+ "latent_current_nrmse": nrmse(
735
+ latent[:, latent_chunk_start:latent_chunk_end],
736
+ reference_latent[:, latent_chunk_start:latent_chunk_end],
737
+ ),
738
+ "latent_tail_nrmse": nrmse(
739
+ latent[:, latent_chunk_start:],
740
+ reference_latent[:, latent_chunk_start:],
741
+ ),
742
+ "psnr": frame["psnr"],
743
+ "ssim": frame["ssim"],
744
+ "lpips": frame["lpips"],
745
+ "current_pixel_mse": current["pixel_mse"],
746
+ "current_psnr": current["psnr"],
747
+ "current_ssim": current["ssim"],
748
+ "current_lpips": current["lpips"],
749
+ "tail_pixel_mse": tail["pixel_mse"],
750
+ "tail_psnr": tail["psnr"],
751
+ "tail_ssim": tail["ssim"],
752
+ "tail_lpips": tail["lpips"],
753
+ **diagnostics,
754
+ "total_time_s": time.perf_counter() - started,
755
+ }
756
+ base.atomic_json(destination, record)
757
+ records.append(record)
758
+ completed += 1
759
+ print(
760
+ f"[run] {completed}/{total} p={prompt_id} {schedule} c={chunk} "
761
+ f"x0={record['x0_nrmse_mean']:.5f} "
762
+ f"tail_lpips={record['tail_lpips']:.5f} "
763
+ f"time={record['total_time_s']:.1f}s",
764
+ flush=True,
765
+ )
766
+ if hasattr(vae.model, "clear_cache"):
767
+ vae.model.clear_cache()
768
+ del latent, pixels, prediction_u8, frame
769
+ torch.cuda.empty_cache()
770
+ del reference_latent, reference_u8
771
+
772
+ fields = sorted({key for record in records for key in record if key != "local_errors"})
773
+ flattened = [{key: row.get(key) for key in fields} for row in records]
774
+ write_csv(args.output_dir / "interventions.csv", flattened, fields)
775
+ aggregate(records, args.output_dir)
776
+ manifest["status"] = "complete"
777
+ base.atomic_json(args.output_dir / "manifest.json", manifest)
778
+ print(f"[complete] results -> {args.output_dir}", flush=True)
779
+
780
+
781
+ if __name__ == "__main__":
782
+ main()
scripts/evaluate_layer17_dynamic_gate.py ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate chunk-aware dynamic gating for the frozen Layer-17 Predictor."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import math
10
+ import os
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ def _preparse_gpu() -> str:
18
+ parser = argparse.ArgumentParser(add_help=False)
19
+ parser.add_argument("--gpu", default="4")
20
+ args, _ = parser.parse_known_args()
21
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
22
+ return str(args.gpu)
23
+
24
+
25
+ PHYSICAL_GPU = _preparse_gpu()
26
+
27
+ import lpips
28
+ import torch
29
+ from omegaconf import OmegaConf
30
+ from safetensors.torch import load_file
31
+
32
+ REPO_ROOT = Path(__file__).resolve().parents[1]
33
+ if str(REPO_ROOT) not in sys.path:
34
+ sys.path.insert(0, str(REPO_ROOT))
35
+
36
+ from predictor_training.confidence import PredictorConfidenceHead
37
+ from scripts import evaluate_layer17_chunk_impact as impact_eval
38
+ from scripts import evaluate_single_block_fppf as base
39
+ from scripts.run_single_block_init_sweep import hidden_to_flow
40
+ from utils.misc import set_seed
41
+ from utils.wan_wrapper import WanVAEWrapper
42
+ from wan.modules.model import sinusoidal_embedding_1d
43
+
44
+
45
+ BETAS = (0.0, 1.0, 1.5, 2.0)
46
+ TARGET_ACCEPTS = (4, 6, 8, 10)
47
+
48
+
49
+ def parse_args() -> argparse.Namespace:
50
+ parser = argparse.ArgumentParser(description=__doc__)
51
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
52
+ parser.add_argument("--mode", choices=("smoke", "validation", "test"), required=True)
53
+ parser.add_argument(
54
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
55
+ )
56
+ parser.add_argument(
57
+ "--checkpoint_path", type=Path, default=Path("checkpoints/self_forcing_dmd.pt")
58
+ )
59
+ parser.add_argument(
60
+ "--dataset_root", type=Path,
61
+ default=Path("outputs/predictor_offline_100_all_blocks"),
62
+ )
63
+ parser.add_argument(
64
+ "--sweep_dir", type=Path, default=Path("outputs/single_block_init_sweep")
65
+ )
66
+ parser.add_argument(
67
+ "--predictor_weights",
68
+ type=Path,
69
+ default=None,
70
+ help=(
71
+ "Optional direct Layer-17 Predictor weights. When supplied, this "
72
+ "takes precedence over teacher_layer_17 in --sweep_dir."
73
+ ),
74
+ )
75
+ parser.add_argument(
76
+ "--confidence_weights", type=Path,
77
+ default=Path(
78
+ "outputs/layer17_confidence_teacher_forced_20260830/"
79
+ "confidence_best.safetensors"
80
+ ),
81
+ )
82
+ parser.add_argument(
83
+ "--validation_predictions", type=Path,
84
+ default=Path(
85
+ "outputs/layer17_confidence_teacher_forced_20260830/"
86
+ "validation_predictions.csv"
87
+ ),
88
+ )
89
+ parser.add_argument(
90
+ "--reference_root", type=Path, default=Path("outputs/single_block_fppf_eval")
91
+ )
92
+ parser.add_argument(
93
+ "--output_root", type=Path,
94
+ default=Path("outputs/layer17_dynamic_gate_20260830"),
95
+ )
96
+ parser.add_argument("--generation_seed", type=int, default=0)
97
+ parser.add_argument("--metric_batch_size", type=int, default=4)
98
+ parser.add_argument(
99
+ "--candidate_steps", type=int, nargs="+", choices=(1, 2, 3),
100
+ default=[1, 2],
101
+ )
102
+ parser.add_argument("--target_accepts", type=int, nargs="*", default=None)
103
+ parser.add_argument("--selected_path", type=Path, default=None)
104
+ parser.add_argument(
105
+ "--config_names", nargs="*", default=None,
106
+ help="Optional exact configuration names to run in validation/test mode.",
107
+ )
108
+ parser.add_argument(
109
+ "--prompt_ids", type=int, nargs="*", default=None,
110
+ help="Optional prompt shard; shard-level CSV/manifest files get a GPU suffix.",
111
+ )
112
+ parser.add_argument("--save_videos", action="store_true")
113
+ parser.add_argument("--overwrite", action="store_true")
114
+ parser.add_argument(
115
+ "--skip_lpips", action=argparse.BooleanOptionalAction, default=False
116
+ )
117
+ args = parser.parse_args()
118
+ for name in (
119
+ "config_path", "checkpoint_path", "dataset_root", "sweep_dir",
120
+ "predictor_weights",
121
+ "confidence_weights", "validation_predictions", "reference_root",
122
+ "output_root", "selected_path",
123
+ ):
124
+ value = getattr(args, name)
125
+ if value is None:
126
+ continue
127
+ path = value.expanduser()
128
+ setattr(args, name, path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve())
129
+ args.candidate_steps = sorted(set(args.candidate_steps))
130
+ max_accepts = 6 * len(args.candidate_steps)
131
+ if args.target_accepts is None:
132
+ args.target_accepts = (
133
+ [4, 6, 8, 10] if len(args.candidate_steps) == 2
134
+ else [6, 9, 12, 15]
135
+ )
136
+ if any(value < 1 or value >= max_accepts for value in args.target_accepts):
137
+ parser.error(f"target accepts must be in [1, {max_accepts - 1}]")
138
+ return args
139
+
140
+
141
+ def atomic_json(path: Path, value: Any) -> None:
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ temporary = path.with_suffix(path.suffix + ".tmp")
144
+ temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
145
+ os.replace(temporary, path)
146
+
147
+
148
+ def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
149
+ temporary = path.with_suffix(path.suffix + ".tmp")
150
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
151
+ writer = csv.DictWriter(handle, fieldnames=fields)
152
+ writer.writeheader()
153
+ writer.writerows(rows)
154
+ os.replace(temporary, path)
155
+
156
+
157
+ def quantile(values: list[float], fraction: float) -> float:
158
+ ordered = sorted(values)
159
+ position = fraction * (len(ordered) - 1)
160
+ lower = int(math.floor(position))
161
+ upper = int(math.ceil(position))
162
+ if lower == upper:
163
+ return ordered[lower]
164
+ weight = position - lower
165
+ return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
166
+
167
+
168
+ def threshold_grid(
169
+ predictions_path: Path,
170
+ candidate_steps: list[int],
171
+ targets: list[int],
172
+ ) -> dict[tuple[float, int], float]:
173
+ rows = list(csv.DictReader(predictions_path.open(encoding="utf-8")))
174
+ expected = 10 * 6 * len(candidate_steps)
175
+ if len(rows) != expected:
176
+ raise ValueError(f"Expected {expected} validation predictions, got {len(rows)}")
177
+ thresholds = {}
178
+ for beta in BETAS:
179
+ risks = []
180
+ for row in rows:
181
+ chunk = int(row["chunk"])
182
+ alpha = (base.NUM_CHUNKS - 1 - chunk) / (base.NUM_CHUNKS - 2)
183
+ local_error = float(row["predicted_hidden_nrmse"])
184
+ risks.append(local_error * (1.0 + beta * alpha))
185
+ max_accepts = 6 * len(candidate_steps)
186
+ for target in targets:
187
+ thresholds[(beta, target)] = quantile(risks, target / max_accepts)
188
+ return thresholds
189
+
190
+
191
+ def dynamic_configs(
192
+ predictions_path: Path,
193
+ candidate_steps: list[int],
194
+ targets: list[int],
195
+ ) -> list[dict[str, Any]]:
196
+ thresholds = threshold_grid(predictions_path, candidate_steps, targets)
197
+ return [
198
+ {
199
+ "name": f"dynamic_b{str(beta).replace('.', 'p')}_k{target:02d}",
200
+ "policy": "dynamic",
201
+ "beta": beta,
202
+ "target_accepts": target,
203
+ "threshold": thresholds[(beta, target)],
204
+ }
205
+ for beta in BETAS
206
+ for target in targets
207
+ ]
208
+
209
+
210
+ def static_configs(targets: list[int]) -> list[dict[str, Any]]:
211
+ return [
212
+ {
213
+ "name": f"static_late_k{target:02d}",
214
+ "policy": "static_late",
215
+ "beta": None,
216
+ "target_accepts": target,
217
+ "threshold": None,
218
+ }
219
+ for target in targets
220
+ ]
221
+
222
+
223
+ def load_selected(path: Path) -> list[dict[str, Any]]:
224
+ value = json.loads(path.read_text(encoding="utf-8"))
225
+ return [
226
+ {
227
+ "name": row["config_name"],
228
+ "policy": "dynamic",
229
+ "beta": float(row["beta"]),
230
+ "target_accepts": int(row["target_accepts"]),
231
+ "threshold": float(row["threshold"]),
232
+ }
233
+ for row in value["selected_dynamic"]
234
+ ]
235
+
236
+
237
+ @torch.no_grad()
238
+ def predictor_with_features(
239
+ *,
240
+ predictor: Any,
241
+ teacher: Any,
242
+ noisy_input: torch.Tensor,
243
+ timestep: torch.Tensor,
244
+ anchor_hidden: torch.Tensor,
245
+ previous_hidden: torch.Tensor,
246
+ history_cache: dict[str, torch.Tensor],
247
+ cross_cache: dict[str, torch.Tensor],
248
+ current_start: int,
249
+ anchor_timestep: torch.Tensor | None = None,
250
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
251
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
252
+ current_tokens = teacher.patch_embedding(
253
+ noisy_input.permute(0, 2, 1, 3, 4)
254
+ ).flatten(2).transpose(1, 2)
255
+ time_embedding = teacher.time_embedding(
256
+ sinusoidal_embedding_1d(
257
+ teacher.freq_dim, timestep.flatten()
258
+ ).type_as(current_tokens)
259
+ )
260
+ timestep_modulation = teacher.time_projection(
261
+ time_embedding
262
+ ).unflatten(1, (6, teacher.dim)).unflatten(dim=0, sizes=timestep.shape)
263
+ head_embedding = time_embedding.unflatten(
264
+ dim=0, sizes=timestep.shape
265
+ ).unsqueeze(2)
266
+ condition_per_frame = time_embedding.unflatten(
267
+ dim=0, sizes=timestep.shape
268
+ )
269
+ condition_tokens = (
270
+ condition_per_frame[:, :, None, :]
271
+ .expand(
272
+ timestep.shape[0],
273
+ timestep.shape[1],
274
+ 30 * 52,
275
+ teacher.dim,
276
+ )
277
+ .reshape(timestep.shape[0], -1, teacher.dim)
278
+ )
279
+ anchor_distance = None
280
+ if predictor.input_variant == "atc":
281
+ if anchor_timestep is None:
282
+ raise ValueError("ATC inference requires anchor_timestep")
283
+ anchor_distance = (
284
+ timestep.float() - anchor_timestep.float()
285
+ ).abs().mean(dim=1)
286
+ grid_sizes = torch.tensor(
287
+ [[base.FRAMES_PER_CHUNK, 30, 52]], dtype=torch.long, device="cpu"
288
+ )
289
+ history_length = int(history_cache["local_end_index"].item())
290
+ output = predictor(
291
+ current_tokens=current_tokens,
292
+ anchor_hidden=anchor_hidden,
293
+ previous_hidden=previous_hidden,
294
+ timestep_modulation=timestep_modulation,
295
+ grid_sizes=grid_sizes,
296
+ freqs=teacher.freqs,
297
+ history_k=history_cache["k"][:, :history_length],
298
+ history_v=history_cache["v"][:, :history_length],
299
+ cross_k=cross_cache["k"],
300
+ cross_v=cross_cache["v"],
301
+ current_start=current_start,
302
+ return_features=True,
303
+ condition_tokens=condition_tokens,
304
+ anchor_distance=anchor_distance,
305
+ )
306
+ if not isinstance(output, tuple):
307
+ raise RuntimeError("Predictor did not return internal features")
308
+ pred_hidden, transformed = output
309
+ pred_flow = hidden_to_flow(
310
+ pred_hidden, head_embedding, grid_sizes, teacher
311
+ )
312
+ return pred_hidden, pred_flow, transformed
313
+
314
+
315
+ @torch.inference_mode()
316
+ def generate(
317
+ *,
318
+ pipeline: Any,
319
+ dataset_root: Path,
320
+ prompt_id: int,
321
+ generation_seed: int,
322
+ device: torch.device,
323
+ predictor: Any,
324
+ head: PredictorConfidenceHead,
325
+ config: dict[str, Any],
326
+ candidate_steps: list[int],
327
+ ) -> tuple[torch.Tensor, dict[str, Any]]:
328
+ base.reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
329
+ set_seed(generation_seed)
330
+ noise = torch.randn(
331
+ 1,
332
+ base.NUM_CHUNKS * base.FRAMES_PER_CHUNK,
333
+ base.LATENT_CHANNELS,
334
+ base.LATENT_HEIGHT,
335
+ base.LATENT_WIDTH,
336
+ dtype=torch.bfloat16,
337
+ device=device,
338
+ )
339
+ timesteps = pipeline.denoising_step_list.to(device=device)
340
+ teacher = pipeline.generator.model
341
+ text_dim = int(teacher.text_embedding[0].in_features)
342
+ conditional_dict = {
343
+ "prompt_embeds": torch.zeros(
344
+ 1, 1, text_dim, dtype=torch.bfloat16, device=device
345
+ )
346
+ }
347
+ capture = base.FinalHiddenCapture(teacher)
348
+ output_chunks: list[torch.Tensor] = []
349
+ previous_chunk_hidden: list[torch.Tensor | None] | None = None
350
+ decisions: list[dict[str, Any]] = []
351
+ full_calls = 0
352
+ predictor_calls = 0
353
+ accepted_predictor_calls = 0
354
+ timing_events: dict[str, list[tuple[torch.cuda.Event, torch.cuda.Event]]] = {
355
+ "full_dit": [],
356
+ "predictor": [],
357
+ "confidence": [],
358
+ "context_dit": [],
359
+ }
360
+
361
+ def start_timing() -> tuple[torch.cuda.Event, torch.cuda.Event]:
362
+ start_event = torch.cuda.Event(enable_timing=True)
363
+ end_event = torch.cuda.Event(enable_timing=True)
364
+ start_event.record()
365
+ return start_event, end_event
366
+
367
+ def finish_timing(
368
+ category: str,
369
+ events: tuple[torch.cuda.Event, torch.cuda.Event],
370
+ ) -> None:
371
+ events[1].record()
372
+ timing_events[category].append(events)
373
+
374
+ started = time.perf_counter()
375
+ try:
376
+ for chunk in range(base.NUM_CHUNKS):
377
+ noisy_input = noise[
378
+ :, chunk * base.FRAMES_PER_CHUNK : (chunk + 1) * base.FRAMES_PER_CHUNK
379
+ ]
380
+ current_hidden: list[torch.Tensor | None] = [None] * base.NUM_DENOISING_STEPS
381
+ denoised_pred: torch.Tensor | None = None
382
+ timestep: torch.Tensor | None = None
383
+ for step, current_timestep in enumerate(timesteps):
384
+ timestep = torch.ones(
385
+ [1, base.FRAMES_PER_CHUNK], dtype=torch.int64, device=device
386
+ ) * current_timestep
387
+ candidate = chunk > 0 and step in candidate_steps
388
+ policy = str(config["policy"])
389
+ run_predictor = False
390
+ static_accept = False
391
+ if candidate and policy == "dynamic":
392
+ run_predictor = True
393
+ elif candidate and policy == "fppf":
394
+ run_predictor = True
395
+ static_accept = True
396
+ elif candidate and policy == "static_late":
397
+ first_chunk = (
398
+ base.NUM_CHUNKS
399
+ - int(config["target_accepts"]) // len(candidate_steps)
400
+ )
401
+ static_accept = chunk >= first_chunk
402
+ run_predictor = static_accept
403
+
404
+ accepted = False
405
+ pred_hidden = None
406
+ pred_x0 = None
407
+ predicted_local_error = None
408
+ risk = None
409
+ alpha = None
410
+ if run_predictor:
411
+ if previous_chunk_hidden is None:
412
+ raise RuntimeError("Previous chunk hidden is unavailable")
413
+ anchor_hidden = current_hidden[step - 1]
414
+ previous_hidden = previous_chunk_hidden[step]
415
+ if anchor_hidden is None or previous_hidden is None:
416
+ raise RuntimeError("Predictor inputs are unavailable")
417
+ predictor_events = start_timing()
418
+ pred_hidden, pred_flow, transformed = predictor_with_features(
419
+ predictor=predictor,
420
+ teacher=teacher,
421
+ noisy_input=noisy_input,
422
+ timestep=timestep,
423
+ anchor_hidden=anchor_hidden,
424
+ previous_hidden=previous_hidden,
425
+ history_cache=pipeline.kv_cache1[17],
426
+ cross_cache=pipeline.crossattn_cache[17],
427
+ current_start=chunk * base.TOKENS_PER_CHUNK,
428
+ anchor_timestep=(
429
+ torch.ones_like(timestep) * timesteps[step - 1]
430
+ ),
431
+ )
432
+ finish_timing("predictor", predictor_events)
433
+ pred_x0 = pipeline.generator._convert_flow_pred_to_x0(
434
+ flow_pred=pred_flow.flatten(0, 1),
435
+ xt=noisy_input.flatten(0, 1),
436
+ timestep=timestep.flatten(0, 1),
437
+ ).unflatten(0, pred_flow.shape[:2])
438
+ predictor_calls += 1
439
+ if policy == "dynamic":
440
+ chunk_position = torch.tensor(
441
+ [(chunk - 1) / 5.0], device=device
442
+ )
443
+ step_tensor = torch.tensor([step], dtype=torch.long, device=device)
444
+ confidence_events = start_timing()
445
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
446
+ predicted_log = head(
447
+ transformed_hidden=transformed,
448
+ pred_hidden=pred_hidden,
449
+ anchor_hidden=anchor_hidden,
450
+ chunk_position=chunk_position,
451
+ step_id=step_tensor,
452
+ )
453
+ finish_timing("confidence", confidence_events)
454
+ predicted_local_error = float(predicted_log.exp()[0])
455
+ alpha = (base.NUM_CHUNKS - 1 - chunk) / (base.NUM_CHUNKS - 2)
456
+ risk = predicted_local_error * (
457
+ 1.0 + float(config["beta"]) * alpha
458
+ )
459
+ accepted = risk <= float(config["threshold"])
460
+ else:
461
+ accepted = static_accept
462
+
463
+ if accepted:
464
+ assert pred_hidden is not None and pred_x0 is not None
465
+ current_hidden[step] = pred_hidden
466
+ denoised_pred = pred_x0
467
+ accepted_predictor_calls += 1
468
+ else:
469
+ full_events = start_timing()
470
+ capture.start()
471
+ _, denoised_pred = pipeline.generator(
472
+ noisy_image_or_video=noisy_input,
473
+ conditional_dict=conditional_dict,
474
+ timestep=timestep,
475
+ kv_cache=pipeline.kv_cache1,
476
+ crossattn_cache=pipeline.crossattn_cache,
477
+ current_start=chunk * base.TOKENS_PER_CHUNK,
478
+ )
479
+ current_hidden[step] = capture.finish()
480
+ finish_timing("full_dit", full_events)
481
+ full_calls += 1
482
+
483
+ if candidate:
484
+ decisions.append(
485
+ {
486
+ "chunk": chunk,
487
+ "step": step,
488
+ "ran_predictor": run_predictor,
489
+ "accepted": accepted,
490
+ "predicted_local_error": predicted_local_error,
491
+ "chunk_alpha": alpha,
492
+ "impact_risk": risk,
493
+ }
494
+ )
495
+ if step < base.NUM_DENOISING_STEPS - 1:
496
+ if denoised_pred is None:
497
+ raise RuntimeError("Denoising step produced no x0")
498
+ next_timestep = timesteps[step + 1]
499
+ flat = denoised_pred.flatten(0, 1)
500
+ noisy_input = pipeline.scheduler.add_noise(
501
+ flat,
502
+ torch.randn_like(flat),
503
+ next_timestep
504
+ * torch.ones(
505
+ [base.FRAMES_PER_CHUNK], dtype=torch.long, device=device
506
+ ),
507
+ ).unflatten(0, denoised_pred.shape[:2])
508
+
509
+ if denoised_pred is None or timestep is None:
510
+ raise RuntimeError("Chunk produced no clean latent")
511
+ output_chunks.append(denoised_pred)
512
+ context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
513
+ context_events = start_timing()
514
+ pipeline.generator(
515
+ noisy_image_or_video=denoised_pred,
516
+ conditional_dict=conditional_dict,
517
+ timestep=context_timestep,
518
+ kv_cache=pipeline.kv_cache1,
519
+ crossattn_cache=pipeline.crossattn_cache,
520
+ current_start=chunk * base.TOKENS_PER_CHUNK,
521
+ )
522
+ finish_timing("context_dit", context_events)
523
+ previous_chunk_hidden = current_hidden
524
+ finally:
525
+ capture.close()
526
+ torch.cuda.synchronize()
527
+ elapsed = {
528
+ category: sum(start.elapsed_time(end) for start, end in events)
529
+ for category, events in timing_events.items()
530
+ }
531
+ actual_dit_time_ms = (
532
+ elapsed["full_dit"] + elapsed["predictor"] + elapsed["context_dit"]
533
+ )
534
+ return torch.cat(output_chunks, dim=1), {
535
+ "generation_time_s": time.perf_counter() - started,
536
+ "full_calls": full_calls,
537
+ "predictor_calls": predictor_calls,
538
+ "accepted_predictor_calls": accepted_predictor_calls,
539
+ "rejected_predictor_calls": predictor_calls - accepted_predictor_calls,
540
+ "full_dit_time_ms": elapsed["full_dit"],
541
+ "predictor_time_ms": elapsed["predictor"],
542
+ "confidence_head_time_ms": elapsed["confidence"],
543
+ "context_dit_time_ms": elapsed["context_dit"],
544
+ "actual_dit_time_ms": actual_dit_time_ms,
545
+ "model_path_time_ms": actual_dit_time_ms + elapsed["confidence"],
546
+ "decisions": decisions,
547
+ }
548
+
549
+
550
+ def load_models(
551
+ args: argparse.Namespace, device: torch.device
552
+ ) -> tuple[Any, Any, Any, Any, Any]:
553
+ print("[setup] loading VAE", flush=True)
554
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
555
+ config = OmegaConf.merge(
556
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
557
+ OmegaConf.load(args.config_path),
558
+ )
559
+ print("[setup] loading frozen generator and Predictor", flush=True)
560
+ pipeline = base.build_pipeline(config, args.checkpoint_path, vae, device)
561
+ if args.predictor_weights is not None:
562
+ experiment = {
563
+ "name": "direct_layer17_predictor",
564
+ "source_layer": 17,
565
+ "weights": args.predictor_weights,
566
+ "gate_mode": "baseline",
567
+ }
568
+ else:
569
+ experiment = base.discover_experiments(
570
+ args.sweep_dir, ["teacher_layer_17"], None
571
+ )[0]
572
+ predictor = base.load_predictor(pipeline.generator.model, experiment, device)
573
+ head = PredictorConfidenceHead(
574
+ num_steps=max(args.candidate_steps)
575
+ ).to(device=device).eval()
576
+ head.load_state_dict(load_file(str(args.confidence_weights), device="cpu"), strict=True)
577
+ head.requires_grad_(False)
578
+ lpips_model = None
579
+ if not args.skip_lpips:
580
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
581
+ lpips_model.requires_grad_(False)
582
+ return vae, pipeline, predictor, head, lpips_model
583
+
584
+
585
+ def smoke(args: argparse.Namespace, pipeline: Any, predictor: Any, head: Any, device: torch.device) -> None:
586
+ max_accepts = 6 * len(args.candidate_steps)
587
+ all_name = "fppf" if args.candidate_steps == [1, 2] else "fppp"
588
+ configurations = [
589
+ {"name": "ffff", "policy": "ffff", "beta": None, "threshold": None, "target_accepts": 0},
590
+ {"name": "dynamic_all_fallback", "policy": "dynamic", "beta": 1.5, "threshold": -math.inf, "target_accepts": 0},
591
+ {"name": all_name, "policy": "fppf", "beta": None, "threshold": None, "target_accepts": max_accepts},
592
+ {"name": "dynamic_all_accept", "policy": "dynamic", "beta": 1.5, "threshold": math.inf, "target_accepts": max_accepts},
593
+ ]
594
+ latents = {}
595
+ diagnostics = {}
596
+ for config in configurations:
597
+ latent, diagnostic = generate(
598
+ pipeline=pipeline, dataset_root=args.dataset_root, prompt_id=80,
599
+ generation_seed=args.generation_seed, device=device, predictor=predictor,
600
+ head=head, config=config, candidate_steps=args.candidate_steps,
601
+ )
602
+ latents[config["name"]] = latent.cpu()
603
+ diagnostics[config["name"]] = diagnostic
604
+ print(
605
+ f"[smoke] {config['name']} full={diagnostic['full_calls']} "
606
+ f"pred={diagnostic['predictor_calls']} accept={diagnostic['accepted_predictor_calls']}",
607
+ flush=True,
608
+ )
609
+ fallback_diff = float((latents["ffff"].float() - latents["dynamic_all_fallback"].float()).abs().max())
610
+ accept_diff = float((latents[all_name].float() - latents["dynamic_all_accept"].float()).abs().max())
611
+ result = {
612
+ "status": "complete",
613
+ "prompt_id": 80,
614
+ "ffff_vs_all_fallback_max_abs": fallback_diff,
615
+ "fppf_vs_all_accept_max_abs": accept_diff,
616
+ "diagnostics": diagnostics,
617
+ }
618
+ atomic_json(args.output_root / "smoke.json", result)
619
+ if fallback_diff != 0.0 or accept_diff != 0.0:
620
+ raise RuntimeError(f"Smoke consistency failed: {result}")
621
+ print("[smoke] exact consistency passed", flush=True)
622
+
623
+
624
+ def aggregate(records: list[dict[str, Any]], output_dir: Path) -> list[dict[str, Any]]:
625
+ numeric = [
626
+ "accepted_predictor_calls", "full_calls", "predictor_calls",
627
+ "full_dit_time_ms", "predictor_time_ms", "confidence_head_time_ms",
628
+ "context_dit_time_ms", "actual_dit_time_ms", "model_path_time_ms",
629
+ "generation_time_s", "total_time_s", "latent_nrmse", "latent_tail_nrmse",
630
+ "psnr", "ssim", "lpips", "tail_psnr", "tail_ssim", "tail_lpips",
631
+ ]
632
+ summary = []
633
+ for name in sorted({str(row["config_name"]) for row in records}):
634
+ selected = [row for row in records if row["config_name"] == name]
635
+ first = selected[0]
636
+ item = {
637
+ "config_name": name,
638
+ "policy": first["policy"],
639
+ "beta": first["beta"],
640
+ "target_accepts": first["target_accepts"],
641
+ "threshold": first["threshold"],
642
+ "num_prompts": len(selected),
643
+ }
644
+ for field in numeric:
645
+ item[field] = sum(float(row[field]) for row in selected) / len(selected)
646
+ summary.append(item)
647
+ fields = [
648
+ "config_name", "policy", "beta", "target_accepts", "threshold",
649
+ "num_prompts", *numeric,
650
+ ]
651
+ write_csv(output_dir / "summary.csv", summary, fields)
652
+ return summary
653
+
654
+
655
+ def select_validation(
656
+ summary: list[dict[str, Any]], output_dir: Path, targets: list[int]
657
+ ) -> None:
658
+ selected_dynamic = []
659
+ for target in targets:
660
+ candidates = [
661
+ row for row in summary
662
+ if row["policy"] == "dynamic" and int(row["target_accepts"]) == target
663
+ ]
664
+ same_budget = [
665
+ row for row in candidates
666
+ if abs(float(row["accepted_predictor_calls"]) - target) <= 0.5 + 1e-8
667
+ ]
668
+ if not same_budget:
669
+ closest = min(
670
+ abs(float(row["accepted_predictor_calls"]) - target)
671
+ for row in candidates
672
+ )
673
+ same_budget = [
674
+ row for row in candidates
675
+ if abs(abs(float(row["accepted_predictor_calls"]) - target) - closest)
676
+ <= 1e-8
677
+ ]
678
+ same_budget.sort(
679
+ key=lambda row: (
680
+ float(row["tail_lpips"]),
681
+ abs(float(row["accepted_predictor_calls"]) - target),
682
+ float(row["beta"]),
683
+ )
684
+ )
685
+ selected_dynamic.append(same_budget[0])
686
+ atomic_json(
687
+ output_dir / "selected.json",
688
+ {
689
+ "selection_rule": (
690
+ "within target accepted calls +/-0.5, lowest validation tail LPIPS; "
691
+ "then budget distance and lower beta"
692
+ ),
693
+ "selected_dynamic": selected_dynamic,
694
+ },
695
+ )
696
+
697
+
698
+ def formal(
699
+ args: argparse.Namespace,
700
+ vae: Any,
701
+ pipeline: Any,
702
+ predictor: Any,
703
+ head: Any,
704
+ lpips_model: Any,
705
+ device: torch.device,
706
+ ) -> None:
707
+ split = args.mode
708
+ split_prompt_ids = (
709
+ list(range(80, 90)) if split == "validation" else list(range(90, 100))
710
+ )
711
+ prompt_ids = args.prompt_ids or split_prompt_ids
712
+ invalid_prompt_ids = sorted(set(prompt_ids) - set(split_prompt_ids))
713
+ if invalid_prompt_ids:
714
+ raise ValueError(
715
+ f"Prompt IDs {invalid_prompt_ids} are outside the {split} split"
716
+ )
717
+ if split == "validation":
718
+ configurations = dynamic_configs(
719
+ args.validation_predictions, args.candidate_steps, args.target_accepts
720
+ ) + static_configs(args.target_accepts)
721
+ max_accepts = 6 * len(args.candidate_steps)
722
+ all_name = "fppf" if args.candidate_steps == [1, 2] else "fppp"
723
+ configurations += [
724
+ {"name": "ffff", "policy": "ffff", "beta": None, "threshold": None, "target_accepts": 0},
725
+ {"name": all_name, "policy": "fppf", "beta": None, "threshold": None, "target_accepts": max_accepts},
726
+ ]
727
+ else:
728
+ selected_path = args.selected_path or (
729
+ args.output_root / "validation" / "selected.json"
730
+ )
731
+ configurations = load_selected(selected_path) + static_configs(args.target_accepts)
732
+ max_accepts = 6 * len(args.candidate_steps)
733
+ all_name = "fppf" if args.candidate_steps == [1, 2] else "fppp"
734
+ configurations += [
735
+ {"name": "ffff", "policy": "ffff", "beta": None, "threshold": None, "target_accepts": 0},
736
+ {"name": all_name, "policy": "fppf", "beta": None, "threshold": None, "target_accepts": max_accepts},
737
+ ]
738
+ if args.config_names:
739
+ requested = set(args.config_names)
740
+ available = {str(config["name"]) for config in configurations}
741
+ missing = requested - available
742
+ if missing:
743
+ raise ValueError(
744
+ f"Unknown config_names {sorted(missing)}; available={sorted(available)}"
745
+ )
746
+ configurations = [
747
+ config for config in configurations if config["name"] in requested
748
+ ]
749
+ output_dir = args.output_root / split
750
+ output_dir.mkdir(parents=True, exist_ok=True)
751
+ missing_references = [
752
+ prompt_id for prompt_id in prompt_ids
753
+ if not (
754
+ args.reference_root / "ffff_reference_frames"
755
+ / f"prompt_{prompt_id:04d}.safetensors"
756
+ ).exists()
757
+ ]
758
+ if missing_references:
759
+ base.prepare_reference_frames(
760
+ vae=vae, dataset_root=args.dataset_root, output_dir=args.reference_root,
761
+ prompt_ids=missing_references, device=device, rebuild=False,
762
+ )
763
+ total = len(configurations) * len(prompt_ids)
764
+ records = []
765
+ completed = 0
766
+ print("[warmup] one unmeasured Full+Predictor+Head rollout", flush=True)
767
+ warmup_config = {
768
+ "name": "warmup",
769
+ "policy": "dynamic",
770
+ "beta": 1.0,
771
+ "threshold": -math.inf,
772
+ "target_accepts": 0,
773
+ }
774
+ warmup_latent, _ = generate(
775
+ pipeline=pipeline,
776
+ dataset_root=args.dataset_root,
777
+ prompt_id=prompt_ids[0],
778
+ generation_seed=args.generation_seed,
779
+ device=device,
780
+ predictor=predictor,
781
+ head=head,
782
+ config=warmup_config,
783
+ candidate_steps=args.candidate_steps,
784
+ )
785
+ del warmup_latent
786
+ torch.cuda.empty_cache()
787
+ for config in configurations:
788
+ for prompt_id in prompt_ids:
789
+ destination = output_dir / "per_run" / config["name"] / f"prompt_{prompt_id:04d}.json"
790
+ if destination.exists() and not args.overwrite:
791
+ records.append(json.loads(destination.read_text(encoding="utf-8")))
792
+ completed += 1
793
+ print(f"[cached] {completed}/{total} {config['name']} p={prompt_id}", flush=True)
794
+ continue
795
+ started = time.perf_counter()
796
+ reference_latent = base.load_ffff_latent(args.dataset_root, prompt_id).to(
797
+ device=device, dtype=torch.bfloat16
798
+ )
799
+ reference_u8 = base.load_reference_frames(args.reference_root, prompt_id)
800
+ latent, diagnostic = generate(
801
+ pipeline=pipeline, dataset_root=args.dataset_root, prompt_id=prompt_id,
802
+ generation_seed=args.generation_seed, device=device, predictor=predictor,
803
+ head=head, config=config, candidate_steps=args.candidate_steps,
804
+ )
805
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
806
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
807
+ prediction_u8 = base.pixels_to_u8(pixels)
808
+ if args.save_videos:
809
+ base.save_mp4(
810
+ prediction_u8,
811
+ output_dir / "videos" / config["name"]
812
+ / f"prompt_{prompt_id:04d}.mp4",
813
+ )
814
+ frame = base.frame_metrics(
815
+ reference_u8=reference_u8, prediction_u8=prediction_u8,
816
+ lpips_model=lpips_model, batch_size=args.metric_batch_size, device=device,
817
+ )
818
+ tail_start = impact_eval.chunk_frame_slice(1).start
819
+ tail = impact_eval.summarize_frame_range(frame, slice(tail_start, None))
820
+ record = {
821
+ "config_name": config["name"],
822
+ "policy": config["policy"],
823
+ "beta": config["beta"],
824
+ "target_accepts": config["target_accepts"],
825
+ "threshold": config["threshold"],
826
+ "prompt_id": prompt_id,
827
+ "latent_nrmse": impact_eval.nrmse(latent, reference_latent),
828
+ "latent_tail_nrmse": impact_eval.nrmse(
829
+ latent[:, base.FRAMES_PER_CHUNK:],
830
+ reference_latent[:, base.FRAMES_PER_CHUNK:],
831
+ ),
832
+ "psnr": frame["psnr"],
833
+ "ssim": frame["ssim"],
834
+ "lpips": frame["lpips"],
835
+ "tail_psnr": tail["psnr"],
836
+ "tail_ssim": tail["ssim"],
837
+ "tail_lpips": tail["lpips"],
838
+ **diagnostic,
839
+ "total_time_s": time.perf_counter() - started,
840
+ }
841
+ atomic_json(destination, record)
842
+ records.append(record)
843
+ completed += 1
844
+ print(
845
+ f"[run] {completed}/{total} {config['name']} p={prompt_id} "
846
+ f"accept={record['accepted_predictor_calls']} "
847
+ f"tail_lpips={record['tail_lpips']:.5f} "
848
+ f"time={record['total_time_s']:.1f}s",
849
+ flush=True,
850
+ )
851
+ if hasattr(vae.model, "clear_cache"):
852
+ vae.model.clear_cache()
853
+ del reference_latent, reference_u8, latent, pixels, prediction_u8, frame
854
+ torch.cuda.empty_cache()
855
+ flat_fields = sorted({key for row in records for key in row if key != "decisions"})
856
+ shard_suffix = f"_gpu{args.gpu}" if args.prompt_ids else ""
857
+ write_csv(
858
+ output_dir / f"runs{shard_suffix}.csv",
859
+ [{key: row.get(key) for key in flat_fields} for row in records],
860
+ flat_fields,
861
+ )
862
+ summary_output_dir = output_dir
863
+ if shard_suffix:
864
+ summary_output_dir = output_dir / f".summary_shard_gpu{args.gpu}"
865
+ summary_output_dir.mkdir(parents=True, exist_ok=True)
866
+ summary = aggregate(records, summary_output_dir)
867
+ if shard_suffix:
868
+ os.replace(
869
+ summary_output_dir / "summary.csv",
870
+ output_dir / f"summary{shard_suffix}.csv",
871
+ )
872
+ summary_output_dir.rmdir()
873
+ if split == "validation" and not shard_suffix:
874
+ select_validation(summary, output_dir, args.target_accepts)
875
+ atomic_json(
876
+ output_dir / f"manifest{shard_suffix}.json",
877
+ {
878
+ "status": "complete", "split": split, "prompt_ids": prompt_ids,
879
+ "num_configs": len(configurations), "num_runs": len(records),
880
+ "configs": configurations, "candidate_steps": args.candidate_steps,
881
+ "target_accepts": args.target_accepts,
882
+ "predictor_weights": (
883
+ str(args.predictor_weights) if args.predictor_weights else None
884
+ ),
885
+ },
886
+ )
887
+ print(f"[complete] {split} -> {output_dir}", flush=True)
888
+
889
+
890
+ def main() -> None:
891
+ args = parse_args()
892
+ args.output_root.mkdir(parents=True, exist_ok=True)
893
+ device = torch.device("cuda")
894
+ torch.set_grad_enabled(False)
895
+ set_seed(args.generation_seed)
896
+ vae, pipeline, predictor, head, lpips_model = load_models(args, device)
897
+ if args.mode == "smoke":
898
+ smoke(args, pipeline, predictor, head, device)
899
+ else:
900
+ formal(args, vae, pipeline, predictor, head, lpips_model, device)
901
+
902
+
903
+ if __name__ == "__main__":
904
+ main()
scripts/evaluate_layer17_moviebench_step2000.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate a trained Self-Forcing Layer-17 predictor on MovieBench prompts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+
14
+ def preparse_gpu() -> str:
15
+ parser = argparse.ArgumentParser(add_help=False)
16
+ parser.add_argument("--gpu", required=True)
17
+ args, _ = parser.parse_known_args()
18
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
19
+ return args.gpu
20
+
21
+
22
+ GPU = preparse_gpu()
23
+
24
+ import lpips
25
+ import torch
26
+ from omegaconf import OmegaConf
27
+
28
+ ROOT = Path(__file__).resolve().parents[1]
29
+ if str(ROOT) not in sys.path:
30
+ sys.path.insert(0, str(ROOT))
31
+
32
+ from predictor_training.offline_data import TOKENS_PER_CHUNK
33
+ from scripts.evaluate_single_block_fppf import (
34
+ FinalHiddenCapture,
35
+ atomic_json,
36
+ build_pipeline,
37
+ frame_metrics,
38
+ load_predictor,
39
+ pixels_to_u8,
40
+ predictor_step,
41
+ save_mp4,
42
+ )
43
+ from utils.misc import set_seed
44
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
45
+
46
+
47
+ NUM_CHUNKS = 7
48
+ FRAMES_PER_CHUNK = 3
49
+ NUM_STEPS = 4
50
+ LATENT_CHANNELS = 16
51
+ LATENT_HEIGHT = 60
52
+ LATENT_WIDTH = 104
53
+
54
+
55
+ def read_lines(path: Path) -> list[str]:
56
+ return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
57
+
58
+
59
+ def reset_caches(pipeline, device: torch.device) -> None:
60
+ if pipeline.kv_cache1 is None:
61
+ pipeline._initialize_kv_cache(1, torch.bfloat16, device)
62
+ pipeline._initialize_crossattn_cache(1, torch.bfloat16, device)
63
+ for cache in pipeline.kv_cache1:
64
+ cache["global_end_index"].zero_()
65
+ cache["local_end_index"].zero_()
66
+ for cache in pipeline.crossattn_cache:
67
+ cache["is_init"] = False
68
+
69
+
70
+ @torch.inference_mode()
71
+ def rollout(pipeline, conditional_dict, seed: int, device: torch.device, predictor=None):
72
+ reset_caches(pipeline, device)
73
+ set_seed(seed)
74
+ noise = torch.randn(
75
+ 1,
76
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
77
+ LATENT_CHANNELS,
78
+ LATENT_HEIGHT,
79
+ LATENT_WIDTH,
80
+ dtype=torch.bfloat16,
81
+ device=device,
82
+ )
83
+ teacher = pipeline.generator.model
84
+ timesteps = pipeline.denoising_step_list.to(device=device)
85
+ outputs = []
86
+ previous_chunk_hidden = None
87
+ capture = FinalHiddenCapture(teacher)
88
+ full_calls = predictor_calls = 0
89
+ started = time.perf_counter()
90
+ try:
91
+ for chunk in range(NUM_CHUNKS):
92
+ noisy_input = noise[:, chunk * 3 : (chunk + 1) * 3]
93
+ current_hidden = [None] * NUM_STEPS
94
+ denoised_pred = timestep = None
95
+ for step, current_timestep in enumerate(timesteps):
96
+ timestep = torch.ones([1, 3], dtype=torch.int64, device=device) * current_timestep
97
+ use_predictor = predictor is not None and chunk > 0 and step in {1, 2}
98
+ if use_predictor:
99
+ hidden, flow, _ = predictor_step(
100
+ predictor=predictor,
101
+ teacher=teacher,
102
+ noisy_input=noisy_input,
103
+ timestep=timestep,
104
+ anchor_hidden=current_hidden[step - 1],
105
+ previous_hidden=previous_chunk_hidden[step],
106
+ history_cache=pipeline.kv_cache1[17],
107
+ cross_cache=pipeline.crossattn_cache[17],
108
+ current_start=chunk * TOKENS_PER_CHUNK,
109
+ )
110
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
111
+ flow_pred=flow.flatten(0, 1),
112
+ xt=noisy_input.flatten(0, 1),
113
+ timestep=timestep.flatten(0, 1),
114
+ ).unflatten(0, flow.shape[:2])
115
+ current_hidden[step] = hidden
116
+ predictor_calls += 1
117
+ else:
118
+ capture.start()
119
+ _, denoised_pred = pipeline.generator(
120
+ noisy_image_or_video=noisy_input,
121
+ conditional_dict=conditional_dict,
122
+ timestep=timestep,
123
+ kv_cache=pipeline.kv_cache1,
124
+ crossattn_cache=pipeline.crossattn_cache,
125
+ current_start=chunk * TOKENS_PER_CHUNK,
126
+ )
127
+ current_hidden[step] = capture.finish()
128
+ full_calls += 1
129
+ if step < NUM_STEPS - 1:
130
+ flat = denoised_pred.flatten(0, 1)
131
+ noisy_input = pipeline.scheduler.add_noise(
132
+ flat,
133
+ torch.randn_like(flat),
134
+ timesteps[step + 1] * torch.ones([3], dtype=torch.long, device=device),
135
+ ).unflatten(0, denoised_pred.shape[:2])
136
+ outputs.append(denoised_pred)
137
+ pipeline.generator(
138
+ noisy_image_or_video=denoised_pred,
139
+ conditional_dict=conditional_dict,
140
+ timestep=torch.ones_like(timestep) * pipeline.args.context_noise,
141
+ kv_cache=pipeline.kv_cache1,
142
+ crossattn_cache=pipeline.crossattn_cache,
143
+ current_start=chunk * TOKENS_PER_CHUNK,
144
+ )
145
+ previous_chunk_hidden = current_hidden
146
+ finally:
147
+ capture.close()
148
+ torch.cuda.synchronize()
149
+ return torch.cat(outputs, dim=1), {
150
+ "generation_time_s": time.perf_counter() - started,
151
+ "full_calls": full_calls,
152
+ "predictor_calls": predictor_calls,
153
+ }
154
+
155
+
156
+ def main() -> None:
157
+ parser = argparse.ArgumentParser(description=__doc__)
158
+ parser.add_argument("--gpu", default=GPU)
159
+ parser.add_argument("--prompt_ids", type=int, nargs="+", required=True)
160
+ parser.add_argument("--original_prompts", type=Path, required=True)
161
+ parser.add_argument("--extended_prompts", type=Path, required=True)
162
+ parser.add_argument("--output_dir", type=Path, required=True)
163
+ parser.add_argument("--weights", type=Path, required=True)
164
+ parser.add_argument("--seed", type=int, default=0)
165
+ parser.add_argument("--metric_batch_size", type=int, default=4)
166
+ args = parser.parse_args()
167
+ args.output_dir.mkdir(parents=True, exist_ok=True)
168
+ original = read_lines(args.original_prompts)
169
+ extended = read_lines(args.extended_prompts)
170
+ if len(original) != len(extended) or min(args.prompt_ids) < 0 or max(args.prompt_ids) >= len(original):
171
+ raise ValueError("MovieBench original/extended prompt pairing is invalid")
172
+
173
+ atomic_json(
174
+ args.output_dir / "manifest.json",
175
+ {
176
+ "status": "running",
177
+ "gpu": args.gpu,
178
+ "prompt_ids": args.prompt_ids,
179
+ "weights": str(args.weights.resolve()),
180
+ "teacher": str((ROOT / "checkpoints/self_forcing_dmd.pt").resolve()),
181
+ "generation_seed_reset_per_prompt": args.seed,
182
+ "generation_prompts": str(args.extended_prompts.resolve()),
183
+ "evaluation_prompts": str(args.original_prompts.resolve()),
184
+ "schedule": "chunk0=FFFF; chunks1-6=FPPF",
185
+ "metrics": ["PSNR", "SSIM", "LPIPS", "rollout-only PSNR/SSIM/LPIPS"],
186
+ },
187
+ )
188
+
189
+ device = torch.device("cuda")
190
+ torch.set_grad_enabled(False)
191
+ config = OmegaConf.merge(
192
+ OmegaConf.load(ROOT / "configs/default_config.yaml"),
193
+ OmegaConf.load(ROOT / "configs/self_forcing_sid.yaml"),
194
+ )
195
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
196
+ pipeline = build_pipeline(config, ROOT / "checkpoints/self_forcing_dmd.pt", vae, device)
197
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
198
+ text_encoder.requires_grad_(False)
199
+ predictor = load_predictor(
200
+ pipeline.generator.model,
201
+ {"source_layer": 17, "weights": args.weights, "gate_mode": "baseline"},
202
+ device,
203
+ )
204
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
205
+ lpips_model.requires_grad_(False)
206
+
207
+ reference_dir = args.output_dir / "videos" / "ffff"
208
+ prediction_dir = args.output_dir / "videos" / "fppf_step2000"
209
+ for offset, prompt_id in enumerate(args.prompt_ids, start=1):
210
+ result_path = args.output_dir / "per_prompt" / f"prompt_{prompt_id:04d}.json"
211
+ if (
212
+ result_path.exists()
213
+ and (reference_dir / f"{prompt_id:05d}.mp4").exists()
214
+ and (prediction_dir / f"{prompt_id:05d}.mp4").exists()
215
+ ):
216
+ print(f"[skip] {offset}/{len(args.prompt_ids)} id={prompt_id}", flush=True)
217
+ continue
218
+ print(f"[encode] {offset}/{len(args.prompt_ids)} id={prompt_id}", flush=True)
219
+ conditional = text_encoder(text_prompts=[extended[prompt_id]])
220
+ reference_latent, ffff_counts = rollout(pipeline, conditional, args.seed, device)
221
+ prediction_latent, fppf_counts = rollout(
222
+ pipeline, conditional, args.seed, device, predictor=predictor
223
+ )
224
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
225
+ reference_pixels = vae.decode_to_pixel(reference_latent, use_cache=False)
226
+ prediction_pixels = vae.decode_to_pixel(prediction_latent, use_cache=False)
227
+ reference_u8 = pixels_to_u8(reference_pixels)
228
+ prediction_u8 = pixels_to_u8(prediction_pixels)
229
+ save_mp4(reference_u8, reference_dir / f"{prompt_id:05d}.mp4")
230
+ save_mp4(prediction_u8, prediction_dir / f"{prompt_id:05d}.mp4")
231
+ metrics = frame_metrics(
232
+ reference_u8=reference_u8,
233
+ prediction_u8=prediction_u8,
234
+ lpips_model=lpips_model,
235
+ batch_size=args.metric_batch_size,
236
+ device=device,
237
+ )
238
+ atomic_json(
239
+ result_path,
240
+ {
241
+ "status": "complete",
242
+ "prompt_id": prompt_id,
243
+ "original_prompt": original[prompt_id],
244
+ "generation_prompt": extended[prompt_id],
245
+ "seed": args.seed,
246
+ "latent_frames": NUM_CHUNKS * FRAMES_PER_CHUNK,
247
+ "decoded_frames": metrics["num_frames"],
248
+ "schedule": "chunk0=FFFF; chunks1-6=FPPF",
249
+ "ffff": ffff_counts,
250
+ "fppf": fppf_counts,
251
+ **metrics,
252
+ },
253
+ )
254
+ print(
255
+ f"[result] id={prompt_id} psnr={metrics['psnr']:.4f} "
256
+ f"ssim={metrics['ssim']:.6f} lpips={metrics['lpips']:.6f}",
257
+ flush=True,
258
+ )
259
+ if hasattr(vae.model, "clear_cache"):
260
+ vae.model.clear_cache()
261
+ del conditional, reference_latent, prediction_latent, reference_pixels
262
+ del prediction_pixels, reference_u8, prediction_u8
263
+ torch.cuda.empty_cache()
264
+
265
+ manifest = json.loads((args.output_dir / "manifest.json").read_text(encoding="utf-8"))
266
+ manifest["status"] = "complete"
267
+ atomic_json(args.output_dir / "manifest.json", manifest)
268
+
269
+
270
+ if __name__ == "__main__":
271
+ main()
scripts/evaluate_long_video_fppf.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate 2x/4x long FFFF and one-block Layer-17 FPPF rollouts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+
14
+ def _preparse_gpu() -> str:
15
+ parser = argparse.ArgumentParser(add_help=False)
16
+ parser.add_argument("--gpu", default="0")
17
+ args, _ = parser.parse_known_args()
18
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
19
+ return str(args.gpu)
20
+
21
+
22
+ PHYSICAL_GPU = _preparse_gpu()
23
+
24
+ import lpips
25
+ import torch
26
+ from omegaconf import OmegaConf
27
+ from torchvision.io import write_video
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[1]
30
+ if str(REPO_ROOT) not in sys.path:
31
+ sys.path.insert(0, str(REPO_ROOT))
32
+
33
+ from predictor_training.offline_data import TOKENS_PER_CHUNK
34
+ from scripts.evaluate_single_block_fppf import (
35
+ DEFAULT_PROMPT_IDS,
36
+ FRAMES_PER_CHUNK,
37
+ LATENT_CHANNELS,
38
+ LATENT_HEIGHT,
39
+ LATENT_WIDTH,
40
+ NUM_DENOISING_STEPS,
41
+ FinalHiddenCapture,
42
+ atomic_json,
43
+ build_pipeline,
44
+ discover_experiments,
45
+ frame_metrics,
46
+ load_predictor,
47
+ load_prompt_metadata,
48
+ pixels_to_u8,
49
+ predictor_step,
50
+ reset_kv_and_load_cross_cache,
51
+ )
52
+ from utils.misc import set_seed
53
+ from utils.wan_wrapper import WanVAEWrapper
54
+
55
+
56
+ def parse_args() -> argparse.Namespace:
57
+ parser = argparse.ArgumentParser(description=__doc__)
58
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
59
+ parser.add_argument("--prompt_ids", type=int, nargs="+", required=True)
60
+ parser.add_argument("--latent_lengths", type=int, nargs="+", default=[42, 84])
61
+ parser.add_argument(
62
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
63
+ )
64
+ parser.add_argument(
65
+ "--checkpoint_path", type=Path, default=Path("checkpoints/self_forcing_dmd.pt")
66
+ )
67
+ parser.add_argument(
68
+ "--dataset_root", type=Path,
69
+ default=Path("outputs/predictor_offline_100_all_blocks"),
70
+ )
71
+ parser.add_argument(
72
+ "--sweep_dir", type=Path, default=Path("outputs/single_block_init_sweep")
73
+ )
74
+ parser.add_argument(
75
+ "--output_dir", type=Path, default=Path("outputs/long_video_2x4x_eval")
76
+ )
77
+ parser.add_argument("--metric_batch_size", type=int, default=4)
78
+ parser.add_argument("--generation_seed", type=int, default=0)
79
+ args = parser.parse_args()
80
+ if any(length <= 0 or length % FRAMES_PER_CHUNK for length in args.latent_lengths):
81
+ parser.error("Latent lengths must be positive multiples of 3")
82
+ if any(prompt not in DEFAULT_PROMPT_IDS for prompt in args.prompt_ids):
83
+ parser.error("This evaluation is restricted to validation prompt IDs 80..99")
84
+ return args
85
+
86
+
87
+ def resolve(path: Path) -> Path:
88
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
89
+
90
+
91
+ @torch.inference_mode()
92
+ def generate_rollout(
93
+ *, pipeline, dataset_root: Path, prompt_id: int, latent_length: int,
94
+ generation_seed: int, device: torch.device, predictor, source_layer: int | None,
95
+ schedule: str,
96
+ ) -> tuple[torch.Tensor, dict[str, float | int]]:
97
+ if schedule not in {"FFFF", "FPPF"}:
98
+ raise ValueError(schedule)
99
+ if schedule == "FPPF" and (predictor is None or source_layer is None):
100
+ raise ValueError("FPPF requires the Predictor")
101
+ num_chunks = latent_length // FRAMES_PER_CHUNK
102
+ reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
103
+ set_seed(generation_seed)
104
+ noise = torch.randn(
105
+ 1, latent_length, LATENT_CHANNELS, LATENT_HEIGHT, LATENT_WIDTH,
106
+ dtype=torch.bfloat16, device=device,
107
+ )
108
+ teacher = pipeline.generator.model
109
+ text_dim = int(teacher.text_embedding[0].in_features)
110
+ conditional_dict = {
111
+ "prompt_embeds": torch.zeros(
112
+ 1, 1, text_dim, dtype=torch.bfloat16, device=device
113
+ )
114
+ }
115
+ timesteps = pipeline.denoising_step_list.to(device=device)
116
+ output_chunks = []
117
+ previous_chunk_hidden = None
118
+ capture = FinalHiddenCapture(teacher)
119
+ full_calls = predictor_calls = 0
120
+ started = time.perf_counter()
121
+ try:
122
+ for chunk in range(num_chunks):
123
+ noisy_input = noise[:, chunk * 3:(chunk + 1) * 3]
124
+ current_hidden = [None] * NUM_DENOISING_STEPS
125
+ denoised_pred = timestep = None
126
+ for step, current_timestep in enumerate(timesteps):
127
+ timestep = torch.ones(
128
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device=device
129
+ ) * current_timestep
130
+ use_predictor = schedule == "FPPF" and chunk > 0 and step in {1, 2}
131
+ if use_predictor:
132
+ pred_hidden, flow, _ = predictor_step(
133
+ predictor=predictor,
134
+ teacher=teacher,
135
+ noisy_input=noisy_input,
136
+ timestep=timestep,
137
+ anchor_hidden=current_hidden[step - 1],
138
+ previous_hidden=previous_chunk_hidden[step],
139
+ history_cache=pipeline.kv_cache1[source_layer],
140
+ cross_cache=pipeline.crossattn_cache[source_layer],
141
+ current_start=chunk * TOKENS_PER_CHUNK,
142
+ )
143
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
144
+ flow_pred=flow.flatten(0, 1),
145
+ xt=noisy_input.flatten(0, 1),
146
+ timestep=timestep.flatten(0, 1),
147
+ ).unflatten(0, flow.shape[:2])
148
+ current_hidden[step] = pred_hidden
149
+ predictor_calls += 1
150
+ else:
151
+ capture.start()
152
+ _, denoised_pred = pipeline.generator(
153
+ noisy_image_or_video=noisy_input,
154
+ conditional_dict=conditional_dict,
155
+ timestep=timestep,
156
+ kv_cache=pipeline.kv_cache1,
157
+ crossattn_cache=pipeline.crossattn_cache,
158
+ current_start=chunk * TOKENS_PER_CHUNK,
159
+ )
160
+ current_hidden[step] = capture.finish()
161
+ full_calls += 1
162
+ if step < NUM_DENOISING_STEPS - 1:
163
+ flat = denoised_pred.flatten(0, 1)
164
+ noisy_input = pipeline.scheduler.add_noise(
165
+ flat, torch.randn_like(flat),
166
+ timesteps[step + 1] * torch.ones(
167
+ [FRAMES_PER_CHUNK], dtype=torch.long, device=device
168
+ ),
169
+ ).unflatten(0, denoised_pred.shape[:2])
170
+ output_chunks.append(denoised_pred)
171
+ pipeline.generator(
172
+ noisy_image_or_video=denoised_pred,
173
+ conditional_dict=conditional_dict,
174
+ timestep=torch.ones_like(timestep) * pipeline.args.context_noise,
175
+ kv_cache=pipeline.kv_cache1,
176
+ crossattn_cache=pipeline.crossattn_cache,
177
+ current_start=chunk * TOKENS_PER_CHUNK,
178
+ )
179
+ previous_chunk_hidden = current_hidden
180
+ finally:
181
+ capture.close()
182
+ torch.cuda.synchronize()
183
+ return torch.cat(output_chunks, dim=1), {
184
+ "generation_time_s": time.perf_counter() - started,
185
+ "full_calls": full_calls,
186
+ "predictor_calls": predictor_calls,
187
+ "num_chunks": num_chunks,
188
+ }
189
+
190
+
191
+ def save_mp4(frames: torch.Tensor, path: Path) -> None:
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+ write_video(
194
+ str(path), frames.permute(0, 2, 3, 1), fps=16,
195
+ video_codec="libx264", options={"crf": "18"},
196
+ )
197
+
198
+
199
+ def main() -> None:
200
+ args = parse_args()
201
+ for field in ("config_path", "checkpoint_path", "dataset_root", "sweep_dir", "output_dir"):
202
+ setattr(args, field, resolve(getattr(args, field)))
203
+ args.output_dir.mkdir(parents=True, exist_ok=True)
204
+ atomic_json(args.output_dir / "manifest.json", {
205
+ "status": "running", "physical_gpu": str(args.gpu),
206
+ "prompt_ids": args.prompt_ids, "latent_lengths": args.latent_lengths,
207
+ "methods": ["FFFF", "FPPF_teacher_layer_17"],
208
+ "generation_seed": args.generation_seed,
209
+ })
210
+ device = torch.device("cuda")
211
+ torch.set_grad_enabled(False)
212
+ set_seed(args.generation_seed)
213
+ config = OmegaConf.merge(
214
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
215
+ OmegaConf.load(args.config_path),
216
+ )
217
+ # The released checkpoint uses full attention over its 21-latent training
218
+ # horizon. Long inference keeps exactly that horizon as a rolling window;
219
+ # within the first 21 latents this is numerically the same attention span.
220
+ config.model_kwargs.local_attn_size = 21
221
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
222
+ pipeline = build_pipeline(config, args.checkpoint_path, vae, device)
223
+ experiment = discover_experiments(
224
+ args.sweep_dir, ["teacher_layer_17"], None
225
+ )[0]
226
+ predictor = load_predictor(pipeline.generator.model, experiment, device)
227
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
228
+ lpips_model.requires_grad_(False)
229
+
230
+ for prompt_id in args.prompt_ids:
231
+ prompt = load_prompt_metadata(args.dataset_root, prompt_id)["prompt"]
232
+ for latent_length in args.latent_lengths:
233
+ run_dir = args.output_dir / f"latent_{latent_length}" / f"prompt_{prompt_id:04d}"
234
+ result_path = run_dir / "metrics.json"
235
+ if result_path.exists():
236
+ existing = json.loads(result_path.read_text(encoding="utf-8"))
237
+ if existing.get("status") == "complete":
238
+ print(f"[skip] latent={latent_length} prompt={prompt_id}", flush=True)
239
+ continue
240
+ print(f"[run] latent={latent_length} prompt={prompt_id} FFFF", flush=True)
241
+ reference_latent, ffff_counts = generate_rollout(
242
+ pipeline=pipeline, dataset_root=args.dataset_root,
243
+ prompt_id=prompt_id, latent_length=latent_length,
244
+ generation_seed=args.generation_seed, device=device,
245
+ predictor=None, source_layer=None, schedule="FFFF",
246
+ )
247
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
248
+ reference_pixels = vae.decode_to_pixel(reference_latent, use_cache=False)
249
+ reference_u8 = pixels_to_u8(reference_pixels)
250
+ save_mp4(reference_u8, run_dir / "ffff.mp4")
251
+ del reference_latent, reference_pixels
252
+ if hasattr(vae.model, "clear_cache"):
253
+ vae.model.clear_cache()
254
+ torch.cuda.empty_cache()
255
+
256
+ print(f"[run] latent={latent_length} prompt={prompt_id} FPPF", flush=True)
257
+ prediction_latent, fppf_counts = generate_rollout(
258
+ pipeline=pipeline, dataset_root=args.dataset_root,
259
+ prompt_id=prompt_id, latent_length=latent_length,
260
+ generation_seed=args.generation_seed, device=device,
261
+ predictor=predictor, source_layer=17, schedule="FPPF",
262
+ )
263
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
264
+ prediction_pixels = vae.decode_to_pixel(prediction_latent, use_cache=False)
265
+ prediction_u8 = pixels_to_u8(prediction_pixels)
266
+ save_mp4(prediction_u8, run_dir / "fppf_layer17.mp4")
267
+ metrics = frame_metrics(
268
+ reference_u8=reference_u8, prediction_u8=prediction_u8,
269
+ lpips_model=lpips_model, batch_size=args.metric_batch_size,
270
+ device=device,
271
+ )
272
+ atomic_json(result_path, {
273
+ "status": "complete", "prompt_id": prompt_id, "prompt": prompt,
274
+ "latent_length": latent_length, "decoded_frames": metrics["num_frames"],
275
+ "reference": "FFFF same prompt/seed/noise",
276
+ "predictor": "single_block_teacher_layer_17",
277
+ "ffff": ffff_counts, "fppf": fppf_counts, **metrics,
278
+ })
279
+ print(
280
+ f"[result] latent={latent_length} prompt={prompt_id} "
281
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
282
+ f"lpips={metrics['lpips']:.6f}", flush=True,
283
+ )
284
+ del prediction_latent, prediction_pixels, reference_u8, prediction_u8
285
+ if hasattr(vae.model, "clear_cache"):
286
+ vae.model.clear_cache()
287
+ torch.cuda.empty_cache()
288
+
289
+ manifest = json.loads((args.output_dir / "manifest.json").read_text(encoding="utf-8"))
290
+ manifest["status"] = "complete"
291
+ atomic_json(args.output_dir / "manifest.json", manifest)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()
scripts/evaluate_long_video_vbench.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run all standard VBench dimensions for one long-video condition."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+
12
+ def preparse_gpu() -> str:
13
+ parser = argparse.ArgumentParser(add_help=False)
14
+ parser.add_argument("--gpu", required=True)
15
+ args, _ = parser.parse_known_args()
16
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
17
+ os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="vbench_mpl_"))
18
+ return args.gpu
19
+
20
+
21
+ GPU = preparse_gpu()
22
+
23
+ from vbench import VBench
24
+
25
+
26
+ DIMENSIONS = [
27
+ "subject_consistency", "background_consistency", "motion_smoothness",
28
+ "aesthetic_quality", "imaging_quality",
29
+ ]
30
+
31
+
32
+ def main() -> None:
33
+ parser = argparse.ArgumentParser(description=__doc__)
34
+ parser.add_argument("--gpu", default=GPU)
35
+ parser.add_argument("--video_dir", type=Path, required=True)
36
+ parser.add_argument("--output_dir", type=Path, required=True)
37
+ parser.add_argument("--name", required=True)
38
+ args = parser.parse_args()
39
+ args.video_dir = args.video_dir.resolve()
40
+ args.output_dir = args.output_dir.resolve()
41
+ args.output_dir.mkdir(parents=True, exist_ok=True)
42
+ bench = VBench(
43
+ device="cuda", full_info_dir=str(args.video_dir / "full_info.json"),
44
+ output_path=str(args.output_dir),
45
+ )
46
+ bench.evaluate(
47
+ videos_path=str(args.video_dir), name=args.name,
48
+ dimension_list=DIMENSIONS, mode="custom_input",
49
+ )
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
scripts/evaluate_single_block_fppf.py ADDED
@@ -0,0 +1,1135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate trained one-block Predictors with an FPPF rollout against FFFF.
3
+
4
+ The evaluation uses the held-out offline prompt shards. FFFF clean latents
5
+ are decoded once and cached as 8-bit RGB reference frames. For every trained
6
+ Predictor, chunk 0 is generated with FFFF (there is no previous chunk), while
7
+ chunks 1..6 use Full-Predictor-Predictor-Full. PSNR, Gaussian SSIM, and
8
+ AlexNet LPIPS are computed frame by frame against the matching FFFF video.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import csv
15
+ import json
16
+ import math
17
+ import os
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ def _preparse_gpu() -> str:
25
+ parser = argparse.ArgumentParser(add_help=False)
26
+ parser.add_argument("--gpu", default="2")
27
+ args, _ = parser.parse_known_args()
28
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
29
+ return str(args.gpu)
30
+
31
+
32
+ PHYSICAL_GPU = _preparse_gpu()
33
+
34
+ import lpips
35
+ import torch
36
+ import torch.nn.functional as F
37
+ from omegaconf import OmegaConf
38
+ from safetensors import safe_open
39
+ from safetensors.torch import load_file, save_file
40
+ from torchvision.io import write_video
41
+
42
+ REPO_ROOT = Path(__file__).resolve().parents[1]
43
+ if str(REPO_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(REPO_ROOT))
45
+
46
+ from pipeline import CausalInferencePipeline
47
+ from predictor_training.offline_data import TOKENS_PER_CHUNK
48
+ from predictor_training.single_block import (
49
+ SingleBlockPredictor,
50
+ initialize_predictor_block,
51
+ )
52
+ from scripts.run_single_block_init_sweep import hidden_to_flow
53
+ from utils.misc import set_seed
54
+ from utils.wan_wrapper import WanDiffusionWrapper, WanVAEWrapper
55
+ from wan.modules.model import sinusoidal_embedding_1d
56
+
57
+
58
+ LATENT_CHANNELS = 16
59
+ LATENT_HEIGHT = 60
60
+ LATENT_WIDTH = 104
61
+ FRAMES_PER_CHUNK = 3
62
+ NUM_CHUNKS = 7
63
+ NUM_DENOISING_STEPS = 4
64
+ PIXEL_FRAMES_FIRST_CHUNK = 1 + 4 * (FRAMES_PER_CHUNK - 1)
65
+ DEFAULT_PROMPT_IDS = list(range(80, 100))
66
+
67
+
68
+ def parse_args() -> argparse.Namespace:
69
+ parser = argparse.ArgumentParser(description=__doc__)
70
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
71
+ parser.add_argument(
72
+ "--config_path",
73
+ type=Path,
74
+ default=Path("configs/self_forcing_sid.yaml"),
75
+ )
76
+ parser.add_argument(
77
+ "--checkpoint_path",
78
+ type=Path,
79
+ default=Path("checkpoints/self_forcing_dmd.pt"),
80
+ )
81
+ parser.add_argument(
82
+ "--dataset_root",
83
+ type=Path,
84
+ default=Path("outputs/predictor_offline_100_all_blocks"),
85
+ )
86
+ parser.add_argument(
87
+ "--sweep_dir",
88
+ type=Path,
89
+ default=Path("outputs/single_block_init_sweep"),
90
+ )
91
+ parser.add_argument(
92
+ "--output_dir",
93
+ type=Path,
94
+ default=Path("outputs/single_block_fppf_eval"),
95
+ )
96
+ parser.add_argument(
97
+ "--prompt_ids", type=int, nargs="*", default=DEFAULT_PROMPT_IDS
98
+ )
99
+ parser.add_argument(
100
+ "--experiments",
101
+ nargs="*",
102
+ default=None,
103
+ help="Experiment directory names. Omit to evaluate all summary rows.",
104
+ )
105
+ parser.add_argument("--max_prompts", type=int, default=None)
106
+ parser.add_argument("--max_experiments", type=int, default=None)
107
+ parser.add_argument("--metric_batch_size", type=int, default=4)
108
+ parser.add_argument("--generation_seed", type=int, default=0)
109
+ parser.add_argument(
110
+ "--verify_ffff",
111
+ action=argparse.BooleanOptionalAction,
112
+ default=True,
113
+ help="Re-run FFFF once and compare its latent exactly to offline data.",
114
+ )
115
+ parser.add_argument(
116
+ "--skip_lpips",
117
+ action=argparse.BooleanOptionalAction,
118
+ default=False,
119
+ help="Only for quick diagnostics; formal evaluation should keep LPIPS.",
120
+ )
121
+ parser.add_argument(
122
+ "--rebuild_references",
123
+ action=argparse.BooleanOptionalAction,
124
+ default=False,
125
+ )
126
+ parser.add_argument(
127
+ "--save_videos",
128
+ action=argparse.BooleanOptionalAction,
129
+ default=False,
130
+ help="Save each FPPF prediction as a 16-fps H.264 MP4 for VBench.",
131
+ )
132
+ args = parser.parse_args()
133
+ if args.metric_batch_size < 1:
134
+ parser.error("--metric_batch_size must be positive")
135
+ if not args.prompt_ids:
136
+ parser.error("At least one prompt ID is required")
137
+ if any(value < 0 or value >= 100 for value in args.prompt_ids):
138
+ parser.error("Prompt IDs must be in [0, 99]")
139
+ return args
140
+
141
+
142
+ def resolve(path: Path) -> Path:
143
+ path = path.expanduser()
144
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
145
+
146
+
147
+ def atomic_json(path: Path, value: Any) -> None:
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ temporary = path.with_suffix(path.suffix + ".tmp")
150
+ temporary.write_text(
151
+ json.dumps(value, indent=2, ensure_ascii=False, allow_nan=True) + "\n",
152
+ encoding="utf-8",
153
+ )
154
+ os.replace(temporary, path)
155
+
156
+
157
+ def atomic_safetensors(
158
+ path: Path, tensors: dict[str, torch.Tensor], metadata: dict[str, str]
159
+ ) -> None:
160
+ path.parent.mkdir(parents=True, exist_ok=True)
161
+ temporary = path.with_suffix(path.suffix + ".tmp")
162
+ save_file(tensors, temporary, metadata=metadata)
163
+ os.replace(temporary, path)
164
+
165
+
166
+ def load_prompt_metadata(dataset_root: Path, prompt_id: int) -> dict[str, Any]:
167
+ path = dataset_root / f"prompt_{prompt_id:04d}" / "metadata.json"
168
+ return json.loads(path.read_text(encoding="utf-8"))
169
+
170
+
171
+ def load_ffff_latent(dataset_root: Path, prompt_id: int) -> torch.Tensor:
172
+ path = dataset_root / f"prompt_{prompt_id:04d}" / "trajectory.safetensors"
173
+ with safe_open(path, framework="pt", device="cpu") as handle:
174
+ chunks = [
175
+ handle.get_tensor(f"chunk_{chunk:02d}_clean_latent")
176
+ for chunk in range(NUM_CHUNKS)
177
+ ]
178
+ return torch.cat(chunks, dim=1).contiguous()
179
+
180
+
181
+ def pixels_to_u8(video: torch.Tensor) -> torch.Tensor:
182
+ """Convert [1,T,3,H,W] pixels in [-1,1] to CPU uint8 frames."""
183
+ return (
184
+ ((video.squeeze(0).float() + 1.0) * 127.5)
185
+ .round_()
186
+ .clamp_(0, 255)
187
+ .to(device="cpu", dtype=torch.uint8)
188
+ .contiguous()
189
+ )
190
+
191
+
192
+ def save_mp4(frames: torch.Tensor, path: Path) -> None:
193
+ path.parent.mkdir(parents=True, exist_ok=True)
194
+ write_video(
195
+ str(path), frames.permute(0, 2, 3, 1), fps=16,
196
+ video_codec="libx264", options={"crf": "18"},
197
+ )
198
+
199
+
200
+ @torch.inference_mode()
201
+ def prepare_reference_frames(
202
+ *,
203
+ vae: WanVAEWrapper,
204
+ dataset_root: Path,
205
+ output_dir: Path,
206
+ prompt_ids: list[int],
207
+ device: torch.device,
208
+ rebuild: bool,
209
+ ) -> None:
210
+ reference_dir = output_dir / "ffff_reference_frames"
211
+ reference_dir.mkdir(parents=True, exist_ok=True)
212
+ for offset, prompt_id in enumerate(prompt_ids, start=1):
213
+ destination = reference_dir / f"prompt_{prompt_id:04d}.safetensors"
214
+ if destination.exists() and not rebuild:
215
+ print(
216
+ f"[reference] {offset}/{len(prompt_ids)} prompt={prompt_id} cached",
217
+ flush=True,
218
+ )
219
+ continue
220
+ latent = load_ffff_latent(dataset_root, prompt_id).to(
221
+ device=device, dtype=torch.bfloat16
222
+ )
223
+ started = time.perf_counter()
224
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
225
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
226
+ frames = pixels_to_u8(pixels)
227
+ atomic_safetensors(
228
+ destination,
229
+ {"frames": frames},
230
+ {
231
+ "reference": "FFFF",
232
+ "prompt_id": str(prompt_id),
233
+ "range": "uint8_0_255",
234
+ "layout": "TCHW",
235
+ },
236
+ )
237
+ if hasattr(vae.model, "clear_cache"):
238
+ vae.model.clear_cache()
239
+ del latent, pixels, frames
240
+ torch.cuda.empty_cache()
241
+ print(
242
+ f"[reference] {offset}/{len(prompt_ids)} prompt={prompt_id} "
243
+ f"decoded={time.perf_counter() - started:.1f}s",
244
+ flush=True,
245
+ )
246
+
247
+
248
+ def load_reference_frames(output_dir: Path, prompt_id: int) -> torch.Tensor:
249
+ path = output_dir / "ffff_reference_frames" / f"prompt_{prompt_id:04d}.safetensors"
250
+ with safe_open(path, framework="pt", device="cpu") as handle:
251
+ return handle.get_tensor("frames")
252
+
253
+
254
+ def discover_experiments(
255
+ sweep_dir: Path,
256
+ requested: list[str] | None,
257
+ max_experiments: int | None,
258
+ ) -> list[dict[str, Any]]:
259
+ summary_path = sweep_dir / "summary.csv"
260
+ with summary_path.open("r", encoding="utf-8", newline="") as handle:
261
+ rows = list(csv.DictReader(handle))
262
+ by_name = {row["name"]: row for row in rows}
263
+ names = list(by_name) if requested is None else requested
264
+ unknown = [name for name in names if name not in by_name]
265
+ if unknown:
266
+ raise KeyError(f"Unknown sweep experiments: {unknown}")
267
+ if max_experiments is not None:
268
+ names = names[:max_experiments]
269
+
270
+ experiments = []
271
+ for name in names:
272
+ run_dir = sweep_dir / name
273
+ config = json.loads((run_dir / "config.json").read_text(encoding="utf-8"))
274
+ weights = run_dir / "predictor_final.safetensors"
275
+ if not weights.exists():
276
+ raise FileNotFoundError(weights)
277
+ row = by_name[name]
278
+ experiment = {
279
+ "name": name,
280
+ "initialization_method": config["initialization_method"],
281
+ "source_layer": int(config["source_layer"]),
282
+ "weights": weights,
283
+ "gate_mode": config.get("gate_mode", "baseline"),
284
+ "gate_hidden_dim": int(config.get("gate_hidden_dim", 128)),
285
+ "gate_initial_bias": float(
286
+ config.get("gate_initial_bias", 4.6)
287
+ ),
288
+ "gate_floor": float(config.get("gate_floor", 0.0)),
289
+ "constant_gate": float(config.get("constant_gate", 1.0)),
290
+ "gate_override": config.get("gate_override"),
291
+ "offline_final_val_flow_mse": float(row["final_val_flow_mse"]),
292
+ "offline_final_val_hidden_mse": float(row["final_val_hidden_mse"]),
293
+ }
294
+ experiments.append(experiment)
295
+ if experiment["gate_mode"] == "learned":
296
+ training_metrics = json.loads(
297
+ (run_dir / "metrics.json").read_text(encoding="utf-8")
298
+ )
299
+ gate_mean = float(training_metrics["evaluations"][-1]["gate_mean"])
300
+ experiments.append(
301
+ {
302
+ **experiment,
303
+ "name": f"{name}_constant_mean",
304
+ "gate_override": gate_mean,
305
+ "constant_gate": gate_mean,
306
+ }
307
+ )
308
+ return experiments
309
+
310
+
311
+ def build_pipeline(
312
+ config: Any,
313
+ checkpoint_path: Path,
314
+ vae: WanVAEWrapper,
315
+ device: torch.device,
316
+ ) -> CausalInferencePipeline:
317
+ generator = WanDiffusionWrapper(
318
+ **getattr(config, "model_kwargs", {}), is_causal=True
319
+ )
320
+ pipeline = CausalInferencePipeline(
321
+ config,
322
+ device=device,
323
+ generator=generator,
324
+ text_encoder=torch.nn.Identity(),
325
+ vae=vae,
326
+ )
327
+ checkpoint = torch.load(
328
+ checkpoint_path, map_location="cpu", weights_only=False, mmap=True
329
+ )
330
+ if set(checkpoint) != {"generator_ema"}:
331
+ raise KeyError(f"Unexpected Teacher checkpoint keys: {sorted(checkpoint)}")
332
+ pipeline.generator.load_state_dict(checkpoint["generator_ema"], strict=True)
333
+ del checkpoint
334
+ pipeline.to(dtype=torch.bfloat16)
335
+ pipeline.generator.to(device=device)
336
+ pipeline.eval()
337
+ pipeline.generator.requires_grad_(False)
338
+ return pipeline
339
+
340
+
341
+ def reset_kv_and_load_cross_cache(
342
+ pipeline: CausalInferencePipeline,
343
+ dataset_root: Path,
344
+ prompt_id: int,
345
+ device: torch.device,
346
+ ) -> None:
347
+ if pipeline.kv_cache1 is None:
348
+ pipeline._initialize_kv_cache(1, torch.bfloat16, device)
349
+ pipeline._initialize_crossattn_cache(1, torch.bfloat16, device)
350
+ for cache in pipeline.kv_cache1:
351
+ cache["global_end_index"].zero_()
352
+ cache["local_end_index"].zero_()
353
+
354
+ cross_path = (
355
+ dataset_root / f"prompt_{prompt_id:04d}" / "cross_attention.safetensors"
356
+ )
357
+ with safe_open(cross_path, framework="pt", device="cpu") as handle:
358
+ for layer, cache in enumerate(pipeline.crossattn_cache):
359
+ cache["k"] = handle.get_tensor(f"block_{layer:02d}_k").to(
360
+ device=device, dtype=torch.bfloat16
361
+ )
362
+ cache["v"] = handle.get_tensor(f"block_{layer:02d}_v").to(
363
+ device=device, dtype=torch.bfloat16
364
+ )
365
+ cache["is_init"] = True
366
+
367
+
368
+ class FinalHiddenCapture:
369
+ def __init__(self, teacher: torch.nn.Module) -> None:
370
+ self.enabled = False
371
+ self.value: torch.Tensor | None = None
372
+ self.handle = teacher.head.register_forward_pre_hook(self._hook)
373
+
374
+ def close(self) -> None:
375
+ self.handle.remove()
376
+
377
+ def _hook(
378
+ self, _module: torch.nn.Module, inputs: tuple[torch.Tensor, ...]
379
+ ) -> None:
380
+ if self.enabled:
381
+ if self.value is not None:
382
+ raise RuntimeError("Teacher head was called twice in one Full step")
383
+ self.value = inputs[0].detach()
384
+
385
+ def start(self) -> None:
386
+ self.value = None
387
+ self.enabled = True
388
+
389
+ def finish(self) -> torch.Tensor:
390
+ self.enabled = False
391
+ if self.value is None:
392
+ raise RuntimeError("Teacher final hidden was not captured")
393
+ value = self.value
394
+ self.value = None
395
+ return value
396
+
397
+
398
+ def load_predictor(
399
+ teacher: torch.nn.Module,
400
+ experiment: dict[str, Any],
401
+ device: torch.device,
402
+ ) -> SingleBlockPredictor:
403
+ source_layer = int(experiment["source_layer"])
404
+ block = initialize_predictor_block(
405
+ teacher.blocks[source_layer], "teacher_full"
406
+ )
407
+ predictor = SingleBlockPredictor(
408
+ block=block,
409
+ dim=teacher.dim,
410
+ gradient_checkpointing=False,
411
+ input_variant=experiment.get("input_variant", "self_forcing"),
412
+ gate_mode=experiment.get("gate_mode", "baseline"),
413
+ gate_hidden_dim=int(experiment.get("gate_hidden_dim", 128)),
414
+ gate_initial_bias=float(experiment.get("gate_initial_bias", 4.6)),
415
+ gate_floor=float(experiment.get("gate_floor", 0.0)),
416
+ constant_gate=float(experiment.get("constant_gate", 1.0)),
417
+ atc_previous_scope=experiment.get("atc_previous_scope", "chunk"),
418
+ atc_freq_dim=int(experiment.get("atc_freq_dim", 256)),
419
+ atc_mlp_hidden_dim=int(experiment.get("atc_mlp_hidden_dim", 3072)),
420
+ atc_gate_hidden_dim=int(experiment.get("atc_gate_hidden_dim", 512)),
421
+ atc_transport_residual_scale=float(
422
+ experiment.get("atc_transport_residual_scale", 0.1)
423
+ ),
424
+ atc_gate_initial_probability=float(
425
+ experiment.get("atc_gate_initial_probability", 0.3)
426
+ ),
427
+ atc_collect_diagnostics=bool(
428
+ experiment.get("atc_collect_diagnostics", False)
429
+ ),
430
+ )
431
+ state = load_file(str(experiment["weights"]), device="cpu")
432
+ predictor.load_state_dict(state, strict=True)
433
+ if experiment.get("gate_override") is not None:
434
+ predictor.fusion.gate_override = float(experiment["gate_override"])
435
+ predictor.to(device=device)
436
+ predictor.eval().requires_grad_(False)
437
+ return predictor
438
+
439
+
440
+ @torch.inference_mode()
441
+ def predictor_step(
442
+ *,
443
+ predictor: SingleBlockPredictor,
444
+ teacher: torch.nn.Module,
445
+ noisy_input: torch.Tensor,
446
+ timestep: torch.Tensor,
447
+ anchor_hidden: torch.Tensor,
448
+ previous_hidden: torch.Tensor,
449
+ history_cache: dict[str, torch.Tensor],
450
+ cross_cache: dict[str, torch.Tensor],
451
+ current_start: int,
452
+ anchor_timestep: torch.Tensor | None = None,
453
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
454
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
455
+ current_tokens = teacher.patch_embedding(
456
+ noisy_input.permute(0, 2, 1, 3, 4)
457
+ ).flatten(2).transpose(1, 2)
458
+ time_embedding = teacher.time_embedding(
459
+ sinusoidal_embedding_1d(
460
+ teacher.freq_dim, timestep.flatten()
461
+ ).type_as(current_tokens)
462
+ )
463
+ timestep_modulation = teacher.time_projection(
464
+ time_embedding
465
+ ).unflatten(1, (6, teacher.dim)).unflatten(
466
+ dim=0, sizes=timestep.shape
467
+ )
468
+ head_embedding = time_embedding.unflatten(
469
+ dim=0, sizes=timestep.shape
470
+ ).unsqueeze(2)
471
+ condition_per_frame = time_embedding.unflatten(
472
+ dim=0, sizes=timestep.shape
473
+ )
474
+ condition_tokens = (
475
+ condition_per_frame[:, :, None, :]
476
+ .expand(
477
+ timestep.shape[0],
478
+ timestep.shape[1],
479
+ 30 * 52,
480
+ teacher.dim,
481
+ )
482
+ .reshape(timestep.shape[0], -1, teacher.dim)
483
+ )
484
+ anchor_distance = None
485
+ if predictor.input_variant == "atc":
486
+ if anchor_timestep is None:
487
+ raise ValueError("ATC inference requires anchor_timestep")
488
+ anchor_distance = (
489
+ timestep.float() - anchor_timestep.float()
490
+ ).abs().mean(dim=1)
491
+ grid_sizes = torch.tensor(
492
+ [[FRAMES_PER_CHUNK, 30, 52]], dtype=torch.long, device="cpu"
493
+ )
494
+ history_length = int(history_cache["local_end_index"].item())
495
+ pred_hidden = predictor(
496
+ current_tokens=current_tokens,
497
+ anchor_hidden=anchor_hidden,
498
+ previous_hidden=previous_hidden,
499
+ timestep_modulation=timestep_modulation,
500
+ grid_sizes=grid_sizes,
501
+ freqs=teacher.freqs,
502
+ history_k=history_cache["k"][:, :history_length],
503
+ history_v=history_cache["v"][:, :history_length],
504
+ cross_k=cross_cache["k"],
505
+ cross_v=cross_cache["v"],
506
+ current_start=current_start,
507
+ condition_tokens=condition_tokens,
508
+ anchor_distance=anchor_distance,
509
+ )
510
+ pred_flow = hidden_to_flow(
511
+ pred_hidden, head_embedding, grid_sizes, teacher
512
+ )
513
+ return pred_hidden, pred_flow, current_tokens
514
+
515
+
516
+ @torch.inference_mode()
517
+ def generate_rollout(
518
+ *,
519
+ pipeline: CausalInferencePipeline,
520
+ dataset_root: Path,
521
+ prompt_id: int,
522
+ generation_seed: int,
523
+ device: torch.device,
524
+ predictor: SingleBlockPredictor | None,
525
+ source_layer: int | None,
526
+ schedule: str,
527
+ ) -> tuple[torch.Tensor, dict[str, float | int]]:
528
+ if schedule not in {"FFFF", "FPPF"}:
529
+ raise ValueError(schedule)
530
+ if schedule == "FPPF" and (predictor is None or source_layer is None):
531
+ raise ValueError("FPPF requires a Predictor and source layer")
532
+
533
+ reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
534
+ set_seed(generation_seed)
535
+ noise = torch.randn(
536
+ 1,
537
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
538
+ LATENT_CHANNELS,
539
+ LATENT_HEIGHT,
540
+ LATENT_WIDTH,
541
+ dtype=torch.bfloat16,
542
+ device=device,
543
+ )
544
+ teacher = pipeline.generator.model
545
+ text_dim = int(teacher.text_embedding[0].in_features)
546
+ conditional_dict = {
547
+ "prompt_embeds": torch.zeros(
548
+ 1, 1, text_dim, dtype=torch.bfloat16, device=device
549
+ )
550
+ }
551
+ timesteps = pipeline.denoising_step_list.to(device=device)
552
+ output_chunks: list[torch.Tensor] = []
553
+ previous_chunk_hidden: list[torch.Tensor | None] | None = None
554
+ capture = FinalHiddenCapture(teacher)
555
+ full_calls = 0
556
+ predictor_calls = 0
557
+ started = time.perf_counter()
558
+
559
+ try:
560
+ for chunk in range(NUM_CHUNKS):
561
+ noisy_input = noise[
562
+ :, chunk * FRAMES_PER_CHUNK : (chunk + 1) * FRAMES_PER_CHUNK
563
+ ]
564
+ current_hidden: list[torch.Tensor | None] = [None] * NUM_DENOISING_STEPS
565
+ denoised_pred: torch.Tensor | None = None
566
+ timestep: torch.Tensor | None = None
567
+ for step, current_timestep in enumerate(timesteps):
568
+ timestep = torch.ones(
569
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device=device
570
+ ) * current_timestep
571
+ use_predictor = schedule == "FPPF" and chunk > 0 and step in {1, 2}
572
+
573
+ if use_predictor:
574
+ anchor_hidden = current_hidden[step - 1]
575
+ assert anchor_hidden is not None
576
+ assert previous_chunk_hidden is not None
577
+ previous_hidden = previous_chunk_hidden[step]
578
+ assert previous_hidden is not None
579
+ history = pipeline.kv_cache1[int(source_layer)]
580
+ cross = pipeline.crossattn_cache[int(source_layer)]
581
+ pred_hidden, flow, _ = predictor_step(
582
+ predictor=predictor,
583
+ teacher=teacher,
584
+ noisy_input=noisy_input,
585
+ timestep=timestep,
586
+ anchor_hidden=anchor_hidden,
587
+ previous_hidden=previous_hidden,
588
+ history_cache=history,
589
+ cross_cache=cross,
590
+ current_start=chunk * TOKENS_PER_CHUNK,
591
+ anchor_timestep=(
592
+ torch.ones_like(timestep) * timesteps[step - 1]
593
+ ),
594
+ )
595
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
596
+ flow_pred=flow.flatten(0, 1),
597
+ xt=noisy_input.flatten(0, 1),
598
+ timestep=timestep.flatten(0, 1),
599
+ ).unflatten(0, flow.shape[:2])
600
+ current_hidden[step] = pred_hidden
601
+ predictor_calls += 1
602
+ else:
603
+ capture.start()
604
+ _, denoised_pred = pipeline.generator(
605
+ noisy_image_or_video=noisy_input,
606
+ conditional_dict=conditional_dict,
607
+ timestep=timestep,
608
+ kv_cache=pipeline.kv_cache1,
609
+ crossattn_cache=pipeline.crossattn_cache,
610
+ current_start=chunk * TOKENS_PER_CHUNK,
611
+ )
612
+ current_hidden[step] = capture.finish()
613
+ full_calls += 1
614
+
615
+ if step < NUM_DENOISING_STEPS - 1:
616
+ next_timestep = timesteps[step + 1]
617
+ denoised_flat = denoised_pred.flatten(0, 1)
618
+ noisy_input = pipeline.scheduler.add_noise(
619
+ denoised_flat,
620
+ torch.randn_like(denoised_flat),
621
+ next_timestep
622
+ * torch.ones(
623
+ [FRAMES_PER_CHUNK], dtype=torch.long, device=device
624
+ ),
625
+ ).unflatten(0, denoised_pred.shape[:2])
626
+
627
+ if denoised_pred is None or timestep is None:
628
+ raise RuntimeError("Denoising loop produced no clean latent")
629
+ output_chunks.append(denoised_pred)
630
+
631
+ context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
632
+ pipeline.generator(
633
+ noisy_image_or_video=denoised_pred,
634
+ conditional_dict=conditional_dict,
635
+ timestep=context_timestep,
636
+ kv_cache=pipeline.kv_cache1,
637
+ crossattn_cache=pipeline.crossattn_cache,
638
+ current_start=chunk * TOKENS_PER_CHUNK,
639
+ )
640
+ previous_chunk_hidden = current_hidden
641
+ finally:
642
+ capture.close()
643
+
644
+ torch.cuda.synchronize()
645
+ return torch.cat(output_chunks, dim=1), {
646
+ "generation_time_s": time.perf_counter() - started,
647
+ "full_calls": full_calls,
648
+ "predictor_calls": predictor_calls,
649
+ }
650
+
651
+
652
+ def gaussian_kernel(
653
+ device: torch.device, dtype: torch.dtype, channels: int = 3
654
+ ) -> torch.Tensor:
655
+ coordinates = torch.arange(11, device=device, dtype=dtype) - 5
656
+ kernel_1d = torch.exp(-(coordinates.square()) / (2 * 1.5**2))
657
+ kernel_1d /= kernel_1d.sum()
658
+ kernel_2d = torch.outer(kernel_1d, kernel_1d)
659
+ return kernel_2d.expand(channels, 1, 11, 11).contiguous()
660
+
661
+
662
+ def ssim_per_frame(
663
+ reference: torch.Tensor, prediction: torch.Tensor, kernel: torch.Tensor
664
+ ) -> torch.Tensor:
665
+ channels = reference.shape[1]
666
+ mu_x = F.conv2d(reference, kernel, groups=channels)
667
+ mu_y = F.conv2d(prediction, kernel, groups=channels)
668
+ mu_x2 = mu_x.square()
669
+ mu_y2 = mu_y.square()
670
+ mu_xy = mu_x * mu_y
671
+ sigma_x2 = F.conv2d(reference.square(), kernel, groups=channels) - mu_x2
672
+ sigma_y2 = F.conv2d(prediction.square(), kernel, groups=channels) - mu_y2
673
+ sigma_xy = F.conv2d(reference * prediction, kernel, groups=channels) - mu_xy
674
+ c1 = 0.01**2
675
+ c2 = 0.03**2
676
+ score = ((2 * mu_xy + c1) * (2 * sigma_xy + c2)) / (
677
+ (mu_x2 + mu_y2 + c1) * (sigma_x2 + sigma_y2 + c2)
678
+ )
679
+ return score.mean(dim=(1, 2, 3))
680
+
681
+
682
+ @torch.inference_mode()
683
+ def frame_metrics(
684
+ *,
685
+ reference_u8: torch.Tensor,
686
+ prediction_u8: torch.Tensor,
687
+ lpips_model: torch.nn.Module | None,
688
+ batch_size: int,
689
+ device: torch.device,
690
+ ) -> dict[str, Any]:
691
+ if reference_u8.shape != prediction_u8.shape:
692
+ raise ValueError(
693
+ f"Reference/prediction shapes differ: {reference_u8.shape}, "
694
+ f"{prediction_u8.shape}"
695
+ )
696
+ kernel = gaussian_kernel(device, torch.float32)
697
+ psnr_values: list[float] = []
698
+ mse_values: list[float] = []
699
+ ssim_values: list[float] = []
700
+ lpips_values: list[float] = []
701
+ for start in range(0, reference_u8.shape[0], batch_size):
702
+ end = min(start + batch_size, reference_u8.shape[0])
703
+ reference = reference_u8[start:end].to(
704
+ device=device, dtype=torch.float32
705
+ ) / 255.0
706
+ prediction = prediction_u8[start:end].to(
707
+ device=device, dtype=torch.float32
708
+ ) / 255.0
709
+ mse = (reference - prediction).square().mean(dim=(1, 2, 3))
710
+ psnr = -10.0 * torch.log10(mse.clamp_min(1e-12))
711
+ ssim = ssim_per_frame(reference, prediction, kernel)
712
+ mse_values.extend(float(value) for value in mse.cpu())
713
+ psnr_values.extend(float(value) for value in psnr.cpu())
714
+ ssim_values.extend(float(value) for value in ssim.cpu())
715
+ if lpips_model is not None:
716
+ distance = lpips_model(
717
+ reference.mul(2).sub(1), prediction.mul(2).sub(1)
718
+ ).flatten()
719
+ lpips_values.extend(float(value) for value in distance.cpu())
720
+ del reference, prediction, mse, psnr, ssim
721
+ global_mse = sum(mse_values) / len(mse_values)
722
+ rollout_mse = sum(mse_values[PIXEL_FRAMES_FIRST_CHUNK:]) / len(
723
+ mse_values[PIXEL_FRAMES_FIRST_CHUNK:]
724
+ )
725
+ return {
726
+ "mse_per_frame": mse_values,
727
+ "psnr_per_frame": psnr_values,
728
+ "ssim_per_frame": ssim_values,
729
+ "lpips_per_frame": lpips_values,
730
+ "pixel_mse": global_mse,
731
+ "psnr": -10.0 * math.log10(max(global_mse, 1e-12)),
732
+ "psnr_frame_mean": sum(psnr_values) / len(psnr_values),
733
+ "ssim": sum(ssim_values) / len(ssim_values),
734
+ "lpips": (
735
+ sum(lpips_values) / len(lpips_values)
736
+ if lpips_values
737
+ else None
738
+ ),
739
+ "rollout_start_frame": PIXEL_FRAMES_FIRST_CHUNK,
740
+ "rollout_pixel_mse": rollout_mse,
741
+ "rollout_psnr": -10.0 * math.log10(max(rollout_mse, 1e-12)),
742
+ "rollout_ssim": sum(ssim_values[PIXEL_FRAMES_FIRST_CHUNK:])
743
+ / len(ssim_values[PIXEL_FRAMES_FIRST_CHUNK:]),
744
+ "rollout_lpips": (
745
+ sum(lpips_values[PIXEL_FRAMES_FIRST_CHUNK:])
746
+ / len(lpips_values[PIXEL_FRAMES_FIRST_CHUNK:])
747
+ if lpips_values
748
+ else None
749
+ ),
750
+ "num_frames": len(psnr_values),
751
+ }
752
+
753
+
754
+ def mean_std(values: list[float]) -> tuple[float, float]:
755
+ mean = sum(values) / len(values)
756
+ variance = sum((value - mean) ** 2 for value in values) / len(values)
757
+ return mean, math.sqrt(variance)
758
+
759
+
760
+ def aggregate_prompt_results(
761
+ experiment: dict[str, Any], prompt_results: list[dict[str, Any]]
762
+ ) -> dict[str, Any]:
763
+ mse_frames = [
764
+ value
765
+ for result in prompt_results
766
+ for value in result["mse_per_frame"]
767
+ ]
768
+ psnr_frames = [
769
+ value
770
+ for result in prompt_results
771
+ for value in result["psnr_per_frame"]
772
+ ]
773
+ ssim_frames = [
774
+ value
775
+ for result in prompt_results
776
+ for value in result["ssim_per_frame"]
777
+ ]
778
+ lpips_frames = [
779
+ value
780
+ for result in prompt_results
781
+ for value in result["lpips_per_frame"]
782
+ ]
783
+ rollout_mse_frames = [
784
+ value
785
+ for result in prompt_results
786
+ for value in result["mse_per_frame"][PIXEL_FRAMES_FIRST_CHUNK:]
787
+ ]
788
+ rollout_ssim_frames = [
789
+ value
790
+ for result in prompt_results
791
+ for value in result["ssim_per_frame"][PIXEL_FRAMES_FIRST_CHUNK:]
792
+ ]
793
+ rollout_lpips_frames = [
794
+ value
795
+ for result in prompt_results
796
+ for value in result["lpips_per_frame"][PIXEL_FRAMES_FIRST_CHUNK:]
797
+ ]
798
+ pixel_mse = sum(mse_frames) / len(mse_frames)
799
+ psnr_frame_mean, psnr_std = mean_std(psnr_frames)
800
+ ssim, ssim_std = mean_std(ssim_frames)
801
+ if lpips_frames:
802
+ lpips_mean, lpips_std = mean_std(lpips_frames)
803
+ else:
804
+ lpips_mean, lpips_std = None, None
805
+ rollout_pixel_mse = sum(rollout_mse_frames) / len(rollout_mse_frames)
806
+ rollout_ssim = sum(rollout_ssim_frames) / len(rollout_ssim_frames)
807
+ rollout_lpips = (
808
+ sum(rollout_lpips_frames) / len(rollout_lpips_frames)
809
+ if rollout_lpips_frames
810
+ else None
811
+ )
812
+ return {
813
+ "status": "complete",
814
+ "name": experiment["name"],
815
+ "initialization_method": experiment["initialization_method"],
816
+ "source_layer": experiment["source_layer"],
817
+ "offline_final_val_flow_mse": experiment["offline_final_val_flow_mse"],
818
+ "offline_final_val_hidden_mse": experiment[
819
+ "offline_final_val_hidden_mse"
820
+ ],
821
+ "schedule": "chunk0=FFFF; chunks1-6=FPPF",
822
+ "reference": "matching FFFF, same prompt and seed",
823
+ "pixel_domain": "VAE-decoded RGB, rounded to uint8",
824
+ "aggregation": (
825
+ "PSNR from global pixel MSE; SSIM/LPIPS mean over decoded frames"
826
+ ),
827
+ "num_prompts": len(prompt_results),
828
+ "num_frames": len(psnr_frames),
829
+ "pixel_mse": pixel_mse,
830
+ "psnr": -10.0 * math.log10(max(pixel_mse, 1e-12)),
831
+ "psnr_frame_mean": psnr_frame_mean,
832
+ "psnr_frame_std": psnr_std,
833
+ "ssim": ssim,
834
+ "ssim_frame_std": ssim_std,
835
+ "lpips": lpips_mean,
836
+ "lpips_frame_std": lpips_std,
837
+ "rollout_start_frame": PIXEL_FRAMES_FIRST_CHUNK,
838
+ "rollout_num_frames": len(rollout_mse_frames),
839
+ "rollout_pixel_mse": rollout_pixel_mse,
840
+ "rollout_psnr": -10.0
841
+ * math.log10(max(rollout_pixel_mse, 1e-12)),
842
+ "rollout_ssim": rollout_ssim,
843
+ "rollout_lpips": rollout_lpips,
844
+ "mean_generation_time_s": sum(
845
+ result["generation_time_s"] for result in prompt_results
846
+ )
847
+ / len(prompt_results),
848
+ "full_calls_per_prompt": prompt_results[0]["full_calls"],
849
+ "predictor_calls_per_prompt": prompt_results[0]["predictor_calls"],
850
+ "prompt_ids": [result["prompt_id"] for result in prompt_results],
851
+ }
852
+
853
+
854
+ def write_summary(
855
+ output_dir: Path, experiments: list[dict[str, Any]]
856
+ ) -> None:
857
+ rows: list[dict[str, Any]] = []
858
+ for experiment in experiments:
859
+ path = output_dir / experiment["name"] / "metrics.json"
860
+ if not path.exists():
861
+ continue
862
+ metrics = json.loads(path.read_text(encoding="utf-8"))
863
+ if metrics.get("status") != "complete":
864
+ continue
865
+ rows.append(
866
+ {
867
+ "name": metrics["name"],
868
+ "initialization_method": metrics["initialization_method"],
869
+ "source_layer": metrics["source_layer"],
870
+ "num_prompts": metrics["num_prompts"],
871
+ "num_frames": metrics["num_frames"],
872
+ "psnr": metrics["psnr"],
873
+ "ssim": metrics["ssim"],
874
+ "lpips": metrics["lpips"],
875
+ "rollout_psnr": metrics["rollout_psnr"],
876
+ "rollout_ssim": metrics["rollout_ssim"],
877
+ "rollout_lpips": metrics["rollout_lpips"],
878
+ "offline_final_val_flow_mse": metrics[
879
+ "offline_final_val_flow_mse"
880
+ ],
881
+ "mean_generation_time_s": metrics["mean_generation_time_s"],
882
+ }
883
+ )
884
+ rows.sort(key=lambda row: float(row["lpips"] or math.inf))
885
+ fields = [
886
+ "name",
887
+ "initialization_method",
888
+ "source_layer",
889
+ "num_prompts",
890
+ "num_frames",
891
+ "psnr",
892
+ "ssim",
893
+ "lpips",
894
+ "rollout_psnr",
895
+ "rollout_ssim",
896
+ "rollout_lpips",
897
+ "offline_final_val_flow_mse",
898
+ "mean_generation_time_s",
899
+ ]
900
+ destination = output_dir / "summary.csv"
901
+ temporary = destination.with_suffix(".csv.tmp")
902
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
903
+ writer = csv.DictWriter(handle, fieldnames=fields)
904
+ writer.writeheader()
905
+ writer.writerows(rows)
906
+ os.replace(temporary, destination)
907
+
908
+
909
+ def load_completed_prompt_results(
910
+ run_dir: Path, prompt_ids: list[int]
911
+ ) -> list[dict[str, Any]]:
912
+ results = []
913
+ for prompt_id in prompt_ids:
914
+ path = run_dir / "per_prompt" / f"prompt_{prompt_id:04d}.json"
915
+ if path.exists():
916
+ results.append(json.loads(path.read_text(encoding="utf-8")))
917
+ return results
918
+
919
+
920
+ def main() -> None:
921
+ args = parse_args()
922
+ args.config_path = resolve(args.config_path)
923
+ args.checkpoint_path = resolve(args.checkpoint_path)
924
+ args.dataset_root = resolve(args.dataset_root)
925
+ args.sweep_dir = resolve(args.sweep_dir)
926
+ args.output_dir = resolve(args.output_dir)
927
+ args.output_dir.mkdir(parents=True, exist_ok=True)
928
+
929
+ prompt_ids = sorted(set(args.prompt_ids))
930
+ if args.max_prompts is not None:
931
+ prompt_ids = prompt_ids[: args.max_prompts]
932
+ experiments = discover_experiments(
933
+ args.sweep_dir, args.experiments, args.max_experiments
934
+ )
935
+ device = torch.device("cuda")
936
+ torch.set_grad_enabled(False)
937
+ set_seed(args.generation_seed)
938
+
939
+ config = OmegaConf.merge(
940
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
941
+ OmegaConf.load(args.config_path),
942
+ )
943
+ manifest = {
944
+ "status": "running",
945
+ "gpu": str(args.gpu),
946
+ "config_path": str(args.config_path),
947
+ "checkpoint_path": str(args.checkpoint_path),
948
+ "dataset_root": str(args.dataset_root),
949
+ "sweep_dir": str(args.sweep_dir),
950
+ "prompt_ids": prompt_ids,
951
+ "generation_seed_reset_per_prompt": args.generation_seed,
952
+ "experiments": [item["name"] for item in experiments],
953
+ "fppf_definition": "chunk0=FFFF; chunks1-6=FPPF",
954
+ "reference": "offline FFFF clean latents from the same prompt/seed",
955
+ "metrics": {
956
+ "psnr": "RGB PSNR from global pixel MSE",
957
+ "ssim": "11x11 Gaussian sigma=1.5 RGB SSIM, then frame mean",
958
+ "lpips": "AlexNet LPIPS on RGB [-1,1], then frame mean",
959
+ "pixel_quantization": "both inputs rounded to uint8",
960
+ "rollout_only": (
961
+ "also reported for decoded frames 9..80 after the FFFF-only "
962
+ "first chunk"
963
+ ),
964
+ },
965
+ }
966
+ atomic_json(args.output_dir / "manifest.json", manifest)
967
+
968
+ print("[setup] loading VAE and preparing FFFF reference frames", flush=True)
969
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
970
+ prepare_reference_frames(
971
+ vae=vae,
972
+ dataset_root=args.dataset_root,
973
+ output_dir=args.output_dir,
974
+ prompt_ids=prompt_ids,
975
+ device=device,
976
+ rebuild=args.rebuild_references,
977
+ )
978
+
979
+ print("[setup] loading frozen generator_ema", flush=True)
980
+ pipeline = build_pipeline(config, args.checkpoint_path, vae, device)
981
+ teacher = pipeline.generator.model
982
+ lpips_model = None
983
+ if not args.skip_lpips:
984
+ print("[setup] loading AlexNet LPIPS", flush=True)
985
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
986
+ lpips_model.requires_grad_(False)
987
+
988
+ if args.verify_ffff:
989
+ prompt_id = prompt_ids[0]
990
+ print(f"[verify] reproducing FFFF prompt={prompt_id}", flush=True)
991
+ reproduced, counts = generate_rollout(
992
+ pipeline=pipeline,
993
+ dataset_root=args.dataset_root,
994
+ prompt_id=prompt_id,
995
+ generation_seed=args.generation_seed,
996
+ device=device,
997
+ predictor=None,
998
+ source_layer=None,
999
+ schedule="FFFF",
1000
+ )
1001
+ expected = load_ffff_latent(args.dataset_root, prompt_id).to(
1002
+ device=device, dtype=torch.bfloat16
1003
+ )
1004
+ difference = reproduced.float() - expected.float()
1005
+ verification = {
1006
+ "prompt_id": prompt_id,
1007
+ "max_abs_latent_error": float(difference.abs().max()),
1008
+ "latent_mse": float(difference.square().mean()),
1009
+ **counts,
1010
+ }
1011
+ atomic_json(args.output_dir / "ffff_reproduction.json", verification)
1012
+ print(f"[verify] {verification}", flush=True)
1013
+ if verification["max_abs_latent_error"] > 1e-3:
1014
+ raise RuntimeError(
1015
+ "FFFF reproduction differs from offline reference; refusing "
1016
+ "to evaluate FPPF with unmatched randomness/caches"
1017
+ )
1018
+ del reproduced, expected, difference
1019
+ torch.cuda.empty_cache()
1020
+
1021
+ for experiment_index, experiment in enumerate(experiments, start=1):
1022
+ run_dir = args.output_dir / experiment["name"]
1023
+ run_dir.mkdir(parents=True, exist_ok=True)
1024
+ metrics_path = run_dir / "metrics.json"
1025
+ if metrics_path.exists():
1026
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
1027
+ if (
1028
+ existing.get("status") == "complete"
1029
+ and existing.get("prompt_ids") == prompt_ids
1030
+ and (args.skip_lpips or existing.get("lpips") is not None)
1031
+ ):
1032
+ print(
1033
+ f"[run] {experiment_index}/{len(experiments)} "
1034
+ f"skip complete {experiment['name']}",
1035
+ flush=True,
1036
+ )
1037
+ continue
1038
+
1039
+ print(
1040
+ f"[run] {experiment_index}/{len(experiments)} "
1041
+ f"{experiment['name']} source={experiment['source_layer']}",
1042
+ flush=True,
1043
+ )
1044
+ predictor = load_predictor(teacher, experiment, device)
1045
+ existing_results = {
1046
+ result["prompt_id"]: result
1047
+ for result in load_completed_prompt_results(run_dir, prompt_ids)
1048
+ }
1049
+
1050
+ for prompt_index, prompt_id in enumerate(prompt_ids, start=1):
1051
+ video_path = run_dir / "videos" / f"prompt_{prompt_id:04d}.mp4"
1052
+ if prompt_id in existing_results and (
1053
+ not args.save_videos or video_path.exists()
1054
+ ):
1055
+ print(
1056
+ f"[prompt] {experiment['name']} {prompt_index}/{len(prompt_ids)} "
1057
+ f"id={prompt_id} cached",
1058
+ flush=True,
1059
+ )
1060
+ continue
1061
+ started = time.perf_counter()
1062
+ latent, counts = generate_rollout(
1063
+ pipeline=pipeline,
1064
+ dataset_root=args.dataset_root,
1065
+ prompt_id=prompt_id,
1066
+ generation_seed=args.generation_seed,
1067
+ device=device,
1068
+ predictor=predictor,
1069
+ source_layer=experiment["source_layer"],
1070
+ schedule="FPPF",
1071
+ )
1072
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1073
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
1074
+ prediction_u8 = pixels_to_u8(pixels)
1075
+ if args.save_videos:
1076
+ save_mp4(prediction_u8, video_path)
1077
+ reference_u8 = load_reference_frames(args.output_dir, prompt_id)
1078
+ metrics = frame_metrics(
1079
+ reference_u8=reference_u8,
1080
+ prediction_u8=prediction_u8,
1081
+ lpips_model=lpips_model,
1082
+ batch_size=args.metric_batch_size,
1083
+ device=device,
1084
+ )
1085
+ prompt_result = {
1086
+ "prompt_id": prompt_id,
1087
+ "prompt": load_prompt_metadata(args.dataset_root, prompt_id)[
1088
+ "prompt"
1089
+ ],
1090
+ **counts,
1091
+ **metrics,
1092
+ "total_time_s": time.perf_counter() - started,
1093
+ }
1094
+ atomic_json(
1095
+ run_dir / "per_prompt" / f"prompt_{prompt_id:04d}.json",
1096
+ prompt_result,
1097
+ )
1098
+ existing_results[prompt_id] = prompt_result
1099
+ print(
1100
+ f"[prompt] {experiment['name']} {prompt_index}/{len(prompt_ids)} "
1101
+ f"id={prompt_id} psnr={metrics['psnr']:.4f} "
1102
+ f"ssim={metrics['ssim']:.6f} "
1103
+ f"lpips={metrics['lpips']} "
1104
+ f"time={prompt_result['total_time_s']:.1f}s",
1105
+ flush=True,
1106
+ )
1107
+ if hasattr(vae.model, "clear_cache"):
1108
+ vae.model.clear_cache()
1109
+ del latent, pixels, prediction_u8, reference_u8
1110
+ torch.cuda.empty_cache()
1111
+
1112
+ prompt_results = [existing_results[prompt_id] for prompt_id in prompt_ids]
1113
+ aggregate = aggregate_prompt_results(experiment, prompt_results)
1114
+ atomic_json(metrics_path, aggregate)
1115
+ write_summary(args.output_dir, experiments)
1116
+ print(
1117
+ f"[result] {experiment['name']} psnr={aggregate['psnr']:.4f} "
1118
+ f"ssim={aggregate['ssim']:.6f} lpips={aggregate['lpips']}",
1119
+ flush=True,
1120
+ )
1121
+ del predictor
1122
+ torch.cuda.empty_cache()
1123
+
1124
+ manifest["status"] = "complete"
1125
+ atomic_json(args.output_dir / "manifest.json", manifest)
1126
+ write_summary(args.output_dir, experiments)
1127
+ print(
1128
+ f"[complete] {len(experiments)} experiments -> "
1129
+ f"{args.output_dir / 'summary.csv'}",
1130
+ flush=True,
1131
+ )
1132
+
1133
+
1134
+ if __name__ == "__main__":
1135
+ main()
scripts/evaluate_single_chunk_frrr.py ADDED
@@ -0,0 +1,838 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Measure the effect of introducing a reuse schedule in one AR chunk.
3
+
4
+ The four letters describe the four denoising steps of a chunk. ``F`` runs
5
+ the full generator. ``R`` reuses the flow prediction from the most recent
6
+ full step and only applies the timestep-dependent x0 conversion. For every
7
+ prompt this script creates an all-FFFF reference and seven interventions; in
8
+ intervention k only chunk k uses the requested schedule and all other chunks
9
+ use FFFF.
10
+
11
+ All rollouts for a prompt reset the RNG to the same seed, so the initial noise
12
+ and the three re-noising tensors per chunk are identical. Metrics are
13
+ computed on VAE-decoded, rounded uint8 RGB frames before MP4 compression.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import csv
20
+ import json
21
+ import math
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ def _preparse_gpu() -> str:
30
+ parser = argparse.ArgumentParser(add_help=False)
31
+ parser.add_argument("--gpu", default="0")
32
+ args, _ = parser.parse_known_args()
33
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
34
+ return str(args.gpu)
35
+
36
+
37
+ PHYSICAL_GPU = _preparse_gpu()
38
+
39
+ import lpips
40
+ import torch
41
+ import torch.nn.functional as F
42
+ from omegaconf import OmegaConf
43
+ from safetensors.torch import load_file, save_file
44
+ from torchvision.io import write_video
45
+
46
+ REPO_ROOT = Path(
47
+ os.environ.get("EVAL_REPO_ROOT", str(Path(__file__).resolve().parents[1]))
48
+ ).resolve()
49
+ if str(REPO_ROOT) not in sys.path:
50
+ sys.path.insert(0, str(REPO_ROOT))
51
+
52
+ from pipeline import CausalInferencePipeline
53
+ from utils.misc import set_seed
54
+
55
+
56
+ LATENT_CHANNELS = 16
57
+ LATENT_HEIGHT = 60
58
+ LATENT_WIDTH = 104
59
+ FRAMES_PER_CHUNK = 3
60
+ NUM_CHUNKS = 7
61
+ NUM_DENOISING_STEPS = 4
62
+ TOKENS_PER_FRAME = 30 * 52
63
+ TOKENS_PER_CHUNK = FRAMES_PER_CHUNK * TOKENS_PER_FRAME
64
+ DECODED_FRAMES = 1 + 4 * (NUM_CHUNKS * FRAMES_PER_CHUNK - 1)
65
+
66
+
67
+ def parse_args() -> argparse.Namespace:
68
+ parser = argparse.ArgumentParser(description=__doc__)
69
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
70
+ parser.add_argument(
71
+ "--config_path", type=Path, default=Path("configs/self_forcing_dmd.yaml")
72
+ )
73
+ parser.add_argument(
74
+ "--checkpoint_path",
75
+ type=Path,
76
+ default=Path("checkpoints/self_forcing_dmd.pt"),
77
+ )
78
+ parser.add_argument(
79
+ "--prompt_path",
80
+ type=Path,
81
+ default=Path("prompts/MovieGenVideoBench_extended.txt"),
82
+ )
83
+ parser.add_argument(
84
+ "--output_dir",
85
+ type=Path,
86
+ default=Path("outputs/single_chunk_frrr_first10"),
87
+ )
88
+ parser.add_argument("--prompt_ids", type=int, nargs="*", default=list(range(10)))
89
+ parser.add_argument("--seed", type=int, default=0)
90
+ parser.add_argument(
91
+ "--num_chunks",
92
+ type=int,
93
+ default=7,
94
+ help="Number of 3-latent-frame autoregressive chunks.",
95
+ )
96
+ parser.add_argument(
97
+ "--intervention_schedule",
98
+ choices=["FRRR", "FRRF"],
99
+ default="FRRR",
100
+ help="Four-step schedule used in the selected chunk.",
101
+ )
102
+ parser.add_argument("--metric_batch_size", type=int, default=4)
103
+ parser.add_argument("--use_ema", action=argparse.BooleanOptionalAction, default=True)
104
+ parser.add_argument("--save_videos", action=argparse.BooleanOptionalAction, default=True)
105
+ parser.add_argument(
106
+ "--low_memory",
107
+ action=argparse.BooleanOptionalAction,
108
+ default=True,
109
+ help="Keep only the currently used text encoder/generator/VAE on CUDA.",
110
+ )
111
+ parser.add_argument("--overwrite", action="store_true")
112
+ parser.add_argument("--aggregate_only", action="store_true")
113
+ parser.add_argument(
114
+ "--worker",
115
+ action="store_true",
116
+ help="Shard worker: do not rewrite shared config or aggregate files.",
117
+ )
118
+ args = parser.parse_args()
119
+ if not args.prompt_ids:
120
+ parser.error("--prompt_ids cannot be empty")
121
+ if any(index < 0 for index in args.prompt_ids):
122
+ parser.error("prompt IDs must be non-negative")
123
+ if args.metric_batch_size < 1:
124
+ parser.error("--metric_batch_size must be positive")
125
+ if args.num_chunks < 1:
126
+ parser.error("--num_chunks must be positive")
127
+ global NUM_CHUNKS, DECODED_FRAMES
128
+ NUM_CHUNKS = args.num_chunks
129
+ DECODED_FRAMES = 1 + 4 * (NUM_CHUNKS * FRAMES_PER_CHUNK - 1)
130
+ return args
131
+
132
+
133
+ def resolve(path: Path) -> Path:
134
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
135
+
136
+
137
+ def atomic_json(path: Path, value: Any) -> None:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ temporary = path.with_suffix(path.suffix + ".tmp")
140
+ temporary.write_text(
141
+ json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n",
142
+ encoding="utf-8",
143
+ )
144
+ os.replace(temporary, path)
145
+
146
+
147
+ def read_prompts(path: Path) -> list[str]:
148
+ with path.open("r", encoding="utf-8") as handle:
149
+ return [line.strip() for line in handle if line.strip()]
150
+
151
+
152
+ def build_pipeline(args: argparse.Namespace) -> CausalInferencePipeline:
153
+ config = OmegaConf.merge(
154
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
155
+ OmegaConf.load(resolve(args.config_path)),
156
+ )
157
+ pipeline = CausalInferencePipeline(config, device=torch.device("cuda"))
158
+ checkpoint = torch.load(
159
+ resolve(args.checkpoint_path), map_location="cpu", weights_only=False
160
+ )
161
+ state_key = "generator_ema" if args.use_ema else "generator"
162
+ pipeline.generator.load_state_dict(checkpoint[state_key])
163
+ del checkpoint
164
+ pipeline = pipeline.to(dtype=torch.bfloat16)
165
+ if not args.low_memory:
166
+ pipeline.text_encoder.to(device="cuda")
167
+ pipeline.generator.to(device="cuda")
168
+ pipeline.vae.to(device="cuda")
169
+ pipeline.eval()
170
+ if pipeline.num_frame_per_block != FRAMES_PER_CHUNK:
171
+ raise ValueError(
172
+ f"Expected {FRAMES_PER_CHUNK} latent frames/chunk, got "
173
+ f"{pipeline.num_frame_per_block}"
174
+ )
175
+ if len(pipeline.denoising_step_list) != NUM_DENOISING_STEPS:
176
+ raise ValueError(
177
+ f"Expected {NUM_DENOISING_STEPS} denoising steps, got "
178
+ f"{len(pipeline.denoising_step_list)}"
179
+ )
180
+ return pipeline
181
+
182
+
183
+ def reset_caches(pipeline: CausalInferencePipeline) -> None:
184
+ device = torch.device("cuda")
185
+ if pipeline.kv_cache1 is None:
186
+ pipeline._initialize_kv_cache(1, torch.bfloat16, device)
187
+ pipeline._initialize_crossattn_cache(1, torch.bfloat16, device)
188
+ required_tokens = NUM_CHUNKS * TOKENS_PER_CHUNK
189
+ for cache in pipeline.kv_cache1:
190
+ if cache["k"].shape[1] < required_tokens:
191
+ heads, head_dim = cache["k"].shape[2:]
192
+ cache["k"] = torch.zeros(
193
+ [1, required_tokens, heads, head_dim],
194
+ dtype=torch.bfloat16,
195
+ device=device,
196
+ )
197
+ cache["v"] = torch.zeros_like(cache["k"])
198
+ for cache in pipeline.kv_cache1:
199
+ cache["global_end_index"].zero_()
200
+ cache["local_end_index"].zero_()
201
+ for cache in pipeline.crossattn_cache:
202
+ cache["is_init"] = False
203
+
204
+
205
+ @torch.inference_mode()
206
+ def generate_latents(
207
+ *,
208
+ pipeline: CausalInferencePipeline,
209
+ conditional_dict: dict[str, torch.Tensor],
210
+ seed: int,
211
+ reuse_chunk: int | None,
212
+ intervention_schedule: str,
213
+ ) -> tuple[torch.Tensor, dict[str, Any]]:
214
+ """Generate 21 latents with FFFF or exactly one reuse-schedule chunk."""
215
+ reset_caches(pipeline)
216
+ set_seed(seed)
217
+ noise = torch.randn(
218
+ 1,
219
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
220
+ LATENT_CHANNELS,
221
+ LATENT_HEIGHT,
222
+ LATENT_WIDTH,
223
+ dtype=torch.bfloat16,
224
+ device="cuda",
225
+ )
226
+ timesteps = pipeline.denoising_step_list.to(device="cuda")
227
+ output_chunks: list[torch.Tensor] = []
228
+ full_calls = 0
229
+ reuse_calls = 0
230
+ started = time.perf_counter()
231
+
232
+ for chunk in range(NUM_CHUNKS):
233
+ noisy_input = noise[
234
+ :, chunk * FRAMES_PER_CHUNK : (chunk + 1) * FRAMES_PER_CHUNK
235
+ ]
236
+ cached_flow: torch.Tensor | None = None
237
+ denoised_pred: torch.Tensor | None = None
238
+ timestep: torch.Tensor | None = None
239
+
240
+ for step, current_timestep in enumerate(timesteps):
241
+ timestep = torch.ones(
242
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device="cuda"
243
+ ) * current_timestep
244
+ selected_step = (
245
+ intervention_schedule[step] if reuse_chunk == chunk else "F"
246
+ )
247
+ use_reuse = selected_step == "R"
248
+ if use_reuse:
249
+ if cached_flow is None:
250
+ raise RuntimeError("Reuse requested before any full step")
251
+ flow = cached_flow
252
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
253
+ flow_pred=flow.flatten(0, 1),
254
+ xt=noisy_input.flatten(0, 1),
255
+ timestep=timestep.flatten(0, 1),
256
+ ).unflatten(0, flow.shape[:2])
257
+ reuse_calls += 1
258
+ else:
259
+ flow, denoised_pred = pipeline.generator(
260
+ noisy_image_or_video=noisy_input,
261
+ conditional_dict=conditional_dict,
262
+ timestep=timestep,
263
+ kv_cache=pipeline.kv_cache1,
264
+ crossattn_cache=pipeline.crossattn_cache,
265
+ current_start=chunk * TOKENS_PER_CHUNK,
266
+ )
267
+ if reuse_chunk == chunk:
268
+ cached_flow = flow.detach().clone()
269
+ full_calls += 1
270
+
271
+ if step < NUM_DENOISING_STEPS - 1:
272
+ if denoised_pred is None:
273
+ raise RuntimeError("Denoising step did not produce x0")
274
+ next_timestep = timesteps[step + 1]
275
+ flat = denoised_pred.flatten(0, 1)
276
+ noisy_input = pipeline.scheduler.add_noise(
277
+ flat,
278
+ torch.randn_like(flat),
279
+ next_timestep
280
+ * torch.ones(
281
+ [FRAMES_PER_CHUNK], dtype=torch.long, device="cuda"
282
+ ),
283
+ ).unflatten(0, denoised_pred.shape[:2])
284
+
285
+ if denoised_pred is None or timestep is None:
286
+ raise RuntimeError("Chunk did not produce a clean latent")
287
+ output_chunks.append(denoised_pred)
288
+
289
+ # The clean context pass is always full, as in baseline Self-Forcing.
290
+ context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
291
+ pipeline.generator(
292
+ noisy_image_or_video=denoised_pred,
293
+ conditional_dict=conditional_dict,
294
+ timestep=context_timestep,
295
+ kv_cache=pipeline.kv_cache1,
296
+ crossattn_cache=pipeline.crossattn_cache,
297
+ current_start=chunk * TOKENS_PER_CHUNK,
298
+ )
299
+
300
+ torch.cuda.synchronize()
301
+ return torch.cat(output_chunks, dim=1), {
302
+ "generation_time_s": time.perf_counter() - started,
303
+ "full_denoising_calls": full_calls,
304
+ "reuse_denoising_calls": reuse_calls,
305
+ "full_context_calls": NUM_CHUNKS,
306
+ }
307
+
308
+
309
+ @torch.inference_mode()
310
+ def decode_u8(pipeline: CausalInferencePipeline, latents: torch.Tensor) -> torch.Tensor:
311
+ video = pipeline.vae.decode_to_pixel(latents, use_cache=False)
312
+ frames = (
313
+ ((video.squeeze(0).float() + 1.0) * 127.5)
314
+ .round_()
315
+ .clamp_(0, 255)
316
+ .to(device="cpu", dtype=torch.uint8)
317
+ .contiguous()
318
+ )
319
+ pipeline.vae.model.clear_cache()
320
+ if frames.shape != (DECODED_FRAMES, 3, 480, 832):
321
+ raise ValueError(f"Unexpected decoded shape: {tuple(frames.shape)}")
322
+ return frames
323
+
324
+
325
+ def save_mp4(frames: torch.Tensor, path: Path) -> None:
326
+ path.parent.mkdir(parents=True, exist_ok=True)
327
+ write_video(
328
+ str(path),
329
+ frames.permute(0, 2, 3, 1),
330
+ fps=16,
331
+ video_codec="libx264",
332
+ options={"crf": "18"},
333
+ )
334
+
335
+
336
+ def gaussian_kernel(device: torch.device, channels: int = 3) -> torch.Tensor:
337
+ coordinates = torch.arange(11, device=device, dtype=torch.float32) - 5
338
+ kernel_1d = torch.exp(-coordinates.square() / (2 * 1.5**2))
339
+ kernel_1d /= kernel_1d.sum()
340
+ return torch.outer(kernel_1d, kernel_1d).expand(channels, 1, 11, 11).contiguous()
341
+
342
+
343
+ def ssim_per_frame(
344
+ reference: torch.Tensor, prediction: torch.Tensor, kernel: torch.Tensor
345
+ ) -> torch.Tensor:
346
+ channels = reference.shape[1]
347
+ mu_x = F.conv2d(reference, kernel, groups=channels)
348
+ mu_y = F.conv2d(prediction, kernel, groups=channels)
349
+ mu_x2 = mu_x.square()
350
+ mu_y2 = mu_y.square()
351
+ mu_xy = mu_x * mu_y
352
+ sigma_x2 = F.conv2d(reference.square(), kernel, groups=channels) - mu_x2
353
+ sigma_y2 = F.conv2d(prediction.square(), kernel, groups=channels) - mu_y2
354
+ sigma_xy = F.conv2d(reference * prediction, kernel, groups=channels) - mu_xy
355
+ c1, c2 = 0.01**2, 0.03**2
356
+ score = ((2 * mu_xy + c1) * (2 * sigma_xy + c2)) / (
357
+ (mu_x2 + mu_y2 + c1) * (sigma_x2 + sigma_y2 + c2)
358
+ )
359
+ return score.mean(dim=(1, 2, 3))
360
+
361
+
362
+ def decoded_chunk_slices() -> list[slice]:
363
+ # Wan VAE maps the first latent to one pixel frame and every subsequent
364
+ # latent to four frames. The first 3-latent chunk therefore has 9 frames;
365
+ # each later chunk has 12.
366
+ result = [slice(0, 9)]
367
+ result.extend(
368
+ slice(9 + 12 * index, 9 + 12 * (index + 1))
369
+ for index in range(NUM_CHUNKS - 1)
370
+ )
371
+ if result[-1].stop != DECODED_FRAMES:
372
+ raise AssertionError(result)
373
+ return result
374
+
375
+
376
+ @torch.inference_mode()
377
+ def frame_metrics(
378
+ *,
379
+ reference_u8: torch.Tensor,
380
+ prediction_u8: torch.Tensor,
381
+ lpips_model: torch.nn.Module,
382
+ batch_size: int,
383
+ ) -> dict[str, Any]:
384
+ if reference_u8.shape != prediction_u8.shape:
385
+ raise ValueError(
386
+ f"Frame shapes differ: {tuple(reference_u8.shape)} vs "
387
+ f"{tuple(prediction_u8.shape)}"
388
+ )
389
+ device = torch.device("cuda")
390
+ kernel = gaussian_kernel(device)
391
+ mse_values: list[float] = []
392
+ psnr_values: list[float] = []
393
+ ssim_values: list[float] = []
394
+ lpips_values: list[float] = []
395
+ max_abs_values: list[int] = []
396
+
397
+ for start in range(0, len(reference_u8), batch_size):
398
+ end = min(start + batch_size, len(reference_u8))
399
+ reference = reference_u8[start:end].to(device=device, dtype=torch.float32) / 255
400
+ prediction = prediction_u8[start:end].to(device=device, dtype=torch.float32) / 255
401
+ mse = (reference - prediction).square().mean(dim=(1, 2, 3))
402
+ psnr = -10 * torch.log10(mse.clamp_min(1e-12))
403
+ ssim = ssim_per_frame(reference, prediction, kernel)
404
+ distance = lpips_model(reference.mul(2).sub(1), prediction.mul(2).sub(1)).flatten()
405
+ mse_values.extend(float(value) for value in mse.cpu())
406
+ psnr_values.extend(float(value) for value in psnr.cpu())
407
+ ssim_values.extend(float(value) for value in ssim.cpu())
408
+ lpips_values.extend(float(value) for value in distance.cpu())
409
+ maximum = (
410
+ reference_u8[start:end].to(torch.int16)
411
+ - prediction_u8[start:end].to(torch.int16)
412
+ ).abs().flatten(1).max(1).values
413
+ max_abs_values.extend(int(value) for value in maximum)
414
+
415
+ def summarize(indices: range) -> dict[str, float | int]:
416
+ selected_mse = [mse_values[index] for index in indices]
417
+ selected_ssim = [ssim_values[index] for index in indices]
418
+ selected_lpips = [lpips_values[index] for index in indices]
419
+ mse = sum(selected_mse) / len(selected_mse)
420
+ return {
421
+ "start_frame": indices.start,
422
+ "end_frame_exclusive": indices.stop,
423
+ "num_frames": len(selected_mse),
424
+ "pixel_mse": mse,
425
+ "psnr": -10 * math.log10(max(mse, 1e-12)),
426
+ "ssim": sum(selected_ssim) / len(selected_ssim),
427
+ "lpips": sum(selected_lpips) / len(selected_lpips),
428
+ "max_abs_u8": max(max_abs_values[index] for index in indices),
429
+ }
430
+
431
+ full = summarize(range(DECODED_FRAMES))
432
+ by_chunk = []
433
+ for chunk, frame_slice in enumerate(decoded_chunk_slices()):
434
+ summary = summarize(range(frame_slice.start, frame_slice.stop))
435
+ summary["chunk"] = chunk
436
+ by_chunk.append(summary)
437
+ return {
438
+ **full,
439
+ "mse_per_frame": mse_values,
440
+ "psnr_per_frame": psnr_values,
441
+ "ssim_per_frame": ssim_values,
442
+ "lpips_per_frame": lpips_values,
443
+ "max_abs_u8_per_frame": max_abs_values,
444
+ "by_output_chunk": by_chunk,
445
+ }
446
+
447
+
448
+ def save_reference_frames(path: Path, frames: torch.Tensor, prompt_id: int) -> None:
449
+ path.parent.mkdir(parents=True, exist_ok=True)
450
+ temporary = path.with_suffix(path.suffix + ".tmp")
451
+ save_file(
452
+ {"frames": frames},
453
+ str(temporary),
454
+ metadata={"prompt_id": str(prompt_id), "pixel_domain": "uint8_rgb"},
455
+ )
456
+ os.replace(temporary, path)
457
+
458
+
459
+ def load_reference_frames(path: Path) -> torch.Tensor:
460
+ return load_file(str(path), device="cpu")["frames"]
461
+
462
+
463
+ def move_module(module: torch.nn.Module, device: str) -> None:
464
+ module.to(device=device)
465
+ if device == "cpu":
466
+ torch.cuda.empty_cache()
467
+
468
+
469
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
470
+ if not rows:
471
+ return
472
+ fields: list[str] = []
473
+ for row in rows:
474
+ for key in row:
475
+ if key not in fields:
476
+ fields.append(key)
477
+ with path.open("w", newline="", encoding="utf-8") as handle:
478
+ writer = csv.DictWriter(handle, fieldnames=fields)
479
+ writer.writeheader()
480
+ writer.writerows(rows)
481
+
482
+
483
+ def aggregate(
484
+ output_dir: Path, prompt_ids: list[int], intervention_schedule: str
485
+ ) -> None:
486
+ prompt_rows: list[dict[str, Any]] = []
487
+ propagation_buckets: dict[tuple[int, int], list[dict[str, Any]]] = {}
488
+ intervention_metrics: dict[int, list[dict[str, Any]]] = {
489
+ chunk: [] for chunk in range(NUM_CHUNKS)
490
+ }
491
+ completed_prompt_ids: list[int] = []
492
+
493
+ for prompt_id in prompt_ids:
494
+ prompt_dir = output_dir / f"prompt_{prompt_id:04d}"
495
+ prompt_complete = True
496
+ for reuse_chunk in range(NUM_CHUNKS):
497
+ path = prompt_dir / f"reuse_chunk_{reuse_chunk}" / "metrics.json"
498
+ if not path.exists():
499
+ prompt_complete = False
500
+ continue
501
+ value = json.loads(path.read_text(encoding="utf-8"))
502
+ if value.get("status") != "complete":
503
+ prompt_complete = False
504
+ continue
505
+ prompt_rows.append(
506
+ {
507
+ "prompt_id": prompt_id,
508
+ "reuse_chunk": reuse_chunk,
509
+ "psnr": value["psnr"],
510
+ "ssim": value["ssim"],
511
+ "lpips": value["lpips"],
512
+ "pixel_mse": value["pixel_mse"],
513
+ "max_abs_u8": value["max_abs_u8"],
514
+ "generation_time_s": value["generation_time_s"],
515
+ }
516
+ )
517
+ intervention_metrics[reuse_chunk].append(value)
518
+ for chunk_value in value["by_output_chunk"]:
519
+ propagation_buckets.setdefault(
520
+ (reuse_chunk, int(chunk_value["chunk"])), []
521
+ ).append(chunk_value)
522
+ if prompt_complete:
523
+ completed_prompt_ids.append(prompt_id)
524
+
525
+ summary_rows: list[dict[str, Any]] = []
526
+ for reuse_chunk in range(NUM_CHUNKS):
527
+ rows = [row for row in prompt_rows if row["reuse_chunk"] == reuse_chunk]
528
+ if not rows:
529
+ continue
530
+ global_mse = sum(float(row["pixel_mse"]) for row in rows) / len(rows)
531
+ summary_rows.append(
532
+ {
533
+ "reuse_chunk": reuse_chunk,
534
+ "num_prompts": len(rows),
535
+ "psnr_from_global_mse": -10 * math.log10(max(global_mse, 1e-12)),
536
+ "mean_prompt_psnr": sum(float(row["psnr"]) for row in rows) / len(rows),
537
+ "mean_ssim": sum(float(row["ssim"]) for row in rows) / len(rows),
538
+ "mean_lpips": sum(float(row["lpips"]) for row in rows) / len(rows),
539
+ "mean_generation_time_s": sum(
540
+ float(row["generation_time_s"]) for row in rows
541
+ ) / len(rows),
542
+ }
543
+ )
544
+
545
+ propagation_rows: list[dict[str, Any]] = []
546
+ for reuse_chunk in range(NUM_CHUNKS):
547
+ for output_chunk in range(NUM_CHUNKS):
548
+ values = propagation_buckets.get((reuse_chunk, output_chunk), [])
549
+ if not values:
550
+ continue
551
+ mse = sum(float(value["pixel_mse"]) for value in values) / len(values)
552
+ propagation_rows.append(
553
+ {
554
+ "reuse_chunk": reuse_chunk,
555
+ "output_chunk": output_chunk,
556
+ "relative_chunk": output_chunk - reuse_chunk,
557
+ "num_prompts": len(values),
558
+ "psnr_from_global_mse": -10 * math.log10(max(mse, 1e-12)),
559
+ "mean_ssim": sum(float(value["ssim"]) for value in values)
560
+ / len(values),
561
+ "mean_lpips": sum(float(value["lpips"]) for value in values)
562
+ / len(values),
563
+ "mean_max_abs_u8": sum(float(value["max_abs_u8"]) for value in values)
564
+ / len(values),
565
+ }
566
+ )
567
+
568
+ affected_tail_rows: list[dict[str, Any]] = []
569
+ chunk_frames = decoded_chunk_slices()
570
+ for reuse_chunk in range(NUM_CHUNKS):
571
+ values = intervention_metrics[reuse_chunk]
572
+ if not values:
573
+ continue
574
+ start_frame = int(chunk_frames[reuse_chunk].start)
575
+ mse_values = [
576
+ frame
577
+ for value in values
578
+ for frame in value["mse_per_frame"][start_frame:]
579
+ ]
580
+ ssim_values = [
581
+ frame
582
+ for value in values
583
+ for frame in value["ssim_per_frame"][start_frame:]
584
+ ]
585
+ lpips_values = [
586
+ frame
587
+ for value in values
588
+ for frame in value["lpips_per_frame"][start_frame:]
589
+ ]
590
+ mse = sum(mse_values) / len(mse_values)
591
+ affected_tail_rows.append(
592
+ {
593
+ "reuse_chunk": reuse_chunk,
594
+ "start_frame": start_frame,
595
+ "affected_frames_per_prompt": DECODED_FRAMES - start_frame,
596
+ "num_prompts": len(values),
597
+ "psnr_from_global_mse": -10 * math.log10(max(mse, 1e-12)),
598
+ "mean_ssim": sum(ssim_values) / len(ssim_values),
599
+ "mean_lpips": sum(lpips_values) / len(lpips_values),
600
+ }
601
+ )
602
+
603
+ output_dir.mkdir(parents=True, exist_ok=True)
604
+ write_csv(output_dir / "per_prompt.csv", prompt_rows)
605
+ write_csv(output_dir / "summary_by_reuse_chunk.csv", summary_rows)
606
+ write_csv(output_dir / "error_propagation_matrix.csv", propagation_rows)
607
+ write_csv(output_dir / "summary_affected_tail.csv", affected_tail_rows)
608
+ atomic_json(
609
+ output_dir / "summary.json",
610
+ {
611
+ "status": (
612
+ "complete"
613
+ if sorted(completed_prompt_ids) == sorted(prompt_ids)
614
+ else "partial"
615
+ ),
616
+ "requested_prompt_ids": prompt_ids,
617
+ "completed_prompt_ids": completed_prompt_ids,
618
+ "num_completed_prompts": len(completed_prompt_ids),
619
+ "schedule_reference": "all chunks FFFF",
620
+ "schedule_intervention": (
621
+ f"one selected chunk {intervention_schedule}; all others FFFF"
622
+ ),
623
+ "reuse_definition": (
624
+ "R reuses the selected chunk's most recent F flow prediction, "
625
+ "then applies current-timestep x0 conversion"
626
+ ),
627
+ "summary_by_reuse_chunk": summary_rows,
628
+ "summary_affected_tail": affected_tail_rows,
629
+ },
630
+ )
631
+
632
+
633
+ @torch.inference_mode()
634
+ def run(args: argparse.Namespace) -> None:
635
+ output_dir = resolve(args.output_dir)
636
+ prompt_path = resolve(args.prompt_path)
637
+ prompts = read_prompts(prompt_path)
638
+ if max(args.prompt_ids) >= len(prompts):
639
+ raise ValueError(
640
+ f"Prompt ID {max(args.prompt_ids)} exceeds {len(prompts)} prompts"
641
+ )
642
+ output_dir.mkdir(parents=True, exist_ok=True)
643
+ if not args.worker:
644
+ atomic_json(
645
+ output_dir / "experiment_config.json",
646
+ {
647
+ "config_path": str(resolve(args.config_path)),
648
+ "checkpoint_path": str(resolve(args.checkpoint_path)),
649
+ "prompt_path": str(prompt_path),
650
+ "prompt_ids": args.prompt_ids,
651
+ "seed": args.seed,
652
+ "physical_gpu": args.gpu,
653
+ "use_ema": args.use_ema,
654
+ "low_memory": args.low_memory,
655
+ "latent_frames": NUM_CHUNKS * FRAMES_PER_CHUNK,
656
+ "decoded_frames": DECODED_FRAMES,
657
+ "num_chunks": NUM_CHUNKS,
658
+ "denoising_steps_per_chunk": NUM_DENOISING_STEPS,
659
+ "reference_schedule": (
660
+ "FFFFFFF at chunk level; FFFF within every chunk"
661
+ ),
662
+ "intervention_schedule": (
663
+ f"one {args.intervention_schedule} chunk and "
664
+ f"{NUM_CHUNKS - 1} FFFF chunks"
665
+ ),
666
+ "reuse_definition": (
667
+ "R reuses the most recent F flow prediction and applies "
668
+ "the current-timestep x0 conversion"
669
+ ),
670
+ "metric_domain": (
671
+ "VAE-decoded RGB rounded to uint8 before MP4 encoding"
672
+ ),
673
+ },
674
+ )
675
+ if args.aggregate_only:
676
+ aggregate(output_dir, args.prompt_ids, args.intervention_schedule)
677
+ return
678
+
679
+ pipeline = build_pipeline(args)
680
+ lpips_model = lpips.LPIPS(net="alex").eval()
681
+ if not args.low_memory:
682
+ lpips_model.to("cuda")
683
+
684
+ for prompt_offset, prompt_id in enumerate(args.prompt_ids, start=1):
685
+ prompt = prompts[prompt_id]
686
+ prompt_dir = output_dir / f"prompt_{prompt_id:04d}"
687
+ prompt_dir.mkdir(parents=True, exist_ok=True)
688
+ atomic_json(
689
+ prompt_dir / "prompt.json", {"prompt_id": prompt_id, "prompt": prompt}
690
+ )
691
+ reference_frames_path = prompt_dir / "reference_ffff_frames.safetensors"
692
+ reference_video_path = prompt_dir / "reference_ffff.mp4"
693
+ reference_metrics_path = prompt_dir / "reference_ffff.json"
694
+
695
+ print(
696
+ f"[prompt] {prompt_offset}/{len(args.prompt_ids)} id={prompt_id}",
697
+ flush=True,
698
+ )
699
+ reference_pending = args.overwrite or not reference_frames_path.exists()
700
+ pending_reuse_chunks: list[int] = []
701
+ for reuse_chunk in range(NUM_CHUNKS):
702
+ variant_dir = prompt_dir / f"reuse_chunk_{reuse_chunk}"
703
+ metrics_path = variant_dir / "metrics.json"
704
+ if metrics_path.exists() and not args.overwrite:
705
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
706
+ if existing.get("status") == "complete":
707
+ print(f"[skip] reuse_chunk={reuse_chunk}", flush=True)
708
+ continue
709
+ pending_reuse_chunks.append(reuse_chunk)
710
+
711
+ if not reference_pending and not pending_reuse_chunks:
712
+ if not args.worker:
713
+ aggregate(output_dir, args.prompt_ids, args.intervention_schedule)
714
+ continue
715
+
716
+ # Generation phase: keep only T5, then only the generator, on CUDA.
717
+ if args.low_memory:
718
+ move_module(pipeline.text_encoder, "cuda")
719
+ conditional_dict = pipeline.text_encoder(text_prompts=[prompt])
720
+ if args.low_memory:
721
+ move_module(pipeline.text_encoder, "cpu")
722
+ move_module(pipeline.generator, "cuda")
723
+
724
+ generated_latents: dict[int | None, torch.Tensor] = {}
725
+ generation_timings: dict[int | None, dict[str, Any]] = {}
726
+ if reference_pending:
727
+ print("[run] reference FFFF", flush=True)
728
+ latents, timing = generate_latents(
729
+ pipeline=pipeline,
730
+ conditional_dict=conditional_dict,
731
+ seed=args.seed,
732
+ reuse_chunk=None,
733
+ intervention_schedule=args.intervention_schedule,
734
+ )
735
+ generated_latents[None] = latents.to(device="cpu")
736
+ generation_timings[None] = timing
737
+ del latents
738
+
739
+ for reuse_chunk in pending_reuse_chunks:
740
+ print(
741
+ f"[run] reuse_chunk={reuse_chunk}: "
742
+ f"{args.intervention_schedule}",
743
+ flush=True,
744
+ )
745
+ latents, timing = generate_latents(
746
+ pipeline=pipeline,
747
+ conditional_dict=conditional_dict,
748
+ seed=args.seed,
749
+ reuse_chunk=reuse_chunk,
750
+ intervention_schedule=args.intervention_schedule,
751
+ )
752
+ generated_latents[reuse_chunk] = latents.to(device="cpu")
753
+ generation_timings[reuse_chunk] = timing
754
+ del latents
755
+
756
+ if args.low_memory:
757
+ pipeline.kv_cache1 = None
758
+ pipeline.crossattn_cache = None
759
+ move_module(pipeline.generator, "cpu")
760
+ move_module(pipeline.vae, "cuda")
761
+ move_module(lpips_model, "cuda")
762
+
763
+ # Decode/metric phase. Keeping latents on CPU makes the phase boundary
764
+ # cheap and limits peak CUDA memory while other jobs occupy the GPU.
765
+ if reference_pending:
766
+ frames = decode_u8(pipeline, generated_latents.pop(None).to("cuda"))
767
+ save_reference_frames(reference_frames_path, frames, prompt_id)
768
+ if args.save_videos:
769
+ save_mp4(frames, reference_video_path)
770
+ atomic_json(
771
+ reference_metrics_path,
772
+ {
773
+ "status": "complete",
774
+ "prompt_id": prompt_id,
775
+ "schedule": "all chunks FFFF",
776
+ **generation_timings[None],
777
+ },
778
+ )
779
+ del frames
780
+ reference_frames = load_reference_frames(reference_frames_path)
781
+
782
+ for reuse_chunk in pending_reuse_chunks:
783
+ variant_dir = prompt_dir / f"reuse_chunk_{reuse_chunk}"
784
+ metrics_path = variant_dir / "metrics.json"
785
+ frames = decode_u8(
786
+ pipeline, generated_latents.pop(reuse_chunk).to("cuda")
787
+ )
788
+ metrics = frame_metrics(
789
+ reference_u8=reference_frames,
790
+ prediction_u8=frames,
791
+ lpips_model=lpips_model,
792
+ batch_size=args.metric_batch_size,
793
+ )
794
+ variant_dir.mkdir(parents=True, exist_ok=True)
795
+ if args.save_videos:
796
+ save_mp4(frames, variant_dir / "video.mp4")
797
+ atomic_json(
798
+ metrics_path,
799
+ {
800
+ "status": "complete",
801
+ "prompt_id": prompt_id,
802
+ "reuse_chunk": reuse_chunk,
803
+ "schedule": (
804
+ f"chunk {reuse_chunk}={args.intervention_schedule}; "
805
+ "all other chunks=FFFF"
806
+ ),
807
+ "reference": "matching FFFF; same prompt, seed, and RNG sequence",
808
+ **generation_timings[reuse_chunk],
809
+ **metrics,
810
+ },
811
+ )
812
+ print(
813
+ f"[metric] chunk={reuse_chunk} PSNR={metrics['psnr']:.4f} "
814
+ f"SSIM={metrics['ssim']:.6f} LPIPS={metrics['lpips']:.6f}",
815
+ flush=True,
816
+ )
817
+ del frames
818
+ torch.cuda.empty_cache()
819
+
820
+ if args.low_memory:
821
+ move_module(pipeline.vae, "cpu")
822
+ move_module(lpips_model, "cpu")
823
+ del reference_frames, conditional_dict, generated_latents
824
+ if not args.worker:
825
+ aggregate(output_dir, args.prompt_ids, args.intervention_schedule)
826
+
827
+ if not args.worker:
828
+ aggregate(output_dir, args.prompt_ids, args.intervention_schedule)
829
+ print(f"[done] {output_dir}", flush=True)
830
+
831
+
832
+ def main() -> None:
833
+ args = parse_args()
834
+ run(args)
835
+
836
+
837
+ if __name__ == "__main__":
838
+ main()
scripts/evaluate_three_block_fppf.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate consecutive three-block Predictors with the FPPF protocol."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ from evaluate_two_block_fppf import main
9
+
10
+
11
+ DEFAULTS = {
12
+ "--architecture": "three_block",
13
+ "--sweep_dir": "outputs/three_block_consecutive_sweep",
14
+ "--output_dir": "outputs/three_block_fppf_eval",
15
+ "--reference_root": "outputs/single_block_fppf_eval",
16
+ "--schedule": "FPPF",
17
+ }
18
+
19
+
20
+ if __name__ == "__main__":
21
+ present = set(sys.argv[1:])
22
+ for option, value in DEFAULTS.items():
23
+ if option not in present:
24
+ sys.argv.extend([option, value])
25
+ main()
scripts/evaluate_trained_long_predictors.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate 2x/4x-trained Layer-17 predictors at 1x, 2x, and 4x."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def preparse_gpu() -> str:
14
+ parser = argparse.ArgumentParser(add_help=False)
15
+ parser.add_argument("--gpu", required=True)
16
+ args, _ = parser.parse_known_args()
17
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
18
+ return args.gpu
19
+
20
+
21
+ GPU = preparse_gpu()
22
+
23
+ import lpips
24
+ import torch
25
+ from omegaconf import OmegaConf
26
+
27
+ ROOT = Path(__file__).resolve().parents[1]
28
+ if str(ROOT) not in sys.path:
29
+ sys.path.insert(0, str(ROOT))
30
+
31
+ from scripts.evaluate_long_video_fppf import generate_rollout, save_mp4
32
+ from scripts.evaluate_single_block_fppf import (
33
+ atomic_json, build_pipeline, frame_metrics, load_predictor,
34
+ load_prompt_metadata, pixels_to_u8,
35
+ )
36
+ from utils.misc import set_seed
37
+ from utils.wan_wrapper import WanVAEWrapper
38
+
39
+
40
+ PREDICTORS = {
41
+ "trained_2x": ROOT / "outputs/layer17_long_training_four_gpu_v2/2x/predictor_final.safetensors",
42
+ "trained_4x": ROOT / "outputs/layer17_long_training_four_gpu_v2/4x/predictor_final.safetensors",
43
+ }
44
+
45
+
46
+ def main() -> None:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument("--gpu", default=GPU)
49
+ parser.add_argument("--prompt_ids", type=int, nargs="+", required=True)
50
+ parser.add_argument("--latent_lengths", type=int, nargs="+", default=[21, 42, 84])
51
+ parser.add_argument(
52
+ "--dataset_root", type=Path,
53
+ default=Path("outputs/predictor_offline_100_all_blocks"),
54
+ )
55
+ parser.add_argument(
56
+ "--output_dir", type=Path,
57
+ default=Path("outputs/layer17_long_training_eval"),
58
+ )
59
+ parser.add_argument("--generation_seed", type=int, default=0)
60
+ parser.add_argument("--metric_batch_size", type=int, default=4)
61
+ args = parser.parse_args()
62
+ args.dataset_root = (ROOT / args.dataset_root).resolve() if not args.dataset_root.is_absolute() else args.dataset_root
63
+ args.output_dir = (ROOT / args.output_dir).resolve() if not args.output_dir.is_absolute() else args.output_dir
64
+ args.output_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ device = torch.device("cuda")
67
+ set_seed(args.generation_seed)
68
+ config = OmegaConf.merge(
69
+ OmegaConf.load(ROOT / "configs/default_config.yaml"),
70
+ OmegaConf.load(ROOT / "configs/self_forcing_sid.yaml"),
71
+ )
72
+ config.model_kwargs.local_attn_size = 21
73
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
74
+ pipeline = build_pipeline(
75
+ config, ROOT / "checkpoints/self_forcing_dmd.pt", vae, device,
76
+ )
77
+ predictors = {
78
+ name: load_predictor(
79
+ pipeline.generator.model,
80
+ {"source_layer": 17, "weights": path},
81
+ device,
82
+ )
83
+ for name, path in PREDICTORS.items()
84
+ }
85
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
86
+ lpips_model.requires_grad_(False)
87
+
88
+ for prompt_id in args.prompt_ids:
89
+ prompt = load_prompt_metadata(args.dataset_root, prompt_id)["prompt"]
90
+ for latent_length in args.latent_lengths:
91
+ run_dir = args.output_dir / f"latent_{latent_length}" / f"prompt_{prompt_id:04d}"
92
+ result_path = run_dir / "metrics.json"
93
+ if result_path.exists():
94
+ existing = json.loads(result_path.read_text())
95
+ if existing.get("status") == "complete":
96
+ print(f"[skip] prompt={prompt_id} latent={latent_length}", flush=True)
97
+ continue
98
+
99
+ print(f"[run] prompt={prompt_id} latent={latent_length} FFFF", flush=True)
100
+ reference_latent, ffff_counts = generate_rollout(
101
+ pipeline=pipeline, dataset_root=args.dataset_root,
102
+ prompt_id=prompt_id, latent_length=latent_length,
103
+ generation_seed=args.generation_seed, device=device,
104
+ predictor=None, source_layer=None, schedule="FFFF",
105
+ )
106
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
107
+ reference_pixels = vae.decode_to_pixel(reference_latent, use_cache=False)
108
+ reference_u8 = pixels_to_u8(reference_pixels)
109
+ save_mp4(reference_u8, run_dir / "ffff.mp4")
110
+ del reference_latent, reference_pixels
111
+ if hasattr(vae.model, "clear_cache"):
112
+ vae.model.clear_cache()
113
+ torch.cuda.empty_cache()
114
+
115
+ results = {}
116
+ for name, predictor in predictors.items():
117
+ print(f"[run] prompt={prompt_id} latent={latent_length} {name}", flush=True)
118
+ latent, counts = generate_rollout(
119
+ pipeline=pipeline, dataset_root=args.dataset_root,
120
+ prompt_id=prompt_id, latent_length=latent_length,
121
+ generation_seed=args.generation_seed, device=device,
122
+ predictor=predictor, source_layer=17, schedule="FPPF",
123
+ )
124
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
125
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
126
+ prediction_u8 = pixels_to_u8(pixels)
127
+ save_mp4(prediction_u8, run_dir / f"{name}.mp4")
128
+ metrics = frame_metrics(
129
+ reference_u8=reference_u8,
130
+ prediction_u8=prediction_u8,
131
+ lpips_model=lpips_model,
132
+ batch_size=args.metric_batch_size,
133
+ device=device,
134
+ )
135
+ results[name] = {"fppf": counts, **metrics}
136
+ print(
137
+ f"[result] {name} prompt={prompt_id} latent={latent_length} "
138
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
139
+ f"lpips={metrics['lpips']:.6f}", flush=True,
140
+ )
141
+ del latent, pixels, prediction_u8
142
+ if hasattr(vae.model, "clear_cache"):
143
+ vae.model.clear_cache()
144
+ torch.cuda.empty_cache()
145
+
146
+ atomic_json(result_path, {
147
+ "status": "complete", "prompt_id": prompt_id,
148
+ "prompt": prompt, "latent_length": latent_length,
149
+ "decoded_frames": next(iter(results.values()))["num_frames"],
150
+ "reference": "FFFF same prompt/seed/noise",
151
+ "ffff": ffff_counts, "predictors": results,
152
+ })
153
+ del reference_u8
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
scripts/evaluate_two_block_fppf.py ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate trained multi-block Predictors against matching FFFF rollouts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def _preparse_gpu() -> str:
17
+ parser = argparse.ArgumentParser(add_help=False)
18
+ parser.add_argument("--gpu", default="2")
19
+ args, _ = parser.parse_known_args()
20
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
21
+ return str(args.gpu)
22
+
23
+
24
+ PHYSICAL_GPU = _preparse_gpu()
25
+
26
+ import lpips
27
+ import torch
28
+ from omegaconf import OmegaConf
29
+ from safetensors.torch import load_file
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parents[1]
32
+ if str(REPO_ROOT) not in sys.path:
33
+ sys.path.insert(0, str(REPO_ROOT))
34
+
35
+ from predictor_training.offline_data import TOKENS_PER_CHUNK
36
+ from predictor_training.three_block import ThreeBlockPredictor
37
+ from predictor_training.two_block import TwoBlockPredictor
38
+ from scripts.evaluate_single_block_fppf import (
39
+ DEFAULT_PROMPT_IDS,
40
+ FRAMES_PER_CHUNK,
41
+ LATENT_CHANNELS,
42
+ LATENT_HEIGHT,
43
+ LATENT_WIDTH,
44
+ NUM_CHUNKS,
45
+ NUM_DENOISING_STEPS,
46
+ FinalHiddenCapture,
47
+ aggregate_prompt_results,
48
+ atomic_json,
49
+ build_pipeline,
50
+ frame_metrics,
51
+ load_ffff_latent,
52
+ load_prompt_metadata,
53
+ load_reference_frames,
54
+ pixels_to_u8,
55
+ prepare_reference_frames,
56
+ reset_kv_and_load_cross_cache,
57
+ )
58
+ from scripts.run_single_block_init_sweep import hidden_to_flow
59
+ from utils.misc import set_seed
60
+ from utils.wan_wrapper import WanVAEWrapper
61
+ from wan.modules.model import sinusoidal_embedding_1d
62
+
63
+
64
+ def parse_args() -> argparse.Namespace:
65
+ parser = argparse.ArgumentParser(description=__doc__)
66
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
67
+ parser.add_argument(
68
+ "--architecture",
69
+ choices=("two_block", "three_block"),
70
+ default="two_block",
71
+ help="Predictor architecture represented by the sweep directory.",
72
+ )
73
+ parser.add_argument(
74
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
75
+ )
76
+ parser.add_argument(
77
+ "--checkpoint_path",
78
+ type=Path,
79
+ default=Path("checkpoints/self_forcing_dmd.pt"),
80
+ )
81
+ parser.add_argument(
82
+ "--dataset_root",
83
+ type=Path,
84
+ default=Path("outputs/predictor_offline_100_all_blocks"),
85
+ )
86
+ parser.add_argument(
87
+ "--sweep_dir", type=Path, default=Path("outputs/two_block_pair_sweep")
88
+ )
89
+ parser.add_argument(
90
+ "--output_dir", type=Path, default=Path("outputs/two_block_pair_fppf_eval")
91
+ )
92
+ parser.add_argument(
93
+ "--schedule",
94
+ choices=("FPPF", "FPPP"),
95
+ default="FPPF",
96
+ help=(
97
+ "Denoising schedule for chunks 1-6; chunk 0 always uses FFFF. "
98
+ "FPPF predicts steps 1-2, while FPPP predicts steps 1-3."
99
+ ),
100
+ )
101
+ parser.add_argument(
102
+ "--reference_root",
103
+ type=Path,
104
+ default=Path("outputs/single_block_fppf_eval"),
105
+ help="Directory containing reusable ffff_reference_frames/.",
106
+ )
107
+ parser.add_argument(
108
+ "--prompt_ids", type=int, nargs="*", default=DEFAULT_PROMPT_IDS
109
+ )
110
+ parser.add_argument("--experiments", nargs="*", default=None)
111
+ parser.add_argument("--max_prompts", type=int, default=None)
112
+ parser.add_argument("--max_experiments", type=int, default=None)
113
+ parser.add_argument("--metric_batch_size", type=int, default=4)
114
+ parser.add_argument("--generation_seed", type=int, default=0)
115
+ parser.add_argument(
116
+ "--verify_ffff", action=argparse.BooleanOptionalAction, default=True
117
+ )
118
+ parser.add_argument(
119
+ "--skip_lpips", action=argparse.BooleanOptionalAction, default=False
120
+ )
121
+ args = parser.parse_args()
122
+ if not args.prompt_ids:
123
+ parser.error("At least one prompt ID is required")
124
+ if args.metric_batch_size < 1:
125
+ parser.error("--metric_batch_size must be positive")
126
+ return args
127
+
128
+
129
+ def resolve(path: Path) -> Path:
130
+ path = path.expanduser()
131
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
132
+
133
+
134
+ def discover_experiments(
135
+ sweep_dir: Path,
136
+ requested: list[str] | None,
137
+ max_experiments: int | None,
138
+ architecture: str = "two_block",
139
+ ) -> list[dict[str, Any]]:
140
+ with (sweep_dir / "summary.csv").open(
141
+ "r", encoding="utf-8", newline=""
142
+ ) as handle:
143
+ rows = list(csv.DictReader(handle))
144
+ by_name = {row["name"]: row for row in rows}
145
+ names = list(by_name) if requested is None else requested
146
+ unknown = [name for name in names if name not in by_name]
147
+ if unknown:
148
+ raise KeyError(f"Unknown experiments: {unknown}")
149
+ if max_experiments is not None:
150
+ names = names[:max_experiments]
151
+
152
+ output = []
153
+ for name in names:
154
+ run_dir = sweep_dir / name
155
+ config = json.loads((run_dir / "config.json").read_text(encoding="utf-8"))
156
+ weights = run_dir / "predictor_final.safetensors"
157
+ if not weights.exists():
158
+ raise FileNotFoundError(weights)
159
+ row = by_name[name]
160
+ kind_key = "pair_kind" if architecture == "two_block" else "triple_kind"
161
+ output.append(
162
+ {
163
+ "name": name,
164
+ "source_layers": [int(value) for value in config["source_layers"]],
165
+ "experiment_kind": config[kind_key],
166
+ "initialization_method": "teacher_full",
167
+ "weights": weights,
168
+ "offline_final_val_flow_mse": float(row["final_val_flow_mse"]),
169
+ "offline_final_val_hidden_mse": float(
170
+ row["final_val_hidden_mse"]
171
+ ),
172
+ }
173
+ )
174
+ return output
175
+
176
+
177
+ def load_predictor(
178
+ teacher: torch.nn.Module,
179
+ experiment: dict[str, Any],
180
+ device: torch.device,
181
+ ) -> TwoBlockPredictor | ThreeBlockPredictor:
182
+ source_layers = experiment["source_layers"]
183
+ predictor_class = (
184
+ TwoBlockPredictor if len(source_layers) == 2 else ThreeBlockPredictor
185
+ )
186
+ predictor = predictor_class(
187
+ [teacher.blocks[layer] for layer in source_layers],
188
+ dim=teacher.dim,
189
+ gradient_checkpointing=False,
190
+ )
191
+ predictor.load_state_dict(
192
+ load_file(str(experiment["weights"]), device="cpu"), strict=True
193
+ )
194
+ predictor.to(device=device).eval().requires_grad_(False)
195
+ return predictor
196
+
197
+
198
+ @torch.inference_mode()
199
+ def predictor_step(
200
+ *,
201
+ predictor: TwoBlockPredictor | ThreeBlockPredictor,
202
+ teacher: torch.nn.Module,
203
+ noisy_input: torch.Tensor,
204
+ timestep: torch.Tensor,
205
+ anchor_hidden: torch.Tensor,
206
+ previous_hidden: torch.Tensor,
207
+ history_caches: list[dict[str, torch.Tensor]],
208
+ cross_caches: list[dict[str, torch.Tensor]],
209
+ current_start: int,
210
+ ) -> tuple[torch.Tensor, torch.Tensor]:
211
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
212
+ current_tokens = teacher.patch_embedding(
213
+ noisy_input.permute(0, 2, 1, 3, 4)
214
+ ).flatten(2).transpose(1, 2)
215
+ time_embedding = teacher.time_embedding(
216
+ sinusoidal_embedding_1d(
217
+ teacher.freq_dim, timestep.flatten()
218
+ ).type_as(current_tokens)
219
+ )
220
+ timestep_modulation = teacher.time_projection(
221
+ time_embedding
222
+ ).unflatten(1, (6, teacher.dim)).unflatten(
223
+ dim=0, sizes=timestep.shape
224
+ )
225
+ head_embedding = time_embedding.unflatten(
226
+ dim=0, sizes=timestep.shape
227
+ ).unsqueeze(2)
228
+ grid_sizes = torch.tensor(
229
+ [[FRAMES_PER_CHUNK, 30, 52]], dtype=torch.long, device="cpu"
230
+ )
231
+ pred_hidden = predictor(
232
+ current_tokens=current_tokens,
233
+ anchor_hidden=anchor_hidden,
234
+ previous_hidden=previous_hidden,
235
+ timestep_modulation=timestep_modulation,
236
+ grid_sizes=grid_sizes,
237
+ freqs=teacher.freqs,
238
+ history_ks=[
239
+ cache["k"][:, :current_start] for cache in history_caches
240
+ ],
241
+ history_vs=[
242
+ cache["v"][:, :current_start] for cache in history_caches
243
+ ],
244
+ cross_ks=[cache["k"] for cache in cross_caches],
245
+ cross_vs=[cache["v"] for cache in cross_caches],
246
+ current_start=current_start,
247
+ )
248
+ pred_flow = hidden_to_flow(
249
+ pred_hidden, head_embedding, grid_sizes, teacher
250
+ )
251
+ return pred_hidden, pred_flow
252
+
253
+
254
+ @torch.inference_mode()
255
+ def generate_rollout(
256
+ *,
257
+ pipeline,
258
+ dataset_root: Path,
259
+ prompt_id: int,
260
+ generation_seed: int,
261
+ device: torch.device,
262
+ predictor: TwoBlockPredictor | ThreeBlockPredictor | None,
263
+ source_layers: list[int] | None,
264
+ schedule: str,
265
+ ) -> tuple[torch.Tensor, dict[str, float | int]]:
266
+ if schedule not in {"FFFF", "FPPF", "FPPP"}:
267
+ raise ValueError(schedule)
268
+ if schedule != "FFFF" and (predictor is None or source_layers is None):
269
+ raise ValueError(f"{schedule} requires a Predictor and source layers")
270
+ reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
271
+ set_seed(generation_seed)
272
+ noise = torch.randn(
273
+ 1,
274
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
275
+ LATENT_CHANNELS,
276
+ LATENT_HEIGHT,
277
+ LATENT_WIDTH,
278
+ dtype=torch.bfloat16,
279
+ device=device,
280
+ )
281
+ teacher = pipeline.generator.model
282
+ text_dim = int(teacher.text_embedding[0].in_features)
283
+ conditional_dict = {
284
+ "prompt_embeds": torch.zeros(
285
+ 1, 1, text_dim, dtype=torch.bfloat16, device=device
286
+ )
287
+ }
288
+ timesteps = pipeline.denoising_step_list.to(device=device)
289
+ output_chunks: list[torch.Tensor] = []
290
+ previous_chunk_hidden: list[torch.Tensor | None] | None = None
291
+ capture = FinalHiddenCapture(teacher)
292
+ full_calls = 0
293
+ predictor_calls = 0
294
+ started = time.perf_counter()
295
+
296
+ try:
297
+ for chunk in range(NUM_CHUNKS):
298
+ noisy_input = noise[
299
+ :, chunk * FRAMES_PER_CHUNK : (chunk + 1) * FRAMES_PER_CHUNK
300
+ ]
301
+ current_hidden: list[torch.Tensor | None] = [None] * NUM_DENOISING_STEPS
302
+ denoised_pred = None
303
+ timestep = None
304
+ for step, current_timestep in enumerate(timesteps):
305
+ timestep = torch.ones(
306
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device=device
307
+ ) * current_timestep
308
+ predictor_steps = {1, 2} if schedule == "FPPF" else {1, 2, 3}
309
+ use_predictor = (
310
+ schedule != "FFFF" and chunk > 0 and step in predictor_steps
311
+ )
312
+ if use_predictor:
313
+ anchor_hidden = current_hidden[step - 1]
314
+ assert anchor_hidden is not None
315
+ assert previous_chunk_hidden is not None
316
+ previous_hidden = previous_chunk_hidden[step]
317
+ assert previous_hidden is not None
318
+ pred_hidden, flow = predictor_step(
319
+ predictor=predictor,
320
+ teacher=teacher,
321
+ noisy_input=noisy_input,
322
+ timestep=timestep,
323
+ anchor_hidden=anchor_hidden,
324
+ previous_hidden=previous_hidden,
325
+ history_caches=[
326
+ pipeline.kv_cache1[layer] for layer in source_layers
327
+ ],
328
+ cross_caches=[
329
+ pipeline.crossattn_cache[layer]
330
+ for layer in source_layers
331
+ ],
332
+ current_start=chunk * TOKENS_PER_CHUNK,
333
+ )
334
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
335
+ flow_pred=flow.flatten(0, 1),
336
+ xt=noisy_input.flatten(0, 1),
337
+ timestep=timestep.flatten(0, 1),
338
+ ).unflatten(0, flow.shape[:2])
339
+ current_hidden[step] = pred_hidden
340
+ predictor_calls += 1
341
+ else:
342
+ capture.start()
343
+ _, denoised_pred = pipeline.generator(
344
+ noisy_image_or_video=noisy_input,
345
+ conditional_dict=conditional_dict,
346
+ timestep=timestep,
347
+ kv_cache=pipeline.kv_cache1,
348
+ crossattn_cache=pipeline.crossattn_cache,
349
+ current_start=chunk * TOKENS_PER_CHUNK,
350
+ )
351
+ current_hidden[step] = capture.finish()
352
+ full_calls += 1
353
+
354
+ if step < NUM_DENOISING_STEPS - 1:
355
+ next_timestep = timesteps[step + 1]
356
+ denoised_flat = denoised_pred.flatten(0, 1)
357
+ noisy_input = pipeline.scheduler.add_noise(
358
+ denoised_flat,
359
+ torch.randn_like(denoised_flat),
360
+ next_timestep
361
+ * torch.ones(
362
+ [FRAMES_PER_CHUNK], dtype=torch.long, device=device
363
+ ),
364
+ ).unflatten(0, denoised_pred.shape[:2])
365
+
366
+ if denoised_pred is None or timestep is None:
367
+ raise RuntimeError("Denoising loop produced no clean latent")
368
+ output_chunks.append(denoised_pred)
369
+ pipeline.generator(
370
+ noisy_image_or_video=denoised_pred,
371
+ conditional_dict=conditional_dict,
372
+ timestep=torch.ones_like(timestep) * pipeline.args.context_noise,
373
+ kv_cache=pipeline.kv_cache1,
374
+ crossattn_cache=pipeline.crossattn_cache,
375
+ current_start=chunk * TOKENS_PER_CHUNK,
376
+ )
377
+ previous_chunk_hidden = current_hidden
378
+ finally:
379
+ capture.close()
380
+
381
+ torch.cuda.synchronize()
382
+ return torch.cat(output_chunks, dim=1), {
383
+ "generation_time_s": time.perf_counter() - started,
384
+ "full_calls": full_calls,
385
+ "predictor_calls": predictor_calls,
386
+ }
387
+
388
+
389
+ def write_summary(output_dir: Path, experiments: list[dict[str, Any]]) -> None:
390
+ rows = []
391
+ for experiment in experiments:
392
+ path = output_dir / experiment["name"] / "metrics.json"
393
+ if not path.exists():
394
+ continue
395
+ metrics = json.loads(path.read_text(encoding="utf-8"))
396
+ if metrics.get("status") != "complete":
397
+ continue
398
+ source_layers = metrics["source_layers"]
399
+ kind_field = "pair_kind" if len(source_layers) == 2 else "triple_kind"
400
+ row = {
401
+ "name": metrics["name"],
402
+ **{
403
+ f"source_layer_{index + 1}": layer
404
+ for index, layer in enumerate(source_layers)
405
+ },
406
+ kind_field: metrics.get(
407
+ kind_field, metrics.get("experiment_kind")
408
+ ),
409
+ "schedule": metrics["schedule"],
410
+ "num_prompts": metrics["num_prompts"],
411
+ "psnr": metrics["psnr"],
412
+ "ssim": metrics["ssim"],
413
+ "lpips": metrics["lpips"],
414
+ "rollout_psnr": metrics["rollout_psnr"],
415
+ "rollout_ssim": metrics["rollout_ssim"],
416
+ "rollout_lpips": metrics["rollout_lpips"],
417
+ "offline_final_val_flow_mse": metrics[
418
+ "offline_final_val_flow_mse"
419
+ ],
420
+ "mean_generation_time_s": metrics["mean_generation_time_s"],
421
+ }
422
+ rows.append(row)
423
+ rows.sort(key=lambda row: float(row["lpips"]))
424
+ if not rows:
425
+ return
426
+ destination = output_dir / "summary.csv"
427
+ temporary = destination.with_suffix(".csv.tmp")
428
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
429
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
430
+ writer.writeheader()
431
+ writer.writerows(rows)
432
+ os.replace(temporary, destination)
433
+ atomic_json(output_dir / "summary.json", rows)
434
+
435
+
436
+ def main() -> None:
437
+ args = parse_args()
438
+ args.config_path = resolve(args.config_path)
439
+ args.checkpoint_path = resolve(args.checkpoint_path)
440
+ args.dataset_root = resolve(args.dataset_root)
441
+ args.sweep_dir = resolve(args.sweep_dir)
442
+ args.output_dir = resolve(args.output_dir)
443
+ args.reference_root = resolve(args.reference_root)
444
+ args.output_dir.mkdir(parents=True, exist_ok=True)
445
+
446
+ prompt_ids = sorted(set(args.prompt_ids))
447
+ if args.max_prompts is not None:
448
+ prompt_ids = prompt_ids[: args.max_prompts]
449
+ experiments = discover_experiments(
450
+ args.sweep_dir, args.experiments, args.max_experiments, args.architecture
451
+ )
452
+ device = torch.device("cuda")
453
+ torch.set_grad_enabled(False)
454
+ set_seed(args.generation_seed)
455
+ config = OmegaConf.merge(
456
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
457
+ OmegaConf.load(args.config_path),
458
+ )
459
+ schedule_description = f"chunk0=FFFF; chunks1-6={args.schedule}"
460
+ manifest = {
461
+ "status": "running",
462
+ "architecture": f"{args.architecture}_predictor",
463
+ "prompt_ids": prompt_ids,
464
+ "experiments": [item["name"] for item in experiments],
465
+ "rollout_schedule": args.schedule,
466
+ "rollout_definition": schedule_description,
467
+ "reference_root": str(args.reference_root),
468
+ "generation_seed_reset_per_prompt": args.generation_seed,
469
+ }
470
+ atomic_json(args.output_dir / "manifest.json", manifest)
471
+
472
+ print("[setup] loading VAE and checking FFFF reference frames", flush=True)
473
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
474
+ prepare_reference_frames(
475
+ vae=vae,
476
+ dataset_root=args.dataset_root,
477
+ output_dir=args.reference_root,
478
+ prompt_ids=prompt_ids,
479
+ device=device,
480
+ rebuild=False,
481
+ )
482
+ print("[setup] loading frozen generator_ema", flush=True)
483
+ pipeline = build_pipeline(config, args.checkpoint_path, vae, device)
484
+ teacher = pipeline.generator.model
485
+ lpips_model = None
486
+ if not args.skip_lpips:
487
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
488
+ lpips_model.requires_grad_(False)
489
+
490
+ if args.verify_ffff:
491
+ prompt_id = prompt_ids[0]
492
+ reproduced, counts = generate_rollout(
493
+ pipeline=pipeline,
494
+ dataset_root=args.dataset_root,
495
+ prompt_id=prompt_id,
496
+ generation_seed=args.generation_seed,
497
+ device=device,
498
+ predictor=None,
499
+ source_layers=None,
500
+ schedule="FFFF",
501
+ )
502
+ expected = load_ffff_latent(args.dataset_root, prompt_id).to(
503
+ device=device, dtype=torch.bfloat16
504
+ )
505
+ difference = reproduced.float() - expected.float()
506
+ verification = {
507
+ "prompt_id": prompt_id,
508
+ "max_abs_latent_error": float(difference.abs().max()),
509
+ "latent_mse": float(difference.square().mean()),
510
+ **counts,
511
+ }
512
+ atomic_json(args.output_dir / "ffff_reproduction.json", verification)
513
+ print(f"[verify] {verification}", flush=True)
514
+ if verification["max_abs_latent_error"] > 1e-3:
515
+ raise RuntimeError("FFFF reproduction does not match offline reference")
516
+ del reproduced, expected, difference
517
+ torch.cuda.empty_cache()
518
+
519
+ for experiment_index, experiment in enumerate(experiments, start=1):
520
+ run_dir = args.output_dir / experiment["name"]
521
+ run_dir.mkdir(parents=True, exist_ok=True)
522
+ metrics_path = run_dir / "metrics.json"
523
+ if metrics_path.exists():
524
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
525
+ if (
526
+ existing.get("status") == "complete"
527
+ and existing.get("prompt_ids") == prompt_ids
528
+ and existing.get("schedule") == schedule_description
529
+ and (args.skip_lpips or existing.get("lpips") is not None)
530
+ ):
531
+ print(f"[run] skip complete {experiment['name']}", flush=True)
532
+ continue
533
+ print(
534
+ f"[run] {experiment_index}/{len(experiments)} {experiment['name']}",
535
+ flush=True,
536
+ )
537
+ predictor = load_predictor(teacher, experiment, device)
538
+ existing_results = {}
539
+ for prompt_id in prompt_ids:
540
+ path = run_dir / "per_prompt" / f"prompt_{prompt_id:04d}.json"
541
+ if path.exists():
542
+ cached = json.loads(path.read_text(encoding="utf-8"))
543
+ if cached.get("rollout_schedule", "FPPF") == args.schedule:
544
+ existing_results[prompt_id] = cached
545
+
546
+ for prompt_index, prompt_id in enumerate(prompt_ids, start=1):
547
+ if prompt_id in existing_results:
548
+ print(
549
+ f"[prompt] {experiment['name']} {prompt_index}/{len(prompt_ids)} "
550
+ f"id={prompt_id} cached",
551
+ flush=True,
552
+ )
553
+ continue
554
+ started = time.perf_counter()
555
+ latent, counts = generate_rollout(
556
+ pipeline=pipeline,
557
+ dataset_root=args.dataset_root,
558
+ prompt_id=prompt_id,
559
+ generation_seed=args.generation_seed,
560
+ device=device,
561
+ predictor=predictor,
562
+ source_layers=experiment["source_layers"],
563
+ schedule=args.schedule,
564
+ )
565
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
566
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
567
+ prediction_u8 = pixels_to_u8(pixels)
568
+ reference_u8 = load_reference_frames(args.reference_root, prompt_id)
569
+ metrics = frame_metrics(
570
+ reference_u8=reference_u8,
571
+ prediction_u8=prediction_u8,
572
+ lpips_model=lpips_model,
573
+ batch_size=args.metric_batch_size,
574
+ device=device,
575
+ )
576
+ prompt_result = {
577
+ "prompt_id": prompt_id,
578
+ "prompt": load_prompt_metadata(args.dataset_root, prompt_id)[
579
+ "prompt"
580
+ ],
581
+ "rollout_schedule": args.schedule,
582
+ **counts,
583
+ **metrics,
584
+ "total_time_s": time.perf_counter() - started,
585
+ }
586
+ atomic_json(
587
+ run_dir / "per_prompt" / f"prompt_{prompt_id:04d}.json",
588
+ prompt_result,
589
+ )
590
+ existing_results[prompt_id] = prompt_result
591
+ print(
592
+ f"[prompt] {experiment['name']} {prompt_index}/{len(prompt_ids)} "
593
+ f"id={prompt_id} psnr={metrics['psnr']:.4f} "
594
+ f"ssim={metrics['ssim']:.6f} lpips={metrics['lpips']} "
595
+ f"time={prompt_result['total_time_s']:.1f}s",
596
+ flush=True,
597
+ )
598
+ if hasattr(vae.model, "clear_cache"):
599
+ vae.model.clear_cache()
600
+ del latent, pixels, prediction_u8, reference_u8
601
+ torch.cuda.empty_cache()
602
+
603
+ base_experiment = {
604
+ **experiment,
605
+ "source_layer": experiment["source_layers"],
606
+ }
607
+ aggregate = aggregate_prompt_results(
608
+ base_experiment,
609
+ [existing_results[prompt_id] for prompt_id in prompt_ids],
610
+ )
611
+ aggregate["source_layers"] = experiment["source_layers"]
612
+ kind_field = (
613
+ "pair_kind"
614
+ if len(experiment["source_layers"]) == 2
615
+ else "triple_kind"
616
+ )
617
+ aggregate[kind_field] = experiment["experiment_kind"]
618
+ aggregate["schedule"] = schedule_description
619
+ aggregate.pop("source_layer", None)
620
+ atomic_json(metrics_path, aggregate)
621
+ write_summary(args.output_dir, experiments)
622
+ print(
623
+ f"[result] {experiment['name']} psnr={aggregate['psnr']:.4f} "
624
+ f"ssim={aggregate['ssim']:.6f} lpips={aggregate['lpips']}",
625
+ flush=True,
626
+ )
627
+ del predictor
628
+ torch.cuda.empty_cache()
629
+
630
+ manifest["status"] = "complete"
631
+ atomic_json(args.output_dir / "manifest.json", manifest)
632
+ write_summary(args.output_dir, experiments)
633
+ print(
634
+ f"[complete] {len(experiments)} experiments -> {args.output_dir / 'summary.csv'}",
635
+ flush=True,
636
+ )
637
+
638
+
639
+ if __name__ == "__main__":
640
+ main()
scripts/generate_ode_pairs.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from utils.distributed import launch_distributed_job
2
+ from utils.scheduler import FlowMatchScheduler
3
+ from utils.wan_wrapper import WanDiffusionWrapper, WanTextEncoder
4
+ from utils.dataset import TextDataset
5
+ import torch.distributed as dist
6
+ from tqdm import tqdm
7
+ import argparse
8
+ import torch
9
+ import math
10
+ import os
11
+
12
+
13
+ def init_model(device):
14
+ model = WanDiffusionWrapper().to(device).to(torch.float32)
15
+ encoder = WanTextEncoder().to(device).to(torch.float32)
16
+ model.model.requires_grad_(False)
17
+
18
+ scheduler = FlowMatchScheduler(
19
+ shift=8.0, sigma_min=0.0, extra_one_step=True)
20
+ scheduler.set_timesteps(num_inference_steps=48, denoising_strength=1.0)
21
+ scheduler.sigmas = scheduler.sigmas.to(device)
22
+
23
+ sample_neg_prompt = '色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走'
24
+
25
+ unconditional_dict = encoder(
26
+ text_prompts=[sample_neg_prompt]
27
+ )
28
+
29
+ return model, encoder, scheduler, unconditional_dict
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser()
34
+ parser.add_argument("--local_rank", type=int, default=-1)
35
+ parser.add_argument("--output_folder", type=str)
36
+ parser.add_argument("--caption_path", type=str)
37
+ parser.add_argument("--guidance_scale", type=float, default=6.0)
38
+
39
+ args = parser.parse_args()
40
+
41
+ # launch_distributed_job()
42
+ launch_distributed_job()
43
+
44
+ device = torch.cuda.current_device()
45
+
46
+ torch.set_grad_enabled(False)
47
+ torch.backends.cuda.matmul.allow_tf32 = True
48
+ torch.backends.cudnn.allow_tf32 = True
49
+
50
+ model, encoder, scheduler, unconditional_dict = init_model(device=device)
51
+
52
+ dataset = TextDataset(args.caption_path)
53
+
54
+ # if global_rank == 0:
55
+ os.makedirs(args.output_folder, exist_ok=True)
56
+
57
+ for index in tqdm(range(int(math.ceil(len(dataset) / dist.get_world_size()))), disable=dist.get_rank() != 0):
58
+ prompt_index = index * dist.get_world_size() + dist.get_rank()
59
+ if prompt_index >= len(dataset):
60
+ continue
61
+ prompt = dataset[prompt_index]
62
+
63
+ conditional_dict = encoder(text_prompts=prompt)
64
+
65
+ latents = torch.randn(
66
+ [1, 21, 16, 60, 104], dtype=torch.float32, device=device
67
+ )
68
+
69
+ noisy_input = []
70
+
71
+ for progress_id, t in enumerate(tqdm(scheduler.timesteps)):
72
+ timestep = t * \
73
+ torch.ones([1, 21], device=device, dtype=torch.float32)
74
+
75
+ noisy_input.append(latents)
76
+
77
+ _, x0_pred_cond = model(
78
+ latents, conditional_dict, timestep
79
+ )
80
+
81
+ _, x0_pred_uncond = model(
82
+ latents, unconditional_dict, timestep
83
+ )
84
+
85
+ x0_pred = x0_pred_uncond + args.guidance_scale * (
86
+ x0_pred_cond - x0_pred_uncond
87
+ )
88
+
89
+ flow_pred = model._convert_x0_to_flow_pred(
90
+ scheduler=scheduler,
91
+ x0_pred=x0_pred.flatten(0, 1),
92
+ xt=latents.flatten(0, 1),
93
+ timestep=timestep.flatten(0, 1)
94
+ ).unflatten(0, x0_pred.shape[:2])
95
+
96
+ latents = scheduler.step(
97
+ flow_pred.flatten(0, 1),
98
+ scheduler.timesteps[progress_id] * torch.ones(
99
+ [1, 21], device=device, dtype=torch.long).flatten(0, 1),
100
+ latents.flatten(0, 1)
101
+ ).unflatten(dim=0, sizes=flow_pred.shape[:2])
102
+
103
+ noisy_input.append(latents)
104
+
105
+ noisy_inputs = torch.stack(noisy_input, dim=1)
106
+
107
+ noisy_inputs = noisy_inputs[:, [0, 12, 24, 36, -1]]
108
+
109
+ stored_data = noisy_inputs
110
+
111
+ torch.save(
112
+ {prompt: stored_data.cpu().detach()},
113
+ os.path.join(args.output_folder, f"{prompt_index:05d}.pt")
114
+ )
115
+
116
+ dist.barrier()
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
scripts/generate_vbench8_extended_atc.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate ATC FPPF/FPPP videos against the existing Extended-251 FFFF run."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parents[1]
13
+ if str(REPO_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(REPO_ROOT))
15
+
16
+ from scripts import generate_vbench8_extended_strategies as extended
17
+
18
+ import lpips
19
+ import torch
20
+ from omegaconf import OmegaConf
21
+ from safetensors import safe_open
22
+
23
+ from scripts import evaluate_single_block_fppf as base
24
+ from scripts.generate_vbench8_extended_disca import artifact_paths, read_u8_video
25
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
26
+
27
+
28
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
29
+ REFERENCE_DEFAULT = (
30
+ REPO_ROOT / "evaluation_runs/vbench8_extended_stage1_step2000_20260901"
31
+ )
32
+ OUTPUT_DEFAULT = (
33
+ REPO_ROOT / "evaluation_runs/vbench8_extended_atc_stage1_step2000_20260901"
34
+ )
35
+ PATTERN_TO_STEPS = {"FPPF": [1, 2], "FPPP": [1, 2, 3]}
36
+
37
+
38
+ def read_predictor_config(weights: Path) -> dict[str, Any]:
39
+ with safe_open(weights, framework="pt", device="cpu") as handle:
40
+ metadata = handle.metadata() or {}
41
+ raw = metadata.get("predictor_config")
42
+ if raw is None:
43
+ raise ValueError(f"ATC checkpoint has no predictor_config metadata: {weights}")
44
+ config = json.loads(raw)
45
+ if config.get("input_variant") != "atc":
46
+ raise ValueError(f"Checkpoint is not ATC: {config}")
47
+ return config
48
+
49
+
50
+ def strategy_configs(
51
+ scope: str, patterns: list[str] | tuple[str, ...]
52
+ ) -> list[dict[str, Any]]:
53
+ return [
54
+ {
55
+ "name": f"atc_{scope}_{pattern.lower()}",
56
+ "policy": "fppf",
57
+ "pattern": pattern,
58
+ "candidate_steps": PATTERN_TO_STEPS[pattern],
59
+ "allow_chunk0_predictor": False,
60
+ "beta": None,
61
+ "threshold": None,
62
+ "target_accepts": 6 * len(PATTERN_TO_STEPS[pattern]),
63
+ "head": None,
64
+ "input_variant": "atc",
65
+ "atc_previous_scope": scope,
66
+ }
67
+ for pattern in patterns
68
+ ]
69
+
70
+
71
+ def load_models(
72
+ weights: Path, scope: str, device: torch.device
73
+ ) -> tuple[Any, Any, Any, Any, None, None, Any]:
74
+ predictor_config = read_predictor_config(weights)
75
+ if predictor_config.get("atc_previous_scope") != scope:
76
+ raise ValueError(
77
+ f"Checkpoint scope={predictor_config.get('atc_previous_scope')} "
78
+ f"does not match requested scope={scope}"
79
+ )
80
+ print("[setup] loading VAE", flush=True)
81
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
82
+ config = OmegaConf.merge(
83
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
84
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
85
+ )
86
+ print(f"[setup] loading Teacher and ATC scope={scope}", flush=True)
87
+ pipeline = base.build_pipeline(
88
+ config, REPO_ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
89
+ )
90
+ predictor = base.load_predictor(
91
+ pipeline.generator.model,
92
+ {
93
+ **predictor_config,
94
+ "weights": weights,
95
+ "atc_collect_diagnostics": False,
96
+ },
97
+ device,
98
+ )
99
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
100
+ text_encoder.requires_grad_(False)
101
+ print("[setup] loading LPIPS", flush=True)
102
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
103
+ lpips_model.requires_grad_(False)
104
+ return vae, pipeline, predictor, text_encoder, None, None, lpips_model
105
+
106
+
107
+ def is_complete(
108
+ output_root: Path,
109
+ row: dict[str, Any],
110
+ strategies: list[dict[str, Any]],
111
+ ) -> bool:
112
+ return all(
113
+ all(path.is_file() for path in artifact_paths(output_root, row, config["name"]))
114
+ for config in strategies
115
+ )
116
+
117
+
118
+ def run_prompt(
119
+ *,
120
+ row: dict[str, Any],
121
+ strategies: list[dict[str, Any]],
122
+ reference_root: Path,
123
+ output_root: Path,
124
+ seed: int,
125
+ models: tuple[Any, ...],
126
+ device: torch.device,
127
+ ) -> None:
128
+ global_index = int(row["global_index"])
129
+ suite = str(row["prompt_suite"])
130
+ suite_index = int(row["suite_index"])
131
+ prompt = str(row["extended_prompt"])
132
+ reference_path = (
133
+ reference_root / "generated_videos/ffff" / suite / f"{suite_index:03d}.mp4"
134
+ )
135
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
136
+ conditional = models[3](text_prompts=[prompt])
137
+ for strategy in strategies:
138
+ name = str(strategy["name"])
139
+ video_path, record_path = artifact_paths(output_root, row, name)
140
+ if video_path.is_file() and record_path.is_file():
141
+ print(f"[cached-strategy] global={global_index} strategy={name}", flush=True)
142
+ continue
143
+ latent, diagnostic = extended.generate_rollout(
144
+ pipeline=models[1],
145
+ conditional_dict=conditional,
146
+ seed=seed,
147
+ device=device,
148
+ predictor=models[2],
149
+ head=None,
150
+ config=strategy,
151
+ )
152
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
153
+ pixels = models[0].decode_to_pixel(latent, use_cache=False)
154
+ prediction_u8 = base.pixels_to_u8(pixels)
155
+ extended.atomic_video(prediction_u8, video_path)
156
+ reference_decoded = read_u8_video(reference_path)
157
+ prediction_decoded = read_u8_video(video_path)
158
+ metrics = base.frame_metrics(
159
+ reference_u8=reference_decoded,
160
+ prediction_u8=prediction_decoded,
161
+ lpips_model=models[6],
162
+ batch_size=4,
163
+ device=device,
164
+ )
165
+ extended.atomic_json(
166
+ record_path,
167
+ {
168
+ "status": "complete",
169
+ "strategy": name,
170
+ "policy": "fppf",
171
+ "pattern": strategy["pattern"],
172
+ "input_variant": "atc",
173
+ "atc_previous_scope": strategy["atc_previous_scope"],
174
+ "candidate_steps": strategy["candidate_steps"],
175
+ "allow_chunk0_predictor": False,
176
+ "global_index": global_index,
177
+ "prompt_suite": suite,
178
+ "suite_index": suite_index,
179
+ "prompt": prompt,
180
+ "seed": seed,
181
+ "generation": {
182
+ key: value
183
+ for key, value in diagnostic.items()
184
+ if key != "decisions"
185
+ },
186
+ "decisions": diagnostic["decisions"],
187
+ "pixel_metrics_vs_ffff": extended.compact_metrics(metrics),
188
+ "pixel_metric_input": (
189
+ "MP4-decoded uint8 RGB, all 81 frames on both sides"
190
+ ),
191
+ "reference_video": str(reference_path),
192
+ "video": str(video_path.relative_to(output_root)),
193
+ },
194
+ )
195
+ print(
196
+ f"[result] global={global_index} strategy={name} "
197
+ f"accept={diagnostic['accepted_predictor_calls']} "
198
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
199
+ f"lpips={metrics['lpips']:.6f}",
200
+ flush=True,
201
+ )
202
+ if hasattr(models[0].model, "clear_cache"):
203
+ models[0].model.clear_cache()
204
+ del latent, pixels, prediction_u8, reference_decoded, prediction_decoded
205
+ torch.cuda.empty_cache()
206
+ del conditional
207
+
208
+
209
+ def parse_args() -> argparse.Namespace:
210
+ parser = argparse.ArgumentParser(description=__doc__)
211
+ parser.add_argument("--gpu", default=extended.PHYSICAL_GPU)
212
+ parser.add_argument("--scope", choices=("chunk", "last_frame"), required=True)
213
+ parser.add_argument("--predictor-weights", type=Path, required=True)
214
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
215
+ parser.add_argument("--reference-root", type=Path, default=REFERENCE_DEFAULT)
216
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
217
+ parser.add_argument("--pattern", action="append", choices=tuple(PATTERN_TO_STEPS))
218
+ parser.add_argument("--shard-index", type=int, default=0)
219
+ parser.add_argument("--num-shards", type=int, default=1)
220
+ parser.add_argument("--global-index", action="append", type=int, default=None)
221
+ parser.add_argument("--seed", type=int, default=0)
222
+ return parser.parse_args()
223
+
224
+
225
+ def main() -> None:
226
+ args = parse_args()
227
+ if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
228
+ raise ValueError("Invalid shard index/number")
229
+ weights = args.predictor_weights.resolve()
230
+ mapping = extended.read_mapping(args.mapping.resolve())
231
+ if args.global_index is None:
232
+ rows = [
233
+ row for index, row in enumerate(mapping)
234
+ if index % args.num_shards == args.shard_index
235
+ ]
236
+ else:
237
+ requested = set(args.global_index)
238
+ rows = [row for row in mapping if int(row["global_index"]) in requested]
239
+ if len(rows) != len(requested):
240
+ raise ValueError("Unknown global index")
241
+ patterns = args.pattern or ["FPPF", "FPPP"]
242
+ strategies = strategy_configs(args.scope, patterns)
243
+ reference_root = args.reference_root.resolve()
244
+ if len(list((reference_root / "generated_videos/ffff").rglob("*.mp4"))) != 251:
245
+ raise ValueError("Incomplete FFFF reference")
246
+ output_root = args.output_root.resolve()
247
+ output_root.mkdir(parents=True, exist_ok=True)
248
+ extended.write_shard_manifest(
249
+ output_root,
250
+ str(args.gpu),
251
+ args.shard_index,
252
+ args.num_shards,
253
+ rows,
254
+ strategies,
255
+ "running",
256
+ )
257
+ device = torch.device("cuda")
258
+ torch.set_grad_enabled(False)
259
+ models = load_models(weights, args.scope, device)
260
+ completed = 0
261
+ try:
262
+ for row in rows:
263
+ if is_complete(output_root, row, strategies):
264
+ completed += 1
265
+ print(f"[cached] {completed}/{len(rows)} global={row['global_index']}", flush=True)
266
+ continue
267
+ run_prompt(
268
+ row=row,
269
+ strategies=strategies,
270
+ reference_root=reference_root,
271
+ output_root=output_root,
272
+ seed=args.seed,
273
+ models=models,
274
+ device=device,
275
+ )
276
+ completed += 1
277
+ print(f"[progress] {completed}/{len(rows)} prompts", flush=True)
278
+ finally:
279
+ extended.write_shard_manifest(
280
+ output_root,
281
+ str(args.gpu),
282
+ args.shard_index,
283
+ args.num_shards,
284
+ rows,
285
+ strategies,
286
+ "complete" if completed == len(rows) else "failed",
287
+ )
288
+ print(f"[complete] gpu={args.gpu} scope={args.scope} prompts={completed}", flush=True)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
scripts/generate_vbench8_extended_atc_confidence_token.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate one Confidence-token strategy for Extended-251."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def preparse_gpu() -> str:
16
+ parser = argparse.ArgumentParser(add_help=False)
17
+ parser.add_argument("--gpu", default="0")
18
+ args, _ = parser.parse_known_args()
19
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
20
+ os.environ.setdefault(
21
+ "MPLCONFIGDIR", tempfile.mkdtemp(prefix="atc_confidence_token_vbench_")
22
+ )
23
+ return str(args.gpu)
24
+
25
+
26
+ PHYSICAL_GPU = preparse_gpu()
27
+
28
+ import lpips
29
+ import torch
30
+ from omegaconf import OmegaConf
31
+ from safetensors import safe_open
32
+ from safetensors.torch import load_file
33
+
34
+ ROOT = Path(__file__).resolve().parents[1]
35
+ if str(ROOT) not in sys.path:
36
+ sys.path.insert(0, str(ROOT))
37
+
38
+ from predictor_training.confidence import ConfidenceTokenHead
39
+ from scripts import evaluate_single_block_fppf as base
40
+ from scripts import generate_vbench8_extended_strategies as extended
41
+ from scripts.generate_vbench8_extended_disca import artifact_paths, read_u8_video
42
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
43
+
44
+
45
+ MAPPING_DEFAULT = ROOT / "assets/vbench8_extended_subset_mapping.json"
46
+ REFERENCE_DEFAULT = ROOT / "evaluation_runs/vbench8_extended_stage1_step2000_20260901"
47
+ SELECTION_DEFAULT = (
48
+ ROOT
49
+ / "confidence_experiments"
50
+ / "layer17_atc_chunk_confidence_token_beta2_vbench3_20260902"
51
+ / "threshold_summary.json"
52
+ )
53
+ PREDICTOR_DEFAULT = (
54
+ ROOT
55
+ / "training_runs/layer17_atc_chunk_stage1_1000p_4gpu_b16_2000steps"
56
+ / "checkpoint_step_2000/predictor.safetensors"
57
+ )
58
+ HEAD_DEFAULT = (
59
+ ROOT
60
+ / "training_runs"
61
+ / "layer17_atc_chunk_confidence_token_900train_100val_4gpu_b64_warmup_cosine"
62
+ / "confidence_latest.safetensors"
63
+ )
64
+ OUTPUT_DEFAULT = (
65
+ ROOT
66
+ / "evaluation_runs"
67
+ / "vbench8_extended_atc_chunk_confidence_token_beta2_20260902"
68
+ )
69
+ TARGETS = (6, 9, 12, 15)
70
+
71
+
72
+ def read_head_config(path: Path) -> dict[str, Any]:
73
+ with safe_open(path, framework="pt", device="cpu") as handle:
74
+ metadata = handle.metadata() or {}
75
+ raw = metadata.get("head_config")
76
+ if raw is None:
77
+ raise ValueError(f"Missing head_config metadata: {path}")
78
+ config = json.loads(raw)
79
+ expected = {
80
+ "architecture": "ConfidenceTokenHead",
81
+ "dim": 1536,
82
+ "token_dim": 512,
83
+ "context_dim": 64,
84
+ "num_heads": 8,
85
+ "ffn_dim": 2048,
86
+ "num_steps": 3,
87
+ }
88
+ for key, value in expected.items():
89
+ if config.get(key) != value:
90
+ raise ValueError(f"Unexpected head config {key}={config.get(key)}")
91
+ return config
92
+
93
+
94
+ def read_predictor_config(
95
+ path: Path, input_variant: str, atc_previous_scope: str
96
+ ) -> dict[str, Any]:
97
+ with safe_open(path, framework="pt", device="cpu") as handle:
98
+ metadata = handle.metadata() or {}
99
+ raw = metadata.get("predictor_config")
100
+ if raw is None:
101
+ if input_variant != "self_forcing":
102
+ raise ValueError(
103
+ f"Missing predictor_config metadata: {path}; only an explicitly "
104
+ "selected legacy self_forcing/concat checkpoint may omit it"
105
+ )
106
+ return {
107
+ "source_layer": 17,
108
+ "input_variant": "self_forcing",
109
+ "gate_mode": "baseline",
110
+ }
111
+ config = json.loads(raw)
112
+ actual_variant = str(config.get("input_variant", "self_forcing"))
113
+ if actual_variant != input_variant:
114
+ raise ValueError(
115
+ f"Predictor variant mismatch: expected={input_variant} "
116
+ f"actual={actual_variant}"
117
+ )
118
+ if input_variant == "atc":
119
+ actual_scope = str(config.get("atc_previous_scope", "chunk"))
120
+ if actual_scope != atc_previous_scope:
121
+ raise ValueError(
122
+ f"ATC scope mismatch: expected={atc_previous_scope} "
123
+ f"actual={actual_scope}"
124
+ )
125
+ return config
126
+
127
+
128
+ def strategy_prefix(input_variant: str, atc_previous_scope: str) -> str:
129
+ if input_variant == "self_forcing":
130
+ return "concat"
131
+ return f"atc_{atc_previous_scope}"
132
+
133
+
134
+ def load_strategy(
135
+ path: Path,
136
+ target_k: int,
137
+ input_variant: str,
138
+ atc_previous_scope: str,
139
+ ) -> dict[str, Any]:
140
+ payload = json.loads(path.read_text(encoding="utf-8"))
141
+ if payload.get("status") != "complete" or float(payload.get("beta")) != 2.0:
142
+ raise ValueError(f"Selection is not complete fixed-beta=2: {path}")
143
+ if [int(value) for value in payload.get("candidate_steps", [])] != [1, 2, 3]:
144
+ raise ValueError("Selection is not Step123")
145
+ if payload.get("predictor_input_variant") != input_variant:
146
+ raise ValueError(
147
+ "Threshold selection Predictor variant does not match evaluation"
148
+ )
149
+ expected_scope = atc_previous_scope if input_variant == "atc" else None
150
+ if payload.get("atc_previous_scope") != expected_scope:
151
+ raise ValueError("Threshold selection ATC scope does not match evaluation")
152
+ matches = [
153
+ row for row in payload.get("selected", [])
154
+ if int(row["target_accepts"]) == target_k
155
+ ]
156
+ if len(matches) != 1:
157
+ raise ValueError(f"Expected one selected K{target_k:02d}, got {len(matches)}")
158
+ row = matches[0]
159
+ return {
160
+ "name": (
161
+ f"{strategy_prefix(input_variant, atc_previous_scope)}"
162
+ f"_confidence_token_beta2_k{target_k:02d}"
163
+ ),
164
+ "policy": "dynamic",
165
+ "candidate_steps": [1, 2, 3],
166
+ "beta": 2.0,
167
+ "threshold": float(row["threshold"]),
168
+ "target_accepts": target_k,
169
+ "head": "confidence_token",
170
+ "risk_mode": "outgoing_span",
171
+ "allow_chunk0_predictor": False,
172
+ "source_config_name": str(row["name"]),
173
+ "calibration_mean_accepted_predictor_calls": float(
174
+ row["mean_accepted_predictor_calls"]
175
+ ),
176
+ }
177
+
178
+
179
+ def load_models(
180
+ predictor_weights: Path,
181
+ head_weights: Path,
182
+ input_variant: str,
183
+ atc_previous_scope: str,
184
+ device: torch.device,
185
+ ) -> tuple[Any, Any, Any, ConfidenceTokenHead, Any]:
186
+ predictor_config = read_predictor_config(
187
+ predictor_weights, input_variant, atc_previous_scope
188
+ )
189
+
190
+ print("[setup] loading VAE", flush=True)
191
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
192
+ config = OmegaConf.merge(
193
+ OmegaConf.load(ROOT / "configs/default_config.yaml"),
194
+ OmegaConf.load(ROOT / "configs/self_forcing_sid.yaml"),
195
+ )
196
+ print(
197
+ f"[setup] loading frozen Teacher and {input_variant} "
198
+ f"scope={atc_previous_scope if input_variant == 'atc' else 'n/a'} Predictor",
199
+ flush=True,
200
+ )
201
+ pipeline = base.build_pipeline(
202
+ config, ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
203
+ )
204
+ predictor = base.load_predictor(
205
+ pipeline.generator.model,
206
+ {
207
+ **predictor_config,
208
+ "weights": predictor_weights,
209
+ "atc_collect_diagnostics": False,
210
+ },
211
+ device,
212
+ )
213
+
214
+ print("[setup] loading text encoder and Confidence-token Head", flush=True)
215
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
216
+ text_encoder.requires_grad_(False)
217
+ head_config = read_head_config(head_weights)
218
+ head = ConfidenceTokenHead(
219
+ dim=int(head_config["dim"]),
220
+ token_dim=int(head_config["token_dim"]),
221
+ context_dim=int(head_config["context_dim"]),
222
+ num_heads=int(head_config["num_heads"]),
223
+ ffn_dim=int(head_config["ffn_dim"]),
224
+ dropout=float(head_config.get("dropout", 0.1)),
225
+ num_steps=int(head_config["num_steps"]),
226
+ ).to(device=device).eval()
227
+ head.load_state_dict(load_file(str(head_weights), device="cpu"), strict=True)
228
+ head.requires_grad_(False)
229
+
230
+ print("[setup] loading LPIPS", flush=True)
231
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
232
+ lpips_model.requires_grad_(False)
233
+ return vae, pipeline, predictor, text_encoder, head, lpips_model
234
+
235
+
236
+ def run_prompt(
237
+ *,
238
+ row: dict[str, Any],
239
+ strategy: dict[str, Any],
240
+ reference_root: Path,
241
+ output_root: Path,
242
+ seed: int,
243
+ models: tuple[Any, ...],
244
+ device: torch.device,
245
+ ) -> None:
246
+ global_index = int(row["global_index"])
247
+ suite = str(row["prompt_suite"])
248
+ suite_index = int(row["suite_index"])
249
+ prompt = str(row["extended_prompt"])
250
+ name = str(strategy["name"])
251
+ reference_path = (
252
+ reference_root / "generated_videos/ffff" / suite / f"{suite_index:03d}.mp4"
253
+ )
254
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
255
+ conditional = models[3](text_prompts=[prompt])
256
+ latent, diagnostic = extended.generate_rollout(
257
+ pipeline=models[1],
258
+ conditional_dict=conditional,
259
+ seed=seed,
260
+ device=device,
261
+ predictor=models[2],
262
+ head=models[4],
263
+ config=strategy,
264
+ )
265
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
266
+ pixels = models[0].decode_to_pixel(latent, use_cache=False)
267
+ prediction_u8 = base.pixels_to_u8(pixels)
268
+ video_path, record_path = artifact_paths(output_root, row, name)
269
+ extended.atomic_video(prediction_u8, video_path)
270
+
271
+ reference_decoded = read_u8_video(reference_path)
272
+ prediction_decoded = read_u8_video(video_path)
273
+ metrics = base.frame_metrics(
274
+ reference_u8=reference_decoded,
275
+ prediction_u8=prediction_decoded,
276
+ lpips_model=models[5],
277
+ batch_size=4,
278
+ device=device,
279
+ )
280
+ extended.atomic_json(
281
+ record_path,
282
+ {
283
+ "status": "complete",
284
+ "strategy": name,
285
+ "policy": "dynamic",
286
+ "input_variant": strategy["input_variant"],
287
+ "atc_previous_scope": strategy["atc_previous_scope"],
288
+ "candidate_steps": [1, 2, 3],
289
+ "beta": 2.0,
290
+ "threshold": float(strategy["threshold"]),
291
+ "target_accepts": int(strategy["target_accepts"]),
292
+ "risk_mode": "outgoing_span",
293
+ "allow_chunk0_predictor": False,
294
+ "source_config_name": strategy["source_config_name"],
295
+ "global_index": global_index,
296
+ "prompt_suite": suite,
297
+ "suite_index": suite_index,
298
+ "prompt": prompt,
299
+ "seed": seed,
300
+ "generation": {
301
+ key: value for key, value in diagnostic.items() if key != "decisions"
302
+ },
303
+ "decisions": diagnostic["decisions"],
304
+ "pixel_metrics_vs_ffff": extended.compact_metrics(metrics),
305
+ "pixel_metric_input": "MP4-decoded uint8 RGB, all 81 frames on both sides",
306
+ "reference_video": str(reference_path),
307
+ "video": str(video_path.relative_to(output_root)),
308
+ },
309
+ )
310
+ print(
311
+ f"[result] global={global_index} strategy={name} "
312
+ f"accept={diagnostic['accepted_predictor_calls']} "
313
+ f"full={diagnostic['full_calls']} "
314
+ f"latency={diagnostic['policy_latency_ms']:.2f}ms "
315
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
316
+ f"lpips={metrics['lpips']:.6f}",
317
+ flush=True,
318
+ )
319
+ if hasattr(models[0].model, "clear_cache"):
320
+ models[0].model.clear_cache()
321
+ del conditional, latent, pixels, prediction_u8, reference_decoded, prediction_decoded
322
+ torch.cuda.empty_cache()
323
+
324
+
325
+ def parse_args() -> argparse.Namespace:
326
+ parser = argparse.ArgumentParser(description=__doc__)
327
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
328
+ parser.add_argument("--target-k", type=int, choices=TARGETS, required=True)
329
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
330
+ parser.add_argument("--selection", type=Path, default=SELECTION_DEFAULT)
331
+ parser.add_argument("--predictor-weights", type=Path, default=PREDICTOR_DEFAULT)
332
+ parser.add_argument("--head-weights", type=Path, default=HEAD_DEFAULT)
333
+ parser.add_argument(
334
+ "--predictor-input-variant",
335
+ choices=("self_forcing", "atc"),
336
+ default="atc",
337
+ )
338
+ parser.add_argument(
339
+ "--atc-previous-scope",
340
+ choices=("chunk", "last_frame"),
341
+ default="chunk",
342
+ )
343
+ parser.add_argument("--reference-root", type=Path, default=REFERENCE_DEFAULT)
344
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
345
+ parser.add_argument("--global-index", action="append", type=int, default=None)
346
+ parser.add_argument("--seed", type=int, default=0)
347
+ parser.add_argument("--overwrite", action="store_true")
348
+ args = parser.parse_args()
349
+ for name in (
350
+ "mapping", "selection", "predictor_weights", "head_weights",
351
+ "reference_root", "output_root",
352
+ ):
353
+ setattr(args, name, getattr(args, name).expanduser().resolve())
354
+ return args
355
+
356
+
357
+ def main() -> None:
358
+ args = parse_args()
359
+ strategy = load_strategy(
360
+ args.selection,
361
+ args.target_k,
362
+ args.predictor_input_variant,
363
+ args.atc_previous_scope,
364
+ )
365
+ strategy["input_variant"] = args.predictor_input_variant
366
+ strategy["atc_previous_scope"] = (
367
+ args.atc_previous_scope
368
+ if args.predictor_input_variant == "atc"
369
+ else None
370
+ )
371
+ mapping = extended.read_mapping(args.mapping)
372
+ if args.global_index is not None:
373
+ requested = set(args.global_index)
374
+ mapping = [row for row in mapping if int(row["global_index"]) in requested]
375
+ if len(mapping) != len(requested):
376
+ raise ValueError("At least one requested global index is unknown")
377
+ if len(list((args.reference_root / "generated_videos/ffff").rglob("*.mp4"))) != 251:
378
+ raise ValueError("Incomplete FFFF reference videos")
379
+ if len(list((args.reference_root / "generation_metrics/per_prompt/ffff").glob("global_*.json"))) != 251:
380
+ raise ValueError("Incomplete FFFF reference records")
381
+
382
+ args.output_root.mkdir(parents=True, exist_ok=True)
383
+ extended.write_shard_manifest(
384
+ args.output_root, PHYSICAL_GPU, 0, 1, mapping, [strategy], "running"
385
+ )
386
+ print(
387
+ f"[setup] physical_gpu={PHYSICAL_GPU} strategy={strategy['name']} "
388
+ f"threshold={strategy['threshold']:.10f} prompts={len(mapping)} "
389
+ "candidate_steps=1,2,3 chunk0=FFFF",
390
+ flush=True,
391
+ )
392
+ device = torch.device("cuda")
393
+ torch.set_grad_enabled(False)
394
+ models = load_models(
395
+ args.predictor_weights,
396
+ args.head_weights,
397
+ args.predictor_input_variant,
398
+ args.atc_previous_scope,
399
+ device,
400
+ )
401
+ completed = 0
402
+ try:
403
+ for row in mapping:
404
+ video_path, record_path = artifact_paths(
405
+ args.output_root, row, strategy["name"]
406
+ )
407
+ if not args.overwrite and video_path.is_file() and record_path.is_file():
408
+ completed += 1
409
+ print(f"[cached] {completed}/{len(mapping)} global={row['global_index']}", flush=True)
410
+ continue
411
+ run_prompt(
412
+ row=row,
413
+ strategy=strategy,
414
+ reference_root=args.reference_root,
415
+ output_root=args.output_root,
416
+ seed=args.seed,
417
+ models=models,
418
+ device=device,
419
+ )
420
+ completed += 1
421
+ print(f"[progress] {completed}/{len(mapping)} prompts", flush=True)
422
+ finally:
423
+ extended.write_shard_manifest(
424
+ args.output_root,
425
+ PHYSICAL_GPU,
426
+ 0,
427
+ 1,
428
+ mapping,
429
+ [strategy],
430
+ "complete" if completed == len(mapping) else "failed",
431
+ )
432
+ print(
433
+ f"[complete] gpu={PHYSICAL_GPU} strategy={strategy['name']} prompts={completed}",
434
+ flush=True,
435
+ )
436
+
437
+
438
+ if __name__ == "__main__":
439
+ main()
scripts/generate_vbench8_extended_concat.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate original-concat FPPP videos against the existing FFFF reference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ if str(REPO_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(REPO_ROOT))
13
+
14
+ from scripts import evaluate_single_block_fppf as base
15
+ from scripts import generate_vbench8_extended_disca as runner
16
+
17
+ import lpips
18
+ import torch
19
+ from omegaconf import OmegaConf
20
+
21
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
22
+
23
+
24
+ WEIGHTS_DEFAULT = (
25
+ REPO_ROOT
26
+ / "training_runs/layer17_stage1_1000p_4gpu_b16_2000steps"
27
+ / "checkpoint_step_2000/predictor.safetensors"
28
+ )
29
+ OUTPUT_DEFAULT = (
30
+ REPO_ROOT
31
+ / "evaluation_runs/vbench8_extended_concat_fppp_stage1_step2000_20260902"
32
+ )
33
+
34
+
35
+ def strategy_configs(patterns: list[str] | tuple[str, ...]) -> list[dict[str, Any]]:
36
+ if patterns != ["FPPP"] and tuple(patterns) != ("FPPP",):
37
+ raise ValueError("Original-concat evaluation supports exactly --pattern FPPP")
38
+ return [
39
+ {
40
+ "name": "concat_fppp",
41
+ "policy": "fppf",
42
+ "pattern": "FPPP",
43
+ "candidate_steps": [1, 2, 3],
44
+ "allow_chunk0_predictor": False,
45
+ "beta": None,
46
+ "threshold": None,
47
+ "target_accepts": 18,
48
+ "head": None,
49
+ "input_variant": "self_forcing",
50
+ }
51
+ ]
52
+
53
+
54
+ def load_models(
55
+ weights: Path, device: torch.device
56
+ ) -> tuple[Any, Any, Any, Any, None, None, Any]:
57
+ print("[setup] loading VAE", flush=True)
58
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
59
+ config = OmegaConf.merge(
60
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
61
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
62
+ )
63
+ print("[setup] loading frozen Teacher and original concat Predictor", flush=True)
64
+ pipeline = base.build_pipeline(
65
+ config, REPO_ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
66
+ )
67
+ predictor = base.load_predictor(
68
+ pipeline.generator.model,
69
+ {
70
+ "source_layer": 17,
71
+ "weights": weights,
72
+ "input_variant": "self_forcing",
73
+ "gate_mode": "baseline",
74
+ },
75
+ device,
76
+ )
77
+ if predictor.input_variant != "self_forcing":
78
+ raise RuntimeError("Loaded Predictor is not the original concat variant")
79
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
80
+ text_encoder.requires_grad_(False)
81
+ print("[setup] loading LPIPS", flush=True)
82
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
83
+ lpips_model.requires_grad_(False)
84
+ return vae, pipeline, predictor, text_encoder, None, None, lpips_model
85
+
86
+
87
+ def main() -> None:
88
+ runner.WEIGHTS_DEFAULT = WEIGHTS_DEFAULT
89
+ runner.OUTPUT_DEFAULT = OUTPUT_DEFAULT
90
+ runner.strategy_configs = strategy_configs
91
+ runner.load_models = load_models
92
+ runner.main()
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
scripts/generate_vbench8_extended_disca.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate DISCA pattern videos using an existing Extended-251 FFFF reference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[1]
12
+ if str(REPO_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(REPO_ROOT))
14
+
15
+ from scripts import generate_vbench8_extended_strategies as extended
16
+
17
+ import lpips
18
+ import torch
19
+ from omegaconf import OmegaConf
20
+ from torchvision.io import read_video
21
+
22
+ from scripts import evaluate_single_block_fppf as base
23
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
24
+
25
+
26
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
27
+ WEIGHTS_DEFAULT = (
28
+ REPO_ROOT
29
+ / "training_runs/disca_layer17_stage1_1000p_4gpu_b16_2000steps"
30
+ / "checkpoint_step_2000/predictor.safetensors"
31
+ )
32
+ OUTPUT_DEFAULT = (
33
+ REPO_ROOT
34
+ / "evaluation_runs/vbench8_extended_disca_chunk0_fppf_stage1_step2000"
35
+ )
36
+ REFERENCE_DEFAULT = (
37
+ REPO_ROOT / "evaluation_runs/vbench8_extended_stage1_step2000_20260901"
38
+ )
39
+ PATTERN_TO_CANDIDATE_STEPS = {
40
+ "FPFF": [1],
41
+ "FPPF": [1, 2],
42
+ "FPPP": [1, 2, 3],
43
+ }
44
+
45
+
46
+ def strategy_configs(patterns: list[str] | tuple[str, ...]) -> list[dict[str, Any]]:
47
+ configs: list[dict[str, Any]] = []
48
+ for pattern in patterns:
49
+ candidate_steps = PATTERN_TO_CANDIDATE_STEPS[pattern]
50
+ configs.append(
51
+ {
52
+ "name": f"disca_{pattern.lower()}",
53
+ "policy": "fppf",
54
+ "pattern": pattern,
55
+ "candidate_steps": candidate_steps,
56
+ "allow_chunk0_predictor": True,
57
+ "beta": None,
58
+ "threshold": None,
59
+ "target_accepts": 7 * len(candidate_steps),
60
+ "head": None,
61
+ "input_variant": "disca",
62
+ }
63
+ )
64
+ return configs
65
+
66
+
67
+ def load_models(
68
+ weights: Path, device: torch.device
69
+ ) -> tuple[Any, Any, Any, Any, None, None, Any]:
70
+ print("[setup] loading VAE", flush=True)
71
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
72
+ config = OmegaConf.merge(
73
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
74
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
75
+ )
76
+ print("[setup] loading frozen Teacher and DISCA Predictor", flush=True)
77
+ pipeline = base.build_pipeline(
78
+ config, REPO_ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
79
+ )
80
+ predictor = base.load_predictor(
81
+ pipeline.generator.model,
82
+ {
83
+ "source_layer": 17,
84
+ "weights": weights,
85
+ "input_variant": "disca",
86
+ "gate_mode": "baseline",
87
+ },
88
+ device,
89
+ )
90
+ if predictor.input_variant != "disca":
91
+ raise RuntimeError("Loaded Predictor is not the DISCA input variant")
92
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
93
+ text_encoder.requires_grad_(False)
94
+ print("[setup] loading LPIPS", flush=True)
95
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
96
+ lpips_model.requires_grad_(False)
97
+ return vae, pipeline, predictor, text_encoder, None, None, lpips_model
98
+
99
+
100
+ def read_u8_video(path: Path) -> torch.Tensor:
101
+ if not path.is_file():
102
+ raise FileNotFoundError(path)
103
+ frames, _, _ = read_video(str(path), pts_unit="sec", output_format="TCHW")
104
+ frames = frames.to(device="cpu", dtype=torch.uint8).contiguous()
105
+ if frames.ndim != 4 or frames.shape[0] != 81 or frames.shape[1] != 3:
106
+ raise RuntimeError(f"Unexpected decoded video shape {tuple(frames.shape)}: {path}")
107
+ return frames
108
+
109
+
110
+ def artifact_paths(
111
+ output_root: Path, mapping_row: dict[str, Any], strategy_name: str
112
+ ) -> tuple[Path, Path]:
113
+ suite = str(mapping_row["prompt_suite"])
114
+ suite_index = int(mapping_row["suite_index"])
115
+ global_index = int(mapping_row["global_index"])
116
+ return (
117
+ output_root
118
+ / "generated_videos"
119
+ / strategy_name
120
+ / suite
121
+ / f"{suite_index:03d}.mp4",
122
+ output_root
123
+ / "generation_metrics/per_prompt"
124
+ / strategy_name
125
+ / f"global_{global_index:04d}.json",
126
+ )
127
+
128
+
129
+ def is_prompt_complete(
130
+ output_root: Path,
131
+ mapping_row: dict[str, Any],
132
+ strategies: list[dict[str, Any]],
133
+ ) -> bool:
134
+ return all(
135
+ all(
136
+ path.is_file()
137
+ for path in artifact_paths(
138
+ output_root, mapping_row, str(strategy["name"])
139
+ )
140
+ )
141
+ for strategy in strategies
142
+ )
143
+
144
+
145
+ def run_prompt(
146
+ *,
147
+ mapping_row: dict[str, Any],
148
+ strategy: dict[str, Any],
149
+ reference_root: Path,
150
+ output_root: Path,
151
+ seed: int,
152
+ vae: Any,
153
+ pipeline: Any,
154
+ predictor: Any,
155
+ text_encoder: Any,
156
+ lpips_model: Any,
157
+ device: torch.device,
158
+ ) -> None:
159
+ global_index = int(mapping_row["global_index"])
160
+ suite = str(mapping_row["prompt_suite"])
161
+ suite_index = int(mapping_row["suite_index"])
162
+ prompt = str(mapping_row["extended_prompt"])
163
+ strategy_name = str(strategy["name"])
164
+ reference_path = (
165
+ reference_root
166
+ / "generated_videos/ffff"
167
+ / suite
168
+ / f"{suite_index:03d}.mp4"
169
+ )
170
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
171
+ conditional = text_encoder(text_prompts=[prompt])
172
+ latent, diagnostic = extended.generate_rollout(
173
+ pipeline=pipeline,
174
+ conditional_dict=conditional,
175
+ seed=seed,
176
+ device=device,
177
+ predictor=predictor,
178
+ head=None,
179
+ config=strategy,
180
+ )
181
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
182
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
183
+ prediction_u8 = base.pixels_to_u8(pixels)
184
+ video_path, record_path = artifact_paths(output_root, mapping_row, strategy_name)
185
+ extended.atomic_video(prediction_u8, video_path)
186
+
187
+ # The original run retained FFFF MP4s, not its pre-encoding uint8 frames.
188
+ # Decode both sides so the reused reference and DISCA prediction receive
189
+ # the same H.264/CRF-18 treatment before full-81-frame pixel metrics.
190
+ reference_decoded = read_u8_video(reference_path)
191
+ prediction_decoded = read_u8_video(video_path)
192
+ metrics = base.frame_metrics(
193
+ reference_u8=reference_decoded,
194
+ prediction_u8=prediction_decoded,
195
+ lpips_model=lpips_model,
196
+ batch_size=4,
197
+ device=device,
198
+ )
199
+ record = {
200
+ "status": "complete",
201
+ "strategy": strategy_name,
202
+ "policy": "fppf",
203
+ "pattern": strategy["pattern"],
204
+ "input_variant": strategy["input_variant"],
205
+ "candidate_steps": strategy["candidate_steps"],
206
+ "allow_chunk0_predictor": strategy["allow_chunk0_predictor"],
207
+ "global_index": global_index,
208
+ "prompt_suite": suite,
209
+ "suite_index": suite_index,
210
+ "prompt": prompt,
211
+ "seed": seed,
212
+ "generation": {
213
+ key: value
214
+ for key, value in diagnostic.items()
215
+ if key != "decisions"
216
+ },
217
+ "decisions": diagnostic["decisions"],
218
+ "pixel_metrics_vs_ffff": extended.compact_metrics(metrics),
219
+ "pixel_metric_input": "MP4-decoded uint8 RGB, all 81 frames on both sides",
220
+ "reference_video": str(reference_path),
221
+ "video": str(video_path.relative_to(output_root)),
222
+ }
223
+ extended.atomic_json(record_path, record)
224
+ print(
225
+ f"[result] global={global_index} strategy={strategy_name} "
226
+ f"accept={diagnostic['accepted_predictor_calls']} "
227
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
228
+ f"lpips={metrics['lpips']:.6f}",
229
+ flush=True,
230
+ )
231
+ if hasattr(vae.model, "clear_cache"):
232
+ vae.model.clear_cache()
233
+ del (
234
+ conditional,
235
+ latent,
236
+ pixels,
237
+ prediction_u8,
238
+ reference_decoded,
239
+ prediction_decoded,
240
+ metrics,
241
+ )
242
+ torch.cuda.empty_cache()
243
+
244
+
245
+ def parse_args() -> argparse.Namespace:
246
+ parser = argparse.ArgumentParser(description=__doc__)
247
+ parser.add_argument("--gpu", default=extended.PHYSICAL_GPU)
248
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
249
+ parser.add_argument("--predictor-weights", type=Path, default=WEIGHTS_DEFAULT)
250
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
251
+ parser.add_argument("--reference-root", type=Path, default=REFERENCE_DEFAULT)
252
+ parser.add_argument(
253
+ "--pattern",
254
+ action="append",
255
+ choices=tuple(PATTERN_TO_CANDIDATE_STEPS),
256
+ default=None,
257
+ help="DISCA denoise pattern; repeat to generate several. Default: FPPF.",
258
+ )
259
+ parser.add_argument("--shard-index", type=int, default=0)
260
+ parser.add_argument("--num-shards", type=int, default=1)
261
+ parser.add_argument(
262
+ "--global-index",
263
+ dest="global_indices",
264
+ action="append",
265
+ type=int,
266
+ default=None,
267
+ help="Process only this global mapping index; may be repeated.",
268
+ )
269
+ parser.add_argument("--seed", type=int, default=0)
270
+ parser.add_argument("--overwrite", action="store_true")
271
+ return parser.parse_args()
272
+
273
+
274
+ def main() -> None:
275
+ args = parse_args()
276
+ if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
277
+ raise ValueError("Invalid shard index/number of shards")
278
+ weights = args.predictor_weights.resolve()
279
+ if not weights.is_file():
280
+ raise FileNotFoundError(weights)
281
+ mapping = extended.read_mapping(args.mapping.resolve())
282
+ patterns = args.pattern or ["FPPF"]
283
+ if len(set(patterns)) != len(patterns):
284
+ raise ValueError("--pattern values must be unique")
285
+ strategies = strategy_configs(patterns)
286
+ reference_root = args.reference_root.resolve()
287
+ reference_records = reference_root / "generation_metrics/per_prompt/ffff"
288
+ reference_videos = reference_root / "generated_videos/ffff"
289
+ if len(list(reference_records.glob("global_*.json"))) != 251:
290
+ raise ValueError(f"Incomplete FFFF reference records: {reference_records}")
291
+ if len(list(reference_videos.rglob("*.mp4"))) != 251:
292
+ raise ValueError(f"Incomplete FFFF reference videos: {reference_videos}")
293
+ if args.global_indices is None:
294
+ rows = [
295
+ row
296
+ for position, row in enumerate(mapping)
297
+ if position % args.num_shards == args.shard_index
298
+ ]
299
+ else:
300
+ requested = set(args.global_indices)
301
+ mapping_by_global = {int(row["global_index"]): row for row in mapping}
302
+ unknown = sorted(requested - set(mapping_by_global))
303
+ if unknown:
304
+ raise ValueError(f"Unknown global indices: {unknown}")
305
+ rows = [row for row in mapping if int(row["global_index"]) in requested]
306
+
307
+ output_root = args.output_root.resolve()
308
+ output_root.mkdir(parents=True, exist_ok=True)
309
+ extended.write_shard_manifest(
310
+ output_root,
311
+ str(args.gpu),
312
+ args.shard_index,
313
+ args.num_shards,
314
+ rows,
315
+ strategies,
316
+ "running",
317
+ )
318
+ print(
319
+ f"[setup] physical_gpu={args.gpu} shard={args.shard_index}/{args.num_shards} "
320
+ f"prompts={len(rows)} strategies={','.join(strategy['name'] for strategy in strategies)} "
321
+ "reference=existing_ffff",
322
+ flush=True,
323
+ )
324
+ device = torch.device("cuda")
325
+ torch.set_grad_enabled(False)
326
+ models = load_models(weights, device)
327
+ completed = 0
328
+ try:
329
+ for row in rows:
330
+ if not args.overwrite and is_prompt_complete(
331
+ output_root, row, strategies
332
+ ):
333
+ completed += 1
334
+ print(
335
+ f"[cached] {completed}/{len(rows)} global={row['global_index']}",
336
+ flush=True,
337
+ )
338
+ continue
339
+ for strategy in strategies:
340
+ video, record = artifact_paths(
341
+ output_root, row, str(strategy["name"])
342
+ )
343
+ if not args.overwrite and video.is_file() and record.is_file():
344
+ continue
345
+ run_prompt(
346
+ mapping_row=row,
347
+ strategy=strategy,
348
+ reference_root=reference_root,
349
+ output_root=output_root,
350
+ seed=args.seed,
351
+ vae=models[0],
352
+ pipeline=models[1],
353
+ predictor=models[2],
354
+ text_encoder=models[3],
355
+ lpips_model=models[6],
356
+ device=device,
357
+ )
358
+ completed += 1
359
+ print(f"[progress] {completed}/{len(rows)} prompts", flush=True)
360
+ finally:
361
+ extended.write_shard_manifest(
362
+ output_root,
363
+ str(args.gpu),
364
+ args.shard_index,
365
+ args.num_shards,
366
+ rows,
367
+ strategies,
368
+ "complete" if completed == len(rows) else "failed",
369
+ )
370
+ print(f"[complete] gpu={args.gpu} prompts={completed}/{len(rows)}", flush=True)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ main()
scripts/generate_vbench8_extended_naive_baselines.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate Extended-251 videos for timestep-skipping and velocity-cache baselines.
3
+
4
+ For every prompt, an original four-step FFFF rollout is generated first with
5
+ the same seed and used as the PSNR/SSIM/LPIPS reference. Timestep-skipping
6
+ policies shorten the denoising schedule itself. Velocity-cache policies retain
7
+ the original four timesteps and, at each R, reuse the velocity from the most
8
+ recent F while converting it to x0 using the current noisy latent and timestep.
9
+ The cache pattern is applied independently to every autoregressive chunk.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ def preparse_gpu() -> str:
26
+ parser = argparse.ArgumentParser(add_help=False)
27
+ parser.add_argument("--gpu", default="0")
28
+ args, _ = parser.parse_known_args()
29
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
30
+ os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="self_forcing_mpl_"))
31
+ return str(args.gpu)
32
+
33
+
34
+ PHYSICAL_GPU = preparse_gpu()
35
+
36
+ import lpips
37
+ import torch
38
+ from omegaconf import OmegaConf
39
+
40
+ REPO_ROOT = Path(__file__).resolve().parents[1]
41
+ if str(REPO_ROOT) not in sys.path:
42
+ sys.path.insert(0, str(REPO_ROOT))
43
+
44
+ from scripts import evaluate_single_block_fppf as base
45
+ from scripts.naive_vbench_policies import (
46
+ EVALUATION_STRATEGY_NAMES,
47
+ POLICIES_BY_NAME,
48
+ REFERENCE_POLICY,
49
+ NaivePolicy,
50
+ )
51
+ from scripts.vbench8_protocol import SUITE_COUNTS
52
+ from utils.misc import set_seed
53
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
54
+
55
+
56
+ NUM_CHUNKS = base.NUM_CHUNKS
57
+ FRAMES_PER_CHUNK = base.FRAMES_PER_CHUNK
58
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
59
+ OUTPUT_DEFAULT = REPO_ROOT / "evaluation_runs/vbench8_extended_naive_baselines"
60
+
61
+
62
+ def atomic_json(path: Path, value: Any) -> None:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ temporary = path.with_suffix(path.suffix + ".tmp")
65
+ temporary.write_text(
66
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=True) + "\n",
67
+ encoding="utf-8",
68
+ )
69
+ os.replace(temporary, path)
70
+
71
+
72
+ def atomic_video(frames: torch.Tensor, path: Path) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ temporary = path.with_name(path.name + ".partial.mp4")
75
+ if temporary.exists():
76
+ temporary.unlink()
77
+ base.save_mp4(frames, temporary)
78
+ os.replace(temporary, path)
79
+
80
+
81
+ def read_mapping(path: Path) -> list[dict[str, Any]]:
82
+ value = json.loads(path.read_text(encoding="utf-8"))
83
+ if not isinstance(value, list) or len(value) != 251:
84
+ raise ValueError(f"Expected a 251-row mapping list: {path}")
85
+ counts = {suite: 0 for suite in SUITE_COUNTS}
86
+ globals_seen: set[int] = set()
87
+ suite_seen: dict[str, set[int]] = {suite: set() for suite in SUITE_COUNTS}
88
+ for row in value:
89
+ suite = str(row["prompt_suite"])
90
+ global_index = int(row["global_index"])
91
+ suite_index = int(row["suite_index"])
92
+ if suite not in counts:
93
+ raise ValueError(f"Unknown prompt suite: {suite}")
94
+ if global_index in globals_seen or suite_index in suite_seen[suite]:
95
+ raise ValueError(
96
+ f"Duplicate mapping index: {global_index} / {suite}:{suite_index}"
97
+ )
98
+ if not str(row["extended_prompt"]).strip():
99
+ raise ValueError(f"Empty prompt at global index {global_index}")
100
+ counts[suite] += 1
101
+ globals_seen.add(global_index)
102
+ suite_seen[suite].add(suite_index)
103
+ if counts != SUITE_COUNTS:
104
+ raise ValueError(f"Unexpected prompt-suite counts: {counts}")
105
+ return sorted(value, key=lambda row: int(row["global_index"]))
106
+
107
+
108
+ def reset_runtime_caches(pipeline: Any, device: torch.device) -> None:
109
+ if pipeline.kv_cache1 is None:
110
+ pipeline._initialize_kv_cache(1, torch.bfloat16, device)
111
+ pipeline._initialize_crossattn_cache(1, torch.bfloat16, device)
112
+ for cache in pipeline.kv_cache1:
113
+ cache["global_end_index"].zero_()
114
+ cache["local_end_index"].zero_()
115
+ for cache in pipeline.crossattn_cache:
116
+ cache["is_init"] = False
117
+
118
+
119
+ def warped_timesteps(pipeline: Any, raw_timesteps: tuple[int, ...]) -> torch.Tensor:
120
+ raw = torch.tensor(raw_timesteps, dtype=torch.long)
121
+ if not bool(pipeline.args.warp_denoising_step):
122
+ return raw
123
+ scheduler_timesteps = torch.cat(
124
+ (pipeline.scheduler.timesteps.cpu(), torch.tensor([0], dtype=torch.float32))
125
+ )
126
+ return scheduler_timesteps[1000 - raw]
127
+
128
+
129
+ def policy_timesteps(pipeline: Any, policy: NaivePolicy) -> torch.Tensor:
130
+ if policy.family in {"full", "velocity_reuse"}:
131
+ actual = pipeline.denoising_step_list.detach().cpu()
132
+ else:
133
+ actual = warped_timesteps(pipeline, policy.raw_timesteps)
134
+ if len(actual) != policy.denoise_steps:
135
+ raise ValueError(
136
+ f"{policy.name}: expected {policy.denoise_steps} timesteps, got {len(actual)}"
137
+ )
138
+ return actual
139
+
140
+
141
+ def start_timing() -> tuple[torch.cuda.Event, torch.cuda.Event]:
142
+ start = torch.cuda.Event(enable_timing=True)
143
+ end = torch.cuda.Event(enable_timing=True)
144
+ start.record()
145
+ return start, end
146
+
147
+
148
+ def finish_timing(
149
+ events_by_category: dict[str, list[tuple[torch.cuda.Event, torch.cuda.Event]]],
150
+ category: str,
151
+ events: tuple[torch.cuda.Event, torch.cuda.Event],
152
+ ) -> None:
153
+ events[1].record()
154
+ events_by_category[category].append(events)
155
+
156
+
157
+ @torch.inference_mode()
158
+ def generate_rollout(
159
+ *,
160
+ pipeline: Any,
161
+ conditional_dict: dict[str, torch.Tensor],
162
+ seed: int,
163
+ device: torch.device,
164
+ policy: NaivePolicy,
165
+ ) -> tuple[torch.Tensor, dict[str, Any]]:
166
+ reset_runtime_caches(pipeline, device)
167
+ set_seed(seed)
168
+ noise = torch.randn(
169
+ 1,
170
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
171
+ base.LATENT_CHANNELS,
172
+ base.LATENT_HEIGHT,
173
+ base.LATENT_WIDTH,
174
+ dtype=torch.bfloat16,
175
+ device=device,
176
+ )
177
+ timesteps_cpu = policy_timesteps(pipeline, policy)
178
+ actual_timestep_values = [float(value) for value in timesteps_cpu]
179
+ timesteps = timesteps_cpu.to(device=device)
180
+ output_chunks: list[torch.Tensor] = []
181
+ decisions: list[dict[str, Any]] = []
182
+ full_calls = 0
183
+ reuse_calls = 0
184
+ timing_events: dict[str, list[tuple[torch.cuda.Event, torch.cuda.Event]]] = {
185
+ "full_dit": [],
186
+ "context_dit": [],
187
+ }
188
+ started = time.perf_counter()
189
+
190
+ for chunk in range(NUM_CHUNKS):
191
+ noisy_input = noise[
192
+ :, chunk * FRAMES_PER_CHUNK : (chunk + 1) * FRAMES_PER_CHUNK
193
+ ]
194
+ cached_flow: torch.Tensor | None = None
195
+ denoised_pred: torch.Tensor | None = None
196
+ timestep: torch.Tensor | None = None
197
+ for step, current_timestep in enumerate(timesteps):
198
+ timestep = torch.ones(
199
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device=device
200
+ ) * current_timestep
201
+ action = (
202
+ policy.cache_pattern[step]
203
+ if policy.family == "velocity_reuse" and policy.cache_pattern is not None
204
+ else "F"
205
+ )
206
+ if action == "R":
207
+ if cached_flow is None:
208
+ raise RuntimeError(
209
+ f"{policy.name}: reuse requested before a full step in chunk {chunk}"
210
+ )
211
+ denoised_pred = pipeline.generator._convert_flow_pred_to_x0(
212
+ flow_pred=cached_flow.flatten(0, 1),
213
+ xt=noisy_input.flatten(0, 1),
214
+ timestep=timestep.flatten(0, 1),
215
+ ).unflatten(0, cached_flow.shape[:2])
216
+ reuse_calls += 1
217
+ else:
218
+ full_events = start_timing()
219
+ flow, denoised_pred = pipeline.generator(
220
+ noisy_image_or_video=noisy_input,
221
+ conditional_dict=conditional_dict,
222
+ timestep=timestep,
223
+ kv_cache=pipeline.kv_cache1,
224
+ crossattn_cache=pipeline.crossattn_cache,
225
+ current_start=chunk * base.TOKENS_PER_CHUNK,
226
+ )
227
+ finish_timing(timing_events, "full_dit", full_events)
228
+ cached_flow = flow.detach()
229
+ full_calls += 1
230
+ decisions.append(
231
+ {
232
+ "chunk": chunk,
233
+ "step": step,
234
+ "action": action,
235
+ "timestep": actual_timestep_values[step],
236
+ }
237
+ )
238
+ if step < len(timesteps) - 1:
239
+ if denoised_pred is None:
240
+ raise RuntimeError("Denoising step did not produce x0")
241
+ next_timestep = timesteps[step + 1]
242
+ flat = denoised_pred.flatten(0, 1)
243
+ noisy_input = pipeline.scheduler.add_noise(
244
+ flat,
245
+ torch.randn_like(flat),
246
+ next_timestep
247
+ * torch.ones(
248
+ [FRAMES_PER_CHUNK], dtype=torch.long, device=device
249
+ ),
250
+ ).unflatten(0, denoised_pred.shape[:2])
251
+
252
+ if denoised_pred is None or timestep is None:
253
+ raise RuntimeError(f"{policy.name}: chunk {chunk} produced no clean latent")
254
+ output_chunks.append(denoised_pred)
255
+ context_events = start_timing()
256
+ pipeline.generator(
257
+ noisy_image_or_video=denoised_pred,
258
+ conditional_dict=conditional_dict,
259
+ timestep=torch.ones_like(timestep) * pipeline.args.context_noise,
260
+ kv_cache=pipeline.kv_cache1,
261
+ crossattn_cache=pipeline.crossattn_cache,
262
+ current_start=chunk * base.TOKENS_PER_CHUNK,
263
+ )
264
+ finish_timing(timing_events, "context_dit", context_events)
265
+
266
+ torch.cuda.synchronize()
267
+ elapsed = {
268
+ category: sum(start.elapsed_time(end) for start, end in events)
269
+ for category, events in timing_events.items()
270
+ }
271
+ expected_full = NUM_CHUNKS * policy.full_calls_per_chunk
272
+ expected_reuse = NUM_CHUNKS * policy.reuse_calls_per_chunk
273
+ if (full_calls, reuse_calls) != (expected_full, expected_reuse):
274
+ raise RuntimeError(
275
+ f"{policy.name}: got calls F={full_calls}, R={reuse_calls}; "
276
+ f"expected F={expected_full}, R={expected_reuse}"
277
+ )
278
+ return torch.cat(output_chunks, dim=1), {
279
+ "generation_time_s": time.perf_counter() - started,
280
+ "full_calls": full_calls,
281
+ "reuse_calls": reuse_calls,
282
+ "predictor_calls": 0,
283
+ "accepted_predictor_calls": 0,
284
+ "rejected_predictor_calls": 0,
285
+ "full_dit_time_ms": elapsed["full_dit"],
286
+ "predictor_time_ms": 0.0,
287
+ "confidence_head_time_ms": 0.0,
288
+ "context_dit_time_ms": elapsed["context_dit"],
289
+ "actual_dit_time_ms": elapsed["full_dit"] + elapsed["context_dit"],
290
+ "model_path_time_ms": elapsed["full_dit"],
291
+ "raw_timesteps": list(policy.raw_timesteps),
292
+ "actual_timesteps": actual_timestep_values,
293
+ "decisions": decisions,
294
+ }
295
+
296
+
297
+ def compact_metrics(value: dict[str, Any]) -> dict[str, Any]:
298
+ keys = (
299
+ "pixel_mse",
300
+ "psnr",
301
+ "psnr_frame_mean",
302
+ "ssim",
303
+ "lpips",
304
+ "rollout_start_frame",
305
+ "rollout_pixel_mse",
306
+ "rollout_psnr",
307
+ "rollout_ssim",
308
+ "rollout_lpips",
309
+ "num_frames",
310
+ )
311
+ return {key: value[key] for key in keys}
312
+
313
+
314
+ def identity_metrics(num_frames: int) -> dict[str, Any]:
315
+ return {
316
+ "pixel_mse": 0.0,
317
+ "psnr": 120.0,
318
+ "psnr_frame_mean": 120.0,
319
+ "ssim": 1.0,
320
+ "lpips": 0.0,
321
+ "rollout_start_frame": base.PIXEL_FRAMES_FIRST_CHUNK,
322
+ "rollout_pixel_mse": 0.0,
323
+ "rollout_psnr": 120.0,
324
+ "rollout_ssim": 1.0,
325
+ "rollout_lpips": 0.0,
326
+ "num_frames": num_frames,
327
+ }
328
+
329
+
330
+ def load_models(device: torch.device) -> tuple[Any, Any, Any, Any]:
331
+ print("[setup] loading VAE", flush=True)
332
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
333
+ config = OmegaConf.merge(
334
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
335
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
336
+ )
337
+ print("[setup] loading frozen Self-Forcing Teacher", flush=True)
338
+ pipeline = base.build_pipeline(
339
+ config, REPO_ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
340
+ )
341
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
342
+ text_encoder.requires_grad_(False)
343
+ print("[setup] loading LPIPS", flush=True)
344
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
345
+ lpips_model.requires_grad_(False)
346
+ return vae, pipeline, text_encoder, lpips_model
347
+
348
+
349
+ def artifact_paths(
350
+ output_root: Path, mapping_row: dict[str, Any], strategy: str
351
+ ) -> tuple[Path, Path]:
352
+ suite = str(mapping_row["prompt_suite"])
353
+ suite_index = int(mapping_row["suite_index"])
354
+ global_index = int(mapping_row["global_index"])
355
+ return (
356
+ output_root / "generated_videos" / strategy / suite / f"{suite_index:03d}.mp4",
357
+ output_root
358
+ / "generation_metrics/per_prompt"
359
+ / strategy
360
+ / f"global_{global_index:04d}.json",
361
+ )
362
+
363
+
364
+ def is_prompt_complete(
365
+ output_root: Path, mapping_row: dict[str, Any], policies: tuple[NaivePolicy, ...]
366
+ ) -> bool:
367
+ for policy in (REFERENCE_POLICY, *policies):
368
+ video, record = artifact_paths(output_root, mapping_row, policy.name)
369
+ if not video.is_file() or not record.is_file():
370
+ return False
371
+ return True
372
+
373
+
374
+ def write_result(
375
+ *,
376
+ output_root: Path,
377
+ mapping_row: dict[str, Any],
378
+ policy: NaivePolicy,
379
+ seed: int,
380
+ diagnostic: dict[str, Any],
381
+ pixel_metrics: dict[str, Any],
382
+ frames: torch.Tensor,
383
+ ) -> None:
384
+ video_path, record_path = artifact_paths(output_root, mapping_row, policy.name)
385
+ atomic_video(frames, video_path)
386
+ diagnostic_without_decisions = {
387
+ key: value for key, value in diagnostic.items() if key != "decisions"
388
+ }
389
+ record = {
390
+ "status": "complete",
391
+ "strategy": policy.name,
392
+ "label": policy.label,
393
+ "policy": policy.to_dict(),
394
+ "global_index": int(mapping_row["global_index"]),
395
+ "prompt_suite": str(mapping_row["prompt_suite"]),
396
+ "suite_index": int(mapping_row["suite_index"]),
397
+ "prompt": str(mapping_row["extended_prompt"]),
398
+ "seed": seed,
399
+ "generation": diagnostic_without_decisions,
400
+ "decisions": diagnostic["decisions"],
401
+ "pixel_metrics_vs_ffff": pixel_metrics,
402
+ "video": str(video_path.relative_to(output_root)),
403
+ }
404
+ atomic_json(record_path, record)
405
+
406
+
407
+ def run_prompt(
408
+ *,
409
+ mapping_row: dict[str, Any],
410
+ policies: tuple[NaivePolicy, ...],
411
+ output_root: Path,
412
+ seed: int,
413
+ vae: Any,
414
+ pipeline: Any,
415
+ text_encoder: Any,
416
+ lpips_model: Any,
417
+ device: torch.device,
418
+ ) -> None:
419
+ global_index = int(mapping_row["global_index"])
420
+ suite = str(mapping_row["prompt_suite"])
421
+ suite_index = int(mapping_row["suite_index"])
422
+ prompt = str(mapping_row["extended_prompt"])
423
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
424
+ conditional = text_encoder(text_prompts=[prompt])
425
+
426
+ reference_latent, reference_diagnostic = generate_rollout(
427
+ pipeline=pipeline,
428
+ conditional_dict=conditional,
429
+ seed=seed,
430
+ device=device,
431
+ policy=REFERENCE_POLICY,
432
+ )
433
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
434
+ reference_pixels = vae.decode_to_pixel(reference_latent, use_cache=False)
435
+ reference_u8 = base.pixels_to_u8(reference_pixels)
436
+ if reference_u8.ndim != 4 or reference_u8.shape[0] != 81:
437
+ raise RuntimeError(f"Unexpected reference shape: {tuple(reference_u8.shape)}")
438
+ write_result(
439
+ output_root=output_root,
440
+ mapping_row=mapping_row,
441
+ policy=REFERENCE_POLICY,
442
+ seed=seed,
443
+ diagnostic=reference_diagnostic,
444
+ pixel_metrics=identity_metrics(int(reference_u8.shape[0])),
445
+ frames=reference_u8,
446
+ )
447
+
448
+ for policy in policies:
449
+ latent, diagnostic = generate_rollout(
450
+ pipeline=pipeline,
451
+ conditional_dict=conditional,
452
+ seed=seed,
453
+ device=device,
454
+ policy=policy,
455
+ )
456
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
457
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
458
+ prediction_u8 = base.pixels_to_u8(pixels)
459
+ metrics = base.frame_metrics(
460
+ reference_u8=reference_u8,
461
+ prediction_u8=prediction_u8,
462
+ lpips_model=lpips_model,
463
+ batch_size=4,
464
+ device=device,
465
+ )
466
+ write_result(
467
+ output_root=output_root,
468
+ mapping_row=mapping_row,
469
+ policy=policy,
470
+ seed=seed,
471
+ diagnostic=diagnostic,
472
+ pixel_metrics=compact_metrics(metrics),
473
+ frames=prediction_u8,
474
+ )
475
+ print(
476
+ f"[result] global={global_index} strategy={policy.name} "
477
+ f"F={diagnostic['full_calls']} R={diagnostic['reuse_calls']} "
478
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
479
+ f"lpips={metrics['lpips']:.6f}",
480
+ flush=True,
481
+ )
482
+ if hasattr(vae.model, "clear_cache"):
483
+ vae.model.clear_cache()
484
+ del latent, pixels, prediction_u8, metrics
485
+ torch.cuda.empty_cache()
486
+
487
+ if hasattr(vae.model, "clear_cache"):
488
+ vae.model.clear_cache()
489
+ del conditional, reference_latent, reference_pixels, reference_u8
490
+ torch.cuda.empty_cache()
491
+
492
+
493
+ def write_manifest(
494
+ *,
495
+ output_root: Path,
496
+ gpu: str,
497
+ shard_index: int,
498
+ num_shards: int,
499
+ rows: list[dict[str, Any]],
500
+ policies: tuple[NaivePolicy, ...],
501
+ seed: int,
502
+ status: str,
503
+ ) -> None:
504
+ selected = "_".join(policy.name for policy in policies)
505
+ selection_id = hashlib.sha256(selected.encode("utf-8")).hexdigest()[:8]
506
+ manifest_name = f"naive_gpu{gpu}_shard{shard_index:02d}_{selection_id}.json"
507
+ atomic_json(
508
+ output_root / "generation_metrics/manifests" / manifest_name,
509
+ {
510
+ "status": status,
511
+ "physical_gpu": gpu,
512
+ "shard_index": shard_index,
513
+ "num_shards": num_shards,
514
+ "global_indices": [int(row["global_index"]) for row in rows],
515
+ "num_prompts": len(rows),
516
+ "strategies": [REFERENCE_POLICY.name, *(policy.name for policy in policies)],
517
+ "seed": seed,
518
+ },
519
+ )
520
+
521
+
522
+ def parse_args() -> argparse.Namespace:
523
+ parser = argparse.ArgumentParser(description=__doc__)
524
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
525
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
526
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
527
+ parser.add_argument("--shard-index", type=int, default=0)
528
+ parser.add_argument("--num-shards", type=int, default=1)
529
+ parser.add_argument(
530
+ "--strategy",
531
+ action="append",
532
+ choices=EVALUATION_STRATEGY_NAMES,
533
+ default=None,
534
+ help="Baseline to generate; repeat to select several. Default: all six.",
535
+ )
536
+ parser.add_argument(
537
+ "--global-index",
538
+ dest="global_indices",
539
+ action="append",
540
+ type=int,
541
+ default=None,
542
+ help="Process only this global mapping index; may be repeated.",
543
+ )
544
+ parser.add_argument("--seed", type=int, default=0)
545
+ parser.add_argument("--overwrite", action="store_true")
546
+ return parser.parse_args()
547
+
548
+
549
+ def main() -> None:
550
+ args = parse_args()
551
+ if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
552
+ raise ValueError("Invalid shard index/number of shards")
553
+ strategy_names = args.strategy or list(EVALUATION_STRATEGY_NAMES)
554
+ if len(set(strategy_names)) != len(strategy_names):
555
+ raise ValueError("--strategy values must be unique")
556
+ policies = tuple(POLICIES_BY_NAME[name] for name in strategy_names)
557
+ mapping = read_mapping(args.mapping.resolve())
558
+ if args.global_indices is None:
559
+ rows = [
560
+ row
561
+ for position, row in enumerate(mapping)
562
+ if position % args.num_shards == args.shard_index
563
+ ]
564
+ else:
565
+ requested = set(args.global_indices)
566
+ mapping_by_global = {int(row["global_index"]): row for row in mapping}
567
+ unknown = sorted(requested - set(mapping_by_global))
568
+ if unknown:
569
+ raise ValueError(f"Unknown global indices: {unknown}")
570
+ rows = [row for row in mapping if int(row["global_index"]) in requested]
571
+
572
+ output_root = args.output_root.resolve()
573
+ output_root.mkdir(parents=True, exist_ok=True)
574
+ write_manifest(
575
+ output_root=output_root,
576
+ gpu=str(args.gpu),
577
+ shard_index=args.shard_index,
578
+ num_shards=args.num_shards,
579
+ rows=rows,
580
+ policies=policies,
581
+ seed=args.seed,
582
+ status="running",
583
+ )
584
+ print(
585
+ f"[setup] physical_gpu={args.gpu} shard={args.shard_index}/{args.num_shards} "
586
+ f"prompts={len(rows)} strategies={','.join(strategy_names)}",
587
+ flush=True,
588
+ )
589
+ device = torch.device("cuda")
590
+ torch.set_grad_enabled(False)
591
+ vae, pipeline, text_encoder, lpips_model = load_models(device)
592
+ completed = 0
593
+ try:
594
+ for row in rows:
595
+ if not args.overwrite and is_prompt_complete(output_root, row, policies):
596
+ completed += 1
597
+ print(
598
+ f"[cached] {completed}/{len(rows)} global={row['global_index']}",
599
+ flush=True,
600
+ )
601
+ continue
602
+ run_prompt(
603
+ mapping_row=row,
604
+ policies=policies,
605
+ output_root=output_root,
606
+ seed=args.seed,
607
+ vae=vae,
608
+ pipeline=pipeline,
609
+ text_encoder=text_encoder,
610
+ lpips_model=lpips_model,
611
+ device=device,
612
+ )
613
+ completed += 1
614
+ print(f"[progress] {completed}/{len(rows)} prompts", flush=True)
615
+ finally:
616
+ write_manifest(
617
+ output_root=output_root,
618
+ gpu=str(args.gpu),
619
+ shard_index=args.shard_index,
620
+ num_shards=args.num_shards,
621
+ rows=rows,
622
+ policies=policies,
623
+ seed=args.seed,
624
+ status="complete" if completed == len(rows) else "failed",
625
+ )
626
+ print(f"[complete] gpu={args.gpu} prompts={completed}/{len(rows)}", flush=True)
627
+
628
+
629
+ if __name__ == "__main__":
630
+ main()
scripts/generate_vbench8_extended_spanrisk.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate one outgoing-span Confidence-Head strategy for Extended-251.
3
+
4
+ The FFFF baseline is reused from the established Extended-251 run. Pixel
5
+ metrics decode both MP4s, so reference and prediction receive identical video
6
+ encoding/decoding treatment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ REPO_ROOT = Path(__file__).resolve().parents[1]
18
+ if str(REPO_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(REPO_ROOT))
20
+
21
+ from scripts import generate_vbench8_extended_strategies as extended
22
+
23
+ import torch
24
+ from torchvision.io import read_video
25
+
26
+ from scripts import evaluate_single_block_fppf as base
27
+
28
+
29
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
30
+ EXPERIMENT_DEFAULT = (
31
+ REPO_ROOT / "confidence_experiments/layer17_stage1_step2000_20260831"
32
+ )
33
+ SELECTION_DEFAULT = (
34
+ REPO_ROOT
35
+ / "confidence_experiments"
36
+ / "layer17_stage1_step2000_spanrisk_beta2_threshold_vbench3_20260901"
37
+ / "threshold_summary.json"
38
+ )
39
+ REFERENCE_DEFAULT = (
40
+ REPO_ROOT / "evaluation_runs/vbench8_extended_stage1_step2000_20260901"
41
+ )
42
+ OUTPUT_DEFAULT = (
43
+ REPO_ROOT
44
+ / "evaluation_runs/vbench8_extended_stage1_step2000_spanrisk_beta2_20260901"
45
+ )
46
+ STRATEGIES = (
47
+ "step12_span_k06",
48
+ "step12_span_k08",
49
+ "step12_span_k10",
50
+ "step123_span_k06",
51
+ "step123_span_k09",
52
+ "step123_span_k12",
53
+ "step123_span_k15",
54
+ )
55
+
56
+
57
+ def load_strategy_configs(path: Path) -> dict[str, dict[str, Any]]:
58
+ payload = json.loads(path.read_text(encoding="utf-8"))
59
+ if payload.get("status") != "complete" or float(payload["beta"]) != 2.0:
60
+ raise ValueError(f"Threshold selection is not completed fixed-beta=2: {path}")
61
+ configs: dict[str, dict[str, Any]] = {}
62
+ for row in payload["selected"]:
63
+ head = str(row["head"])
64
+ target = int(row["target_accepts"])
65
+ name = f"{head}_span_k{target:02d}"
66
+ configs[name] = {
67
+ "name": name,
68
+ "policy": "dynamic",
69
+ "candidate_steps": [int(step) for step in row["candidate_steps"]],
70
+ "beta": float(row["beta"]),
71
+ "threshold": float(row["threshold"]),
72
+ "target_accepts": target,
73
+ "head": head,
74
+ "risk_mode": "outgoing_span",
75
+ "source_config_name": str(row["name"]),
76
+ }
77
+ if tuple(configs) != STRATEGIES:
78
+ raise ValueError(f"Unexpected selected strategies: {tuple(configs)}")
79
+ return configs
80
+
81
+
82
+ def read_u8_video(path: Path) -> torch.Tensor:
83
+ if not path.is_file():
84
+ raise FileNotFoundError(path)
85
+ frames, _, _ = read_video(str(path), pts_unit="sec", output_format="TCHW")
86
+ frames = frames.to(device="cpu", dtype=torch.uint8).contiguous()
87
+ if frames.ndim != 4 or frames.shape[0] != 81 or frames.shape[1] != 3:
88
+ raise RuntimeError(f"Unexpected decoded video shape {tuple(frames.shape)}: {path}")
89
+ return frames
90
+
91
+
92
+ def artifact_paths(
93
+ output_root: Path, mapping_row: dict[str, Any], strategy_name: str
94
+ ) -> tuple[Path, Path]:
95
+ suite = str(mapping_row["prompt_suite"])
96
+ suite_index = int(mapping_row["suite_index"])
97
+ global_index = int(mapping_row["global_index"])
98
+ return (
99
+ output_root / "generated_videos" / strategy_name / suite / f"{suite_index:03d}.mp4",
100
+ output_root
101
+ / "generation_metrics/per_prompt"
102
+ / strategy_name
103
+ / f"global_{global_index:04d}.json",
104
+ )
105
+
106
+
107
+ def run_prompt(
108
+ *,
109
+ mapping_row: dict[str, Any],
110
+ strategy: dict[str, Any],
111
+ reference_root: Path,
112
+ output_root: Path,
113
+ seed: int,
114
+ models: tuple[Any, ...],
115
+ device: torch.device,
116
+ ) -> None:
117
+ global_index = int(mapping_row["global_index"])
118
+ suite = str(mapping_row["prompt_suite"])
119
+ suite_index = int(mapping_row["suite_index"])
120
+ prompt = str(mapping_row["extended_prompt"])
121
+ name = str(strategy["name"])
122
+ reference_path = (
123
+ reference_root / "generated_videos/ffff" / suite / f"{suite_index:03d}.mp4"
124
+ )
125
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
126
+ conditional = models[3](text_prompts=[prompt])
127
+ head = models[4] if strategy["head"] == "step12" else models[5]
128
+ latent, diagnostic = extended.generate_rollout(
129
+ pipeline=models[1],
130
+ conditional_dict=conditional,
131
+ seed=seed,
132
+ device=device,
133
+ predictor=models[2],
134
+ head=head,
135
+ config=strategy,
136
+ )
137
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
138
+ pixels = models[0].decode_to_pixel(latent, use_cache=False)
139
+ prediction_u8 = base.pixels_to_u8(pixels)
140
+ video_path, record_path = artifact_paths(output_root, mapping_row, name)
141
+ extended.atomic_video(prediction_u8, video_path)
142
+
143
+ reference_decoded = read_u8_video(reference_path)
144
+ prediction_decoded = read_u8_video(video_path)
145
+ metrics = base.frame_metrics(
146
+ reference_u8=reference_decoded,
147
+ prediction_u8=prediction_decoded,
148
+ lpips_model=models[6],
149
+ batch_size=4,
150
+ device=device,
151
+ )
152
+ record = {
153
+ "status": "complete",
154
+ "strategy": name,
155
+ "policy": "dynamic",
156
+ "candidate_steps": strategy["candidate_steps"],
157
+ "beta": strategy["beta"],
158
+ "threshold": strategy["threshold"],
159
+ "target_accepts": strategy["target_accepts"],
160
+ "risk_mode": "outgoing_span",
161
+ "allow_chunk0_predictor": False,
162
+ "source_config_name": strategy["source_config_name"],
163
+ "global_index": global_index,
164
+ "prompt_suite": suite,
165
+ "suite_index": suite_index,
166
+ "prompt": prompt,
167
+ "seed": seed,
168
+ "generation": {
169
+ key: value for key, value in diagnostic.items() if key != "decisions"
170
+ },
171
+ "decisions": diagnostic["decisions"],
172
+ "pixel_metrics_vs_ffff": extended.compact_metrics(metrics),
173
+ "pixel_metric_input": "MP4-decoded uint8 RGB, all 81 frames on both sides",
174
+ "reference_video": str(reference_path),
175
+ "video": str(video_path.relative_to(output_root)),
176
+ }
177
+ extended.atomic_json(record_path, record)
178
+ print(
179
+ f"[result] global={global_index} strategy={name} "
180
+ f"accept={diagnostic['accepted_predictor_calls']} "
181
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
182
+ f"lpips={metrics['lpips']:.6f}",
183
+ flush=True,
184
+ )
185
+ if hasattr(models[0].model, "clear_cache"):
186
+ models[0].model.clear_cache()
187
+ del conditional, latent, pixels, prediction_u8, reference_decoded, prediction_decoded
188
+ torch.cuda.empty_cache()
189
+
190
+
191
+ def parse_args() -> argparse.Namespace:
192
+ parser = argparse.ArgumentParser(description=__doc__)
193
+ parser.add_argument("--gpu", default=extended.PHYSICAL_GPU)
194
+ parser.add_argument("--strategy", required=True, choices=STRATEGIES)
195
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
196
+ parser.add_argument("--experiment-root", type=Path, default=EXPERIMENT_DEFAULT)
197
+ parser.add_argument("--selection", type=Path, default=SELECTION_DEFAULT)
198
+ parser.add_argument("--reference-root", type=Path, default=REFERENCE_DEFAULT)
199
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
200
+ parser.add_argument("--global-index", action="append", type=int, default=None)
201
+ parser.add_argument("--seed", type=int, default=0)
202
+ parser.add_argument("--overwrite", action="store_true")
203
+ return parser.parse_args()
204
+
205
+
206
+ def main() -> None:
207
+ args = parse_args()
208
+ mapping = extended.read_mapping(args.mapping.resolve())
209
+ configs = load_strategy_configs(args.selection.resolve())
210
+ strategy = configs[args.strategy]
211
+ reference_root = args.reference_root.resolve()
212
+ if len(list((reference_root / "generation_metrics/per_prompt/ffff").glob("global_*.json"))) != 251:
213
+ raise ValueError("Incomplete FFFF reference records")
214
+ if len(list((reference_root / "generated_videos/ffff").rglob("*.mp4"))) != 251:
215
+ raise ValueError("Incomplete FFFF reference videos")
216
+ if args.global_index is not None:
217
+ requested = set(args.global_index)
218
+ mapping = [row for row in mapping if int(row["global_index"]) in requested]
219
+ if len(mapping) != len(requested):
220
+ raise ValueError("At least one requested global index is unknown")
221
+
222
+ output_root = args.output_root.resolve()
223
+ output_root.mkdir(parents=True, exist_ok=True)
224
+ extended.write_shard_manifest(
225
+ output_root, str(args.gpu), 0, 1, mapping, [strategy], "running"
226
+ )
227
+ print(
228
+ f"[setup] physical_gpu={args.gpu} strategy={args.strategy} "
229
+ f"prompts={len(mapping)} reference=existing_ffff chunk0=ffff",
230
+ flush=True,
231
+ )
232
+ device = torch.device("cuda")
233
+ torch.set_grad_enabled(False)
234
+ models = extended.load_models(args.experiment_root.resolve(), device)
235
+ completed = 0
236
+ try:
237
+ for row in mapping:
238
+ video_path, record_path = artifact_paths(output_root, row, args.strategy)
239
+ if not args.overwrite and video_path.is_file() and record_path.is_file():
240
+ completed += 1
241
+ print(f"[cached] {completed}/{len(mapping)} global={row['global_index']}", flush=True)
242
+ continue
243
+ run_prompt(
244
+ mapping_row=row,
245
+ strategy=strategy,
246
+ reference_root=reference_root,
247
+ output_root=output_root,
248
+ seed=args.seed,
249
+ models=models,
250
+ device=device,
251
+ )
252
+ completed += 1
253
+ print(f"[progress] {completed}/{len(mapping)} prompts", flush=True)
254
+ finally:
255
+ extended.write_shard_manifest(
256
+ output_root,
257
+ str(args.gpu),
258
+ 0,
259
+ 1,
260
+ mapping,
261
+ [strategy],
262
+ "complete" if completed == len(mapping) else "failed",
263
+ )
264
+ print(f"[complete] gpu={args.gpu} strategy={args.strategy} prompts={completed}", flush=True)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
scripts/generate_vbench8_extended_strategies.py ADDED
@@ -0,0 +1,781 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate all Layer-17 strategies for the VBench-8 extended subset.
3
+
4
+ One process handles a prompt shard and keeps the frozen Teacher, Layer-17
5
+ Predictor, both confidence heads, VAE, text encoder, and LPIPS model resident
6
+ on one GPU. For each prompt, FFFF is generated first and all other strategies
7
+ are compared with that same-prompt, same-seed reference before their videos
8
+ are written.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import csv
15
+ import json
16
+ import math
17
+ import os
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ def preparse_gpu() -> str:
26
+ parser = argparse.ArgumentParser(add_help=False)
27
+ parser.add_argument("--gpu", default="0")
28
+ args, _ = parser.parse_known_args()
29
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
30
+ os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="self_forcing_mpl_"))
31
+ return str(args.gpu)
32
+
33
+
34
+ PHYSICAL_GPU = preparse_gpu()
35
+
36
+ import lpips
37
+ import torch
38
+ from omegaconf import OmegaConf
39
+ from safetensors.torch import load_file
40
+
41
+ REPO_ROOT = Path(__file__).resolve().parents[1]
42
+ if str(REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(REPO_ROOT))
44
+
45
+ from predictor_training.confidence import PredictorConfidenceHead
46
+ from scripts import evaluate_single_block_fppf as base
47
+ from scripts.evaluate_layer17_dynamic_gate import predictor_with_features
48
+ from scripts.vbench8_protocol import DIMENSIONS, SUITE_COUNTS
49
+ from utils.misc import set_seed
50
+ from utils.wan_wrapper import WanTextEncoder, WanVAEWrapper
51
+
52
+
53
+ NUM_CHUNKS = base.NUM_CHUNKS
54
+ FRAMES_PER_CHUNK = base.FRAMES_PER_CHUNK
55
+ NUM_DENOISING_STEPS = base.NUM_DENOISING_STEPS
56
+
57
+ MAPPING_DEFAULT = REPO_ROOT / "assets/vbench8_extended_subset_mapping.json"
58
+ EXPERIMENT_DEFAULT = REPO_ROOT / "confidence_experiments/layer17_stage1_step2000_20260831"
59
+ OUTPUT_DEFAULT = REPO_ROOT / "evaluation_runs/vbench8_extended_stage1_step2000_20260901"
60
+
61
+
62
+ def atomic_json(path: Path, value: Any) -> None:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ temporary = path.with_suffix(path.suffix + ".tmp")
65
+ temporary.write_text(
66
+ json.dumps(value, ensure_ascii=False, indent=2, allow_nan=True) + "\n",
67
+ encoding="utf-8",
68
+ )
69
+ os.replace(temporary, path)
70
+
71
+
72
+ def atomic_video(frames: torch.Tensor, path: Path) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ temporary = path.with_name(path.name + ".partial.mp4")
75
+ if temporary.exists():
76
+ temporary.unlink()
77
+ base.save_mp4(frames, temporary)
78
+ os.replace(temporary, path)
79
+
80
+
81
+ def read_mapping(path: Path) -> list[dict[str, Any]]:
82
+ value = json.loads(path.read_text(encoding="utf-8"))
83
+ if not isinstance(value, list):
84
+ raise ValueError(f"Mapping must be a JSON list: {path}")
85
+ if len(value) != 251:
86
+ raise ValueError(f"Expected 251 mapping rows, got {len(value)}")
87
+ counts = {suite: 0 for suite in SUITE_COUNTS}
88
+ seen_global: set[int] = set()
89
+ seen_suite: dict[str, set[int]] = {suite: set() for suite in SUITE_COUNTS}
90
+ for row in value:
91
+ suite = str(row["prompt_suite"])
92
+ global_index = int(row["global_index"])
93
+ suite_index = int(row["suite_index"])
94
+ if suite not in counts:
95
+ raise ValueError(f"Unknown prompt suite in mapping: {suite}")
96
+ if global_index in seen_global:
97
+ raise ValueError(f"Duplicate global index: {global_index}")
98
+ if suite_index in seen_suite[suite]:
99
+ raise ValueError(f"Duplicate suite index: {suite}/{suite_index}")
100
+ if not str(row["extended_prompt"]).strip():
101
+ raise ValueError(f"Empty extended prompt at global index {global_index}")
102
+ seen_global.add(global_index)
103
+ seen_suite[suite].add(suite_index)
104
+ counts[suite] += 1
105
+ if counts != SUITE_COUNTS:
106
+ raise ValueError(f"Unexpected mapping counts: {counts}")
107
+ for suite, expected in SUITE_COUNTS.items():
108
+ if seen_suite[suite] != set(range(expected)):
109
+ raise ValueError(f"Non-contiguous suite indices for {suite}")
110
+ return sorted(value, key=lambda row: int(row["global_index"]))
111
+
112
+
113
+ def find_selected(path: Path, config_name: str) -> dict[str, Any]:
114
+ value = json.loads(path.read_text(encoding="utf-8"))
115
+ matches = [row for row in value["selected_dynamic"] if row["config_name"] == config_name]
116
+ if len(matches) != 1:
117
+ raise ValueError(f"Expected one {config_name} in {path}, got {len(matches)}")
118
+ return matches[0]
119
+
120
+
121
+ def load_strategy_configs(experiment_root: Path) -> list[dict[str, Any]]:
122
+ step12_selected = experiment_root / "dynamic_step12/validation/selected.json"
123
+ step123_selected = experiment_root / "dynamic_step123/validation/selected.json"
124
+ configs: list[dict[str, Any]] = [
125
+ {
126
+ "name": "ffff",
127
+ "policy": "ffff",
128
+ "candidate_steps": [],
129
+ "beta": None,
130
+ "threshold": None,
131
+ "target_accepts": 0,
132
+ "head": None,
133
+ },
134
+ {
135
+ "name": "fppf",
136
+ "policy": "fppf",
137
+ "candidate_steps": [1, 2],
138
+ "beta": None,
139
+ "threshold": None,
140
+ "target_accepts": 12,
141
+ "head": None,
142
+ },
143
+ ]
144
+ for source, head, names in (
145
+ (step12_selected, "step12", ("dynamic_b1p0_k06", "dynamic_b2p0_k08", "dynamic_b0p0_k10")),
146
+ (step123_selected, "step123", ("dynamic_b1p0_k06", "dynamic_b2p0_k09", "dynamic_b1p5_k12", "dynamic_b2p0_k15")),
147
+ ):
148
+ for source_name in names:
149
+ row = find_selected(source, source_name)
150
+ target = int(row["target_accepts"])
151
+ candidates = [1, 2] if head == "step12" else [1, 2, 3]
152
+ configs.append(
153
+ {
154
+ "name": f"{head}_k{target:02d}",
155
+ "policy": "dynamic",
156
+ "candidate_steps": candidates,
157
+ "beta": float(row["beta"]),
158
+ "threshold": float(row["threshold"]),
159
+ "target_accepts": target,
160
+ "head": head,
161
+ "source_config_name": source_name,
162
+ }
163
+ )
164
+ if [config["name"] for config in configs] != [
165
+ "ffff", "fppf", "step12_k06", "step12_k08", "step12_k10",
166
+ "step123_k06", "step123_k09", "step123_k12", "step123_k15",
167
+ ]:
168
+ raise AssertionError("Unexpected strategy order")
169
+ return configs
170
+
171
+
172
+ def reset_runtime_caches(pipeline: Any, device: torch.device) -> None:
173
+ if pipeline.kv_cache1 is None:
174
+ pipeline._initialize_kv_cache(1, torch.bfloat16, device)
175
+ pipeline._initialize_crossattn_cache(1, torch.bfloat16, device)
176
+ for cache in pipeline.kv_cache1:
177
+ cache["global_end_index"].zero_()
178
+ cache["local_end_index"].zero_()
179
+ for cache in pipeline.crossattn_cache:
180
+ cache["is_init"] = False
181
+
182
+
183
+ @torch.inference_mode()
184
+ def generate_rollout(
185
+ *,
186
+ pipeline: Any,
187
+ conditional_dict: dict[str, torch.Tensor],
188
+ seed: int,
189
+ device: torch.device,
190
+ predictor: Any,
191
+ head: Any,
192
+ config: dict[str, Any],
193
+ ) -> tuple[torch.Tensor, dict[str, Any]]:
194
+ reset_runtime_caches(pipeline, device)
195
+ set_seed(seed)
196
+ noise = torch.randn(
197
+ 1,
198
+ NUM_CHUNKS * FRAMES_PER_CHUNK,
199
+ base.LATENT_CHANNELS,
200
+ base.LATENT_HEIGHT,
201
+ base.LATENT_WIDTH,
202
+ dtype=torch.bfloat16,
203
+ device=device,
204
+ )
205
+ teacher = pipeline.generator.model
206
+ timesteps = pipeline.denoising_step_list.to(device=device)
207
+ capture = base.FinalHiddenCapture(teacher)
208
+ output_chunks: list[torch.Tensor] = []
209
+ previous_chunk_hidden: list[torch.Tensor | None] | None = None
210
+ decisions: list[dict[str, Any]] = []
211
+ full_calls = 0
212
+ predictor_calls = 0
213
+ accepted_predictor_calls = 0
214
+ timing_events: dict[str, list[tuple[torch.cuda.Event, torch.cuda.Event]]] = {
215
+ "full_dit": [],
216
+ "predictor": [],
217
+ "confidence": [],
218
+ "context_dit": [],
219
+ }
220
+
221
+ def start_timing() -> tuple[torch.cuda.Event, torch.cuda.Event]:
222
+ start_event = torch.cuda.Event(enable_timing=True)
223
+ end_event = torch.cuda.Event(enable_timing=True)
224
+ start_event.record()
225
+ return start_event, end_event
226
+
227
+ def finish_timing(category: str, events: tuple[torch.cuda.Event, torch.cuda.Event]) -> None:
228
+ events[1].record()
229
+ timing_events[category].append(events)
230
+
231
+ started = time.perf_counter()
232
+ try:
233
+ for chunk in range(NUM_CHUNKS):
234
+ noisy_input = noise[
235
+ :, chunk * FRAMES_PER_CHUNK : (chunk + 1) * FRAMES_PER_CHUNK
236
+ ]
237
+ current_hidden: list[torch.Tensor | None] = [None] * NUM_DENOISING_STEPS
238
+ denoised_pred: torch.Tensor | None = None
239
+ timestep: torch.Tensor | None = None
240
+ for step, current_timestep in enumerate(timesteps):
241
+ timestep = torch.ones(
242
+ [1, FRAMES_PER_CHUNK], dtype=torch.int64, device=device
243
+ ) * current_timestep
244
+ allow_chunk0_predictor = bool(
245
+ config.get("allow_chunk0_predictor", False)
246
+ )
247
+ candidate = (
248
+ step in config["candidate_steps"]
249
+ and (chunk > 0 or allow_chunk0_predictor)
250
+ )
251
+ policy = str(config["policy"])
252
+ run_predictor = candidate and policy in {"dynamic", "fppf"}
253
+ accepted = False
254
+ pred_hidden = None
255
+ pred_x0 = None
256
+ predicted_local_error = None
257
+ risk = None
258
+ alpha = None
259
+ outgoing_span = None
260
+ span_weight = None
261
+ if run_predictor:
262
+ anchor_hidden = current_hidden[step - 1]
263
+ if anchor_hidden is None:
264
+ raise RuntimeError("Predictor inputs are unavailable")
265
+ if previous_chunk_hidden is None:
266
+ if not (
267
+ chunk == 0
268
+ and allow_chunk0_predictor
269
+ and getattr(predictor, "input_variant", None) == "disca"
270
+ ):
271
+ raise RuntimeError("Previous chunk hidden is unavailable")
272
+ # DISCA physically has no previous-chunk channel. Its
273
+ # forward signature retains this argument only for API
274
+ # compatibility, so an anchor-shaped placeholder is safe.
275
+ previous_hidden = anchor_hidden
276
+ else:
277
+ previous_hidden = previous_chunk_hidden[step]
278
+ if previous_hidden is None:
279
+ raise RuntimeError("Previous chunk hidden is unavailable")
280
+ predictor_events = start_timing()
281
+ pred_hidden, pred_flow, transformed = predictor_with_features(
282
+ predictor=predictor,
283
+ teacher=teacher,
284
+ noisy_input=noisy_input,
285
+ timestep=timestep,
286
+ anchor_hidden=anchor_hidden,
287
+ previous_hidden=previous_hidden,
288
+ history_cache=pipeline.kv_cache1[17],
289
+ cross_cache=pipeline.crossattn_cache[17],
290
+ current_start=chunk * base.TOKENS_PER_CHUNK,
291
+ anchor_timestep=(
292
+ torch.ones_like(timestep) * timesteps[step - 1]
293
+ ),
294
+ )
295
+ finish_timing("predictor", predictor_events)
296
+ pred_x0 = pipeline.generator._convert_flow_pred_to_x0(
297
+ flow_pred=pred_flow.flatten(0, 1),
298
+ xt=noisy_input.flatten(0, 1),
299
+ timestep=timestep.flatten(0, 1),
300
+ ).unflatten(0, pred_flow.shape[:2])
301
+ predictor_calls += 1
302
+ if policy == "fppf":
303
+ accepted = True
304
+ else:
305
+ chunk_position = torch.tensor(
306
+ [(chunk - 1) / 5.0], device=device
307
+ )
308
+ step_tensor = torch.tensor([step], dtype=torch.long, device=device)
309
+ confidence_events = start_timing()
310
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
311
+ predicted_log = head(
312
+ transformed_hidden=transformed,
313
+ pred_hidden=pred_hidden,
314
+ anchor_hidden=anchor_hidden,
315
+ chunk_position=chunk_position,
316
+ step_id=step_tensor,
317
+ )
318
+ finish_timing("confidence", confidence_events)
319
+ predicted_local_error = float(predicted_log.exp()[0])
320
+ alpha = (NUM_CHUNKS - 1 - chunk) / (NUM_CHUNKS - 2)
321
+ next_policy_timestep = (
322
+ timesteps[step + 1]
323
+ if step + 1 < len(timesteps)
324
+ else timesteps.new_zeros(())
325
+ )
326
+ outgoing_span = abs(
327
+ float(current_timestep) - float(next_policy_timestep)
328
+ )
329
+ risk_mode = str(config.get("risk_mode", "legacy"))
330
+ if risk_mode == "legacy":
331
+ span_weight = 1.0
332
+ elif risk_mode == "outgoing_span":
333
+ span_weight = outgoing_span / 1000.0
334
+ else:
335
+ raise ValueError(f"Unknown risk mode: {risk_mode}")
336
+ risk = predicted_local_error * span_weight * (
337
+ 1.0 + float(config["beta"]) * alpha
338
+ )
339
+ accepted = risk <= float(config["threshold"])
340
+
341
+ if accepted:
342
+ assert pred_hidden is not None and pred_x0 is not None
343
+ current_hidden[step] = pred_hidden
344
+ denoised_pred = pred_x0
345
+ accepted_predictor_calls += 1
346
+ else:
347
+ full_events = start_timing()
348
+ capture.start()
349
+ _, denoised_pred = pipeline.generator(
350
+ noisy_image_or_video=noisy_input,
351
+ conditional_dict=conditional_dict,
352
+ timestep=timestep,
353
+ kv_cache=pipeline.kv_cache1,
354
+ crossattn_cache=pipeline.crossattn_cache,
355
+ current_start=chunk * base.TOKENS_PER_CHUNK,
356
+ )
357
+ current_hidden[step] = capture.finish()
358
+ finish_timing("full_dit", full_events)
359
+ full_calls += 1
360
+
361
+ if candidate:
362
+ decisions.append(
363
+ {
364
+ "chunk": chunk,
365
+ "step": step,
366
+ "ran_predictor": run_predictor,
367
+ "accepted": accepted,
368
+ "predicted_local_error": predicted_local_error,
369
+ "chunk_alpha": alpha,
370
+ "current_timestep": float(current_timestep),
371
+ "next_policy_timestep": (
372
+ float(timesteps[step + 1])
373
+ if step + 1 < len(timesteps)
374
+ else 0.0
375
+ ),
376
+ "outgoing_span": outgoing_span,
377
+ "span_weight": span_weight,
378
+ "impact_risk": risk,
379
+ }
380
+ )
381
+ if step < NUM_DENOISING_STEPS - 1:
382
+ assert denoised_pred is not None
383
+ next_timestep = timesteps[step + 1]
384
+ flat = denoised_pred.flatten(0, 1)
385
+ noisy_input = pipeline.scheduler.add_noise(
386
+ flat,
387
+ torch.randn_like(flat),
388
+ next_timestep * torch.ones(
389
+ [FRAMES_PER_CHUNK], dtype=torch.long, device=device
390
+ ),
391
+ ).unflatten(0, denoised_pred.shape[:2])
392
+
393
+ assert denoised_pred is not None and timestep is not None
394
+ output_chunks.append(denoised_pred)
395
+ context_events = start_timing()
396
+ pipeline.generator(
397
+ noisy_image_or_video=denoised_pred,
398
+ conditional_dict=conditional_dict,
399
+ timestep=torch.ones_like(timestep) * pipeline.args.context_noise,
400
+ kv_cache=pipeline.kv_cache1,
401
+ crossattn_cache=pipeline.crossattn_cache,
402
+ current_start=chunk * base.TOKENS_PER_CHUNK,
403
+ )
404
+ finish_timing("context_dit", context_events)
405
+ previous_chunk_hidden = current_hidden
406
+ finally:
407
+ capture.close()
408
+
409
+ torch.cuda.synchronize()
410
+ elapsed = {
411
+ category: sum(start.elapsed_time(end) for start, end in events)
412
+ for category, events in timing_events.items()
413
+ }
414
+ actual_dit_time_ms = (
415
+ elapsed["full_dit"] + elapsed["predictor"] + elapsed["context_dit"]
416
+ )
417
+ policy_latency_ms = (
418
+ elapsed["full_dit"] + elapsed["predictor"] + elapsed["confidence"]
419
+ )
420
+ return torch.cat(output_chunks, dim=1), {
421
+ "generation_time_s": time.perf_counter() - started,
422
+ "full_calls": full_calls,
423
+ "predictor_calls": predictor_calls,
424
+ "accepted_predictor_calls": accepted_predictor_calls,
425
+ "rejected_predictor_calls": predictor_calls - accepted_predictor_calls,
426
+ "full_dit_time_ms": elapsed["full_dit"],
427
+ "predictor_time_ms": elapsed["predictor"],
428
+ "confidence_head_time_ms": elapsed["confidence"],
429
+ "context_dit_time_ms": elapsed["context_dit"],
430
+ "actual_dit_time_ms": actual_dit_time_ms,
431
+ "model_path_time_ms": actual_dit_time_ms + elapsed["confidence"],
432
+ "policy_latency_ms": policy_latency_ms,
433
+ "decisions": decisions,
434
+ }
435
+
436
+
437
+ def compact_metrics(value: dict[str, Any]) -> dict[str, Any]:
438
+ keys = (
439
+ "pixel_mse", "psnr", "psnr_frame_mean", "ssim", "lpips",
440
+ "rollout_start_frame", "rollout_pixel_mse", "rollout_psnr",
441
+ "rollout_ssim", "rollout_lpips", "num_frames",
442
+ )
443
+ return {key: value[key] for key in keys}
444
+
445
+
446
+ def identity_metrics(num_frames: int) -> dict[str, Any]:
447
+ return {
448
+ "pixel_mse": 0.0,
449
+ "psnr": 120.0,
450
+ "psnr_frame_mean": 120.0,
451
+ "ssim": 1.0,
452
+ "lpips": 0.0,
453
+ "rollout_start_frame": base.PIXEL_FRAMES_FIRST_CHUNK,
454
+ "rollout_pixel_mse": 0.0,
455
+ "rollout_psnr": 120.0,
456
+ "rollout_ssim": 1.0,
457
+ "rollout_lpips": 0.0,
458
+ "num_frames": num_frames,
459
+ }
460
+
461
+
462
+ def load_models(
463
+ experiment_root: Path, device: torch.device
464
+ ) -> tuple[Any, Any, Any, Any, Any, Any, Any]:
465
+ print("[setup] loading VAE", flush=True)
466
+ vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
467
+ config = OmegaConf.merge(
468
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
469
+ OmegaConf.load(REPO_ROOT / "configs/self_forcing_sid.yaml"),
470
+ )
471
+ print("[setup] loading frozen Teacher and Layer-17 Predictor", flush=True)
472
+ pipeline = base.build_pipeline(
473
+ config, REPO_ROOT / "checkpoints/self_forcing_dmd.pt", vae, device
474
+ )
475
+ predictor = base.load_predictor(
476
+ pipeline.generator.model,
477
+ {
478
+ "source_layer": 17,
479
+ "weights": REPO_ROOT
480
+ / "training_runs/layer17_stage1_1000p_4gpu_b16_2000steps/checkpoint_step_2000/predictor.safetensors",
481
+ "gate_mode": "baseline",
482
+ },
483
+ device,
484
+ )
485
+ print("[setup] loading text encoder and confidence heads", flush=True)
486
+ text_encoder = WanTextEncoder().to(device=device, dtype=torch.bfloat16).eval()
487
+ text_encoder.requires_grad_(False)
488
+ step12_head = PredictorConfidenceHead(num_steps=2).to(device=device).eval()
489
+ step12_head.load_state_dict(
490
+ load_file(
491
+ str(experiment_root / "confidence_step12/confidence_best.safetensors"),
492
+ device="cpu",
493
+ ),
494
+ strict=True,
495
+ )
496
+ step12_head.requires_grad_(False)
497
+ step123_head = PredictorConfidenceHead(num_steps=3).to(device=device).eval()
498
+ step123_head.load_state_dict(
499
+ load_file(
500
+ str(experiment_root / "confidence_step123/confidence_best.safetensors"),
501
+ device="cpu",
502
+ ),
503
+ strict=True,
504
+ )
505
+ step123_head.requires_grad_(False)
506
+ print("[setup] loading LPIPS", flush=True)
507
+ lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
508
+ lpips_model.requires_grad_(False)
509
+ return (
510
+ vae,
511
+ pipeline,
512
+ predictor,
513
+ text_encoder,
514
+ step12_head,
515
+ step123_head,
516
+ lpips_model,
517
+ )
518
+
519
+
520
+ def is_prompt_complete(output_root: Path, mapping_row: dict[str, Any], strategies: list[dict[str, Any]]) -> bool:
521
+ global_index = int(mapping_row["global_index"])
522
+ suite = str(mapping_row["prompt_suite"])
523
+ suite_index = int(mapping_row["suite_index"])
524
+ for strategy in strategies:
525
+ name = strategy["name"]
526
+ video = output_root / "generated_videos" / name / suite / f"{suite_index:03d}.mp4"
527
+ record = output_root / "generation_metrics/per_prompt" / name / f"global_{global_index:04d}.json"
528
+ if not video.is_file() or not record.is_file():
529
+ return False
530
+ return True
531
+
532
+
533
+ def run_prompt(
534
+ *,
535
+ mapping_row: dict[str, Any],
536
+ strategies: list[dict[str, Any]],
537
+ output_root: Path,
538
+ seed: int,
539
+ vae: Any,
540
+ pipeline: Any,
541
+ predictor: Any,
542
+ text_encoder: Any,
543
+ step12_head: Any,
544
+ step123_head: Any,
545
+ lpips_model: Any,
546
+ device: torch.device,
547
+ ) -> None:
548
+ global_index = int(mapping_row["global_index"])
549
+ suite = str(mapping_row["prompt_suite"])
550
+ suite_index = int(mapping_row["suite_index"])
551
+ prompt = str(mapping_row["extended_prompt"])
552
+ print(f"[prompt] global={global_index} suite={suite}/{suite_index}", flush=True)
553
+ conditional = text_encoder(text_prompts=[prompt])
554
+ ffff_config = strategies[0]
555
+ reference_latent, ffff_diagnostic = generate_rollout(
556
+ pipeline=pipeline,
557
+ conditional_dict=conditional,
558
+ seed=seed,
559
+ device=device,
560
+ predictor=predictor,
561
+ head=None,
562
+ config=ffff_config,
563
+ )
564
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
565
+ reference_pixels = vae.decode_to_pixel(reference_latent, use_cache=False)
566
+ reference_u8 = base.pixels_to_u8(reference_pixels)
567
+ if reference_u8.ndim != 4 or reference_u8.shape[0] != 81:
568
+ raise RuntimeError(f"Unexpected decoded reference shape: {tuple(reference_u8.shape)}")
569
+ reference_path = (
570
+ output_root / "generated_videos" / "ffff" / suite / f"{suite_index:03d}.mp4"
571
+ )
572
+ atomic_video(reference_u8, reference_path)
573
+ reference_record = {
574
+ "status": "complete",
575
+ "strategy": "ffff",
576
+ "policy": "ffff",
577
+ "global_index": global_index,
578
+ "prompt_suite": suite,
579
+ "suite_index": suite_index,
580
+ "prompt": prompt,
581
+ "seed": seed,
582
+ "generation": {key: value for key, value in ffff_diagnostic.items() if key != "decisions"},
583
+ "pixel_metrics_vs_ffff": identity_metrics(int(reference_u8.shape[0])),
584
+ "video": str(reference_path.relative_to(output_root)),
585
+ }
586
+ atomic_json(
587
+ output_root
588
+ / "generation_metrics/per_prompt/ffff"
589
+ / f"global_{global_index:04d}.json",
590
+ reference_record,
591
+ )
592
+
593
+ for strategy in strategies[1:]:
594
+ name = str(strategy["name"])
595
+ head = step12_head if strategy["head"] == "step12" else step123_head
596
+ latent, diagnostic = generate_rollout(
597
+ pipeline=pipeline,
598
+ conditional_dict=conditional,
599
+ seed=seed,
600
+ device=device,
601
+ predictor=predictor,
602
+ head=head,
603
+ config=strategy,
604
+ )
605
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
606
+ pixels = vae.decode_to_pixel(latent, use_cache=False)
607
+ prediction_u8 = base.pixels_to_u8(pixels)
608
+ metrics = base.frame_metrics(
609
+ reference_u8=reference_u8,
610
+ prediction_u8=prediction_u8,
611
+ lpips_model=lpips_model,
612
+ batch_size=4,
613
+ device=device,
614
+ )
615
+ video_path = output_root / "generated_videos" / name / suite / f"{suite_index:03d}.mp4"
616
+ atomic_video(prediction_u8, video_path)
617
+ record = {
618
+ "status": "complete",
619
+ "strategy": name,
620
+ "policy": strategy["policy"],
621
+ "candidate_steps": strategy["candidate_steps"],
622
+ "beta": strategy["beta"],
623
+ "threshold": strategy["threshold"],
624
+ "target_accepts": strategy["target_accepts"],
625
+ "source_config_name": strategy.get("source_config_name"),
626
+ "global_index": global_index,
627
+ "prompt_suite": suite,
628
+ "suite_index": suite_index,
629
+ "prompt": prompt,
630
+ "seed": seed,
631
+ "generation": {key: value for key, value in diagnostic.items() if key != "decisions"},
632
+ "decisions": diagnostic["decisions"],
633
+ "pixel_metrics_vs_ffff": compact_metrics(metrics),
634
+ "video": str(video_path.relative_to(output_root)),
635
+ }
636
+ atomic_json(
637
+ output_root
638
+ / "generation_metrics/per_prompt"
639
+ / name
640
+ / f"global_{global_index:04d}.json",
641
+ record,
642
+ )
643
+ print(
644
+ f"[result] global={global_index} strategy={name} "
645
+ f"accept={diagnostic['accepted_predictor_calls']} "
646
+ f"psnr={metrics['psnr']:.4f} ssim={metrics['ssim']:.6f} "
647
+ f"lpips={metrics['lpips']:.6f}",
648
+ flush=True,
649
+ )
650
+ if hasattr(vae.model, "clear_cache"):
651
+ vae.model.clear_cache()
652
+ del latent, pixels, prediction_u8, metrics
653
+ torch.cuda.empty_cache()
654
+
655
+ if hasattr(vae.model, "clear_cache"):
656
+ vae.model.clear_cache()
657
+ del conditional, reference_latent, reference_pixels, reference_u8
658
+ torch.cuda.empty_cache()
659
+
660
+
661
+ def write_shard_manifest(
662
+ output_root: Path,
663
+ gpu: str,
664
+ shard_index: int,
665
+ num_shards: int,
666
+ rows: list[dict[str, Any]],
667
+ strategies: list[dict[str, Any]],
668
+ status: str,
669
+ ) -> None:
670
+ atomic_json(
671
+ output_root / "generation_metrics" / f"manifest_gpu{gpu}.json",
672
+ {
673
+ "status": status,
674
+ "physical_gpu": gpu,
675
+ "shard_index": shard_index,
676
+ "num_shards": num_shards,
677
+ "global_indices": [int(row["global_index"]) for row in rows],
678
+ "num_prompts": len(rows),
679
+ "strategies": [strategy["name"] for strategy in strategies],
680
+ "seed": 0,
681
+ },
682
+ )
683
+
684
+
685
+ def parse_args() -> argparse.Namespace:
686
+ parser = argparse.ArgumentParser(description=__doc__)
687
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
688
+ parser.add_argument("--mapping", type=Path, default=MAPPING_DEFAULT)
689
+ parser.add_argument("--experiment-root", type=Path, default=EXPERIMENT_DEFAULT)
690
+ parser.add_argument("--output-root", type=Path, default=OUTPUT_DEFAULT)
691
+ parser.add_argument("--shard-index", type=int, default=0)
692
+ parser.add_argument("--num-shards", type=int, default=1)
693
+ parser.add_argument(
694
+ "--global-index",
695
+ dest="global_indices",
696
+ action="append",
697
+ type=int,
698
+ default=None,
699
+ help="Process only the specified global index; may be repeated.",
700
+ )
701
+ parser.add_argument("--seed", type=int, default=0)
702
+ parser.add_argument("--overwrite", action="store_true")
703
+ return parser.parse_args()
704
+
705
+
706
+ def main() -> None:
707
+ args = parse_args()
708
+ if args.num_shards < 1 or not 0 <= args.shard_index < args.num_shards:
709
+ raise ValueError("Invalid shard index/number of shards")
710
+ mapping = read_mapping(args.mapping.resolve())
711
+ strategies = load_strategy_configs(args.experiment_root.resolve())
712
+ if args.global_indices is None:
713
+ rows = [
714
+ row for position, row in enumerate(mapping)
715
+ if position % args.num_shards == args.shard_index
716
+ ]
717
+ else:
718
+ requested = set(args.global_indices)
719
+ mapping_by_global = {int(row["global_index"]): row for row in mapping}
720
+ unknown = sorted(requested - set(mapping_by_global))
721
+ if unknown:
722
+ raise ValueError(f"Unknown global indices: {unknown}")
723
+ rows = [row for row in mapping if int(row["global_index"]) in requested]
724
+ output_root = args.output_root.resolve()
725
+ output_root.mkdir(parents=True, exist_ok=True)
726
+ write_shard_manifest(
727
+ output_root, str(args.gpu), args.shard_index, args.num_shards,
728
+ rows, strategies, "running",
729
+ )
730
+ print(
731
+ f"[setup] physical_gpu={args.gpu} shard={args.shard_index}/{args.num_shards} "
732
+ f"prompts={len(rows)} strategies={len(strategies)}",
733
+ flush=True,
734
+ )
735
+ device = torch.device("cuda")
736
+ torch.set_grad_enabled(False)
737
+ (
738
+ vae,
739
+ pipeline,
740
+ predictor,
741
+ text_encoder,
742
+ step12_head,
743
+ step123_head,
744
+ lpips_model,
745
+ ) = load_models(args.experiment_root.resolve(), device)
746
+ completed = 0
747
+ try:
748
+ for row in rows:
749
+ if not args.overwrite and is_prompt_complete(output_root, row, strategies):
750
+ completed += 1
751
+ print(
752
+ f"[cached] {completed}/{len(rows)} global={row['global_index']}",
753
+ flush=True,
754
+ )
755
+ continue
756
+ run_prompt(
757
+ mapping_row=row,
758
+ strategies=strategies,
759
+ output_root=output_root,
760
+ seed=args.seed,
761
+ vae=vae,
762
+ pipeline=pipeline,
763
+ predictor=predictor,
764
+ text_encoder=text_encoder,
765
+ step12_head=step12_head,
766
+ step123_head=step123_head,
767
+ lpips_model=lpips_model,
768
+ device=device,
769
+ )
770
+ completed += 1
771
+ print(f"[progress] {completed}/{len(rows)} prompts", flush=True)
772
+ finally:
773
+ write_shard_manifest(
774
+ output_root, str(args.gpu), args.shard_index, args.num_shards,
775
+ rows, strategies, "complete" if completed == len(rows) else "failed",
776
+ )
777
+ print(f"[complete] gpu={args.gpu} prompts={completed}/{len(rows)}", flush=True)
778
+
779
+
780
+ if __name__ == "__main__":
781
+ main()
scripts/merge_three_block_sweep_shards.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Merge four independent consecutive three-block training sweeps."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ EXPECTED_TRIPLES = [(index, index + 1, index + 2) for index in range(28)]
16
+ EXPECTED_PROMPTS = {"train": list(range(80)), "val": list(range(80, 100))}
17
+ GPU_SHARDS = {
18
+ "0": EXPECTED_TRIPLES[0:7],
19
+ "2": EXPECTED_TRIPLES[7:14],
20
+ "6": EXPECTED_TRIPLES[14:21],
21
+ "7": EXPECTED_TRIPLES[21:28],
22
+ }
23
+
24
+
25
+ def parse_args() -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(description=__doc__)
27
+ parser.add_argument("--shard_root", type=Path, required=True)
28
+ parser.add_argument("--output_dir", type=Path, required=True)
29
+ return parser.parse_args()
30
+
31
+
32
+ def experiment_name(triple: tuple[int, int, int]) -> str:
33
+ return "triple_" + "_".join(f"{layer:02d}" for layer in triple)
34
+
35
+
36
+ def atomic_json(path: Path, value: Any) -> None:
37
+ temporary = path.with_suffix(path.suffix + ".tmp")
38
+ temporary.write_text(
39
+ json.dumps(value, indent=2, ensure_ascii=False) + "\n",
40
+ encoding="utf-8",
41
+ )
42
+ os.replace(temporary, path)
43
+
44
+
45
+ def validate_metrics(
46
+ metrics: dict[str, Any], triple: tuple[int, int, int]
47
+ ) -> None:
48
+ name = experiment_name(triple)
49
+ expected = {
50
+ "status": "complete",
51
+ "name": name,
52
+ "architecture": "three_block_predictor",
53
+ "initialization_method": "teacher_full",
54
+ "source_layers": list(triple),
55
+ "seed": 0,
56
+ "max_steps": 1000,
57
+ "train_prompt_ids": EXPECTED_PROMPTS["train"],
58
+ "val_prompt_ids": EXPECTED_PROMPTS["val"],
59
+ }
60
+ for key, value in expected.items():
61
+ if metrics.get(key) != value:
62
+ raise RuntimeError(
63
+ f"{name}: expected {key}={value!r}, got {metrics.get(key)!r}"
64
+ )
65
+
66
+
67
+ def result_row(metrics: dict[str, Any]) -> dict[str, Any]:
68
+ return {
69
+ "name": metrics["name"],
70
+ "source_layer_1": metrics["source_layers"][0],
71
+ "source_layer_2": metrics["source_layers"][1],
72
+ "source_layer_3": metrics["source_layers"][2],
73
+ "final_val_flow_mse": metrics["final_val_flow_mse"],
74
+ "final_val_hidden_mse": metrics["final_val_hidden_mse"],
75
+ "final_val_total_loss": metrics["final_val_total_loss"],
76
+ "val_flow_mse_auc": metrics["val_flow_mse_auc"],
77
+ "val_hidden_mse_auc": metrics["val_hidden_mse_auc"],
78
+ "val_total_loss_auc": metrics["val_total_loss_auc"],
79
+ "training_time_s": metrics["training_time_s"],
80
+ }
81
+
82
+
83
+ def write_summary(output_dir: Path, rows: list[dict[str, Any]]) -> None:
84
+ rows.sort(key=lambda row: float(row["final_val_flow_mse"]))
85
+ destination = output_dir / "summary.csv"
86
+ temporary = destination.with_suffix(".csv.tmp")
87
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
88
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
89
+ writer.writeheader()
90
+ writer.writerows(rows)
91
+ os.replace(temporary, destination)
92
+ atomic_json(output_dir / "summary.json", rows)
93
+
94
+
95
+ def main() -> None:
96
+ args = parse_args()
97
+ shard_root = args.shard_root.resolve()
98
+ output_dir = args.output_dir.resolve()
99
+ output_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ rows = []
102
+ fingerprints = set()
103
+ for gpu, triples in GPU_SHARDS.items():
104
+ shard_dir = shard_root / f"gpu{gpu}"
105
+ shard_manifest = json.loads(
106
+ (shard_dir / "sweep_manifest.json").read_text(encoding="utf-8")
107
+ )
108
+ if shard_manifest.get("status") != "complete":
109
+ raise RuntimeError(f"GPU {gpu} shard is not complete")
110
+ if shard_manifest.get("triples") != [list(value) for value in triples]:
111
+ raise RuntimeError(f"GPU {gpu} shard contains unexpected triples")
112
+ fingerprints.add(shard_manifest["batch_schedule_sha256"])
113
+
114
+ for triple in triples:
115
+ name = experiment_name(triple)
116
+ source = shard_dir / name
117
+ metrics = json.loads(
118
+ (source / "metrics.json").read_text(encoding="utf-8")
119
+ )
120
+ validate_metrics(metrics, triple)
121
+ destination = output_dir / name
122
+ if destination.exists():
123
+ shutil.rmtree(destination)
124
+ shutil.copytree(source, destination)
125
+ rows.append(result_row(metrics))
126
+
127
+ if len(rows) != 28:
128
+ raise RuntimeError(f"Expected 28 results, found {len(rows)}")
129
+ if len(fingerprints) != 1:
130
+ raise RuntimeError(f"Batch schedules differ across shards: {fingerprints}")
131
+ write_summary(output_dir, rows)
132
+ atomic_json(
133
+ output_dir / "sweep_manifest.json",
134
+ {
135
+ "status": "complete",
136
+ "architecture": "three_block_predictor",
137
+ "initialization_method": "teacher_full",
138
+ "triples": [list(value) for value in EXPECTED_TRIPLES],
139
+ "num_triples": 28,
140
+ "max_steps": 1000,
141
+ "seed": 0,
142
+ "train_prompt_ids": EXPECTED_PROMPTS["train"],
143
+ "val_prompt_ids": EXPECTED_PROMPTS["val"],
144
+ "batch_schedule_sha256": fingerprints.pop(),
145
+ "gpu_shards": {
146
+ gpu: [list(value) for value in triples]
147
+ for gpu, triples in GPU_SHARDS.items()
148
+ },
149
+ "source_shard_root": str(shard_root),
150
+ },
151
+ )
152
+ print(
153
+ f"[merge] {len(rows)} triples -> {output_dir / 'summary.csv'}",
154
+ flush=True,
155
+ )
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
scripts/merge_two_block_fppp_shards.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Merge independently evaluated two-block FPPP shards into one result set."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ PROMPT_IDS = list(range(80, 100))
16
+ SCHEDULE = "chunk0=FFFF; chunks1-6=FPPP"
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("--main_dir", type=Path, required=True)
22
+ parser.add_argument("--shard_dir", type=Path, required=True)
23
+ parser.add_argument("--sweep_dir", type=Path, required=True)
24
+ return parser.parse_args()
25
+
26
+
27
+ def atomic_json(path: Path, value: Any) -> None:
28
+ temporary = path.with_suffix(path.suffix + ".tmp")
29
+ temporary.write_text(
30
+ json.dumps(value, indent=2, ensure_ascii=False) + "\n",
31
+ encoding="utf-8",
32
+ )
33
+ os.replace(temporary, path)
34
+
35
+
36
+ def read_experiment_names(sweep_dir: Path) -> list[str]:
37
+ with (sweep_dir / "summary.csv").open(
38
+ "r", encoding="utf-8", newline=""
39
+ ) as handle:
40
+ return [row["name"] for row in csv.DictReader(handle)]
41
+
42
+
43
+ def merge_pair_directories(main_dir: Path, shard_dir: Path) -> None:
44
+ for source in shard_dir.glob("pair_*"):
45
+ if source.is_dir():
46
+ shutil.copytree(source, main_dir / source.name, dirs_exist_ok=True)
47
+
48
+
49
+ def collect_rows(main_dir: Path, names: list[str]) -> list[dict[str, Any]]:
50
+ rows = []
51
+ for name in names:
52
+ metrics_path = main_dir / name / "metrics.json"
53
+ if not metrics_path.exists():
54
+ raise FileNotFoundError(f"Missing completed metrics: {metrics_path}")
55
+ metrics = json.loads(metrics_path.read_text(encoding="utf-8"))
56
+ if metrics.get("status") != "complete":
57
+ raise RuntimeError(f"Incomplete metrics for {name}")
58
+ if metrics.get("schedule") != SCHEDULE:
59
+ raise RuntimeError(f"Unexpected schedule for {name}: {metrics.get('schedule')}")
60
+ if metrics.get("prompt_ids") != PROMPT_IDS:
61
+ raise RuntimeError(f"Unexpected prompt split for {name}")
62
+ rows.append(
63
+ {
64
+ "name": metrics["name"],
65
+ "source_layer_1": metrics["source_layers"][0],
66
+ "source_layer_2": metrics["source_layers"][1],
67
+ "pair_kind": metrics["pair_kind"],
68
+ "schedule": metrics["schedule"],
69
+ "num_prompts": metrics["num_prompts"],
70
+ "psnr": metrics["psnr"],
71
+ "ssim": metrics["ssim"],
72
+ "lpips": metrics["lpips"],
73
+ "rollout_psnr": metrics["rollout_psnr"],
74
+ "rollout_ssim": metrics["rollout_ssim"],
75
+ "rollout_lpips": metrics["rollout_lpips"],
76
+ "offline_final_val_flow_mse": metrics[
77
+ "offline_final_val_flow_mse"
78
+ ],
79
+ "mean_generation_time_s": metrics["mean_generation_time_s"],
80
+ }
81
+ )
82
+ rows.sort(key=lambda row: float(row["lpips"]))
83
+ return rows
84
+
85
+
86
+ def write_summary(main_dir: Path, rows: list[dict[str, Any]]) -> None:
87
+ destination = main_dir / "summary.csv"
88
+ temporary = destination.with_suffix(".csv.tmp")
89
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
90
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
91
+ writer.writeheader()
92
+ writer.writerows(rows)
93
+ os.replace(temporary, destination)
94
+ atomic_json(main_dir / "summary.json", rows)
95
+
96
+
97
+ def main() -> None:
98
+ args = parse_args()
99
+ main_dir = args.main_dir.resolve()
100
+ shard_dir = args.shard_dir.resolve()
101
+ sweep_dir = args.sweep_dir.resolve()
102
+ names = read_experiment_names(sweep_dir)
103
+ if len(names) != 43:
104
+ raise RuntimeError(f"Expected 43 experiments, found {len(names)}")
105
+ merge_pair_directories(main_dir, shard_dir)
106
+ rows = collect_rows(main_dir, names)
107
+ write_summary(main_dir, rows)
108
+ atomic_json(
109
+ main_dir / "manifest.json",
110
+ {
111
+ "status": "complete",
112
+ "architecture": "two_block_predictor",
113
+ "prompt_ids": PROMPT_IDS,
114
+ "experiments": names,
115
+ "rollout_schedule": "FPPP",
116
+ "rollout_definition": SCHEDULE,
117
+ "generation_seed_reset_per_prompt": 0,
118
+ "gpu_shards": {"2": 22, "4": 21},
119
+ "merged_shard_dir": str(shard_dir),
120
+ },
121
+ )
122
+ print(f"[merge] {len(rows)} pairs -> {main_dir / 'summary.csv'}", flush=True)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
scripts/naive_vbench_policies.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Pure policy definitions for the Extended-251 naive baselines."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Literal
8
+
9
+
10
+ PolicyFamily = Literal["full", "timestep_skipping", "velocity_reuse"]
11
+
12
+
13
+ def raw_timestep_schedule(num_steps: int) -> tuple[int, ...]:
14
+ """Return floor-spaced training noise levels in [1000, 0)."""
15
+ if num_steps < 1:
16
+ raise ValueError("num_steps must be positive")
17
+ return tuple(1000 * (num_steps - index) // num_steps for index in range(num_steps))
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NaivePolicy:
22
+ name: str
23
+ label: str
24
+ family: PolicyFamily
25
+ raw_timesteps: tuple[int, ...]
26
+ cache_pattern: str | None = None
27
+
28
+ @property
29
+ def denoise_steps(self) -> int:
30
+ return len(self.raw_timesteps)
31
+
32
+ @property
33
+ def full_calls_per_chunk(self) -> int:
34
+ if self.cache_pattern is None:
35
+ return self.denoise_steps
36
+ return self.cache_pattern.count("F")
37
+
38
+ @property
39
+ def reuse_calls_per_chunk(self) -> int:
40
+ return 0 if self.cache_pattern is None else self.cache_pattern.count("R")
41
+
42
+ def validate(self) -> None:
43
+ if not self.name or not self.label:
44
+ raise ValueError("Policy name and label must be non-empty")
45
+ if self.family == "velocity_reuse":
46
+ if self.cache_pattern is None:
47
+ raise ValueError(f"{self.name}: velocity reuse requires a cache pattern")
48
+ if len(self.cache_pattern) != 4 or self.cache_pattern[0] != "F":
49
+ raise ValueError(f"{self.name}: cache pattern must be four steps and start with F")
50
+ if set(self.cache_pattern) - {"F", "R"}:
51
+ raise ValueError(f"{self.name}: cache pattern may contain only F and R")
52
+ if self.raw_timesteps != (1000, 750, 500, 250):
53
+ raise ValueError(f"{self.name}: cache baselines must keep the original schedule")
54
+ elif self.cache_pattern is not None:
55
+ raise ValueError(f"{self.name}: only velocity-reuse policies have cache patterns")
56
+
57
+ def to_dict(self) -> dict[str, object]:
58
+ value = asdict(self)
59
+ value.update(
60
+ denoise_steps=self.denoise_steps,
61
+ full_calls_per_chunk=self.full_calls_per_chunk,
62
+ reuse_calls_per_chunk=self.reuse_calls_per_chunk,
63
+ )
64
+ return value
65
+
66
+
67
+ ORIGINAL_RAW_TIMESTEPS = (1000, 750, 500, 250)
68
+
69
+ REFERENCE_POLICY = NaivePolicy(
70
+ name="ffff",
71
+ label="FFFF",
72
+ family="full",
73
+ raw_timesteps=ORIGINAL_RAW_TIMESTEPS,
74
+ )
75
+
76
+ EVALUATION_POLICIES = (
77
+ NaivePolicy(
78
+ name="naive_3step",
79
+ label="Naive 3-step / timestep skipping",
80
+ family="timestep_skipping",
81
+ raw_timesteps=raw_timestep_schedule(3),
82
+ ),
83
+ NaivePolicy(
84
+ name="naive_2step",
85
+ label="Naive 2-step / timestep skipping",
86
+ family="timestep_skipping",
87
+ raw_timesteps=raw_timestep_schedule(2),
88
+ ),
89
+ NaivePolicy(
90
+ name="naive_1step",
91
+ label="Naive 1-step / timestep skipping",
92
+ family="timestep_skipping",
93
+ raw_timesteps=raw_timestep_schedule(1),
94
+ ),
95
+ NaivePolicy(
96
+ name="naive_cache_3step_frff",
97
+ label="Naive Cache 3-step (FRFF)",
98
+ family="velocity_reuse",
99
+ raw_timesteps=ORIGINAL_RAW_TIMESTEPS,
100
+ cache_pattern="FRFF",
101
+ ),
102
+ NaivePolicy(
103
+ name="naive_cache_2step_frrf",
104
+ label="Naive Cache 2-step (FRRF)",
105
+ family="velocity_reuse",
106
+ raw_timesteps=ORIGINAL_RAW_TIMESTEPS,
107
+ cache_pattern="FRRF",
108
+ ),
109
+ NaivePolicy(
110
+ name="naive_cache_1step_frrr",
111
+ label="Naive Cache 1-step (FRRR)",
112
+ family="velocity_reuse",
113
+ raw_timesteps=ORIGINAL_RAW_TIMESTEPS,
114
+ cache_pattern="FRRR",
115
+ ),
116
+ )
117
+
118
+ ALL_POLICIES = (REFERENCE_POLICY, *EVALUATION_POLICIES)
119
+ POLICIES_BY_NAME = {policy.name: policy for policy in ALL_POLICIES}
120
+ EVALUATION_STRATEGY_NAMES = tuple(policy.name for policy in EVALUATION_POLICIES)
121
+ ALL_STRATEGY_NAMES = tuple(policy.name for policy in ALL_POLICIES)
122
+
123
+ for _policy in ALL_POLICIES:
124
+ _policy.validate()
125
+ if len(POLICIES_BY_NAME) != len(ALL_POLICIES):
126
+ raise ValueError("Naive policy names must be unique")
127
+
scripts/plot_frrf_chunk_metrics.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Plot 10-prompt mean FRRF metrics versus the reused chunk.
3
+
4
+ The three input ``per_prompt.csv`` files use slightly different identifiers
5
+ (``prompt_id`` for Self/Causal-Forcing and ``case`` for WorldPlay), but share
6
+ the metric columns. We deliberately aggregate from the per-prompt rows so
7
+ that PSNR is also an arithmetic mean over the ten prompts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+
17
+ import matplotlib.pyplot as plt
18
+
19
+
20
+ DEFAULT_ROOTS = {
21
+ "Self-Forcing": Path(
22
+ "/data3/chenzhuo/workspace/Self-Forcing/outputs/"
23
+ "single_chunk_frrf_14chunks_first10"
24
+ ),
25
+ "Causal-Forcing": Path(
26
+ "/data3/chenzhuo/workspace/Causal-Forcing/outputs/"
27
+ "single_chunk_frrf_14chunks_first10"
28
+ ),
29
+ "HY-WorldPlay": Path(
30
+ "/data3/chenzhuo/workspace/HY-WorldPlay-DEV/outputs/"
31
+ "moviebench_single_chunk_frrf_14chunks_first10"
32
+ ),
33
+ }
34
+
35
+ METRICS = ("psnr", "ssim", "lpips")
36
+ Y_LABELS = {"psnr": "PSNR (dB)", "ssim": "SSIM", "lpips": "LPIPS"}
37
+ COLORS = {
38
+ "Self-Forcing": "#1f77b4",
39
+ "Causal-Forcing": "#d62728",
40
+ "HY-WorldPlay": "#2ca02c",
41
+ }
42
+
43
+
44
+ def read_prompt_means(root: Path, num_chunks: int = 14) -> dict[str, list[float]]:
45
+ """Return arithmetic means over prompts for every metric and chunk."""
46
+ csv_path = root / "per_prompt.csv"
47
+ if not csv_path.exists():
48
+ raise FileNotFoundError(csv_path)
49
+
50
+ # values[chunk][metric] -> list of prompt-level values
51
+ values: dict[int, dict[str, list[float]]] = defaultdict(
52
+ lambda: {metric: [] for metric in METRICS}
53
+ )
54
+ with csv_path.open(newline="") as handle:
55
+ reader = csv.DictReader(handle)
56
+ required = {"reuse_chunk", *METRICS}
57
+ missing = required.difference(reader.fieldnames or ())
58
+ if missing:
59
+ raise ValueError(f"{csv_path} is missing columns: {sorted(missing)}")
60
+ for row in reader:
61
+ chunk = int(row["reuse_chunk"])
62
+ if not 0 <= chunk < num_chunks:
63
+ raise ValueError(f"unexpected reuse_chunk={chunk} in {csv_path}")
64
+ for metric in METRICS:
65
+ values[chunk][metric].append(float(row[metric]))
66
+
67
+ result: dict[str, list[float]] = {}
68
+ for metric in METRICS:
69
+ means = []
70
+ for chunk in range(num_chunks):
71
+ prompt_values = values[chunk][metric]
72
+ if len(prompt_values) != 10:
73
+ raise ValueError(
74
+ f"{csv_path}: chunk {chunk} has {len(prompt_values)} rows; "
75
+ "expected 10 prompts"
76
+ )
77
+ means.append(sum(prompt_values) / len(prompt_values))
78
+ result[metric] = means
79
+ return result
80
+
81
+
82
+ def parse_args() -> argparse.Namespace:
83
+ parser = argparse.ArgumentParser(description=__doc__)
84
+ parser.add_argument(
85
+ "--output",
86
+ type=Path,
87
+ default=Path(
88
+ "/data3/chenzhuo/workspace/Self-Forcing/outputs/plots/"
89
+ "frrf_14chunks_metrics_10prompt_mean.png"
90
+ ),
91
+ help="PNG output path (a PDF with the same stem is written too).",
92
+ )
93
+ parser.add_argument("--num-chunks", type=int, default=14)
94
+ return parser.parse_args()
95
+
96
+
97
+ def main() -> None:
98
+ args = parse_args()
99
+ data = {
100
+ label: read_prompt_means(root, args.num_chunks)
101
+ for label, root in DEFAULT_ROOTS.items()
102
+ }
103
+
104
+ plt.rcParams.update(
105
+ {
106
+ "font.size": 11,
107
+ "axes.labelsize": 12,
108
+ "axes.titlesize": 13,
109
+ "legend.fontsize": 10.5,
110
+ "xtick.labelsize": 10,
111
+ "ytick.labelsize": 10,
112
+ "savefig.bbox": "tight",
113
+ }
114
+ )
115
+ fig, axes = plt.subplots(1, 3, figsize=(15.2, 4.7), sharex=True)
116
+ chunks = list(range(args.num_chunks))
117
+
118
+ for axis, metric in zip(axes, METRICS):
119
+ for label, values in data.items():
120
+ axis.plot(
121
+ chunks,
122
+ values[metric],
123
+ color=COLORS[label],
124
+ marker="o",
125
+ markersize=4.5,
126
+ linewidth=2.0,
127
+ label=label,
128
+ )
129
+ axis.set_title(metric.upper())
130
+ axis.set_xlabel("Reuse chunk")
131
+ axis.set_ylabel(Y_LABELS[metric])
132
+ axis.set_xticks(chunks)
133
+ axis.grid(True, linestyle="--", linewidth=0.7, alpha=0.35)
134
+ axis.set_axisbelow(True)
135
+ axis.spines["top"].set_visible(False)
136
+ axis.spines["right"].set_visible(False)
137
+
138
+ # One shared legend for all three panels.
139
+ handles, labels = axes[0].get_legend_handles_labels()
140
+ fig.legend(
141
+ handles,
142
+ labels,
143
+ loc="upper center",
144
+ bbox_to_anchor=(0.5, 0.995),
145
+ ncol=3,
146
+ frameon=False,
147
+ )
148
+ fig.suptitle(
149
+ "FRRF reuse-chunk error",
150
+ y=1.045,
151
+ fontsize=14,
152
+ fontweight="semibold",
153
+ )
154
+ fig.tight_layout(rect=(0, 0, 1, 1.0), w_pad=2.0)
155
+
156
+ args.output.parent.mkdir(parents=True, exist_ok=True)
157
+ fig.savefig(args.output, dpi=300)
158
+ fig.savefig(args.output.with_suffix(".pdf"))
159
+ print(f"saved {args.output}")
160
+ print(f"saved {args.output.with_suffix('.pdf')}")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
scripts/plot_self_causal_evidence.py ADDED
@@ -0,0 +1,776 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create compact publication-style figures for the Self/Causal evidence chain."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import colorsys
8
+ import csv
9
+ from pathlib import Path
10
+
11
+ import matplotlib
12
+
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ from matplotlib.lines import Line2D
17
+ from matplotlib.patches import Patch
18
+
19
+
20
+ SELF = "#0F766E"
21
+ CAUSAL = "#4F46E5"
22
+ INK = "#172033"
23
+ MUTED = "#64748B"
24
+ GRID = "#DCE3EC"
25
+ PALE = "#F1F5F9"
26
+ WARM = "#D97706"
27
+ MODEL_COLOR = {"self_forcing": SELF, "causal_forcing": CAUSAL}
28
+ MODEL_LABEL = {"self_forcing": "Self-Forcing", "causal_forcing": "Causal-Forcing"}
29
+ LAYERS = ("early", "middle", "late", "final")
30
+
31
+
32
+ def parse_args() -> argparse.Namespace:
33
+ parser = argparse.ArgumentParser()
34
+ parser.add_argument("--redundancy_csv", type=Path, required=True)
35
+ parser.add_argument("--motion_bins_csv", type=Path, required=True)
36
+ parser.add_argument("--motion_summary_csv", type=Path, required=True)
37
+ parser.add_argument("--native_gain_csv", type=Path, required=True)
38
+ parser.add_argument(
39
+ "--causal_late_sensitivity_csv",
40
+ type=Path,
41
+ help="Optional sensitivity summary used to replace only Causal late Ridge.",
42
+ )
43
+ parser.add_argument("--sensitivity_scenario", default="exclude_two_folds")
44
+ parser.add_argument("--aligned_probe_csv", type=Path, required=True)
45
+ parser.add_argument(
46
+ "--unified_aligned_csv",
47
+ type=Path,
48
+ help="Optional four-layer 64-D/common-mask Ridge summary used for panels D/E.",
49
+ )
50
+ parser.add_argument("--output_dir", type=Path, required=True)
51
+ return parser.parse_args()
52
+
53
+
54
+ def read_csv(path: Path) -> list[dict[str, str]]:
55
+ with path.open(newline="", encoding="utf-8") as handle:
56
+ return list(csv.DictReader(handle))
57
+
58
+
59
+ def apply_causal_late_sensitivity(
60
+ native_rows: list[dict[str, str]],
61
+ sensitivity_path: Path | None,
62
+ scenario: str,
63
+ ) -> bool:
64
+ """Replace only the Causal late Ridge aggregate with an explicit sensitivity result."""
65
+ if sensitivity_path is None:
66
+ return False
67
+ sensitivity_rows = read_csv(sensitivity_path)
68
+ selected = [row for row in sensitivity_rows if row["scenario"] == scenario]
69
+ if len(selected) != 1:
70
+ raise ValueError(f"Expected one sensitivity row for {scenario!r}, found {len(selected)}")
71
+ source = selected[0]
72
+ targets = [
73
+ row
74
+ for row in native_rows
75
+ if row["method"] == "linear"
76
+ and row["model_family"] == "causal_forcing"
77
+ and row["layer_role"] == "late"
78
+ and row["probe"] == "fusion_same"
79
+ ]
80
+ if len(targets) != 1:
81
+ raise ValueError(f"Expected one Causal late Ridge row, found {len(targets)}")
82
+ target = targets[0]
83
+ for key in ("gain_mean", "gain_ci95_low", "gain_ci95_high", "wins", "signflip_p"):
84
+ target[key] = source[key]
85
+ target["prompt_count"] = source["prompt_count"]
86
+ return True
87
+
88
+
89
+ def lighten(color: str, amount: float = 0.45) -> str:
90
+ r, g, b = matplotlib.colors.to_rgb(color)
91
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
92
+ return matplotlib.colors.to_hex(colorsys.hls_to_rgb(h, 1 - amount * (1 - l), s * 0.85))
93
+
94
+
95
+ def setup_style() -> None:
96
+ plt.rcParams.update({
97
+ "font.family": "DejaVu Sans",
98
+ "font.size": 9,
99
+ "axes.titlesize": 10.5,
100
+ "axes.labelsize": 9,
101
+ "axes.titleweight": "semibold",
102
+ "axes.labelcolor": INK,
103
+ "axes.edgecolor": GRID,
104
+ "axes.linewidth": 0.8,
105
+ "xtick.color": MUTED,
106
+ "ytick.color": MUTED,
107
+ "xtick.labelsize": 8.5,
108
+ "ytick.labelsize": 8.5,
109
+ "grid.color": GRID,
110
+ "grid.linewidth": 0.7,
111
+ "grid.alpha": 0.65,
112
+ "legend.fontsize": 7.7,
113
+ "legend.frameon": False,
114
+ "figure.facecolor": "white",
115
+ "axes.facecolor": "white",
116
+ "savefig.facecolor": "white",
117
+ "savefig.bbox": "tight",
118
+ })
119
+
120
+
121
+ def polish(ax: plt.Axes, grid_axis: str = "y") -> None:
122
+ ax.spines["top"].set_visible(False)
123
+ ax.spines["right"].set_visible(False)
124
+ ax.grid(axis=grid_axis, zorder=0)
125
+ ax.tick_params(length=0)
126
+
127
+
128
+ def panel_label(ax: plt.Axes, label: str) -> None:
129
+ ax.text(
130
+ -0.13,
131
+ 1.08,
132
+ label,
133
+ transform=ax.transAxes,
134
+ fontsize=12,
135
+ fontweight="bold",
136
+ color=INK,
137
+ va="top",
138
+ )
139
+
140
+
141
+ def lookup_rows(rows: list[dict[str, str]], keys: tuple[str, ...]) -> dict[tuple[str, ...], dict[str, str]]:
142
+ return {tuple(row[key] for key in keys): row for row in rows}
143
+
144
+
145
+ def plot_redundancy(
146
+ ax: plt.Axes,
147
+ rows: list[dict[str, str]],
148
+ family: str,
149
+ variant_50: str,
150
+ panel: str,
151
+ ) -> None:
152
+ table = lookup_rows(rows, ("model_family", "model_variant", "layer_role", "comparison"))
153
+ x = np.arange(len(LAYERS))
154
+ color = MODEL_COLOR[family]
155
+ series = [
156
+ (variant_50, "within_adjacent", "50-step · denoising", "#334155", "-", "o"),
157
+ (variant_50, "cross_boundary_to_all", "50-step · boundary", "#94A3B8", "--", "D"),
158
+ ("dmd4", "within_adjacent", "4-step · denoising", color, "-", "o"),
159
+ ("dmd4", "cross_boundary_to_all", "4-step · boundary", lighten(color), "--", "D"),
160
+ ]
161
+ for variant, comparison, label, line_color, linestyle, marker in series:
162
+ values, lows, highs = [], [], []
163
+ for role in LAYERS:
164
+ row = table[(family, variant, role, comparison)]
165
+ value = float(row["token_cosine_mean"])
166
+ values.append(value)
167
+ lows.append(value - float(row["ci95_low"]))
168
+ highs.append(float(row["ci95_high"]) - value)
169
+ ax.errorbar(
170
+ x,
171
+ values,
172
+ yerr=np.asarray([lows, highs]),
173
+ color=line_color,
174
+ linestyle=linestyle,
175
+ linewidth=1.65,
176
+ marker=marker,
177
+ markersize=4.3,
178
+ markeredgewidth=0.7,
179
+ capsize=2,
180
+ label=label,
181
+ zorder=3,
182
+ )
183
+ ax.axvspan(-0.35, 2.35, color=PALE, alpha=0.58, zorder=-2)
184
+ ax.set_xticks(x, [value.title() for value in LAYERS])
185
+ ax.set_ylim(0.62, 1.015)
186
+ ax.set_yticks([0.65, 0.75, 0.85, 0.95, 1.00])
187
+ ax.set_ylabel("Token cosine")
188
+ ax.set_title(MODEL_LABEL[family], loc="left", pad=8)
189
+ polish(ax)
190
+ panel_label(ax, panel)
191
+
192
+
193
+ def plot_motion(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
194
+ table = lookup_rows(rows, ("model", "action", "motion_bin"))
195
+ bins = ("low", "medium", "high")
196
+ x = np.arange(3)
197
+ for family in ("self_forcing", "causal_forcing"):
198
+ color = MODEL_COLOR[family]
199
+ raw = [float(table[(family, "none", motion)]["raw_cosine"]) for motion in bins]
200
+ flow = [float(table[(family, "none", motion)]["flow_aligned_cosine"]) for motion in bins]
201
+ ax.plot(x, raw, color=color, linewidth=1.8, marker="o", markersize=4.5, zorder=3)
202
+ ax.plot(x, flow, color=color, linewidth=1.45, linestyle="--", marker="^", markersize=4.5, zorder=3)
203
+ ax.fill_between(x, raw, flow, color=color, alpha=0.09, zorder=1)
204
+ ax.annotate(
205
+ f"+{100 * (flow[-1] - raw[-1]):.2f} pts",
206
+ (x[-1], flow[-1]),
207
+ xytext=(5, 3),
208
+ textcoords="offset points",
209
+ color=color,
210
+ fontsize=7.3,
211
+ fontweight="semibold",
212
+ )
213
+ ax.set_xticks(x, ["Low", "Medium", "High"])
214
+ ax.set_ylim(0.875, 0.942)
215
+ ax.set_ylabel("Boundary cosine")
216
+ ax.set_title("Motion exposes spatial mismatch", loc="left", pad=8)
217
+ handles = [
218
+ Line2D([], [], color=SELF, marker="o", label="Self · raw"),
219
+ Line2D([], [], color=SELF, marker="^", linestyle="--", label="Self · flow"),
220
+ Line2D([], [], color=CAUSAL, marker="o", label="Causal · raw"),
221
+ Line2D([], [], color=CAUSAL, marker="^", linestyle="--", label="Causal · flow"),
222
+ ]
223
+ ax.legend(handles=handles, ncol=2, loc="lower left", columnspacing=0.8, handlelength=2.0)
224
+ polish(ax)
225
+ panel_label(ax, panel)
226
+
227
+
228
+ def plot_native_gain(
229
+ ax: plt.Axes,
230
+ rows: list[dict[str, str]],
231
+ panel: str,
232
+ sensitivity_applied: bool = False,
233
+ ) -> None:
234
+ table = lookup_rows(rows, ("method", "model_family", "layer_role", "probe"))
235
+ x = np.arange(len(LAYERS))
236
+ offsets = {"self_forcing": -0.11, "causal_forcing": 0.11}
237
+ for family in ("self_forcing", "causal_forcing"):
238
+ color = MODEL_COLOR[family]
239
+ for method, probe, marker, filled in (
240
+ ("linear", "fusion_same", "o", True),
241
+ ("nonlinear", "both_correct", "D", False),
242
+ ):
243
+ values, lows, highs = [], [], []
244
+ for role in LAYERS:
245
+ row = table[(method, family, role, probe)]
246
+ value = 100 * float(row["gain_mean"])
247
+ values.append(value)
248
+ lows.append(value - 100 * float(row["gain_ci95_low"]))
249
+ highs.append(100 * float(row["gain_ci95_high"]) - value)
250
+ ax.errorbar(
251
+ x + offsets[family],
252
+ values,
253
+ yerr=np.asarray([lows, highs]),
254
+ linestyle="none",
255
+ marker=marker,
256
+ markersize=5.2 if method == "linear" else 4.6,
257
+ markerfacecolor=color if filled else "white",
258
+ markeredgecolor=color,
259
+ markeredgewidth=1.25,
260
+ ecolor=color,
261
+ elinewidth=1.1,
262
+ capsize=2.5,
263
+ zorder=4,
264
+ )
265
+ ax.axhline(0, color=INK, linewidth=0.8, zorder=1)
266
+ ax.set_xticks(x, [value.title() for value in LAYERS])
267
+ ax.set_ylabel("Held-out MSE reduction (%)")
268
+ ax.set_ylim(-2.8, 5.8)
269
+ title = "Native previous-boundary adds predictive value"
270
+ if sensitivity_applied:
271
+ title += "†"
272
+ ax.set_title(title, loc="left", pad=8)
273
+ handles = [
274
+ Line2D([], [], color=SELF, marker="o", linestyle="none", label="Self · Ridge"),
275
+ Line2D([], [], color=SELF, marker="D", markerfacecolor="white", linestyle="none", label="Self · MLP"),
276
+ Line2D([], [], color=CAUSAL, marker="o", linestyle="none", label="Causal · Ridge"),
277
+ Line2D([], [], color=CAUSAL, marker="D", markerfacecolor="white", linestyle="none", label="Causal · MLP"),
278
+ ]
279
+ ax.legend(handles=handles, ncol=2, loc="lower left", columnspacing=1.0)
280
+ ax.text(
281
+ 0.995,
282
+ 0.03,
283
+ "95% prompt-bootstrap CI",
284
+ transform=ax.transAxes,
285
+ ha="right",
286
+ color=MUTED,
287
+ fontsize=7.3,
288
+ )
289
+ polish(ax)
290
+ panel_label(ax, panel)
291
+
292
+
293
+ def plot_aligned_predictor(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
294
+ table = lookup_rows(rows, ("model", "probe"))
295
+ x = np.arange(2)
296
+ width = 0.29
297
+ for index, family in enumerate(("self_forcing", "causal_forcing")):
298
+ color = MODEL_COLOR[family]
299
+ for offset, probe, label, shade in (
300
+ (-width / 2, "both_raw", "Raw boundary", lighten(color, 0.28)),
301
+ (width / 2, "both_flow", "Flow-aligned", color),
302
+ ):
303
+ row = table[(family, probe)]
304
+ value = 100 * float(row["mse_gain_vs_step_mean"])
305
+ low = value - 100 * float(row["mse_gain_vs_step_ci95_low"])
306
+ high = 100 * float(row["mse_gain_vs_step_ci95_high"]) - value
307
+ ax.bar(
308
+ x[index] + offset,
309
+ value,
310
+ width=width,
311
+ color=shade,
312
+ edgecolor=color if probe == "both_raw" else "white",
313
+ linewidth=0.9,
314
+ hatch="///" if probe == "both_raw" else None,
315
+ zorder=3,
316
+ )
317
+ ax.errorbar(
318
+ x[index] + offset,
319
+ value,
320
+ yerr=np.asarray([[low], [high]]),
321
+ fmt="none",
322
+ ecolor=INK,
323
+ elinewidth=0.9,
324
+ capsize=2.2,
325
+ zorder=4,
326
+ )
327
+ flow = table[(family, "both_flow")]
328
+ delta = 100 * float(flow["mse_gain_vs_raw_mean"])
329
+ ymax = max(
330
+ 100 * float(table[(family, "both_raw")]["mse_gain_vs_step_ci95_high"]),
331
+ 100 * float(flow["mse_gain_vs_step_ci95_high"]),
332
+ )
333
+ ax.text(x[index], ymax + 0.32, f"+{delta:.2f} pp", ha="center", color=color, fontsize=8, fontweight="semibold")
334
+ ax.axhline(0, color=INK, linewidth=0.8)
335
+ ax.set_xticks(x, ["Self", "Causal"])
336
+ ax.set_ylim(0, 9.3)
337
+ ax.set_ylabel("MSE reduction vs step-only (%)")
338
+ ax.set_title("Alignment further improves prediction", loc="left", pad=8)
339
+ ax.legend(
340
+ handles=[
341
+ Patch(facecolor="white", edgecolor=MUTED, hatch="///", label="Raw boundary"),
342
+ Patch(facecolor="#334155", edgecolor="white", label="Flow-aligned"),
343
+ ],
344
+ loc="lower right",
345
+ )
346
+ polish(ax)
347
+ panel_label(ax, panel)
348
+
349
+
350
+ def plot_unified_raw_gain(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
351
+ table = lookup_rows(rows, ("model", "layer_role", "probe"))
352
+ x = np.arange(len(LAYERS))
353
+ offsets = {"self_forcing": -0.08, "causal_forcing": 0.08}
354
+ for family in ("self_forcing", "causal_forcing"):
355
+ values, lows, highs = [], [], []
356
+ for role in LAYERS:
357
+ row = table[(family, role, "both_raw")]
358
+ value = 100 * float(row["mse_gain_vs_step_mean"])
359
+ values.append(value)
360
+ lows.append(value - 100 * float(row["mse_gain_vs_step_ci95_low"]))
361
+ highs.append(100 * float(row["mse_gain_vs_step_ci95_high"]) - value)
362
+ ax.errorbar(
363
+ x + offsets[family],
364
+ values,
365
+ yerr=np.asarray([lows, highs]),
366
+ color=MODEL_COLOR[family],
367
+ linestyle="none",
368
+ marker="o",
369
+ markeredgecolor="white",
370
+ markeredgewidth=0.7,
371
+ markersize=5.3,
372
+ elinewidth=1.1,
373
+ capsize=2.5,
374
+ label=MODEL_LABEL[family],
375
+ zorder=4,
376
+ )
377
+ ax.axhline(0, color=INK, linewidth=0.8)
378
+ ax.set_xticks(x, [value.title() for value in LAYERS])
379
+ ax.set_ylim(0, 7.25)
380
+ ax.set_ylabel("MSE reduction vs step-only (%)")
381
+ ax.set_title("Raw previous-boundary adds predictive value", loc="left", pad=8)
382
+ ax.legend(ncol=2, loc="upper left", columnspacing=1.0)
383
+ ax.text(
384
+ 0.995,
385
+ 0.03,
386
+ "95% prompt-bootstrap CI",
387
+ transform=ax.transAxes,
388
+ ha="right",
389
+ color=MUTED,
390
+ fontsize=7.3,
391
+ )
392
+ polish(ax)
393
+ panel_label(ax, panel)
394
+
395
+
396
+ def plot_unified_alignment_gain(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
397
+ table = lookup_rows(rows, ("model", "layer_role", "probe"))
398
+ x = np.arange(len(LAYERS))
399
+ for family in ("self_forcing", "causal_forcing"):
400
+ values, lows, highs = [], [], []
401
+ for role in LAYERS:
402
+ row = table[(family, role, "both_flow")]
403
+ value = 100 * float(row["mse_gain_vs_raw_mean"])
404
+ values.append(value)
405
+ lows.append(value - 100 * float(row["mse_gain_vs_raw_ci95_low"]))
406
+ highs.append(100 * float(row["mse_gain_vs_raw_ci95_high"]) - value)
407
+ ax.errorbar(
408
+ x,
409
+ values,
410
+ yerr=np.asarray([lows, highs]),
411
+ color=MODEL_COLOR[family],
412
+ linewidth=1.55,
413
+ marker="o" if family == "self_forcing" else "D",
414
+ markersize=4.7,
415
+ markeredgecolor="white",
416
+ markeredgewidth=0.6,
417
+ capsize=2.2,
418
+ label=MODEL_LABEL[family],
419
+ zorder=4,
420
+ )
421
+ ax.axhline(0, color=INK, linewidth=0.8)
422
+ ax.set_xticks(x, ["Early", "Mid", "Late", "Final"])
423
+ ax.set_ylim(0, 3.0)
424
+ ax.set_ylabel("Additional MSE reduction (%)")
425
+ ax.set_title("Flow alignment adds beyond raw", loc="left", pad=8)
426
+ ax.legend(loc="upper left")
427
+ polish(ax)
428
+ panel_label(ax, panel)
429
+
430
+
431
+ def plot_unified_combined_gain(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
432
+ """Grouped raw/flow bars under one directly comparable y-axis."""
433
+ table = lookup_rows(rows, ("model", "layer_role", "probe"))
434
+ positions = np.asarray([0, 1, 2, 3, 5, 6, 7, 8], dtype=float)
435
+ groups = [
436
+ (family, role)
437
+ for family in ("self_forcing", "causal_forcing")
438
+ for role in LAYERS
439
+ ]
440
+ width = 0.34
441
+ ax.axvspan(-0.55, 3.55, color=SELF, alpha=0.035, zorder=-3)
442
+ ax.axvspan(4.45, 8.55, color=CAUSAL, alpha=0.035, zorder=-3)
443
+ for index, (family, role) in enumerate(groups):
444
+ color = MODEL_COLOR[family]
445
+ raw = table[(family, role, "both_raw")]
446
+ flow = table[(family, role, "both_flow")]
447
+ raw_value = 100 * float(raw["mse_gain_vs_step_mean"])
448
+ flow_value = 100 * float(flow["mse_gain_vs_step_mean"])
449
+ raw_error = np.asarray([[
450
+ raw_value - 100 * float(raw["mse_gain_vs_step_ci95_low"])
451
+ ], [
452
+ 100 * float(raw["mse_gain_vs_step_ci95_high"]) - raw_value
453
+ ]])
454
+ flow_error = np.asarray([[
455
+ flow_value - 100 * float(flow["mse_gain_vs_step_ci95_low"])
456
+ ], [
457
+ 100 * float(flow["mse_gain_vs_step_ci95_high"]) - flow_value
458
+ ]])
459
+ x = positions[index]
460
+ ax.bar(
461
+ x - width / 2,
462
+ raw_value,
463
+ width=width,
464
+ facecolor=lighten(color, 0.27),
465
+ edgecolor=color,
466
+ linewidth=0.9,
467
+ hatch="///",
468
+ zorder=3,
469
+ )
470
+ ax.bar(
471
+ x + width / 2,
472
+ flow_value,
473
+ width=width,
474
+ color=color,
475
+ edgecolor="white",
476
+ linewidth=0.7,
477
+ zorder=3,
478
+ )
479
+ ax.errorbar(
480
+ x - width / 2,
481
+ raw_value,
482
+ yerr=raw_error,
483
+ fmt="none",
484
+ ecolor=INK,
485
+ elinewidth=0.85,
486
+ capsize=2.1,
487
+ zorder=4,
488
+ )
489
+ ax.errorbar(
490
+ x + width / 2,
491
+ flow_value,
492
+ yerr=flow_error,
493
+ fmt="none",
494
+ ecolor=INK,
495
+ elinewidth=0.85,
496
+ capsize=2.1,
497
+ zorder=4,
498
+ )
499
+ delta = 100 * float(flow["mse_gain_vs_raw_mean"])
500
+ annotation_y = 100 * float(flow["mse_gain_vs_step_ci95_high"]) + 0.18
501
+ ax.text(
502
+ x + width / 2,
503
+ annotation_y,
504
+ f"+{delta:.2f}%",
505
+ ha="center",
506
+ va="bottom",
507
+ color=color,
508
+ fontsize=7.0,
509
+ fontweight="semibold",
510
+ )
511
+ ax.axhline(0, color=INK, linewidth=0.8)
512
+ ax.axvline(4.0, color=GRID, linewidth=0.9)
513
+ ax.set_xticks(
514
+ positions,
515
+ [
516
+ "Early\nSelf", "Middle\nSelf", "Late\nSelf", "Final\nSelf",
517
+ "Early\nCausal", "Middle\nCausal", "Late\nCausal", "Final\nCausal",
518
+ ],
519
+ )
520
+ ax.set_xlim(-0.65, 8.65)
521
+ ax.set_ylim(0, 10.5)
522
+ ax.set_ylabel("MSE reduction vs step-only (%)")
523
+ ax.set_title("Raw and flow-aligned boundaries add predictive value", loc="left", pad=8)
524
+ ax.legend(
525
+ handles=[
526
+ Patch(facecolor=lighten(SELF, 0.27), edgecolor=SELF, hatch="///", label="Self · raw"),
527
+ Patch(facecolor=SELF, edgecolor="white", label="Self · flow-aligned"),
528
+ Patch(facecolor=lighten(CAUSAL, 0.27), edgecolor=CAUSAL, hatch="///", label="Causal · raw"),
529
+ Patch(facecolor=CAUSAL, edgecolor="white", label="Causal · flow-aligned"),
530
+ ],
531
+ ncol=4,
532
+ loc="upper left",
533
+ columnspacing=1.0,
534
+ handlelength=1.6,
535
+ )
536
+ polish(ax)
537
+ panel_label(ax, panel)
538
+
539
+
540
+ def main_figure(args: argparse.Namespace, output: Path) -> None:
541
+ redundancy = read_csv(args.redundancy_csv)
542
+ motion = read_csv(args.motion_bins_csv)
543
+ native = read_csv(args.native_gain_csv)
544
+ sensitivity_applied = apply_causal_late_sensitivity(
545
+ native,
546
+ args.causal_late_sensitivity_csv,
547
+ args.sensitivity_scenario,
548
+ )
549
+ aligned = read_csv(args.aligned_probe_csv)
550
+ unified = read_csv(args.unified_aligned_csv) if args.unified_aligned_csv else None
551
+ fig = plt.figure(figsize=(13.2, 7.35), constrained_layout=True)
552
+ grid = fig.add_gridspec(2, 3, height_ratios=(1.0, 1.07), width_ratios=(1, 1, 1.03))
553
+ ax_a = fig.add_subplot(grid[0, 0])
554
+ ax_b = fig.add_subplot(grid[0, 1], sharey=ax_a)
555
+ ax_c = fig.add_subplot(grid[0, 2])
556
+ if unified is None:
557
+ ax_d = fig.add_subplot(grid[1, :2])
558
+ ax_e = fig.add_subplot(grid[1, 2])
559
+ else:
560
+ ax_d = fig.add_subplot(grid[1, :])
561
+ plot_redundancy(ax_a, redundancy, "self_forcing", "wan14b50", "A")
562
+ plot_redundancy(ax_b, redundancy, "causal_forcing", "ar50", "B")
563
+ ax_b.set_ylabel("")
564
+ ax_b.tick_params(labelleft=False)
565
+ ax_a.legend(
566
+ ncol=1,
567
+ loc="center left",
568
+ bbox_to_anchor=(0.015, 0.50),
569
+ handlelength=2.0,
570
+ labelspacing=0.35,
571
+ frameon=True,
572
+ facecolor="white",
573
+ edgecolor="none",
574
+ framealpha=0.88,
575
+ )
576
+ plot_motion(ax_c, motion, "C")
577
+ if unified is None:
578
+ plot_native_gain(ax_d, native, "D", sensitivity_applied=sensitivity_applied)
579
+ plot_aligned_predictor(ax_e, aligned, "E")
580
+ else:
581
+ plot_unified_combined_gain(ax_d, unified, "D")
582
+ fig.suptitle(
583
+ "Previous-chunk boundaries retain useful information—but spatial alignment matters",
584
+ x=0.012,
585
+ ha="left",
586
+ fontsize=14,
587
+ fontweight="bold",
588
+ color=INK,
589
+ )
590
+ footer = (
591
+ "10 prompts; prompt-first aggregation. Error bars show 95% prompt-bootstrap CI where available. "
592
+ "Boundary = previous chunk's final temporal slot broadcast to all current slots."
593
+ )
594
+ if unified is not None:
595
+ footer += " D: 64-D full grid/common mask; bars vs step-only; labels = flow gain over raw; 9-train/1-test; no exclusions."
596
+ elif sensitivity_applied:
597
+ footer += " † Causal late Ridge excludes prompt 6/step 1 and prompt 9/step 2 only."
598
+ fig.text(
599
+ 0.012,
600
+ -0.012,
601
+ footer,
602
+ ha="left",
603
+ fontsize=7.8,
604
+ color=MUTED,
605
+ )
606
+ fig.savefig(output / "self_causal_evidence_overview.png", dpi=240)
607
+ fig.savefig(output / "self_causal_evidence_overview.pdf")
608
+ plt.close(fig)
609
+
610
+
611
+ def plot_alignment_controls(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
612
+ table = lookup_rows(rows, ("model", "action"))
613
+ controls = [
614
+ ("global_gain", "Global shift"),
615
+ ("negated_gain", "Negated flow"),
616
+ ("shuffled_gain", "Shuffled flow"),
617
+ ("flow_gain", "Correct flow"),
618
+ ]
619
+ y = np.arange(len(controls))
620
+ ax.axhspan(2.55, 3.45, color="#ECFDF5", zorder=-2)
621
+ values_by_family = {}
622
+ for family, offset in (("self_forcing", -0.09), ("causal_forcing", 0.09)):
623
+ row = table[(family, "none")]
624
+ values = [100 * float(row[key]) for key, _ in controls]
625
+ values_by_family[family] = values
626
+ ax.scatter(values, y + offset, s=30, color=MODEL_COLOR[family], edgecolor="white", linewidth=0.6, zorder=3, label=MODEL_LABEL[family])
627
+ for index in range(len(controls)):
628
+ ax.plot(
629
+ [values_by_family["self_forcing"][index], values_by_family["causal_forcing"][index]],
630
+ [y[index] - 0.09, y[index] + 0.09],
631
+ color="#AAB7C8",
632
+ linewidth=1.0,
633
+ zorder=1,
634
+ )
635
+ ax.set_yticks(y, [label for _, label in controls])
636
+ ax.set_xlabel("Cosine recovery over raw (×100)")
637
+ ax.set_xlim(0, 1.82)
638
+ ax.set_title("Interpolation controls", loc="left", pad=8)
639
+ ax.legend(loc="upper left")
640
+ polish(ax, "x")
641
+ panel_label(ax, panel)
642
+
643
+
644
+ def plot_predictor_controls(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
645
+ table = lookup_rows(rows, ("method", "model_family", "layer_role", "probe"))
646
+ controls = [
647
+ ("fusion_same", "Correct boundary"),
648
+ ("fusion_wrong_step", "Wrong timestep"),
649
+ ("fusion_distant", "Distant boundary"),
650
+ ("fusion_token_shuffle", "Spatial shuffle"),
651
+ ("fusion_batch_shuffle", "Other video"),
652
+ ("fusion_zero", "Zero"),
653
+ ("fusion_noise", "Matched noise"),
654
+ ]
655
+ y = np.arange(len(controls))
656
+ ax.axhspan(-0.43, 0.43, color="#ECFDF5", zorder=-2)
657
+ for family, offset in (("self_forcing", -0.10), ("causal_forcing", 0.10)):
658
+ values, lows, highs = [], [], []
659
+ for probe, _ in controls:
660
+ row = table[("linear", family, "final", probe)]
661
+ value = 100 * float(row["gain_mean"])
662
+ values.append(value)
663
+ lows.append(value - 100 * float(row["gain_ci95_low"]))
664
+ highs.append(100 * float(row["gain_ci95_high"]) - value)
665
+ ax.errorbar(
666
+ values,
667
+ y + offset,
668
+ xerr=np.asarray([lows, highs]),
669
+ fmt="o",
670
+ color=MODEL_COLOR[family],
671
+ markeredgecolor="white",
672
+ markeredgewidth=0.6,
673
+ markersize=5.0,
674
+ elinewidth=1.0,
675
+ capsize=2,
676
+ label=MODEL_LABEL[family],
677
+ zorder=3,
678
+ )
679
+ ax.axvline(0, color=INK, linewidth=0.8)
680
+ ax.set_yticks(y, [label for _, label in controls])
681
+ ax.set_xlabel("Final-layer held-out MSE reduction (%)")
682
+ ax.set_xlim(-0.45, 4.5)
683
+ ax.set_title("Predictor controls", loc="left", pad=8)
684
+ ax.legend(loc="lower right")
685
+ ax.invert_yaxis()
686
+ polish(ax, "x")
687
+ panel_label(ax, panel)
688
+
689
+
690
+ def plot_unified_predictor_controls(ax: plt.Axes, rows: list[dict[str, str]], panel: str) -> None:
691
+ table = lookup_rows(rows, ("model", "layer_role", "probe"))
692
+ controls = [
693
+ ("both_raw", "Raw boundary"),
694
+ ("both_global", "Global shift"),
695
+ ("both_negated_flow", "Negated flow"),
696
+ ("both_shuffled_flow", "Shuffled flow"),
697
+ ("both_flow", "Correct flow"),
698
+ ]
699
+ y = np.arange(len(controls))
700
+ ax.axhspan(3.57, 4.43, color="#ECFDF5", zorder=-2)
701
+ for family, offset in (("self_forcing", -0.10), ("causal_forcing", 0.10)):
702
+ values, lows, highs = [], [], []
703
+ for probe, _ in controls:
704
+ row = table[(family, "final", probe)]
705
+ value = 100 * float(row["mse_gain_vs_step_mean"])
706
+ values.append(value)
707
+ lows.append(value - 100 * float(row["mse_gain_vs_step_ci95_low"]))
708
+ highs.append(100 * float(row["mse_gain_vs_step_ci95_high"]) - value)
709
+ ax.errorbar(
710
+ values,
711
+ y + offset,
712
+ xerr=np.asarray([lows, highs]),
713
+ fmt="o",
714
+ color=MODEL_COLOR[family],
715
+ markeredgecolor="white",
716
+ markeredgewidth=0.6,
717
+ markersize=5.0,
718
+ elinewidth=1.0,
719
+ capsize=2,
720
+ label=MODEL_LABEL[family],
721
+ zorder=3,
722
+ )
723
+ ax.axvline(0, color=INK, linewidth=0.8)
724
+ ax.set_yticks(y, [label for _, label in controls])
725
+ ax.set_xlabel("Final-layer MSE reduction vs step-only (%)")
726
+ ax.set_xlim(0, 9.5)
727
+ ax.set_title("Unified predictor controls", loc="left", pad=8)
728
+ ax.legend(loc="upper left")
729
+ ax.invert_yaxis()
730
+ polish(ax, "x")
731
+ panel_label(ax, panel)
732
+
733
+
734
+ def controls_figure(args: argparse.Namespace, output: Path) -> None:
735
+ motion = read_csv(args.motion_summary_csv)
736
+ native = read_csv(args.native_gain_csv)
737
+ unified = read_csv(args.unified_aligned_csv) if args.unified_aligned_csv else None
738
+ fig, axes = plt.subplots(1, 2, figsize=(11.4, 4.25), constrained_layout=True)
739
+ plot_alignment_controls(axes[0], motion, "A")
740
+ if unified is None:
741
+ plot_predictor_controls(axes[1], native, "B")
742
+ else:
743
+ plot_unified_predictor_controls(axes[1], unified, "B")
744
+ fig.suptitle(
745
+ "Control experiments isolate correct spatial correspondence",
746
+ x=0.012,
747
+ ha="left",
748
+ fontsize=13,
749
+ fontweight="bold",
750
+ color=INK,
751
+ )
752
+ fig.text(
753
+ 0.012,
754
+ -0.02,
755
+ "Green bands mark correct-flow conditions. Predictor error bars: 95% prompt-bootstrap CI.",
756
+ ha="left",
757
+ fontsize=7.8,
758
+ color=MUTED,
759
+ )
760
+ fig.savefig(output / "self_causal_control_checks.png", dpi=240)
761
+ fig.savefig(output / "self_causal_control_checks.pdf")
762
+ plt.close(fig)
763
+
764
+
765
+ def main() -> None:
766
+ args = parse_args()
767
+ setup_style()
768
+ output = args.output_dir.resolve()
769
+ output.mkdir(parents=True, exist_ok=True)
770
+ main_figure(args, output)
771
+ controls_figure(args, output)
772
+ print(f"[complete] {output}", flush=True)
773
+
774
+
775
+ if __name__ == "__main__":
776
+ main()
scripts/prepare_confidence_stage1_vbench.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prepare the representative Stage-1 confidence-gating VBench conditions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+
12
+ DIMENSIONS = [
13
+ "subject_consistency",
14
+ "background_consistency",
15
+ "motion_smoothness",
16
+ "aesthetic_quality",
17
+ "imaging_quality",
18
+ ]
19
+
20
+
21
+ def selected_name(root: Path, target: int) -> str:
22
+ value = json.loads((root / "validation/selected.json").read_text())
23
+ matches = [
24
+ row["config_name"]
25
+ for row in value["selected_dynamic"]
26
+ if int(row["target_accepts"]) == target
27
+ ]
28
+ if len(matches) != 1:
29
+ raise RuntimeError(f"Expected one target={target} selection under {root}: {matches}")
30
+ return str(matches[0])
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser(description=__doc__)
35
+ parser.add_argument("--experiment_root", type=Path, required=True)
36
+ parser.add_argument("--dataset_root", type=Path, required=True)
37
+ args = parser.parse_args()
38
+ root = args.experiment_root.resolve()
39
+ step12 = root / "dynamic_step12"
40
+ step123 = root / "dynamic_step123"
41
+ conditions = {
42
+ "ffff": step123 / "test/videos/ffff",
43
+ "fppf": step12 / "test/videos/fppf",
44
+ "step12_dynamic_k06": step12 / "test/videos" / selected_name(step12, 6),
45
+ "step12_dynamic_k08": step12 / "test/videos" / selected_name(step12, 8),
46
+ "step12_dynamic_k10": step12 / "test/videos" / selected_name(step12, 10),
47
+ "step123_dynamic_k06": step123 / "test/videos" / selected_name(step123, 6),
48
+ "step123_dynamic_k09": step123 / "test/videos" / selected_name(step123, 9),
49
+ "step123_dynamic_k12": step123 / "test/videos" / selected_name(step123, 12),
50
+ "step123_dynamic_k15": step123 / "test/videos" / selected_name(step123, 15),
51
+ }
52
+ output_root = root / "vbench/inputs"
53
+ for condition, source_dir in conditions.items():
54
+ destination = output_root / condition
55
+ destination.mkdir(parents=True, exist_ok=True)
56
+ info = []
57
+ for prompt_id in range(90, 100):
58
+ filename = f"prompt_{prompt_id:04d}.mp4"
59
+ source = (source_dir / filename).resolve()
60
+ if not source.is_file():
61
+ raise FileNotFoundError(source)
62
+ target = destination / filename
63
+ if target.exists() or target.is_symlink():
64
+ target.unlink()
65
+ target.symlink_to(os.path.relpath(source, destination))
66
+ metadata = json.loads(
67
+ (args.dataset_root / f"prompt_{prompt_id:04d}/metadata.json").read_text()
68
+ )
69
+ info.append(
70
+ {
71
+ "video_list": [filename],
72
+ "prompt_en": metadata["prompt"],
73
+ "dimension": DIMENSIONS,
74
+ }
75
+ )
76
+ (destination / "full_info.json").write_text(
77
+ json.dumps(info, indent=2) + "\n", encoding="utf-8"
78
+ )
79
+ print(f"[prepared] {condition}: {source_dir}")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
scripts/prepare_layer17_dynamic_vbench.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build VBench custom-input directories from dynamic-gate video outputs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+
12
+ DIMENSIONS = [
13
+ "subject_consistency",
14
+ "background_consistency",
15
+ "motion_smoothness",
16
+ "aesthetic_quality",
17
+ "imaging_quality",
18
+ ]
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("--dataset_root", type=Path, required=True)
24
+ parser.add_argument("--output_root", type=Path, required=True)
25
+ parser.add_argument(
26
+ "--condition",
27
+ action="append",
28
+ nargs=2,
29
+ metavar=("NAME", "VIDEO_DIR"),
30
+ required=True,
31
+ help="Condition name and directory containing prompt_XXXX.mp4 files.",
32
+ )
33
+ parser.add_argument("--prompt_start", type=int, default=90)
34
+ parser.add_argument("--prompt_end", type=int, default=100)
35
+ parser.add_argument(
36
+ "--combined_name",
37
+ default=None,
38
+ help="Also build one flattened input directory for a single shared VBench run.",
39
+ )
40
+ return parser.parse_args()
41
+
42
+
43
+ def main() -> None:
44
+ args = parse_args()
45
+ dataset_root = args.dataset_root.resolve()
46
+ output_root = args.output_root.resolve()
47
+ output_root.mkdir(parents=True, exist_ok=True)
48
+ combined_dir = output_root / args.combined_name if args.combined_name else None
49
+ combined_info = []
50
+ if combined_dir is not None:
51
+ combined_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ for name, source_text in args.condition:
54
+ source_dir = Path(source_text).resolve()
55
+ destination_dir = output_root / name
56
+ destination_dir.mkdir(parents=True, exist_ok=True)
57
+ full_info = []
58
+ for prompt_id in range(args.prompt_start, args.prompt_end):
59
+ filename = f"prompt_{prompt_id:04d}.mp4"
60
+ source = source_dir / filename
61
+ if not source.is_file():
62
+ raise FileNotFoundError(source)
63
+ destination = destination_dir / filename
64
+ if destination.is_symlink() or destination.exists():
65
+ destination.unlink()
66
+ destination.symlink_to(os.path.relpath(source, destination_dir))
67
+
68
+ metadata_path = dataset_root / f"prompt_{prompt_id:04d}" / "metadata.json"
69
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
70
+ full_info.append(
71
+ {
72
+ "video_list": [filename],
73
+ "prompt_en": metadata["prompt"],
74
+ "dimension": DIMENSIONS,
75
+ }
76
+ )
77
+ if combined_dir is not None:
78
+ combined_filename = f"{name}__{filename}"
79
+ combined_destination = combined_dir / combined_filename
80
+ if combined_destination.is_symlink() or combined_destination.exists():
81
+ combined_destination.unlink()
82
+ combined_destination.symlink_to(
83
+ os.path.relpath(source, combined_dir)
84
+ )
85
+ combined_info.append(
86
+ {
87
+ "video_list": [combined_filename],
88
+ "prompt_en": metadata["prompt"],
89
+ "dimension": DIMENSIONS,
90
+ }
91
+ )
92
+ (destination_dir / "full_info.json").write_text(
93
+ json.dumps(full_info, indent=2) + "\n", encoding="utf-8"
94
+ )
95
+ print(f"[prepared] {name}: {len(full_info)} videos -> {destination_dir}")
96
+
97
+ if combined_dir is not None:
98
+ (combined_dir / "full_info.json").write_text(
99
+ json.dumps(combined_info, indent=2) + "\n", encoding="utf-8"
100
+ )
101
+ print(
102
+ f"[prepared] combined: {len(combined_info)} videos -> {combined_dir}"
103
+ )
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
scripts/probe_confidence_token_batch_size.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Probe one real frozen-Predictor + Confidence-token-head optimizer step."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ import traceback
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def _preparse_gpu() -> str:
17
+ parser = argparse.ArgumentParser(add_help=False)
18
+ parser.add_argument("--gpu", required=True)
19
+ args, _ = parser.parse_known_args()
20
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
21
+ return str(args.gpu)
22
+
23
+
24
+ PHYSICAL_GPU = _preparse_gpu()
25
+
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from safetensors import safe_open
29
+ from safetensors.torch import load_file
30
+ from torch.optim import AdamW
31
+
32
+ REPO_ROOT = Path(__file__).resolve().parents[1]
33
+ if str(REPO_ROOT) not in sys.path:
34
+ sys.path.insert(0, str(REPO_ROOT))
35
+
36
+ from predictor_training.confidence import ConfidenceTokenHead
37
+ from predictor_training.lazy_offline_data import (
38
+ LazyLayer17Dataset,
39
+ collate_lazy_samples,
40
+ )
41
+ from predictor_training.offline_data import TOKENS_PER_CHUNK
42
+ from predictor_training.single_block import (
43
+ SingleBlockPredictor,
44
+ initialize_predictor_block,
45
+ )
46
+ from scripts.run_single_block_init_sweep import frozen_inputs, load_teacher
47
+ from scripts.train_layer17_stage1_lazy_ddp import move_training_batch
48
+ from utils.misc import set_seed
49
+
50
+
51
+ def parse_args() -> argparse.Namespace:
52
+ parser = argparse.ArgumentParser(description=__doc__)
53
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
54
+ parser.add_argument("--batch_size", type=int, required=True)
55
+ parser.add_argument("--prompt_start", type=int, default=0)
56
+ parser.add_argument("--chunk", type=int, default=6)
57
+ parser.add_argument("--target_step", type=int, default=3)
58
+ parser.add_argument(
59
+ "--dataset_root",
60
+ type=Path,
61
+ default=REPO_ROOT
62
+ / "offline_training_datasets"
63
+ / "predictor_offline_layer17_1000p_21f_seed0_no_chunk0",
64
+ )
65
+ parser.add_argument(
66
+ "--predictor_weights",
67
+ type=Path,
68
+ default=REPO_ROOT
69
+ / "training_runs"
70
+ / "layer17_atc_chunk_stage1_1000p_4gpu_b16_2000steps"
71
+ / "checkpoint_step_2000"
72
+ / "predictor.safetensors",
73
+ )
74
+ parser.add_argument(
75
+ "--predictor_input_variant",
76
+ choices=("auto", "self_forcing", "disca", "atc"),
77
+ default="auto",
78
+ )
79
+ parser.add_argument(
80
+ "--checkpoint_path",
81
+ type=Path,
82
+ default=REPO_ROOT / "checkpoints/self_forcing_dmd.pt",
83
+ )
84
+ parser.add_argument(
85
+ "--config_path",
86
+ type=Path,
87
+ default=REPO_ROOT / "configs/self_forcing_sid.yaml",
88
+ )
89
+ parser.add_argument("--output", type=Path, required=True)
90
+ parser.add_argument("--learning_rate", type=float, default=3e-4)
91
+ parser.add_argument("--weight_decay", type=float, default=0.01)
92
+ parser.add_argument("--seed", type=int, default=0)
93
+ args = parser.parse_args()
94
+ if args.batch_size < 1:
95
+ parser.error("--batch_size must be positive")
96
+ if args.prompt_start < 0 or args.prompt_start + args.batch_size > 900:
97
+ parser.error("probe prompts must stay in the training split 0..899")
98
+ if not 1 <= args.chunk <= 6:
99
+ parser.error("--chunk must be in 1..6")
100
+ if not 1 <= args.target_step <= 3:
101
+ parser.error("--target_step must be in 1..3")
102
+ for name in (
103
+ "dataset_root",
104
+ "predictor_weights",
105
+ "checkpoint_path",
106
+ "config_path",
107
+ "output",
108
+ ):
109
+ path = getattr(args, name).expanduser()
110
+ setattr(
111
+ args,
112
+ name,
113
+ path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve(),
114
+ )
115
+ return args
116
+
117
+
118
+ def atomic_json(path: Path, value: dict[str, Any]) -> None:
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+ temporary = path.with_suffix(path.suffix + ".tmp")
121
+ temporary.write_text(
122
+ json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"
123
+ )
124
+ os.replace(temporary, path)
125
+
126
+
127
+ def read_predictor_config(
128
+ path: Path, expected_input_variant: str = "auto"
129
+ ) -> dict[str, Any]:
130
+ with safe_open(path, framework="pt", device="cpu") as handle:
131
+ metadata = handle.metadata() or {}
132
+ raw = metadata.get("predictor_config")
133
+ if raw is None:
134
+ if expected_input_variant != "self_forcing":
135
+ raise ValueError(
136
+ f"Missing predictor_config metadata: {path}; legacy concat "
137
+ "checkpoints require --predictor_input_variant self_forcing"
138
+ )
139
+ return {
140
+ "source_layer": 17,
141
+ "input_variant": "self_forcing",
142
+ "gate_mode": "baseline",
143
+ "metadata_source": "explicit_legacy_concat_override",
144
+ }
145
+ config = json.loads(raw)
146
+ actual = str(config.get("input_variant", "self_forcing"))
147
+ if expected_input_variant != "auto" and actual != expected_input_variant:
148
+ raise ValueError(
149
+ f"Predictor input variant mismatch: expected={expected_input_variant} "
150
+ f"actual={actual}"
151
+ )
152
+ return config
153
+
154
+
155
+ def load_predictor(
156
+ teacher: torch.nn.Module,
157
+ weights: Path,
158
+ device: torch.device,
159
+ expected_input_variant: str = "auto",
160
+ ) -> tuple[SingleBlockPredictor, dict[str, Any]]:
161
+ config = read_predictor_config(weights, expected_input_variant)
162
+ source_layer = int(config.get("source_layer", 17))
163
+ predictor = SingleBlockPredictor(
164
+ block=initialize_predictor_block(
165
+ teacher.blocks[source_layer], "teacher_full"
166
+ ),
167
+ dim=teacher.dim,
168
+ gradient_checkpointing=False,
169
+ input_variant=str(config.get("input_variant", "self_forcing")),
170
+ atc_previous_scope=config.get("atc_previous_scope", "chunk"),
171
+ atc_freq_dim=int(config.get("atc_freq_dim", 256)),
172
+ atc_mlp_hidden_dim=int(config.get("atc_mlp_hidden_dim", 3072)),
173
+ atc_gate_hidden_dim=int(config.get("atc_gate_hidden_dim", 512)),
174
+ atc_transport_residual_scale=float(
175
+ config.get("atc_transport_residual_scale", 0.1)
176
+ ),
177
+ atc_gate_initial_probability=float(
178
+ config.get("atc_gate_initial_probability", 0.3)
179
+ ),
180
+ atc_collect_diagnostics=False,
181
+ )
182
+ predictor.load_state_dict(load_file(str(weights), device="cpu"), strict=True)
183
+ predictor.to(device=device, dtype=torch.bfloat16)
184
+ predictor.eval().requires_grad_(False)
185
+ return predictor, config
186
+
187
+
188
+ @torch.no_grad()
189
+ def extract_predictor_features(
190
+ predictor: SingleBlockPredictor,
191
+ batch: dict[str, Any],
192
+ teacher: torch.nn.Module,
193
+ device: torch.device,
194
+ ) -> tuple[torch.Tensor, torch.Tensor]:
195
+ frozen = frozen_inputs(batch, teacher, device)
196
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
197
+ output = predictor(
198
+ current_tokens=frozen["current_tokens"],
199
+ anchor_hidden=batch["anchor_hidden"],
200
+ previous_hidden=batch["previous_hidden"],
201
+ timestep_modulation=frozen["timestep_modulation"],
202
+ grid_sizes=frozen["grid_sizes"],
203
+ freqs=frozen["freqs"],
204
+ history_k=batch["history_k"],
205
+ history_v=batch["history_v"],
206
+ cross_k=batch["cross_k"],
207
+ cross_v=batch["cross_v"],
208
+ current_start=batch["chunk"] * TOKENS_PER_CHUNK,
209
+ return_features=True,
210
+ condition_tokens=frozen["condition_tokens"],
211
+ anchor_distance=batch["anchor_distance"],
212
+ )
213
+ if not isinstance(output, tuple):
214
+ raise RuntimeError("Predictor did not return (pred_hidden, transformed)")
215
+ return output
216
+
217
+
218
+ def hidden_nrmse(predicted: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
219
+ error_energy = (predicted.float() - target.float()).square().sum(dim=(1, 2))
220
+ target_energy = target.float().square().sum(dim=(1, 2))
221
+ return torch.sqrt(error_energy / target_energy.clamp_min(1e-8))
222
+
223
+
224
+ def memory_gib(value: int) -> float:
225
+ return value / 2**30
226
+
227
+
228
+ def main() -> None:
229
+ args = parse_args()
230
+ started = time.perf_counter()
231
+ result: dict[str, Any] = {
232
+ "success": False,
233
+ "physical_gpu": str(args.gpu),
234
+ "batch_size": args.batch_size,
235
+ "prompt_ids": [args.prompt_start, args.prompt_start + args.batch_size - 1],
236
+ "split": {"train": "0..899", "validation": "900..999"},
237
+ "chunk": args.chunk,
238
+ "target_step": args.target_step,
239
+ "predictor_weights": str(args.predictor_weights),
240
+ }
241
+ try:
242
+ torch.cuda.set_device(0)
243
+ device = torch.device("cuda", 0)
244
+ set_seed(args.seed)
245
+ torch.set_num_threads(4)
246
+ torch.set_num_interop_threads(1)
247
+ torch.backends.cuda.matmul.allow_tf32 = True
248
+ torch.set_float32_matmul_precision("high")
249
+
250
+ print(f"[probe] gpu={args.gpu} batch={args.batch_size} loading teacher", flush=True)
251
+ teacher = load_teacher(args.checkpoint_path, args.config_path, device)
252
+ predictor, predictor_config = load_predictor(
253
+ teacher,
254
+ args.predictor_weights,
255
+ device,
256
+ expected_input_variant=args.predictor_input_variant,
257
+ )
258
+ result["predictor_config"] = predictor_config
259
+ head = ConfidenceTokenHead(num_steps=3, dropout=0.1).to(device=device)
260
+ result["head_parameters"] = sum(
261
+ parameter.numel() for parameter in head.parameters()
262
+ )
263
+ optimizer = AdamW(
264
+ head.parameters(),
265
+ lr=args.learning_rate,
266
+ betas=(0.9, 0.95),
267
+ weight_decay=args.weight_decay,
268
+ )
269
+
270
+ print(f"[probe] gpu={args.gpu} batch={args.batch_size} loading samples", flush=True)
271
+ dataset = LazyLayer17Dataset(args.dataset_root, layer_id=17)
272
+ cpu_batch = collate_lazy_samples(
273
+ [
274
+ dataset[(prompt_id, args.chunk, args.target_step)]
275
+ for prompt_id in range(
276
+ args.prompt_start, args.prompt_start + args.batch_size
277
+ )
278
+ ]
279
+ )
280
+ torch.cuda.reset_peak_memory_stats()
281
+ batch = move_training_batch(cpu_batch, teacher, device)
282
+ del cpu_batch
283
+ torch.cuda.synchronize()
284
+ feature_started = time.perf_counter()
285
+ pred_hidden, transformed = extract_predictor_features(
286
+ predictor, batch, teacher, device
287
+ )
288
+ target_log = torch.log(
289
+ hidden_nrmse(pred_hidden, batch["target_hidden"]) + 1e-6
290
+ ).detach()
291
+ torch.cuda.synchronize()
292
+ result["predictor_forward_s"] = time.perf_counter() - feature_started
293
+
294
+ chunk_position = torch.full(
295
+ (args.batch_size,),
296
+ (args.chunk - 1) / 5.0,
297
+ dtype=torch.float32,
298
+ device=device,
299
+ )
300
+ step_id = torch.full(
301
+ (args.batch_size,),
302
+ args.target_step,
303
+ dtype=torch.long,
304
+ device=device,
305
+ )
306
+ head_started = time.perf_counter()
307
+ optimizer.zero_grad(set_to_none=True)
308
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
309
+ predicted_log = head(
310
+ transformed_hidden=transformed,
311
+ pred_hidden=pred_hidden,
312
+ anchor_hidden=batch["anchor_hidden"],
313
+ chunk_position=chunk_position,
314
+ step_id=step_id,
315
+ )
316
+ loss = F.smooth_l1_loss(predicted_log, target_log)
317
+ loss.backward()
318
+ grad_norm = torch.nn.utils.clip_grad_norm_(head.parameters(), 1.0)
319
+ optimizer.step()
320
+ torch.cuda.synchronize()
321
+
322
+ free_bytes, total_bytes = torch.cuda.mem_get_info()
323
+ result.update(
324
+ success=True,
325
+ loss=float(loss.detach()),
326
+ grad_norm=float(grad_norm),
327
+ head_step_s=time.perf_counter() - head_started,
328
+ peak_allocated_gib=memory_gib(torch.cuda.max_memory_allocated()),
329
+ peak_reserved_gib=memory_gib(torch.cuda.max_memory_reserved()),
330
+ final_free_gib=memory_gib(free_bytes),
331
+ gpu_total_gib=memory_gib(total_bytes),
332
+ elapsed_s=time.perf_counter() - started,
333
+ )
334
+ print(
335
+ f"[probe] PASS gpu={args.gpu} batch={args.batch_size} "
336
+ f"peak={result['peak_allocated_gib']:.2f}GiB "
337
+ f"reserved={result['peak_reserved_gib']:.2f}GiB",
338
+ flush=True,
339
+ )
340
+ except torch.cuda.OutOfMemoryError as error:
341
+ result.update(
342
+ error_type="CUDAOutOfMemoryError",
343
+ error=str(error),
344
+ peak_allocated_gib=memory_gib(torch.cuda.max_memory_allocated()),
345
+ peak_reserved_gib=memory_gib(torch.cuda.max_memory_reserved()),
346
+ elapsed_s=time.perf_counter() - started,
347
+ )
348
+ print(f"[probe] OOM gpu={args.gpu} batch={args.batch_size}: {error}", flush=True)
349
+ except Exception as error:
350
+ result.update(
351
+ error_type=type(error).__name__,
352
+ error=str(error),
353
+ traceback=traceback.format_exc(),
354
+ elapsed_s=time.perf_counter() - started,
355
+ )
356
+ atomic_json(args.output, result)
357
+ raise
358
+ atomic_json(args.output, result)
359
+ if not result["success"]:
360
+ raise SystemExit(2)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()
scripts/run_aligned_conditional_probe_3models.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Oracle flow-aligned conditional Ridge probes on three AR4 backbones.
3
+
4
+ The target is the current chunk/current denoising-step full-grid feature. The
5
+ first input is the current chunk/previous-step feature. The second input is
6
+ the previous chunk/same-step boundary map, either raw or warped by global,
7
+ correct, negated, or spatially shuffled target-to-source flow.
8
+
9
+ This is an oracle diagnostic because the flow is computed from the generated
10
+ current RGB frame. It tests whether alignment makes the previous-chunk route
11
+ more predictive; it is not an inference-time implementation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import csv
18
+ import json
19
+ import os
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ def preparse_gpu() -> str:
26
+ parser = argparse.ArgumentParser(add_help=False)
27
+ parser.add_argument("--gpu", default="0")
28
+ args, _ = parser.parse_known_args()
29
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
30
+ return str(args.gpu)
31
+
32
+
33
+ PHYSICAL_GPU = preparse_gpu()
34
+
35
+ import numpy as np
36
+ import torch
37
+ import torch.nn.functional as F
38
+
39
+ from analyze_fullgrid_bilinear_3models import (
40
+ GridRun,
41
+ farneback,
42
+ load_causal_runs,
43
+ load_hy_runs,
44
+ load_self_runs,
45
+ resize_flow,
46
+ shuffled_flow,
47
+ warp,
48
+ )
49
+
50
+
51
+ PROBES = (
52
+ "step_only",
53
+ "both_raw",
54
+ "both_global",
55
+ "both_flow",
56
+ "both_negated_flow",
57
+ "both_shuffled_flow",
58
+ )
59
+ LAYER_ROLES = {7: "early", 14: "middle", 22: "late", 29: "final"}
60
+
61
+
62
+ def parse_args() -> argparse.Namespace:
63
+ parser = argparse.ArgumentParser()
64
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
65
+ parser.add_argument("--self_root", type=Path, required=True)
66
+ parser.add_argument("--causal_root", type=Path, required=True)
67
+ parser.add_argument("--hy_root", type=Path)
68
+ parser.add_argument("--hy_cache_root", type=Path)
69
+ parser.add_argument("--output_root", type=Path, required=True)
70
+ parser.add_argument("--projection_dim", type=int, default=64)
71
+ parser.add_argument("--ridge", type=float, default=1e-4)
72
+ parser.add_argument("--seed", type=int, default=20260828)
73
+ parser.add_argument(
74
+ "--multilayer_self_causal",
75
+ action="store_true",
76
+ help="Use four-layer projected full grids for Self/Causal and skip HY.",
77
+ )
78
+ return parser.parse_args()
79
+
80
+
81
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
82
+ if not rows:
83
+ return
84
+ fields: list[str] = []
85
+ for row in rows:
86
+ for key in row:
87
+ if key not in fields:
88
+ fields.append(key)
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ with path.open("w", newline="", encoding="utf-8") as handle:
91
+ writer = csv.DictWriter(handle, fieldnames=fields)
92
+ writer.writeheader()
93
+ writer.writerows(rows)
94
+
95
+
96
+ def load_multilayer_self_runs(root: Path) -> dict[str, list[GridRun]]:
97
+ result = {role: [] for role in LAYER_ROLES.values()}
98
+ for path in sorted((root / "runs").glob("prompt_*.pt")):
99
+ state = torch.load(path, map_location="cpu", weights_only=False)
100
+ projected = state.get("projected_by_layer", {})
101
+ if not projected:
102
+ raise ValueError(f"No multilayer projected features in {path}")
103
+ anchors = np.load(path.with_suffix(".anchors.npz"), allow_pickle=False)["frames"]
104
+ by_layer: dict[int, dict[tuple[int, int], torch.Tensor]] = defaultdict(dict)
105
+ for key, tensor in projected.items():
106
+ layer, chunk, step = (int(value) for value in key.split(":"))
107
+ by_layer[layer][(chunk, step)] = tensor.float()
108
+ for layer, role in LAYER_ROLES.items():
109
+ if layer not in by_layer:
110
+ raise ValueError(f"Missing layer {layer} in {path}")
111
+ result[role].append(
112
+ GridRun(
113
+ "self_forcing",
114
+ "none",
115
+ int(state.get("run_index", len(result[role]))),
116
+ anchors,
117
+ int(state["num_frame_per_block"]),
118
+ by_layer[layer],
119
+ path,
120
+ )
121
+ )
122
+ return result
123
+
124
+
125
+ def load_multilayer_causal_runs(root: Path) -> dict[str, list[GridRun]]:
126
+ result = {role: [] for role in LAYER_ROLES.values()}
127
+ for run_dir in sorted((root / "runs").glob("prompt_*")):
128
+ path = run_dir / "feature_snapshots.pt"
129
+ anchor_path = run_dir / "rgb_anchor_frames.npz"
130
+ if not path.exists() or not anchor_path.exists():
131
+ continue
132
+ state = torch.load(path, map_location="cpu", weights_only=False)
133
+ projected = state.get("projected", {})
134
+ if not projected:
135
+ raise ValueError(f"No projected features in {path}")
136
+ anchors = np.load(anchor_path, allow_pickle=False)["frames"]
137
+ by_layer: dict[int, dict[tuple[int, int], torch.Tensor]] = defaultdict(dict)
138
+ for key, tensor in projected.items():
139
+ layer, chunk, step = (int(value) for value in key.split(":"))
140
+ by_layer[layer][(chunk, step)] = tensor.float()
141
+ for layer, role in LAYER_ROLES.items():
142
+ if layer not in by_layer:
143
+ raise ValueError(f"Missing layer {layer} in {path}")
144
+ result[role].append(
145
+ GridRun(
146
+ "causal_forcing",
147
+ "none",
148
+ int(state["prompt_id"]),
149
+ anchors,
150
+ 3,
151
+ by_layer[layer],
152
+ path,
153
+ )
154
+ )
155
+ return result
156
+
157
+
158
+ def columns(name: str, data: dict[str, torch.Tensor]) -> list[torch.Tensor]:
159
+ ones = torch.ones_like(data["step"])
160
+ mapping = {
161
+ "step_only": [data["step"], ones],
162
+ "both_raw": [data["step"], data["raw"], ones],
163
+ "both_global": [data["step"], data["global"], ones],
164
+ "both_flow": [data["step"], data["flow"], ones],
165
+ "both_negated_flow": [data["step"], data["negated"], ones],
166
+ "both_shuffled_flow": [data["step"], data["shuffled"], ones],
167
+ }
168
+ return mapping[name]
169
+
170
+
171
+ def collect_prompt_step(run: GridRun, target_step: int) -> dict[str, torch.Tensor]:
172
+ collected: dict[str, list[torch.Tensor]] = defaultdict(list)
173
+ for chunk in range(1, run.chunks):
174
+ source_frame_index = chunk * run.chunk_size - 1
175
+ source_frame = run.anchors[source_frame_index]
176
+ source_map = run.features[(chunk - 1, target_step)][-1].float()
177
+ target_maps = run.features[(chunk, target_step)].float()
178
+ step_maps = run.features[(chunk, target_step - 1)].float()
179
+ for slot in range(run.chunk_size):
180
+ target_frame = run.anchors[chunk * run.chunk_size + slot]
181
+ flow = resize_flow(farneback(target_frame, source_frame))
182
+ global_flow = torch.zeros_like(flow)
183
+ global_flow[0].fill_(float(torch.median(flow[0])))
184
+ global_flow[1].fill_(float(torch.median(flow[1])))
185
+ control_flows = {
186
+ "global": global_flow,
187
+ "flow": flow,
188
+ "negated": -flow,
189
+ "shuffled": shuffled_flow(
190
+ flow,
191
+ seed=(run.prompt_id + 1) * 100000
192
+ + chunk * 1000
193
+ + slot * 10
194
+ + target_step,
195
+ ),
196
+ }
197
+ aligned: dict[str, torch.Tensor] = {}
198
+ masks: list[torch.Tensor] = []
199
+ for name, control_flow in control_flows.items():
200
+ aligned[name], mask = warp(source_map, control_flow)
201
+ masks.append(mask)
202
+ common_mask = torch.stack(masks).all(dim=0)
203
+ if not bool(common_mask.any()):
204
+ continue
205
+ collected["target"].append(target_maps[slot][common_mask])
206
+ collected["step"].append(step_maps[slot][common_mask])
207
+ collected["raw"].append(source_map[common_mask])
208
+ for name in control_flows:
209
+ collected[name].append(aligned[name][common_mask])
210
+ result = {key: torch.cat(values, dim=0).contiguous() for key, values in collected.items()}
211
+ expected = {"target", "step", "raw", "global", "flow", "negated", "shuffled"}
212
+ if set(result) != expected:
213
+ raise ValueError(f"Incomplete aligned sample for {run.source}: {set(result)}")
214
+ return result
215
+
216
+
217
+ def ridge_sufficient_statistics(
218
+ data: dict[str, torch.Tensor],
219
+ probe: str,
220
+ device: torch.device,
221
+ ) -> tuple[torch.Tensor, torch.Tensor]:
222
+ design = torch.stack(columns(probe, data), dim=-1).to(device=device, dtype=torch.float64)
223
+ target = data["target"].to(device=device, dtype=torch.float64)
224
+ gram = torch.einsum("ndp,ndq->dpq", design, design)
225
+ rhs = torch.einsum("ndp,nd->dp", design, target)
226
+ return gram, rhs
227
+
228
+
229
+ def solve_ridge(
230
+ gram: torch.Tensor,
231
+ rhs: torch.Tensor,
232
+ ridge: float,
233
+ ) -> torch.Tensor:
234
+ parameter_count = gram.shape[-1]
235
+ device = gram.device
236
+ scale = gram.diagonal(dim1=-2, dim2=-1).mean(dim=-1).clamp_min(1e-8)
237
+ regularizer = torch.eye(parameter_count, dtype=torch.float64, device=device)[None]
238
+ regularizer = regularizer * (float(ridge) * scale[:, None, None])
239
+ regularizer[:, -1, -1] = 0.0
240
+ try:
241
+ weights = torch.linalg.solve(gram + regularizer, rhs.unsqueeze(-1)).squeeze(-1)
242
+ except torch.linalg.LinAlgError:
243
+ weights = (torch.linalg.pinv(gram + regularizer) @ rhs.unsqueeze(-1)).squeeze(-1)
244
+ return weights.float()
245
+
246
+
247
+ def evaluate(
248
+ data: dict[str, torch.Tensor],
249
+ probe: str,
250
+ weights: torch.Tensor,
251
+ device: torch.device,
252
+ ) -> dict[str, float]:
253
+ design = torch.stack(columns(probe, data), dim=-1).to(device=device, dtype=torch.float32)
254
+ target = data["target"].to(device=device, dtype=torch.float32)
255
+ prediction = torch.einsum("ndp,dp->nd", design, weights)
256
+ error = prediction - target
257
+ mse = error.square().mean()
258
+ variance = (target - target.mean()).square().mean().clamp_min(1e-12)
259
+ nmse = mse / variance
260
+ cosine = F.cosine_similarity(prediction, target, dim=-1, eps=1e-8).mean()
261
+ return {
262
+ "mse": float(mse),
263
+ "nMSE": float(nmse),
264
+ "nRMSE": float(torch.sqrt(nmse)),
265
+ "r2": float(1.0 - nmse),
266
+ "cosine": float(cosine),
267
+ }
268
+
269
+
270
+ def bootstrap(values: list[float], seed: int, rounds: int = 10000) -> tuple[float, float, float]:
271
+ array = np.asarray(values, dtype=np.float64)
272
+ generator = np.random.default_rng(seed)
273
+ indices = generator.integers(0, len(array), size=(rounds, len(array)))
274
+ means = array[indices].mean(axis=1)
275
+ return float(array.mean()), float(np.quantile(means, 0.025)), float(np.quantile(means, 0.975))
276
+
277
+
278
+ def summarize(rows: list[dict[str, Any]], seed: int) -> list[dict[str, Any]]:
279
+ groups: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
280
+ for row in rows:
281
+ groups[(row["model"], row["layer_role"], row["probe"])].append(row)
282
+ output = []
283
+ for (model, role, probe), selected in sorted(groups.items()):
284
+ by_prompt: dict[int, list[dict[str, Any]]] = defaultdict(list)
285
+ for row in selected:
286
+ by_prompt[int(row["held_out_prompt"])].append(row)
287
+ prompt_rows = []
288
+ for prompt_id, values in sorted(by_prompt.items()):
289
+ item = {"prompt_id": prompt_id}
290
+ for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
291
+ item[metric] = float(np.mean([float(row[metric]) for row in values]))
292
+ prompt_rows.append(item)
293
+ item: dict[str, Any] = {
294
+ "model": model,
295
+ "layer_role": role,
296
+ "probe": probe,
297
+ "prompt_count": len(prompt_rows),
298
+ "fold_count": len(selected),
299
+ }
300
+ for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
301
+ stable = seed + sum(map(ord, model + role + probe + metric))
302
+ avg, low, high = bootstrap([row[metric] for row in prompt_rows], stable)
303
+ item[f"{metric}_mean"] = avg
304
+ item[f"{metric}_ci95_low"] = low
305
+ item[f"{metric}_ci95_high"] = high
306
+
307
+ output.append(item)
308
+
309
+ prompt_metric: dict[tuple[str, str, str, int], dict[str, float]] = {}
310
+ grouped: dict[tuple[str, str, str, int], list[dict[str, Any]]] = defaultdict(list)
311
+ for row in rows:
312
+ grouped[
313
+ (row["model"], row["layer_role"], row["probe"], int(row["held_out_prompt"]))
314
+ ].append(row)
315
+ for key, values in grouped.items():
316
+ prompt_metric[key] = {
317
+ metric: float(np.mean([float(row[metric]) for row in values]))
318
+ for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine")
319
+ }
320
+ for item in output:
321
+ model, role, probe = item["model"], item["layer_role"], item["probe"]
322
+ if probe == "step_only":
323
+ continue
324
+ gain_step, gain_raw = [], []
325
+ prompt_ids = sorted(
326
+ prompt_id
327
+ for candidate_model, candidate_role, candidate_probe, prompt_id in prompt_metric
328
+ if candidate_model == model and candidate_role == role and candidate_probe == probe
329
+ )
330
+ for prompt_id in prompt_ids:
331
+ current = prompt_metric[(model, role, probe, prompt_id)]["mse"]
332
+ step = prompt_metric[(model, role, "step_only", prompt_id)]["mse"]
333
+ raw = prompt_metric[(model, role, "both_raw", prompt_id)]["mse"]
334
+ gain_step.append((step - current) / max(step, 1e-12))
335
+ gain_raw.append((raw - current) / max(raw, 1e-12))
336
+ avg, low, high = bootstrap(
337
+ gain_step, seed + 300000 + sum(map(ord, model + role + probe))
338
+ )
339
+ item.update({
340
+ "mse_gain_vs_step_mean": avg,
341
+ "mse_gain_vs_step_ci95_low": low,
342
+ "mse_gain_vs_step_ci95_high": high,
343
+ "mse_gain_vs_step_wins": int(sum(value > 0 for value in gain_step)),
344
+ })
345
+ avg, low, high = bootstrap(
346
+ gain_raw, seed + 600000 + sum(map(ord, model + role + probe))
347
+ )
348
+ item.update({
349
+ "mse_gain_vs_raw_mean": avg,
350
+ "mse_gain_vs_raw_ci95_low": low,
351
+ "mse_gain_vs_raw_ci95_high": high,
352
+ "mse_gain_vs_raw_wins": int(sum(value > 0 for value in gain_raw)),
353
+ })
354
+ if probe == "both_flow":
355
+ for baseline_probe, label in (
356
+ ("both_global", "global"),
357
+ ("both_negated_flow", "negated_flow"),
358
+ ("both_shuffled_flow", "shuffled_flow"),
359
+ ):
360
+ gains = []
361
+ for prompt_id in prompt_ids:
362
+ current = prompt_metric[(model, role, probe, prompt_id)]["mse"]
363
+ baseline = prompt_metric[(model, role, baseline_probe, prompt_id)]["mse"]
364
+ gains.append((baseline - current) / max(baseline, 1e-12))
365
+ avg, low, high = bootstrap(
366
+ gains,
367
+ seed + 900000 + sum(map(ord, model + role + baseline_probe)),
368
+ )
369
+ item.update({
370
+ f"mse_gain_vs_{label}_mean": avg,
371
+ f"mse_gain_vs_{label}_ci95_low": low,
372
+ f"mse_gain_vs_{label}_ci95_high": high,
373
+ f"mse_gain_vs_{label}_wins": int(sum(value > 0 for value in gains)),
374
+ })
375
+ return output
376
+
377
+
378
+ def main() -> None:
379
+ args = parse_args()
380
+ output = args.output_root.resolve()
381
+ output.mkdir(parents=True, exist_ok=True)
382
+ device = torch.device("cuda:0")
383
+ if not torch.cuda.is_available():
384
+ raise RuntimeError("CUDA is required for this experiment")
385
+
386
+ if args.multilayer_self_causal:
387
+ model_runs = {
388
+ "self_forcing": load_multilayer_self_runs(args.self_root.resolve()),
389
+ "causal_forcing": load_multilayer_causal_runs(args.causal_root.resolve()),
390
+ }
391
+ else:
392
+ if args.hy_root is None or args.hy_cache_root is None:
393
+ raise ValueError("--hy_root and --hy_cache_root are required without multilayer mode")
394
+ model_runs = {
395
+ "self_forcing": {"final": load_self_runs(args.self_root.resolve())},
396
+ "causal_forcing": {"final": load_causal_runs(args.causal_root.resolve())},
397
+ "hy_static": {
398
+ "final": load_hy_runs(
399
+ args.hy_root.resolve(),
400
+ "static",
401
+ args.hy_cache_root.resolve(),
402
+ args.projection_dim,
403
+ device,
404
+ False,
405
+ )
406
+ },
407
+ }
408
+ fold_rows: list[dict[str, Any]] = []
409
+ for model, role_runs in model_runs.items():
410
+ for role, runs in role_runs.items():
411
+ if len(runs) != 10:
412
+ raise ValueError(f"Expected 10 runs for {model}/{role}, found {len(runs)}")
413
+ for target_step in range(1, 4):
414
+ prepared = [collect_prompt_step(run, target_step) for run in runs]
415
+ token_counts = [int(data["target"].shape[0]) for data in prepared]
416
+ print(
417
+ f"[prepare] {model}/{role} step={target_step} tokens={token_counts}",
418
+ flush=True,
419
+ )
420
+ statistics = {
421
+ probe: [ridge_sufficient_statistics(data, probe, device) for data in prepared]
422
+ for probe in PROBES
423
+ }
424
+ for held_out in range(10):
425
+ test = prepared[held_out]
426
+ for probe in PROBES:
427
+ grams, right_sides = zip(*statistics[probe])
428
+ train_gram = torch.stack(grams).sum(dim=0) - grams[held_out]
429
+ train_rhs = torch.stack(right_sides).sum(dim=0) - right_sides[held_out]
430
+ weights = solve_ridge(train_gram, train_rhs, args.ridge)
431
+ values = evaluate(test, probe, weights, device)
432
+ fold_rows.append({
433
+ "model": model,
434
+ "layer_role": role,
435
+ "target_step": target_step,
436
+ "held_out_prompt": held_out,
437
+ "train_prompts": 9,
438
+ "test_tokens": int(test["target"].shape[0]),
439
+ "probe": probe,
440
+ **values,
441
+ })
442
+ print(
443
+ f"[fold] {model}/{role} step={target_step} heldout={held_out}",
444
+ flush=True,
445
+ )
446
+ del prepared
447
+ torch.cuda.empty_cache()
448
+
449
+ summary = summarize(fold_rows, args.seed)
450
+ write_csv(output / "aligned_probe_folds.csv", fold_rows)
451
+ write_csv(output / "aligned_probe_summary.csv", summary)
452
+ summary_lookup = {
453
+ (row["model"], row["layer_role"], row["probe"]): row for row in summary
454
+ }
455
+ report = [
456
+ "# Oracle flow-aligned conditional Ridge probe",
457
+ "",
458
+ "All gains are prompt-wise relative MSE reductions averaged over 10 held-out prompts and three target denoising steps. Flow is computed from the generated current RGB frame and is therefore an oracle diagnostic.",
459
+ "",
460
+ "| model | layer | raw chunk vs step-only | flow-aligned vs step-only | flow-aligned vs raw chunk | flow-aligned vs shuffled flow | flow-vs-raw wins |",
461
+ "|---|---|---:|---:|---:|---:|---:|",
462
+ ]
463
+ for model, role_runs in model_runs.items():
464
+ for role in role_runs:
465
+ raw = summary_lookup[(model, role, "both_raw")]
466
+ flow = summary_lookup[(model, role, "both_flow")]
467
+ report.append(
468
+ f"| {model} | {role} | {100 * raw['mse_gain_vs_step_mean']:.2f}% | "
469
+ f"{100 * flow['mse_gain_vs_step_mean']:.2f}% | "
470
+ f"{100 * flow['mse_gain_vs_raw_mean']:.2f}% | "
471
+ f"{100 * flow['mse_gain_vs_shuffled_flow_mean']:.2f}% | "
472
+ f"{flow['mse_gain_vs_raw_wins']}/10 |"
473
+ )
474
+ report.extend([
475
+ "",
476
+ "Self-Forcing and Causal-Forcing are evaluated at early, middle, late, and final layers under one identical feature space, mask, split, and Ridge capacity.",
477
+ "",
478
+ "The experiment uses the common intersection of in-bounds masks for every warp, so all predictor variants see identical target tokens. Full-grid features are fixed 64-D signed random projections; conclusions concern within-model paired gains rather than native-space or cross-model absolute errors.",
479
+ ])
480
+ (output / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
481
+ config = {
482
+ "gpu": str(args.gpu),
483
+ "models": list(model_runs),
484
+ "layer_roles": {model: list(role_runs) for model, role_runs in model_runs.items()},
485
+ "prompt_count": 10,
486
+ "target_steps": [1, 2, 3],
487
+ "probes": list(PROBES),
488
+ "ridge": args.ridge,
489
+ "projection_dim": args.projection_dim,
490
+ "grid": [30, 52],
491
+ "split": "leave-one-prompt-out (9 train, 1 test)",
492
+ "support": "intersection of in-bounds masks for global/correct/negated/shuffled warps",
493
+ "flow": "Farneback target RGB to previous-chunk boundary RGB; oracle diagnostic",
494
+ "row_count": len(fold_rows),
495
+ }
496
+ (output / "config.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
497
+ print(f"[complete] {output} rows={len(fold_rows)}", flush=True)
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()
scripts/run_conditional_probe_offline.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run grouped, channel-wise Linear/Ridge conditional probes offline.
3
+
4
+ The input is the normalized per-prompt dataset produced by
5
+ ``build_conditional_probe_dataset.py``. All controls are derived in memory from
6
+ the same canonical features, so no model forward is repeated and every probe
7
+ uses identical target tokens.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import json
15
+ import math
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ def _preparse_gpu() -> str:
23
+ parser = argparse.ArgumentParser(add_help=False)
24
+ parser.add_argument("--gpu", default="0")
25
+ args, _ = parser.parse_known_args()
26
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
27
+ return str(args.gpu)
28
+
29
+
30
+ _preparse_gpu()
31
+
32
+ import numpy as np
33
+ import torch
34
+ import torch.nn.functional as F
35
+
36
+
37
+ FAMILIES = ("self_forcing", "causal_forcing", "hy_worldplay")
38
+ ROLES = ("early", "middle", "late", "final")
39
+ LAYER_INDICES = {
40
+ "self_forcing": {"early": 7, "middle": 14, "late": 22, "final": 29},
41
+ "causal_forcing": {"early": 7, "middle": 14, "late": 22, "final": 29},
42
+ "hy_worldplay": {"early": 13, "middle": 26, "late": 40, "final": 53},
43
+ }
44
+ PROBES = (
45
+ "within_affine",
46
+ "within_quadratic",
47
+ "cross_affine",
48
+ "fusion_same",
49
+ "fusion_step_duplicate",
50
+ "fusion_distant",
51
+ "fusion_wrong_step",
52
+ "fusion_token_shuffle",
53
+ "fusion_batch_shuffle",
54
+ "fusion_zero",
55
+ "fusion_noise",
56
+ )
57
+
58
+
59
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
60
+ if not rows:
61
+ return
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ fields: list[str] = []
64
+ for row in rows:
65
+ for key in row:
66
+ if key not in fields:
67
+ fields.append(key)
68
+ with path.open("w", newline="", encoding="utf-8") as handle:
69
+ writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
70
+ writer.writeheader()
71
+ writer.writerows(rows)
72
+
73
+
74
+ def parse_args() -> argparse.Namespace:
75
+ parser = argparse.ArgumentParser()
76
+ parser.add_argument("--dataset_root", type=Path, required=True)
77
+ parser.add_argument("--output_dir", type=Path, required=True)
78
+ parser.add_argument("--num_prompts", type=int, default=10)
79
+ parser.add_argument("--chunks", type=int, default=4)
80
+ parser.add_argument("--steps", type=int, default=4)
81
+ parser.add_argument("--ridge", type=float, default=1e-4)
82
+ parser.add_argument("--seed", type=int, default=0)
83
+ parser.add_argument(
84
+ "--chunk_pairing",
85
+ choices=("matched_slot", "boundary_to_all"),
86
+ default="matched_slot",
87
+ help=(
88
+ "How the previous-chunk auxiliary feature is paired with the current chunk. "
89
+ "boundary_to_all broadcasts the previous chunk's final temporal slot to "
90
+ "all current temporal slots at matched spatial coordinates."
91
+ ),
92
+ )
93
+ return parser.parse_args()
94
+
95
+
96
+ def load_family(root: Path, family: str, count: int) -> list[dict[str, Any]]:
97
+ runs = []
98
+ for prompt_id in range(count):
99
+ pt_path = root / family / f"prompt_{prompt_id:04d}.pt"
100
+ npz_path = root / family / f"prompt_{prompt_id:04d}.npz"
101
+ path = npz_path if family == "hy_worldplay" and not pt_path.exists() else pt_path
102
+ if not path.exists():
103
+ raise FileNotFoundError(path)
104
+ if path.suffix == ".npz":
105
+ data = np.load(path, allow_pickle=False)
106
+ raw: dict[int, dict[tuple[int, int], torch.Tensor]] = {}
107
+ for index, stage_value in enumerate(data["stages"]):
108
+ stage = str(stage_value)
109
+ if not stage.startswith("block_"):
110
+ continue
111
+ layer = int(stage.split("_")[-1])
112
+ chunk = int(data["chunks"][index])
113
+ step = int(data["steps"][index])
114
+ raw.setdefault(layer, {})[(chunk, step)] = torch.from_numpy(data["features"][index])
115
+ layer_roles = {13: "early", 26: "middle", 40: "late", 53: "final"}
116
+ features = {}
117
+ for layer, role in layer_roles.items():
118
+ rows = []
119
+ for chunk in range(4):
120
+ rows.append(torch.stack([raw[layer][(chunk, step)] for step in range(4)], dim=0))
121
+ features[role] = torch.stack(rows, dim=0).contiguous()
122
+ run = {
123
+ "prompt_id": prompt_id,
124
+ "features": features,
125
+ "timesteps": np.asarray(data["timesteps"], dtype=np.float32),
126
+ "coords": np.asarray(data["coords"], dtype=np.int64),
127
+ "grid_shape": np.asarray(data["grid_shape"], dtype=np.int64),
128
+ }
129
+ else:
130
+ run = torch.load(path, map_location="cpu", weights_only=False)
131
+ if int(run.get("prompt_id", prompt_id)) != prompt_id:
132
+ raise ValueError(f"Prompt id mismatch in {path}")
133
+ runs.append(run)
134
+ return runs
135
+
136
+
137
+ def boundary_to_all(reference: torch.Tensor, run: dict[str, Any]) -> torch.Tensor:
138
+ """Broadcast the last temporal slot while preserving target token order."""
139
+ coords = np.asarray(run.get("coords"))
140
+ if coords.ndim != 2 or coords.shape[1] != 3 or len(coords) != reference.shape[0]:
141
+ raise ValueError(
142
+ "boundary_to_all requires one (temporal,y,x) coordinate per feature token; "
143
+ f"coords={coords.shape}, features={tuple(reference.shape)}"
144
+ )
145
+ slots = sorted(int(value) for value in np.unique(coords[:, 0]))
146
+ if not slots:
147
+ raise ValueError("No temporal slots in coordinates")
148
+ last_mask = coords[:, 0] == slots[-1]
149
+ source_coords = coords[last_mask, 1:]
150
+ source = reference[torch.from_numpy(last_mask)]
151
+ result = torch.empty_like(reference)
152
+ for slot in slots:
153
+ target_mask = coords[:, 0] == slot
154
+ target_coords = coords[target_mask, 1:]
155
+ if not np.array_equal(target_coords, source_coords):
156
+ raise ValueError(
157
+ f"Temporal slot {slot} does not share the boundary slot's spatial grid"
158
+ )
159
+ result[torch.from_numpy(target_mask)] = source
160
+ return result
161
+
162
+
163
+ def samples(
164
+ run: dict[str, Any],
165
+ role: str,
166
+ chunks: int,
167
+ step: int,
168
+ chunk_pairing: str = "matched_slot",
169
+ ) -> dict[str, torch.Tensor]:
170
+ values = run["features"][role].float()
171
+ if values.ndim != 4:
172
+ raise ValueError(f"Expected [chunk,step,token,channel], got {values.shape}")
173
+ if values.shape[0] < chunks or values.shape[1] < 4:
174
+ raise ValueError(f"Insufficient feature grid for {role}: {values.shape}")
175
+ targets, within, cross, distant, wrong = [], [], [], [], []
176
+ # c>=2 is required for the distant-chunk control. Pool c=2 and c=3 for
177
+ # the common four-chunk protocol, while retaining chunk_id downstream.
178
+ for chunk in range(2, chunks):
179
+ targets.append(values[chunk, step])
180
+ within.append(values[chunk, step - 1])
181
+ if chunk_pairing == "boundary_to_all":
182
+ cross.append(boundary_to_all(values[chunk - 1, step], run))
183
+ distant.append(boundary_to_all(values[chunk - 2, step], run))
184
+ wrong.append(boundary_to_all(values[chunk - 1, step - 1], run))
185
+ else:
186
+ cross.append(values[chunk - 1, step])
187
+ distant.append(values[chunk - 2, step])
188
+ wrong.append(values[chunk - 1, step - 1])
189
+ return {
190
+ "target": torch.cat(targets, dim=0),
191
+ "within": torch.cat(within, dim=0),
192
+ "cross": torch.cat(cross, dim=0),
193
+ "distant": torch.cat(distant, dim=0),
194
+ "wrong": torch.cat(wrong, dim=0),
195
+ }
196
+
197
+
198
+ def token_shuffle(value: torch.Tensor, spatial_count: int) -> torch.Tensor:
199
+ # Roll spatial tokens independently inside every chunk/temporal slice,
200
+ # preserving the marginal feature distribution while breaking coordinate
201
+ # correspondence. This supports both 3-slot Self/Causal and 4-slot HY.
202
+ if spatial_count <= 0 or value.shape[0] % spatial_count:
203
+ return value.roll(shifts=max(1, value.shape[0] // 2), dims=0)
204
+ frames = value.reshape(-1, spatial_count, value.shape[1])
205
+ return frames.roll(shifts=1, dims=1).reshape_as(value)
206
+
207
+
208
+ def noise_like(value: torch.Tensor, seed: int) -> torch.Tensor:
209
+ generator = torch.Generator(device="cpu").manual_seed(int(seed))
210
+ noise = torch.randn(value.shape, generator=generator, dtype=value.dtype)
211
+ return noise * value.std(dim=0, keepdim=True).clamp_min(1e-6) + value.mean(dim=0, keepdim=True)
212
+
213
+
214
+ def columns(
215
+ name: str,
216
+ data: dict[str, torch.Tensor],
217
+ batch: torch.Tensor,
218
+ seed: int,
219
+ spatial_count: int,
220
+ ):
221
+ within, cross = data["within"], data["cross"]
222
+ ones = torch.ones_like(within)
223
+ mapping = {
224
+ "within_affine": [within, ones],
225
+ "within_quadratic": [within, within.square(), ones],
226
+ "cross_affine": [cross, ones],
227
+ "fusion_same": [within, cross, ones],
228
+ "fusion_step_duplicate": [within, within, ones],
229
+ "fusion_distant": [within, data["distant"], ones],
230
+ "fusion_wrong_step": [within, data["wrong"], ones],
231
+ "fusion_token_shuffle": [within, token_shuffle(cross, spatial_count), ones],
232
+ "fusion_batch_shuffle": [within, batch, ones],
233
+ "fusion_zero": [within, torch.zeros_like(cross), ones],
234
+ "fusion_noise": [within, noise_like(cross, seed), ones],
235
+ }
236
+ return mapping[name]
237
+
238
+
239
+ def fit_ridge(features: list[torch.Tensor], target: torch.Tensor, ridge: float) -> torch.Tensor:
240
+ design = torch.stack(features, dim=-1).double() # [N,D,P]
241
+ y = target.double()
242
+ gram = torch.einsum("ndp,ndq->dpq", design, design)
243
+ rhs = torch.einsum("ndp,nd->dp", design, y)
244
+ p = gram.shape[-1]
245
+ scale = gram.diagonal(dim1=-2, dim2=-1).mean(dim=-1).clamp_min(1e-8)
246
+ reg = torch.eye(p, dtype=gram.dtype).unsqueeze(0) * (float(ridge) * scale[:, None, None])
247
+ # The last column is the explicit bias and is not regularized.
248
+ reg[:, -1, -1] = 0.0
249
+ try:
250
+ return torch.linalg.solve(gram + reg, rhs.unsqueeze(-1)).squeeze(-1).float()
251
+ except torch.linalg.LinAlgError:
252
+ return (torch.linalg.pinv(gram + reg) @ rhs.unsqueeze(-1)).squeeze(-1).float()
253
+
254
+
255
+ def predict(features: list[torch.Tensor], weights: torch.Tensor) -> torch.Tensor:
256
+ return torch.einsum("ndp,dp->nd", torch.stack(features, dim=-1).float(), weights)
257
+
258
+
259
+ def metrics(pred: torch.Tensor, target: torch.Tensor) -> dict[str, float]:
260
+ pred, target = pred.float(), target.float()
261
+ error = pred - target
262
+ mse = error.square().mean()
263
+ centered = target - target.mean()
264
+ variance = centered.square().mean().clamp_min(1e-12)
265
+ nmse = mse / variance
266
+ cosine = F.cosine_similarity(pred.reshape(1, -1), target.reshape(1, -1), dim=1, eps=1e-8)[0]
267
+ return {
268
+ "mse": float(mse),
269
+ "nMSE": float(nmse),
270
+ "nRMSE": float(torch.sqrt(nmse)),
271
+ "r2": float(1.0 - nmse),
272
+ "cosine": float(cosine),
273
+ }
274
+
275
+
276
+ def bootstrap(values: list[float], seed: int, rounds: int = 4000):
277
+ values = np.asarray(values, dtype=np.float64)
278
+ rng = np.random.default_rng(seed)
279
+ if values.size == 0:
280
+ return float("nan"), float("nan"), float("nan")
281
+ draws = rng.integers(0, values.size, size=(rounds, values.size))
282
+ means = values[draws].mean(axis=1)
283
+ return float(values.mean()), float(np.quantile(means, 0.025)), float(np.quantile(means, 0.975))
284
+
285
+
286
+ def main() -> None:
287
+ args = parse_args()
288
+ args.dataset_root = args.dataset_root.resolve()
289
+ args.output_dir = args.output_dir.resolve()
290
+ args.output_dir.mkdir(parents=True, exist_ok=True)
291
+ all_rows: list[dict[str, Any]] = []
292
+ config = {
293
+ "dataset_root": str(args.dataset_root),
294
+ "num_prompts": args.num_prompts,
295
+ "chunks": args.chunks,
296
+ "steps": args.steps,
297
+ "target_chunks": list(range(2, args.chunks)),
298
+ "ridge": args.ridge,
299
+ "chunk_pairing": args.chunk_pairing,
300
+ "outer_split": "test prompt p; donor (p+1)%N also excluded from training",
301
+ "probes": list(PROBES),
302
+ }
303
+ for family in FAMILIES:
304
+ print(f"[load] {family}", flush=True)
305
+ runs = load_family(args.dataset_root, family, args.num_prompts)
306
+ for layer_index, role in enumerate(ROLES):
307
+ coords = np.asarray(runs[0].get("coords"))
308
+ slots = sorted(int(value) for value in np.unique(coords[:, 0]))
309
+ if not slots or len(coords) != runs[0]["features"][role].shape[2]:
310
+ raise ValueError(
311
+ f"Invalid temporal coordinates for {family}/{role}: "
312
+ f"coords={coords.shape}, tokens={runs[0]['features'][role].shape[2]}"
313
+ )
314
+ spatial_count = int((coords[:, 0] == slots[0]).sum())
315
+ for step in range(1, args.steps):
316
+ prepared = [
317
+ samples(run, role, args.chunks, step, args.chunk_pairing)
318
+ for run in runs
319
+ ]
320
+ for held_out in range(args.num_prompts):
321
+ donor = (held_out + 1) % args.num_prompts
322
+ train_ids = [i for i in range(args.num_prompts) if i not in {held_out, donor}]
323
+ train_data = {key: torch.cat([prepared[i][key] for i in train_ids], dim=0) for key in prepared[0]}
324
+ test_data = prepared[held_out]
325
+ donor_data = prepared[donor]
326
+ train_batch = torch.cat(
327
+ [prepared[train_ids[(position + 1) % len(train_ids)]]["cross"]
328
+ for position in range(len(train_ids))],
329
+ dim=0,
330
+ )
331
+ for probe in PROBES:
332
+ train_cols = columns(
333
+ probe,
334
+ train_data,
335
+ train_batch,
336
+ seed=args.seed + held_out * 100 + step,
337
+ spatial_count=spatial_count,
338
+ )
339
+ test_cols = columns(
340
+ probe,
341
+ test_data,
342
+ donor_data["cross"],
343
+ seed=args.seed + 10000 + held_out * 100 + step,
344
+ spatial_count=spatial_count,
345
+ )
346
+ weights = fit_ridge(train_cols, train_data["target"], args.ridge)
347
+ pred = predict(test_cols, weights)
348
+ row = {
349
+ "model_family": family,
350
+ "layer_role": role,
351
+ "layer_index": LAYER_INDICES[family][role],
352
+ "target_step": step,
353
+ "held_out_prompt": held_out,
354
+ "other_video_prompt": donor,
355
+ "train_prompts": len(train_ids),
356
+ "test_tokens": int(test_data["target"].shape[0]),
357
+ "probe": probe,
358
+ **metrics(pred, test_data["target"]),
359
+ }
360
+ all_rows.append(row)
361
+ if held_out % 2 == 0:
362
+ print(
363
+ f"[progress] {family} {role} step={step} heldout={held_out}",
364
+ flush=True,
365
+ )
366
+
367
+ write_csv(args.output_dir / "linear_probe_folds.csv", all_rows)
368
+ summary_rows = []
369
+ for family in FAMILIES:
370
+ for role in ROLES:
371
+ for step in range(1, args.steps):
372
+ for probe in PROBES:
373
+ selected = [
374
+ row for row in all_rows
375
+ if row["model_family"] == family
376
+ and row["layer_role"] == role
377
+ and row["target_step"] == step
378
+ and row["probe"] == probe
379
+ ]
380
+ if not selected:
381
+ continue
382
+ item = {
383
+ "model_family": family,
384
+ "layer_role": role,
385
+ "target_step": step,
386
+ "probe": probe,
387
+ "prompt_count": len(selected),
388
+ }
389
+ for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
390
+ stable_seed = (
391
+ args.seed
392
+ + 100000 * FAMILIES.index(family)
393
+ + 10000 * ROLES.index(role)
394
+ + 100 * int(step)
395
+ + sum(ord(ch) for ch in probe)
396
+ + sum(ord(ch) for ch in metric)
397
+ )
398
+ mean, low, high = bootstrap(
399
+ [float(row[metric]) for row in selected],
400
+ stable_seed,
401
+ )
402
+ item[f"{metric}_mean"] = mean
403
+ item[f"{metric}_ci95_low"] = low
404
+ item[f"{metric}_ci95_high"] = high
405
+ baseline = [
406
+ row for row in all_rows
407
+ if row["model_family"] == family
408
+ and row["layer_role"] == role
409
+ and row["target_step"] == step
410
+ and row["probe"] == "within_affine"
411
+ ]
412
+ if baseline:
413
+ gains = [
414
+ (float(base["mse"]) - float(cur["mse"])) / max(float(base["mse"]), 1e-12)
415
+ for base, cur in zip(
416
+ sorted(baseline, key=lambda row: row["held_out_prompt"]),
417
+ sorted(selected, key=lambda row: row["held_out_prompt"]),
418
+ )
419
+ ]
420
+ mean, low, high = bootstrap(gains, args.seed + 700000 + step)
421
+ item.update({
422
+ "gain_vs_within_affine_mean": mean,
423
+ "gain_vs_within_affine_ci95_low": low,
424
+ "gain_vs_within_affine_ci95_high": high,
425
+ "gain_vs_within_affine_wins": sum(value > 0 for value in gains),
426
+ })
427
+ summary_rows.append(item)
428
+ write_csv(args.output_dir / "linear_probe_summary.csv", summary_rows)
429
+ config["fold_rows"] = len(all_rows)
430
+ config["summary_rows"] = len(summary_rows)
431
+ (args.output_dir / "config.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
432
+ print(f"[complete] {args.output_dir} rows={len(all_rows)}", flush=True)
433
+
434
+
435
+ if __name__ == "__main__":
436
+ main()
scripts/run_single_block_init_sweep.py ADDED
@@ -0,0 +1,1082 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train and compare single-block Self-Forcing Predictor initializations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import hashlib
9
+ import json
10
+ import math
11
+ import os
12
+ import random
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ def _preparse_gpu() -> str:
20
+ parser = argparse.ArgumentParser(add_help=False)
21
+ parser.add_argument("--gpu", default="2")
22
+ args, _ = parser.parse_known_args()
23
+ # Respect torchrun's inherited multi-GPU visibility. Standalone invocations
24
+ # keep the historical GPU-2 default, while an explicit --gpu still wins.
25
+ gpu_was_explicit = any(
26
+ value == "--gpu" or value.startswith("--gpu=") for value in sys.argv[1:]
27
+ )
28
+ if gpu_was_explicit or "CUDA_VISIBLE_DEVICES" not in os.environ:
29
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
30
+ return str(args.gpu)
31
+
32
+
33
+ PHYSICAL_GPU = _preparse_gpu()
34
+
35
+ import torch
36
+ import torch.nn.functional as F
37
+ from omegaconf import OmegaConf
38
+ from safetensors.torch import save_file
39
+ from torch.optim import AdamW
40
+
41
+ REPO_ROOT = Path(__file__).resolve().parents[1]
42
+ if str(REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(REPO_ROOT))
44
+
45
+ from predictor_training.offline_data import (
46
+ OfflinePredictorStore,
47
+ TOKENS_PER_CHUNK,
48
+ )
49
+ from predictor_training.single_block import (
50
+ SingleBlockPredictor,
51
+ TripleFeatureFusion,
52
+ initialize_predictor_block,
53
+ )
54
+ from utils.misc import set_seed
55
+ from utils.wan_wrapper import WanDiffusionWrapper
56
+ from wan.modules.model import sinusoidal_embedding_1d
57
+
58
+
59
+ OTHER_METHODS = (
60
+ "random_full",
61
+ "teacher_identity",
62
+ "random_identity",
63
+ "full_zero",
64
+ )
65
+
66
+
67
+ def parse_args() -> argparse.Namespace:
68
+ parser = argparse.ArgumentParser(description=__doc__)
69
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
70
+ parser.add_argument(
71
+ "--dataset_root",
72
+ type=Path,
73
+ default=Path("outputs/predictor_offline_100_all_blocks"),
74
+ )
75
+ parser.add_argument(
76
+ "--checkpoint_path",
77
+ type=Path,
78
+ default=Path("checkpoints/self_forcing_dmd.pt"),
79
+ )
80
+ parser.add_argument(
81
+ "--config_path",
82
+ type=Path,
83
+ default=Path("configs/self_forcing_sid.yaml"),
84
+ )
85
+ parser.add_argument(
86
+ "--output_dir",
87
+ type=Path,
88
+ default=Path("outputs/single_block_init_sweep"),
89
+ )
90
+ parser.add_argument(
91
+ "--teacher_layers",
92
+ type=int,
93
+ nargs="*",
94
+ default=None,
95
+ help="Teacher source layers. Omit to sweep 0..29.",
96
+ )
97
+ parser.add_argument("--max_steps", type=int, default=1000)
98
+ parser.add_argument("--batch_size", type=int, default=32)
99
+ parser.add_argument("--eval_batch_size", type=int, default=10)
100
+ parser.add_argument("--eval_every", type=int, default=100)
101
+ parser.add_argument("--log_every", type=int, default=20)
102
+ parser.add_argument("--save_every", type=int, default=100)
103
+ parser.add_argument("--train_prompts", type=int, default=80)
104
+ parser.add_argument("--val_prompts", type=int, default=20)
105
+ parser.add_argument("--seed", type=int, default=0)
106
+ parser.add_argument("--fusion_lr", type=float, default=1e-4)
107
+ parser.add_argument("--block_lr", type=float, default=1e-5)
108
+ parser.add_argument("--weight_decay", type=float, default=0.01)
109
+ parser.add_argument("--hidden_weight", type=float, default=0.1)
110
+ parser.add_argument("--flow_weight", type=float, default=1.0)
111
+ parser.add_argument(
112
+ "--gate_mode", choices=("baseline", "learned", "constant"),
113
+ default="baseline",
114
+ )
115
+ parser.add_argument("--gate_hidden_dim", type=int, default=128)
116
+ parser.add_argument("--gate_initial_bias", type=float, default=4.6)
117
+ parser.add_argument("--gate_floor", type=float, default=0.0)
118
+ parser.add_argument("--gate_lr", type=float, default=None)
119
+ parser.add_argument("--gate_freeze_steps", type=int, default=0)
120
+ parser.add_argument("--constant_gate", type=float, default=1.0)
121
+ parser.add_argument("--grad_clip", type=float, default=1.0)
122
+ parser.add_argument("--fusion_warmup_steps", type=int, default=100)
123
+ parser.add_argument("--block_freeze_steps", type=int, default=100)
124
+ parser.add_argument("--block_warmup_steps", type=int, default=100)
125
+ parser.add_argument(
126
+ "--gradient_checkpointing",
127
+ action=argparse.BooleanOptionalAction,
128
+ default=True,
129
+ )
130
+ parser.add_argument(
131
+ "--run_other_initializations",
132
+ action=argparse.BooleanOptionalAction,
133
+ default=True,
134
+ )
135
+ parser.add_argument(
136
+ "--save_final_weights",
137
+ action=argparse.BooleanOptionalAction,
138
+ default=True,
139
+ )
140
+ args = parser.parse_args()
141
+ if args.max_steps < 1:
142
+ parser.error("--max_steps must be positive")
143
+ if args.train_prompts < 1 or args.val_prompts < 1:
144
+ parser.error("Train and validation prompt counts must be positive")
145
+ if args.train_prompts + args.val_prompts > 100:
146
+ parser.error("The offline dataset contains 100 prompts")
147
+ if args.batch_size > args.train_prompts:
148
+ parser.error("--batch_size cannot exceed --train_prompts")
149
+ if args.eval_batch_size > args.val_prompts:
150
+ args.eval_batch_size = args.val_prompts
151
+ if not 0.0 <= args.constant_gate <= 1.0:
152
+ parser.error("--constant_gate must be in [0, 1]")
153
+ if not 0.0 <= args.gate_floor < 1.0:
154
+ parser.error("--gate_floor must be in [0, 1)")
155
+ if args.gate_freeze_steps < 0:
156
+ parser.error("--gate_freeze_steps must be non-negative")
157
+ if args.gate_lr is None:
158
+ args.gate_lr = args.fusion_lr
159
+ return args
160
+
161
+
162
+ def resolve(path: Path) -> Path:
163
+ path = path.expanduser()
164
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
165
+
166
+
167
+ def atomic_json(path: Path, value: Any) -> None:
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ temporary = path.with_suffix(path.suffix + ".tmp")
170
+ temporary.write_text(
171
+ json.dumps(value, indent=2, ensure_ascii=False) + "\n",
172
+ encoding="utf-8",
173
+ )
174
+ os.replace(temporary, path)
175
+
176
+
177
+ def append_jsonl(path: Path, value: dict[str, Any]) -> None:
178
+ path.parent.mkdir(parents=True, exist_ok=True)
179
+ with path.open("a", encoding="utf-8") as handle:
180
+ handle.write(json.dumps(value, sort_keys=True) + "\n")
181
+
182
+
183
+ def parameter_norm(parameters: list[torch.nn.Parameter]) -> float:
184
+ total = 0.0
185
+ for parameter in parameters:
186
+ total += float(parameter.detach().float().square().sum())
187
+ return math.sqrt(total)
188
+
189
+
190
+ def gradient_norm(parameters: list[torch.nn.Parameter]) -> float:
191
+ total = 0.0
192
+ for parameter in parameters:
193
+ if parameter.grad is not None:
194
+ total += float(parameter.grad.detach().float().square().sum())
195
+ return math.sqrt(total)
196
+
197
+
198
+ def build_shared_nonblock_state(seed: int) -> dict[str, dict[str, torch.Tensor]]:
199
+ set_seed(seed)
200
+ fusion = TripleFeatureFusion(1536)
201
+ residual = torch.nn.Linear(1536, 1536)
202
+ torch.nn.init.zeros_(residual.weight)
203
+ torch.nn.init.zeros_(residual.bias)
204
+ return {
205
+ "fusion": {
206
+ key: value.detach().clone()
207
+ for key, value in fusion.state_dict().items()
208
+ },
209
+ "residual_out": {
210
+ key: value.detach().clone()
211
+ for key, value in residual.state_dict().items()
212
+ },
213
+ }
214
+
215
+
216
+ def load_teacher(
217
+ checkpoint_path: Path,
218
+ config_path: Path,
219
+ device: torch.device,
220
+ ) -> torch.nn.Module:
221
+ config = OmegaConf.merge(
222
+ OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
223
+ OmegaConf.load(config_path),
224
+ )
225
+ wrapper = WanDiffusionWrapper(
226
+ **getattr(config, "model_kwargs", {}),
227
+ is_causal=True,
228
+ )
229
+ checkpoint = torch.load(
230
+ checkpoint_path,
231
+ map_location="cpu",
232
+ weights_only=False,
233
+ mmap=True,
234
+ )
235
+ wrapper.load_state_dict(checkpoint["generator_ema"], strict=True)
236
+ del checkpoint
237
+ wrapper.to(device=device, dtype=torch.bfloat16)
238
+ wrapper.eval().requires_grad_(False)
239
+ model = wrapper.model
240
+ if len(model.blocks) != 30 or model.dim != 1536:
241
+ raise ValueError(
242
+ f"Expected Wan 1.3B with 30×1536 blocks, got "
243
+ f"{len(model.blocks)}×{model.dim}"
244
+ )
245
+ if model.freqs.device != device:
246
+ model.freqs = model.freqs.to(device)
247
+ return model
248
+
249
+
250
+ class BatchSchedule:
251
+ """Precompute identical prompt/group batches for every initialization."""
252
+
253
+ def __init__(
254
+ self,
255
+ prompt_ids: list[int],
256
+ batch_size: int,
257
+ steps: int,
258
+ seed: int,
259
+ ) -> None:
260
+ self.entries: list[tuple[int, int, list[int]]] = []
261
+ rng = random.Random(seed)
262
+ groups = [
263
+ (chunk, target_step)
264
+ for chunk in range(1, 7)
265
+ for target_step in range(1, 4)
266
+ ]
267
+ while len(self.entries) < steps:
268
+ epoch_groups = groups.copy()
269
+ rng.shuffle(epoch_groups)
270
+ for chunk, target_step in epoch_groups:
271
+ prompts = rng.sample(prompt_ids, batch_size)
272
+ self.entries.append((chunk, target_step, prompts))
273
+ if len(self.entries) == steps:
274
+ break
275
+
276
+ def fingerprint(self) -> str:
277
+ payload = json.dumps(self.entries, separators=(",", ":")).encode()
278
+ return hashlib.sha256(payload).hexdigest()
279
+
280
+
281
+ def lr_values(
282
+ step: int,
283
+ max_steps: int,
284
+ fusion_lr: float,
285
+ block_lr: float,
286
+ fusion_warmup_steps: int,
287
+ block_freeze_steps: int,
288
+ block_warmup_steps: int,
289
+ ) -> tuple[float, float]:
290
+ if step < fusion_warmup_steps:
291
+ fusion_factor = float(step + 1) / max(1, fusion_warmup_steps)
292
+ else:
293
+ progress = (step - fusion_warmup_steps) / max(
294
+ 1, max_steps - fusion_warmup_steps
295
+ )
296
+ fusion_factor = 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress)))
297
+
298
+ if step < block_freeze_steps:
299
+ block_factor = 0.0
300
+ elif step < block_freeze_steps + block_warmup_steps:
301
+ block_factor = float(step - block_freeze_steps + 1) / max(
302
+ 1, block_warmup_steps
303
+ )
304
+ else:
305
+ progress = (
306
+ step - block_freeze_steps - block_warmup_steps
307
+ ) / max(1, max_steps - block_freeze_steps - block_warmup_steps)
308
+ block_factor = 0.5 * (
309
+ 1.0 + math.cos(math.pi * min(1.0, progress))
310
+ )
311
+ return fusion_lr * fusion_factor, block_lr * block_factor
312
+
313
+
314
+ def move_batch(
315
+ batch: dict[str, Any],
316
+ device: torch.device,
317
+ ) -> dict[str, Any]:
318
+ output = {
319
+ key: value.to(
320
+ device=device,
321
+ dtype=(
322
+ torch.bfloat16
323
+ if value.is_floating_point() and key != "timestep"
324
+ else value.dtype
325
+ ),
326
+ non_blocking=False,
327
+ )
328
+ for key, value in batch.items()
329
+ if isinstance(value, torch.Tensor)
330
+ }
331
+ output.update(
332
+ {
333
+ "prompt_ids": batch["prompt_ids"],
334
+ "chunk": batch["chunk"],
335
+ "anchor_step": batch["anchor_step"],
336
+ "target_step": batch["target_step"],
337
+ }
338
+ )
339
+ return output
340
+
341
+
342
+ def frozen_inputs(
343
+ batch: dict[str, Any],
344
+ teacher: torch.nn.Module,
345
+ device: torch.device,
346
+ ) -> dict[str, torch.Tensor]:
347
+ noisy = batch["noisy_latent"]
348
+ batch_size = noisy.shape[0]
349
+ with torch.no_grad(), torch.autocast(
350
+ device_type="cuda", dtype=torch.bfloat16
351
+ ):
352
+ current_tokens = teacher.patch_embedding(
353
+ noisy.permute(0, 2, 1, 3, 4)
354
+ ).flatten(2).transpose(1, 2)
355
+ timestep = batch["timestep"]
356
+ time_embedding = teacher.time_embedding(
357
+ sinusoidal_embedding_1d(
358
+ teacher.freq_dim, timestep.flatten()
359
+ ).type_as(current_tokens)
360
+ )
361
+ timestep_modulation = teacher.time_projection(
362
+ time_embedding
363
+ ).unflatten(1, (6, teacher.dim)).unflatten(
364
+ dim=0, sizes=timestep.shape
365
+ )
366
+ head_embedding = time_embedding.unflatten(
367
+ dim=0, sizes=timestep.shape
368
+ ).unsqueeze(2)
369
+ condition_per_frame = time_embedding.unflatten(
370
+ dim=0, sizes=timestep.shape
371
+ )
372
+ tokens_per_frame = 30 * 52
373
+ condition_tokens = (
374
+ condition_per_frame[:, :, None, :]
375
+ .expand(batch_size, timestep.shape[1], tokens_per_frame, teacher.dim)
376
+ .reshape(batch_size, -1, teacher.dim)
377
+ )
378
+ grid_sizes = torch.tensor(
379
+ [[3, 30, 52]] * batch_size,
380
+ dtype=torch.long,
381
+ device="cpu",
382
+ )
383
+ return {
384
+ "current_tokens": current_tokens,
385
+ "timestep_modulation": timestep_modulation,
386
+ "head_embedding": head_embedding,
387
+ "condition_tokens": condition_tokens,
388
+ "grid_sizes": grid_sizes,
389
+ "freqs": teacher.freqs,
390
+ }
391
+
392
+
393
+ def hidden_to_flow(
394
+ pred_hidden: torch.Tensor,
395
+ head_embedding: torch.Tensor,
396
+ grid_sizes: torch.Tensor,
397
+ teacher: torch.nn.Module,
398
+ ) -> torch.Tensor:
399
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
400
+ head_tokens = teacher.head(pred_hidden, head_embedding)
401
+ flow_channels_first = torch.stack(
402
+ teacher.unpatchify(head_tokens, grid_sizes)
403
+ )
404
+ return flow_channels_first.permute(0, 2, 1, 3, 4)
405
+
406
+
407
+ def forward_predictor(
408
+ model: SingleBlockPredictor,
409
+ batch: dict[str, Any],
410
+ teacher: torch.nn.Module,
411
+ device: torch.device,
412
+ ) -> tuple[torch.Tensor, torch.Tensor]:
413
+ frozen = frozen_inputs(batch, teacher, device)
414
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
415
+ pred_hidden = model(
416
+ current_tokens=frozen["current_tokens"],
417
+ anchor_hidden=batch["anchor_hidden"],
418
+ previous_hidden=batch["previous_hidden"],
419
+ timestep_modulation=frozen["timestep_modulation"],
420
+ grid_sizes=frozen["grid_sizes"],
421
+ freqs=frozen["freqs"],
422
+ history_k=batch["history_k"],
423
+ history_v=batch["history_v"],
424
+ cross_k=batch["cross_k"],
425
+ cross_v=batch["cross_v"],
426
+ current_start=batch["chunk"] * TOKENS_PER_CHUNK,
427
+ condition_tokens=frozen["condition_tokens"],
428
+ anchor_distance=batch.get("anchor_distance"),
429
+ )
430
+ pred_flow = hidden_to_flow(
431
+ pred_hidden,
432
+ frozen["head_embedding"],
433
+ frozen["grid_sizes"],
434
+ teacher,
435
+ )
436
+ return pred_hidden, pred_flow
437
+
438
+
439
+ @torch.inference_mode()
440
+ def evaluate(
441
+ model: SingleBlockPredictor,
442
+ store: OfflinePredictorStore,
443
+ val_prompt_ids: list[int],
444
+ batch_size: int,
445
+ teacher: torch.nn.Module,
446
+ device: torch.device,
447
+ hidden_weight: float,
448
+ flow_weight: float,
449
+ ) -> dict[str, float]:
450
+ model.eval()
451
+ hidden_squared = 0.0
452
+ hidden_elements = 0
453
+ flow_squared = 0.0
454
+ flow_elements = 0
455
+ gate_sum = 0.0
456
+ gate_square_sum = 0.0
457
+ gate_count = 0
458
+ gate_histogram = torch.zeros(10, dtype=torch.int64)
459
+ started = time.perf_counter()
460
+ for chunk in range(1, 7):
461
+ for target_step in range(1, 4):
462
+ for start in range(0, len(val_prompt_ids), batch_size):
463
+ prompt_ids = val_prompt_ids[start : start + batch_size]
464
+ batch = move_batch(
465
+ store.batch(prompt_ids, chunk, target_step), device
466
+ )
467
+ pred_hidden, pred_flow = forward_predictor(
468
+ model, batch, teacher, device
469
+ )
470
+ gate = model.fusion.last_gate
471
+ if gate is None:
472
+ raise RuntimeError("Fusion did not expose gate values")
473
+ gate_float = gate.float()
474
+ gate_sum += float(gate_float.sum())
475
+ gate_square_sum += float(gate_float.square().sum())
476
+ gate_count += gate_float.numel()
477
+ gate_histogram += torch.histc(
478
+ gate_float, bins=10, min=0.0, max=1.0
479
+ ).to(device="cpu", dtype=torch.int64)
480
+ hidden_error = pred_hidden.float() - batch["target_hidden"].float()
481
+ flow_error = pred_flow.float() - batch["target_flow"].float()
482
+ hidden_squared += float(hidden_error.square().sum())
483
+ hidden_elements += hidden_error.numel()
484
+ flow_squared += float(flow_error.square().sum())
485
+ flow_elements += flow_error.numel()
486
+ del batch, pred_hidden, pred_flow, hidden_error, flow_error
487
+ hidden_mse = hidden_squared / hidden_elements
488
+ flow_mse = flow_squared / flow_elements
489
+ gate_mean = gate_sum / gate_count
490
+ gate_variance = max(0.0, gate_square_sum / gate_count - gate_mean**2)
491
+ model.train()
492
+ return {
493
+ "hidden_mse": hidden_mse,
494
+ "flow_mse": flow_mse,
495
+ "total_loss": hidden_weight * hidden_mse + flow_weight * flow_mse,
496
+ "gate_mean": gate_mean,
497
+ "gate_std": math.sqrt(gate_variance),
498
+ "gate_histogram_10_bins": gate_histogram.tolist(),
499
+ "gate_count": gate_count,
500
+ "eval_time_s": time.perf_counter() - started,
501
+ }
502
+
503
+
504
+ def normalized_auc(
505
+ evaluations: list[dict[str, Any]], key: str, max_steps: int
506
+ ) -> float:
507
+ if len(evaluations) < 2:
508
+ return float(evaluations[0][key])
509
+ area = 0.0
510
+ for left, right in zip(evaluations, evaluations[1:]):
511
+ width = int(right["step"]) - int(left["step"])
512
+ area += width * (float(left[key]) + float(right[key])) * 0.5
513
+ return area / max_steps
514
+
515
+
516
+ def save_predictor_weights(
517
+ model: SingleBlockPredictor, path: Path
518
+ ) -> None:
519
+ tensors = {
520
+ key: value.detach().to(device="cpu").contiguous()
521
+ for key, value in model.state_dict().items()
522
+ }
523
+ temporary = path.with_suffix(path.suffix + ".tmp")
524
+ save_file(tensors, temporary)
525
+ os.replace(temporary, path)
526
+
527
+
528
+ def make_model(
529
+ teacher: torch.nn.Module,
530
+ source_layer: int,
531
+ method: str,
532
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
533
+ seed: int,
534
+ gradient_checkpointing: bool,
535
+ device: torch.device,
536
+ gate_mode: str = "baseline",
537
+ gate_hidden_dim: int = 128,
538
+ gate_initial_bias: float = 4.6,
539
+ gate_floor: float = 0.0,
540
+ constant_gate: float = 1.0,
541
+ input_variant: str = "self_forcing",
542
+ atc_previous_scope: str = "chunk",
543
+ atc_freq_dim: int = 256,
544
+ atc_mlp_hidden_dim: int = 3072,
545
+ atc_gate_hidden_dim: int = 512,
546
+ atc_transport_residual_scale: float = 0.1,
547
+ atc_gate_initial_probability: float = 0.3,
548
+ ) -> SingleBlockPredictor:
549
+ # Random block baselines use the same seed independently of the shared
550
+ # fusion initialization.
551
+ set_seed(seed)
552
+ block = initialize_predictor_block(
553
+ teacher.blocks[source_layer],
554
+ method,
555
+ )
556
+ model = SingleBlockPredictor(
557
+ block,
558
+ dim=1536,
559
+ gradient_checkpointing=gradient_checkpointing,
560
+ input_variant=input_variant,
561
+ gate_mode=gate_mode,
562
+ gate_hidden_dim=gate_hidden_dim,
563
+ gate_initial_bias=gate_initial_bias,
564
+ gate_floor=gate_floor,
565
+ constant_gate=constant_gate,
566
+ atc_previous_scope=atc_previous_scope,
567
+ atc_freq_dim=atc_freq_dim,
568
+ atc_mlp_hidden_dim=atc_mlp_hidden_dim,
569
+ atc_gate_hidden_dim=atc_gate_hidden_dim,
570
+ atc_transport_residual_scale=atc_transport_residual_scale,
571
+ atc_gate_initial_probability=atc_gate_initial_probability,
572
+ )
573
+ fusion_state = shared_nonblock_state["fusion"]
574
+ if input_variant == "disca":
575
+ # Keep all overlapping initialization exactly matched to the
576
+ # Self-Forcing baseline, while physically removing the third D-wide
577
+ # previous-chunk input channel from proj_in.
578
+ disca_state = {
579
+ key: value
580
+ for key, value in fusion_state.items()
581
+ if key.startswith(("current_norm.", "anchor_norm.", "proj_out."))
582
+ }
583
+ disca_state["proj_in.weight"] = fusion_state["proj_in.weight"][
584
+ :, : 2 * teacher.dim
585
+ ].clone()
586
+ disca_state["proj_in.bias"] = fusion_state["proj_in.bias"].clone()
587
+ model.fusion.load_state_dict(disca_state, strict=True)
588
+ elif input_variant == "self_forcing":
589
+ incompatible = model.fusion.load_state_dict(fusion_state, strict=False)
590
+ unexpected = list(incompatible.unexpected_keys)
591
+ missing = [
592
+ key
593
+ for key in incompatible.missing_keys
594
+ if not key.startswith("gate.")
595
+ ]
596
+ if unexpected or missing:
597
+ raise RuntimeError(
598
+ f"Shared fusion state mismatch: missing={missing}, "
599
+ f"unexpected={unexpected}"
600
+ )
601
+ elif input_variant != "atc":
602
+ raise ValueError(f"Unknown input variant: {input_variant}")
603
+ model.residual_out.load_state_dict(
604
+ shared_nonblock_state["residual_out"], strict=True
605
+ )
606
+ return model.to(device=device)
607
+
608
+
609
+ def run_experiment(
610
+ *,
611
+ name: str,
612
+ method: str,
613
+ source_layer: int,
614
+ args: argparse.Namespace,
615
+ store: OfflinePredictorStore,
616
+ teacher: torch.nn.Module,
617
+ train_prompt_ids: list[int],
618
+ val_prompt_ids: list[int],
619
+ schedule: BatchSchedule,
620
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
621
+ device: torch.device,
622
+ ) -> dict[str, Any]:
623
+ run_dir = args.output_dir / name
624
+ metrics_path = run_dir / "metrics.json"
625
+ if metrics_path.exists():
626
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
627
+ if existing.get("status") == "complete":
628
+ print(f"[run] skip completed {name}", flush=True)
629
+ return existing
630
+ run_dir.mkdir(parents=True, exist_ok=True)
631
+ log_path = run_dir / "train_log.jsonl"
632
+
633
+ model = make_model(
634
+ teacher,
635
+ source_layer,
636
+ method,
637
+ shared_nonblock_state,
638
+ args.seed,
639
+ args.gradient_checkpointing,
640
+ device,
641
+ args.gate_mode,
642
+ args.gate_hidden_dim,
643
+ args.gate_initial_bias,
644
+ args.gate_floor,
645
+ args.constant_gate,
646
+ )
647
+ fusion_parameters = model.fusion_parameters_without_gate()
648
+ gate_parameters = model.gate_parameters()
649
+ block_parameters = model.block_parameters()
650
+ optimizer_groups = [
651
+ {"params": fusion_parameters, "lr": args.fusion_lr},
652
+ ]
653
+ if gate_parameters:
654
+ optimizer_groups.append({"params": gate_parameters, "lr": args.gate_lr})
655
+ block_group_index = len(optimizer_groups)
656
+ optimizer_groups.append({"params": block_parameters, "lr": args.block_lr})
657
+ optimizer = AdamW(
658
+ optimizer_groups,
659
+ betas=(0.9, 0.95),
660
+ weight_decay=args.weight_decay,
661
+ )
662
+ initial_block_norm = parameter_norm(block_parameters)
663
+ evaluations: list[dict[str, Any]] = []
664
+ start_step = 0
665
+ latest_path = run_dir / "training_latest.pt"
666
+ if latest_path.exists():
667
+ state = torch.load(
668
+ latest_path, map_location="cpu", weights_only=False
669
+ )
670
+ model.load_state_dict(state["model"], strict=True)
671
+ optimizer.load_state_dict(state["optimizer"])
672
+ evaluations = state["evaluations"]
673
+ start_step = int(state["step"])
674
+ print(f"[run] resume {name} at step {start_step}", flush=True)
675
+
676
+ model.set_block_trainable(start_step >= args.block_freeze_steps)
677
+ config = {
678
+ "name": name,
679
+ "initialization_method": method,
680
+ "source_layer": source_layer,
681
+ "source_definition": (
682
+ "block weights, clean-history K/V projection/input, and text K/V "
683
+ "all use this generator_ema Teacher layer"
684
+ ),
685
+ "seed": args.seed,
686
+ "train_prompt_ids": train_prompt_ids,
687
+ "val_prompt_ids": val_prompt_ids,
688
+ "max_steps": args.max_steps,
689
+ "batch_size": args.batch_size,
690
+ "eval_batch_size": args.eval_batch_size,
691
+ "fusion_lr": args.fusion_lr,
692
+ "block_lr": args.block_lr,
693
+ "fusion_warmup_steps": args.fusion_warmup_steps,
694
+ "block_freeze_steps": args.block_freeze_steps,
695
+ "block_warmup_steps": args.block_warmup_steps,
696
+ "weight_decay": args.weight_decay,
697
+ "hidden_weight": args.hidden_weight,
698
+ "flow_weight": args.flow_weight,
699
+ "gradient_checkpointing": args.gradient_checkpointing,
700
+ "gate_mode": args.gate_mode,
701
+ "gate_hidden_dim": args.gate_hidden_dim,
702
+ "gate_initial_bias": args.gate_initial_bias,
703
+ "gate_floor": args.gate_floor,
704
+ "gate_lr": args.gate_lr,
705
+ "gate_freeze_steps": args.gate_freeze_steps,
706
+ "constant_gate": args.constant_gate,
707
+ "batch_schedule_sha256": schedule.fingerprint(),
708
+ "initial_block_parameter_norm": initial_block_norm,
709
+ "trainable_parameters": sum(
710
+ parameter.numel() for parameter in model.parameters()
711
+ ),
712
+ }
713
+ atomic_json(run_dir / "config.json", config)
714
+ print(
715
+ f"[run] {name}: method={method}, source={source_layer}, "
716
+ f"start={start_step}",
717
+ flush=True,
718
+ )
719
+
720
+ if not evaluations:
721
+ initial_eval = evaluate(
722
+ model,
723
+ store,
724
+ val_prompt_ids,
725
+ args.eval_batch_size,
726
+ teacher,
727
+ device,
728
+ args.hidden_weight,
729
+ args.flow_weight,
730
+ )
731
+ evaluations.append({"step": 0, **initial_eval})
732
+ print(
733
+ f"[eval] {name} step=0 flow={initial_eval['flow_mse']:.8f} "
734
+ f"hidden={initial_eval['hidden_mse']:.8f}",
735
+ flush=True,
736
+ )
737
+
738
+ optimizer.zero_grad(set_to_none=True)
739
+ model.train()
740
+ run_started = time.perf_counter()
741
+ for step in range(start_step, args.max_steps):
742
+ block_enabled = step >= args.block_freeze_steps
743
+ if any(parameter.requires_grad != block_enabled for parameter in block_parameters):
744
+ model.set_block_trainable(block_enabled)
745
+
746
+ fusion_lr, block_lr = lr_values(
747
+ step,
748
+ args.max_steps,
749
+ args.fusion_lr,
750
+ args.block_lr,
751
+ args.fusion_warmup_steps,
752
+ args.block_freeze_steps,
753
+ args.block_warmup_steps,
754
+ )
755
+ optimizer.param_groups[0]["lr"] = fusion_lr
756
+ if gate_parameters:
757
+ gate_lr, _ = lr_values(
758
+ max(0, step - args.gate_freeze_steps),
759
+ max(1, args.max_steps - args.gate_freeze_steps),
760
+ args.gate_lr, args.block_lr,
761
+ args.fusion_warmup_steps, 0, args.block_warmup_steps,
762
+ )
763
+ if step < args.gate_freeze_steps:
764
+ gate_lr = 0.0
765
+ optimizer.param_groups[1]["lr"] = gate_lr
766
+ else:
767
+ gate_lr = 0.0
768
+ optimizer.param_groups[block_group_index]["lr"] = block_lr
769
+
770
+ chunk, target_step, prompt_ids = schedule.entries[step]
771
+ batch = move_batch(
772
+ store.batch(prompt_ids, chunk, target_step), device
773
+ )
774
+ step_started = time.perf_counter()
775
+ pred_hidden, pred_flow = forward_predictor(
776
+ model, batch, teacher, device
777
+ )
778
+ hidden_loss = F.mse_loss(
779
+ pred_hidden.float(), batch["target_hidden"].float()
780
+ )
781
+ flow_loss = F.mse_loss(
782
+ pred_flow.float(), batch["target_flow"].float()
783
+ )
784
+ loss = (
785
+ args.hidden_weight * hidden_loss
786
+ + args.flow_weight * flow_loss
787
+ )
788
+ loss.backward()
789
+ if step < args.gate_freeze_steps:
790
+ for parameter in gate_parameters:
791
+ parameter.grad = None
792
+ fusion_grad_norm = gradient_norm(fusion_parameters)
793
+ block_grad_norm = gradient_norm(block_parameters)
794
+ total_grad_norm = torch.nn.utils.clip_grad_norm_(
795
+ model.parameters(), args.grad_clip
796
+ )
797
+ optimizer.step()
798
+ optimizer.zero_grad(set_to_none=True)
799
+ completed_step = step + 1
800
+
801
+ if completed_step == 1 or completed_step % args.log_every == 0:
802
+ torch.cuda.synchronize()
803
+ record = {
804
+ "step": completed_step,
805
+ "train_total_loss": float(loss.detach()),
806
+ "train_hidden_mse": float(hidden_loss.detach()),
807
+ "train_flow_mse": float(flow_loss.detach()),
808
+ "fusion_grad_norm": fusion_grad_norm,
809
+ "block_grad_norm": block_grad_norm,
810
+ "total_grad_norm_before_clip": float(total_grad_norm),
811
+ "fusion_lr": fusion_lr,
812
+ "gate_lr": gate_lr,
813
+ "block_lr": block_lr,
814
+ "chunk": chunk,
815
+ "target_step": target_step,
816
+ "step_time_s": time.perf_counter() - step_started,
817
+ "peak_gpu_gib": torch.cuda.max_memory_allocated() / (1024**3),
818
+ }
819
+ append_jsonl(log_path, record)
820
+ print(
821
+ f"[train] {name} {completed_step}/{args.max_steps} "
822
+ f"flow={record['train_flow_mse']:.8f} "
823
+ f"time={record['step_time_s']:.2f}s "
824
+ f"mem={record['peak_gpu_gib']:.1f}G",
825
+ flush=True,
826
+ )
827
+
828
+ should_eval = (
829
+ completed_step % args.eval_every == 0
830
+ or completed_step == args.max_steps
831
+ )
832
+ if should_eval:
833
+ validation = evaluate(
834
+ model,
835
+ store,
836
+ val_prompt_ids,
837
+ args.eval_batch_size,
838
+ teacher,
839
+ device,
840
+ args.hidden_weight,
841
+ args.flow_weight,
842
+ )
843
+ evaluations.append({"step": completed_step, **validation})
844
+ print(
845
+ f"[eval] {name} step={completed_step} "
846
+ f"flow={validation['flow_mse']:.8f} "
847
+ f"hidden={validation['hidden_mse']:.8f}",
848
+ flush=True,
849
+ )
850
+
851
+ should_save = (
852
+ completed_step % args.save_every == 0
853
+ or completed_step == args.max_steps
854
+ )
855
+ if should_save:
856
+ temporary = latest_path.with_suffix(".pt.tmp")
857
+ torch.save(
858
+ {
859
+ "model": {
860
+ key: value.detach().cpu()
861
+ for key, value in model.state_dict().items()
862
+ },
863
+ "optimizer": optimizer.state_dict(),
864
+ "evaluations": evaluations,
865
+ "step": completed_step,
866
+ },
867
+ temporary,
868
+ )
869
+ os.replace(temporary, latest_path)
870
+
871
+ del (
872
+ batch,
873
+ pred_hidden,
874
+ pred_flow,
875
+ hidden_loss,
876
+ flow_loss,
877
+ loss,
878
+ total_grad_norm,
879
+ )
880
+
881
+ final = evaluations[-1]
882
+ result = {
883
+ "status": "complete",
884
+ **config,
885
+ "final_val_hidden_mse": final["hidden_mse"],
886
+ "final_val_flow_mse": final["flow_mse"],
887
+ "final_val_total_loss": final["total_loss"],
888
+ "val_hidden_mse_auc": normalized_auc(
889
+ evaluations, "hidden_mse", args.max_steps
890
+ ),
891
+ "val_flow_mse_auc": normalized_auc(
892
+ evaluations, "flow_mse", args.max_steps
893
+ ),
894
+ "val_total_loss_auc": normalized_auc(
895
+ evaluations, "total_loss", args.max_steps
896
+ ),
897
+ "evaluations": evaluations,
898
+ "training_time_s": time.perf_counter() - run_started,
899
+ "final_block_parameter_norm": parameter_norm(block_parameters),
900
+ }
901
+ if args.save_final_weights:
902
+ save_predictor_weights(model, run_dir / "predictor_final.safetensors")
903
+ atomic_json(metrics_path, result)
904
+ if latest_path.exists():
905
+ latest_path.unlink()
906
+ del model, optimizer
907
+ torch.cuda.empty_cache()
908
+ return result
909
+
910
+
911
+ def write_summary(output_dir: Path, results: list[dict[str, Any]]) -> None:
912
+ rows = [
913
+ {
914
+ "name": result["name"],
915
+ "initialization_method": result["initialization_method"],
916
+ "source_layer": result["source_layer"],
917
+ "final_val_flow_mse": result["final_val_flow_mse"],
918
+ "final_val_hidden_mse": result["final_val_hidden_mse"],
919
+ "final_val_total_loss": result["final_val_total_loss"],
920
+ "val_flow_mse_auc": result["val_flow_mse_auc"],
921
+ "val_hidden_mse_auc": result["val_hidden_mse_auc"],
922
+ "val_total_loss_auc": result["val_total_loss_auc"],
923
+ "training_time_s": result["training_time_s"],
924
+ }
925
+ for result in results
926
+ ]
927
+ rows.sort(key=lambda row: float(row["final_val_flow_mse"]))
928
+ atomic_json(output_dir / "summary.json", rows)
929
+ temporary = output_dir / "summary.csv.tmp"
930
+ with temporary.open("w", newline="", encoding="utf-8") as handle:
931
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
932
+ writer.writeheader()
933
+ writer.writerows(rows)
934
+ os.replace(temporary, output_dir / "summary.csv")
935
+
936
+
937
+ def main() -> None:
938
+ args = parse_args()
939
+ args.dataset_root = resolve(args.dataset_root)
940
+ args.checkpoint_path = resolve(args.checkpoint_path)
941
+ args.config_path = resolve(args.config_path)
942
+ args.output_dir = resolve(args.output_dir)
943
+ args.output_dir.mkdir(parents=True, exist_ok=True)
944
+ layers = (
945
+ list(range(30))
946
+ if args.teacher_layers is None or len(args.teacher_layers) == 0
947
+ else sorted(set(args.teacher_layers))
948
+ )
949
+ if any(layer < 0 or layer >= 30 for layer in layers):
950
+ raise ValueError(f"Invalid Teacher layers: {layers}")
951
+
952
+ set_seed(args.seed)
953
+ torch.backends.cuda.matmul.allow_tf32 = True
954
+ torch.backends.cudnn.allow_tf32 = True
955
+ torch.set_float32_matmul_precision("high")
956
+ device = torch.device("cuda")
957
+
958
+ train_prompt_ids = list(range(args.train_prompts))
959
+ val_prompt_ids = list(
960
+ range(80, 80 + args.val_prompts)
961
+ if args.train_prompts == 80
962
+ else range(args.train_prompts, args.train_prompts + args.val_prompts)
963
+ )
964
+ prompt_ids = sorted(set(train_prompt_ids + val_prompt_ids))
965
+ schedule = BatchSchedule(
966
+ train_prompt_ids,
967
+ args.batch_size,
968
+ args.max_steps,
969
+ args.seed,
970
+ )
971
+ shared_nonblock_state = build_shared_nonblock_state(args.seed)
972
+
973
+ atomic_json(
974
+ args.output_dir / "sweep_config.json",
975
+ {
976
+ **{
977
+ key: str(value) if isinstance(value, Path) else value
978
+ for key, value in vars(args).items()
979
+ },
980
+ "teacher_layers": layers,
981
+ "train_prompt_ids": train_prompt_ids,
982
+ "val_prompt_ids": val_prompt_ids,
983
+ "batch_schedule_sha256": schedule.fingerprint(),
984
+ "other_initializations": list(OTHER_METHODS),
985
+ },
986
+ )
987
+
988
+ print("[setup] loading frozen generator_ema", flush=True)
989
+ teacher = load_teacher(
990
+ args.checkpoint_path,
991
+ args.config_path,
992
+ device,
993
+ )
994
+ print("[setup] loading common offline trajectories into RAM", flush=True)
995
+ store = OfflinePredictorStore(args.dataset_root, prompt_ids)
996
+
997
+ results: list[dict[str, Any]] = []
998
+ for layer in layers:
999
+ name = f"{args.gate_mode}_layer_{layer:02d}"
1000
+ metrics_path = args.output_dir / name / "metrics.json"
1001
+ if metrics_path.exists():
1002
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
1003
+ if existing.get("status") == "complete":
1004
+ results.append(existing)
1005
+ print(f"[sweep] already complete: {name}", flush=True)
1006
+ continue
1007
+ store.load_layer_cache(layer, teacher, device)
1008
+ results.append(
1009
+ run_experiment(
1010
+ name=name,
1011
+ method="teacher_full",
1012
+ source_layer=layer,
1013
+ args=args,
1014
+ store=store,
1015
+ teacher=teacher,
1016
+ train_prompt_ids=train_prompt_ids,
1017
+ val_prompt_ids=val_prompt_ids,
1018
+ schedule=schedule,
1019
+ shared_nonblock_state=shared_nonblock_state,
1020
+ device=device,
1021
+ )
1022
+ )
1023
+ write_summary(args.output_dir, results)
1024
+
1025
+ teacher_results = [
1026
+ result
1027
+ for result in results
1028
+ if result["initialization_method"] == "teacher_full"
1029
+ ]
1030
+ if not teacher_results:
1031
+ raise RuntimeError("No Teacher layer experiment completed")
1032
+ best_teacher = min(
1033
+ teacher_results,
1034
+ key=lambda result: float(result["final_val_flow_mse"]),
1035
+ )
1036
+ best_layer = int(best_teacher["source_layer"])
1037
+ atomic_json(
1038
+ args.output_dir / "best_teacher_layer.json",
1039
+ {
1040
+ "source_layer": best_layer,
1041
+ "selection_metric": "final_val_flow_mse",
1042
+ "value": best_teacher["final_val_flow_mse"],
1043
+ "run": best_teacher["name"],
1044
+ },
1045
+ )
1046
+ print(
1047
+ f"[sweep] best Teacher layer={best_layer}, "
1048
+ f"flow={best_teacher['final_val_flow_mse']:.8f}",
1049
+ flush=True,
1050
+ )
1051
+
1052
+ if args.run_other_initializations:
1053
+ store.load_layer_cache(best_layer, teacher, device)
1054
+ for method in OTHER_METHODS:
1055
+ name = f"{method}_source_{best_layer:02d}"
1056
+ results.append(
1057
+ run_experiment(
1058
+ name=name,
1059
+ method=method,
1060
+ source_layer=best_layer,
1061
+ args=args,
1062
+ store=store,
1063
+ teacher=teacher,
1064
+ train_prompt_ids=train_prompt_ids,
1065
+ val_prompt_ids=val_prompt_ids,
1066
+ schedule=schedule,
1067
+ shared_nonblock_state=shared_nonblock_state,
1068
+ device=device,
1069
+ )
1070
+ )
1071
+ write_summary(args.output_dir, results)
1072
+
1073
+ write_summary(args.output_dir, results)
1074
+ print(
1075
+ f"[sweep] complete: {len(results)} experiments -> "
1076
+ f"{args.output_dir / 'summary.csv'}",
1077
+ flush=True,
1078
+ )
1079
+
1080
+
1081
+ if __name__ == "__main__":
1082
+ main()
scripts/run_three_block_consecutive_sweep.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train three-block Predictors initialized from consecutive Teacher layers."""
3
+
4
+ # ruff: noqa: E402 -- CUDA_VISIBLE_DEVICES must be set before importing torch.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import csv
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _preparse_gpu() -> str:
19
+ parser = argparse.ArgumentParser(add_help=False)
20
+ parser.add_argument("--gpu", default="2")
21
+ args, _ = parser.parse_known_args()
22
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
23
+ return str(args.gpu)
24
+
25
+
26
+ PHYSICAL_GPU = _preparse_gpu()
27
+
28
+ import torch
29
+ import torch.nn.functional as F
30
+ from safetensors.torch import save_file
31
+ from torch.optim import AdamW
32
+
33
+ REPO_ROOT = Path(__file__).resolve().parents[1]
34
+ if str(REPO_ROOT) not in sys.path:
35
+ sys.path.insert(0, str(REPO_ROOT))
36
+
37
+ from predictor_training.offline_data import OfflinePredictorStore, TOKENS_PER_CHUNK
38
+ from predictor_training.three_block import ThreeBlockPredictor
39
+ from scripts.run_single_block_init_sweep import (
40
+ BatchSchedule,
41
+ append_jsonl,
42
+ atomic_json,
43
+ build_shared_nonblock_state,
44
+ frozen_inputs,
45
+ gradient_norm,
46
+ hidden_to_flow,
47
+ load_teacher,
48
+ lr_values,
49
+ move_batch,
50
+ normalized_auc,
51
+ parameter_norm,
52
+ )
53
+ from utils.misc import set_seed
54
+
55
+
56
+ Triple = tuple[int, int, int]
57
+
58
+
59
+ def default_triples() -> list[Triple]:
60
+ return [(index, index + 1, index + 2) for index in range(28)]
61
+
62
+
63
+ def parse_triple(value: str) -> Triple:
64
+ try:
65
+ values = tuple(int(item) for item in value.split(","))
66
+ except ValueError as error:
67
+ raise argparse.ArgumentTypeError(
68
+ f"Triple must look like 0,1,2; got {value!r}"
69
+ ) from error
70
+ if len(values) != 3:
71
+ raise argparse.ArgumentTypeError(
72
+ f"Triple must contain exactly three layers; got {value!r}"
73
+ )
74
+ triple = (values[0], values[1], values[2])
75
+ if not (
76
+ 0 <= triple[0]
77
+ and triple[1] == triple[0] + 1
78
+ and triple[2] == triple[1] + 1
79
+ and triple[2] < 30
80
+ ):
81
+ raise argparse.ArgumentTypeError(
82
+ "Triple must be three consecutive layers within 0..29; "
83
+ f"got {triple}"
84
+ )
85
+ return triple
86
+
87
+
88
+ def parse_args() -> argparse.Namespace:
89
+ parser = argparse.ArgumentParser(description=__doc__)
90
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
91
+ parser.add_argument(
92
+ "--dataset_root",
93
+ type=Path,
94
+ default=Path("outputs/predictor_offline_100_all_blocks"),
95
+ )
96
+ parser.add_argument(
97
+ "--checkpoint_path",
98
+ type=Path,
99
+ default=Path("checkpoints/self_forcing_dmd.pt"),
100
+ )
101
+ parser.add_argument(
102
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
103
+ )
104
+ parser.add_argument(
105
+ "--output_dir",
106
+ type=Path,
107
+ default=Path("outputs/three_block_consecutive_sweep"),
108
+ )
109
+ parser.add_argument(
110
+ "--triples",
111
+ type=parse_triple,
112
+ nargs="*",
113
+ default=None,
114
+ help=(
115
+ "Optional subset such as 0,1,2 1,2,3. "
116
+ "Omit to train all 28 consecutive triples."
117
+ ),
118
+ )
119
+ parser.add_argument("--max_triples", type=int, default=None)
120
+ parser.add_argument("--max_steps", type=int, default=1000)
121
+ parser.add_argument("--batch_size", type=int, default=32)
122
+ parser.add_argument("--eval_batch_size", type=int, default=10)
123
+ parser.add_argument("--eval_every", type=int, default=100)
124
+ parser.add_argument("--log_every", type=int, default=20)
125
+ parser.add_argument("--save_every", type=int, default=100)
126
+ parser.add_argument("--train_prompts", type=int, default=80)
127
+ parser.add_argument("--val_prompts", type=int, default=20)
128
+ parser.add_argument("--seed", type=int, default=0)
129
+ parser.add_argument("--fusion_lr", type=float, default=1e-4)
130
+ parser.add_argument("--block_lr", type=float, default=1e-5)
131
+ parser.add_argument("--weight_decay", type=float, default=0.01)
132
+ parser.add_argument("--hidden_weight", type=float, default=0.1)
133
+ parser.add_argument("--flow_weight", type=float, default=1.0)
134
+ parser.add_argument("--grad_clip", type=float, default=1.0)
135
+ parser.add_argument("--fusion_warmup_steps", type=int, default=100)
136
+ parser.add_argument("--block_freeze_steps", type=int, default=100)
137
+ parser.add_argument("--block_warmup_steps", type=int, default=100)
138
+ parser.add_argument(
139
+ "--gradient_checkpointing",
140
+ action=argparse.BooleanOptionalAction,
141
+ default=True,
142
+ )
143
+ parser.add_argument(
144
+ "--save_final_weights",
145
+ action=argparse.BooleanOptionalAction,
146
+ default=True,
147
+ )
148
+ args = parser.parse_args()
149
+ if args.max_steps < 1:
150
+ parser.error("--max_steps must be positive")
151
+ if args.train_prompts < 1 or args.val_prompts < 1:
152
+ parser.error("Prompt counts must be positive")
153
+ if args.train_prompts + args.val_prompts > 100:
154
+ parser.error("The offline dataset contains 100 prompts")
155
+ if args.batch_size > args.train_prompts:
156
+ parser.error("--batch_size cannot exceed --train_prompts")
157
+ if args.eval_batch_size > args.val_prompts:
158
+ args.eval_batch_size = args.val_prompts
159
+ if args.max_triples is not None and args.max_triples < 1:
160
+ parser.error("--max_triples must be positive")
161
+ return args
162
+
163
+
164
+ def resolve(path: Path) -> Path:
165
+ path = path.expanduser()
166
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
167
+
168
+
169
+ def experiment_name(triple: Triple) -> str:
170
+ return "triple_" + "_".join(f"{layer:02d}" for layer in triple)
171
+
172
+
173
+ def make_model(
174
+ teacher: torch.nn.Module,
175
+ triple: Triple,
176
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
177
+ seed: int,
178
+ gradient_checkpointing: bool,
179
+ device: torch.device,
180
+ ) -> ThreeBlockPredictor:
181
+ set_seed(seed)
182
+ model = ThreeBlockPredictor(
183
+ [teacher.blocks[layer] for layer in triple],
184
+ dim=teacher.dim,
185
+ gradient_checkpointing=gradient_checkpointing,
186
+ )
187
+ model.fusion.load_state_dict(shared_nonblock_state["fusion"], strict=True)
188
+ model.residual_out.load_state_dict(
189
+ shared_nonblock_state["residual_out"], strict=True
190
+ )
191
+ return model.to(device=device)
192
+
193
+
194
+ def forward_predictor(
195
+ model: ThreeBlockPredictor,
196
+ batch: dict[str, Any],
197
+ teacher: torch.nn.Module,
198
+ device: torch.device,
199
+ ) -> tuple[torch.Tensor, torch.Tensor]:
200
+ frozen = frozen_inputs(batch, teacher, device)
201
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
202
+ pred_hidden = model(
203
+ current_tokens=frozen["current_tokens"],
204
+ anchor_hidden=batch["anchor_hidden"],
205
+ previous_hidden=batch["previous_hidden"],
206
+ timestep_modulation=frozen["timestep_modulation"],
207
+ grid_sizes=frozen["grid_sizes"],
208
+ freqs=frozen["freqs"],
209
+ history_ks=[batch[f"history_k_{index}"] for index in range(3)],
210
+ history_vs=[batch[f"history_v_{index}"] for index in range(3)],
211
+ cross_ks=[batch[f"cross_k_{index}"] for index in range(3)],
212
+ cross_vs=[batch[f"cross_v_{index}"] for index in range(3)],
213
+ current_start=batch["chunk"] * TOKENS_PER_CHUNK,
214
+ )
215
+ pred_flow = hidden_to_flow(
216
+ pred_hidden,
217
+ frozen["head_embedding"],
218
+ frozen["grid_sizes"],
219
+ teacher,
220
+ )
221
+ return pred_hidden, pred_flow
222
+
223
+
224
+ @torch.inference_mode()
225
+ def evaluate(
226
+ model: ThreeBlockPredictor,
227
+ store: OfflinePredictorStore,
228
+ triple: Triple,
229
+ val_prompt_ids: list[int],
230
+ batch_size: int,
231
+ teacher: torch.nn.Module,
232
+ device: torch.device,
233
+ hidden_weight: float,
234
+ flow_weight: float,
235
+ ) -> dict[str, float]:
236
+ model.eval()
237
+ hidden_squared = 0.0
238
+ hidden_elements = 0
239
+ flow_squared = 0.0
240
+ flow_elements = 0
241
+ started = time.perf_counter()
242
+ for chunk in range(1, 7):
243
+ for target_step in range(1, 4):
244
+ for start in range(0, len(val_prompt_ids), batch_size):
245
+ prompt_ids = val_prompt_ids[start : start + batch_size]
246
+ batch = move_batch(
247
+ store.batch_layers(prompt_ids, chunk, target_step, triple),
248
+ device,
249
+ )
250
+ pred_hidden, pred_flow = forward_predictor(
251
+ model, batch, teacher, device
252
+ )
253
+ hidden_error = pred_hidden.float() - batch["target_hidden"].float()
254
+ flow_error = pred_flow.float() - batch["target_flow"].float()
255
+ hidden_squared += float(hidden_error.square().sum())
256
+ hidden_elements += hidden_error.numel()
257
+ flow_squared += float(flow_error.square().sum())
258
+ flow_elements += flow_error.numel()
259
+ del batch, pred_hidden, pred_flow, hidden_error, flow_error
260
+ hidden_mse = hidden_squared / hidden_elements
261
+ flow_mse = flow_squared / flow_elements
262
+ model.train()
263
+ return {
264
+ "hidden_mse": hidden_mse,
265
+ "flow_mse": flow_mse,
266
+ "total_loss": hidden_weight * hidden_mse + flow_weight * flow_mse,
267
+ "eval_time_s": time.perf_counter() - started,
268
+ }
269
+
270
+
271
+ def save_predictor_weights(model: ThreeBlockPredictor, path: Path) -> None:
272
+ tensors = {
273
+ key: value.detach().to(device="cpu").contiguous()
274
+ for key, value in model.state_dict().items()
275
+ }
276
+ temporary = path.with_suffix(path.suffix + ".tmp")
277
+ save_file(tensors, temporary)
278
+ os.replace(temporary, path)
279
+
280
+
281
+ def run_experiment(
282
+ *,
283
+ triple: Triple,
284
+ args: argparse.Namespace,
285
+ store: OfflinePredictorStore,
286
+ teacher: torch.nn.Module,
287
+ train_prompt_ids: list[int],
288
+ val_prompt_ids: list[int],
289
+ schedule: BatchSchedule,
290
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
291
+ device: torch.device,
292
+ ) -> dict[str, Any]:
293
+ name = experiment_name(triple)
294
+ run_dir = args.output_dir / name
295
+ metrics_path = run_dir / "metrics.json"
296
+ if metrics_path.exists():
297
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
298
+ if existing.get("status") == "complete":
299
+ print(f"[run] skip completed {name}", flush=True)
300
+ return existing
301
+ run_dir.mkdir(parents=True, exist_ok=True)
302
+ log_path = run_dir / "train_log.jsonl"
303
+
304
+ model = make_model(
305
+ teacher,
306
+ triple,
307
+ shared_nonblock_state,
308
+ args.seed,
309
+ args.gradient_checkpointing,
310
+ device,
311
+ )
312
+ fusion_parameters = model.fusion_parameters()
313
+ block_parameters = model.block_parameters()
314
+ optimizer = AdamW(
315
+ [
316
+ {"params": fusion_parameters, "lr": args.fusion_lr},
317
+ {"params": block_parameters, "lr": args.block_lr},
318
+ ],
319
+ betas=(0.9, 0.95),
320
+ weight_decay=args.weight_decay,
321
+ )
322
+ initial_block_norm = parameter_norm(block_parameters)
323
+ evaluations: list[dict[str, Any]] = []
324
+ start_step = 0
325
+ latest_path = run_dir / "training_latest.pt"
326
+ if latest_path.exists():
327
+ state = torch.load(latest_path, map_location="cpu", weights_only=False)
328
+ model.load_state_dict(state["model"], strict=True)
329
+ optimizer.load_state_dict(state["optimizer"])
330
+ evaluations = state["evaluations"]
331
+ start_step = int(state["step"])
332
+ print(f"[run] resume {name} at step {start_step}", flush=True)
333
+
334
+ model.set_blocks_trainable(start_step >= args.block_freeze_steps)
335
+ config = {
336
+ "name": name,
337
+ "architecture": "three_block_predictor",
338
+ "initialization_method": "teacher_full",
339
+ "source_layers": list(triple),
340
+ "triple_kind": "consecutive",
341
+ "source_definition": (
342
+ "Predictor block i, its clean-history K/V, and its text K/V use "
343
+ "generator_ema source_layers[i]"
344
+ ),
345
+ "seed": args.seed,
346
+ "train_prompt_ids": train_prompt_ids,
347
+ "val_prompt_ids": val_prompt_ids,
348
+ "max_steps": args.max_steps,
349
+ "batch_size": args.batch_size,
350
+ "eval_batch_size": args.eval_batch_size,
351
+ "fusion_lr": args.fusion_lr,
352
+ "block_lr": args.block_lr,
353
+ "fusion_warmup_steps": args.fusion_warmup_steps,
354
+ "block_freeze_steps": args.block_freeze_steps,
355
+ "block_warmup_steps": args.block_warmup_steps,
356
+ "weight_decay": args.weight_decay,
357
+ "hidden_weight": args.hidden_weight,
358
+ "flow_weight": args.flow_weight,
359
+ "gradient_checkpointing": args.gradient_checkpointing,
360
+ "batch_schedule_sha256": schedule.fingerprint(),
361
+ "initial_block_parameter_norm": initial_block_norm,
362
+ "trainable_parameters": sum(
363
+ parameter.numel() for parameter in model.parameters()
364
+ ),
365
+ }
366
+ atomic_json(run_dir / "config.json", config)
367
+ print(f"[run] {name}: start={start_step}", flush=True)
368
+
369
+ if not evaluations:
370
+ initial_eval = evaluate(
371
+ model,
372
+ store,
373
+ triple,
374
+ val_prompt_ids,
375
+ args.eval_batch_size,
376
+ teacher,
377
+ device,
378
+ args.hidden_weight,
379
+ args.flow_weight,
380
+ )
381
+ evaluations.append({"step": 0, **initial_eval})
382
+ print(
383
+ f"[eval] {name} step=0 flow={initial_eval['flow_mse']:.8f} "
384
+ f"hidden={initial_eval['hidden_mse']:.8f}",
385
+ flush=True,
386
+ )
387
+
388
+ optimizer.zero_grad(set_to_none=True)
389
+ model.train()
390
+ run_started = time.perf_counter()
391
+ for step in range(start_step, args.max_steps):
392
+ block_enabled = step >= args.block_freeze_steps
393
+ if any(
394
+ parameter.requires_grad != block_enabled
395
+ for parameter in block_parameters
396
+ ):
397
+ model.set_blocks_trainable(block_enabled)
398
+
399
+ fusion_lr, block_lr = lr_values(
400
+ step,
401
+ args.max_steps,
402
+ args.fusion_lr,
403
+ args.block_lr,
404
+ args.fusion_warmup_steps,
405
+ args.block_freeze_steps,
406
+ args.block_warmup_steps,
407
+ )
408
+ optimizer.param_groups[0]["lr"] = fusion_lr
409
+ optimizer.param_groups[1]["lr"] = block_lr
410
+
411
+ chunk, target_step, prompt_ids = schedule.entries[step]
412
+ batch = move_batch(
413
+ store.batch_layers(prompt_ids, chunk, target_step, triple), device
414
+ )
415
+ step_started = time.perf_counter()
416
+ pred_hidden, pred_flow = forward_predictor(model, batch, teacher, device)
417
+ hidden_loss = F.mse_loss(
418
+ pred_hidden.float(), batch["target_hidden"].float()
419
+ )
420
+ flow_loss = F.mse_loss(pred_flow.float(), batch["target_flow"].float())
421
+ loss = args.hidden_weight * hidden_loss + args.flow_weight * flow_loss
422
+ loss.backward()
423
+ fusion_grad_norm = gradient_norm(fusion_parameters)
424
+ block_grad_norm = gradient_norm(block_parameters)
425
+ per_block_grad_norms = [
426
+ gradient_norm(list(block.parameters())) for block in model.blocks
427
+ ]
428
+ total_grad_norm = torch.nn.utils.clip_grad_norm_(
429
+ model.parameters(), args.grad_clip
430
+ )
431
+ optimizer.step()
432
+ optimizer.zero_grad(set_to_none=True)
433
+ completed_step = step + 1
434
+
435
+ if completed_step == 1 or completed_step % args.log_every == 0:
436
+ torch.cuda.synchronize()
437
+ record = {
438
+ "step": completed_step,
439
+ "train_total_loss": float(loss.detach()),
440
+ "train_hidden_mse": float(hidden_loss.detach()),
441
+ "train_flow_mse": float(flow_loss.detach()),
442
+ "fusion_grad_norm": fusion_grad_norm,
443
+ "block_grad_norm": block_grad_norm,
444
+ "total_grad_norm_before_clip": float(total_grad_norm),
445
+ "fusion_lr": fusion_lr,
446
+ "block_lr": block_lr,
447
+ "chunk": chunk,
448
+ "target_step": target_step,
449
+ "step_time_s": time.perf_counter() - step_started,
450
+ "peak_gpu_gib": torch.cuda.max_memory_allocated() / (1024**3),
451
+ }
452
+ record.update(
453
+ {
454
+ f"block_{index}_grad_norm": value
455
+ for index, value in enumerate(per_block_grad_norms)
456
+ }
457
+ )
458
+ append_jsonl(log_path, record)
459
+ print(
460
+ f"[train] {name} {completed_step}/{args.max_steps} "
461
+ f"flow={record['train_flow_mse']:.8f} "
462
+ f"time={record['step_time_s']:.2f}s "
463
+ f"mem={record['peak_gpu_gib']:.1f}G",
464
+ flush=True,
465
+ )
466
+
467
+ if completed_step % args.eval_every == 0 or completed_step == args.max_steps:
468
+ validation = evaluate(
469
+ model,
470
+ store,
471
+ triple,
472
+ val_prompt_ids,
473
+ args.eval_batch_size,
474
+ teacher,
475
+ device,
476
+ args.hidden_weight,
477
+ args.flow_weight,
478
+ )
479
+ evaluations.append({"step": completed_step, **validation})
480
+ print(
481
+ f"[eval] {name} step={completed_step} "
482
+ f"flow={validation['flow_mse']:.8f} "
483
+ f"hidden={validation['hidden_mse']:.8f}",
484
+ flush=True,
485
+ )
486
+
487
+ if completed_step % args.save_every == 0 or completed_step == args.max_steps:
488
+ temporary = latest_path.with_suffix(".pt.tmp")
489
+ torch.save(
490
+ {
491
+ "model": {
492
+ key: value.detach().cpu()
493
+ for key, value in model.state_dict().items()
494
+ },
495
+ "optimizer": optimizer.state_dict(),
496
+ "evaluations": evaluations,
497
+ "step": completed_step,
498
+ },
499
+ temporary,
500
+ )
501
+ os.replace(temporary, latest_path)
502
+
503
+ del batch, pred_hidden, pred_flow, hidden_loss, flow_loss, loss
504
+ del total_grad_norm
505
+
506
+ final = evaluations[-1]
507
+ result = {
508
+ "status": "complete",
509
+ **config,
510
+ "final_val_hidden_mse": final["hidden_mse"],
511
+ "final_val_flow_mse": final["flow_mse"],
512
+ "final_val_total_loss": final["total_loss"],
513
+ "val_hidden_mse_auc": normalized_auc(
514
+ evaluations, "hidden_mse", args.max_steps
515
+ ),
516
+ "val_flow_mse_auc": normalized_auc(
517
+ evaluations, "flow_mse", args.max_steps
518
+ ),
519
+ "val_total_loss_auc": normalized_auc(
520
+ evaluations, "total_loss", args.max_steps
521
+ ),
522
+ "evaluations": evaluations,
523
+ "training_time_s": time.perf_counter() - run_started,
524
+ "final_block_parameter_norm": parameter_norm(block_parameters),
525
+ }
526
+ if args.save_final_weights:
527
+ save_predictor_weights(model, run_dir / "predictor_final.safetensors")
528
+ atomic_json(metrics_path, result)
529
+ if latest_path.exists():
530
+ latest_path.unlink()
531
+ del model, optimizer
532
+ torch.cuda.empty_cache()
533
+ return result
534
+
535
+
536
+ def write_summary(output_dir: Path, results: list[dict[str, Any]]) -> None:
537
+ rows = [
538
+ {
539
+ "name": result["name"],
540
+ "source_layer_1": result["source_layers"][0],
541
+ "source_layer_2": result["source_layers"][1],
542
+ "source_layer_3": result["source_layers"][2],
543
+ "final_val_flow_mse": result["final_val_flow_mse"],
544
+ "final_val_hidden_mse": result["final_val_hidden_mse"],
545
+ "final_val_total_loss": result["final_val_total_loss"],
546
+ "val_flow_mse_auc": result["val_flow_mse_auc"],
547
+ "val_hidden_mse_auc": result["val_hidden_mse_auc"],
548
+ "val_total_loss_auc": result["val_total_loss_auc"],
549
+ "training_time_s": result["training_time_s"],
550
+ }
551
+ for result in results
552
+ ]
553
+ rows.sort(key=lambda row: float(row["final_val_flow_mse"]))
554
+ if not rows:
555
+ return
556
+ destination = output_dir / "summary.csv"
557
+ temporary = destination.with_suffix(".csv.tmp")
558
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
559
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
560
+ writer.writeheader()
561
+ writer.writerows(rows)
562
+ os.replace(temporary, destination)
563
+ atomic_json(output_dir / "summary.json", rows)
564
+
565
+
566
+ def main() -> None:
567
+ args = parse_args()
568
+ args.dataset_root = resolve(args.dataset_root)
569
+ args.checkpoint_path = resolve(args.checkpoint_path)
570
+ args.config_path = resolve(args.config_path)
571
+ args.output_dir = resolve(args.output_dir)
572
+ args.output_dir.mkdir(parents=True, exist_ok=True)
573
+
574
+ triples = (
575
+ default_triples()
576
+ if args.triples is None
577
+ else list(dict.fromkeys(args.triples))
578
+ )
579
+ if args.max_triples is not None:
580
+ triples = triples[: args.max_triples]
581
+ train_prompt_ids = list(range(args.train_prompts))
582
+ val_prompt_ids = list(
583
+ range(args.train_prompts, args.train_prompts + args.val_prompts)
584
+ )
585
+ all_prompt_ids = train_prompt_ids + val_prompt_ids
586
+ device = torch.device("cuda")
587
+ set_seed(args.seed)
588
+ torch.set_grad_enabled(True)
589
+
590
+ print("[setup] loading frozen generator_ema", flush=True)
591
+ teacher = load_teacher(args.checkpoint_path, args.config_path, device)
592
+ print("[setup] loading common offline trajectories into RAM", flush=True)
593
+ store = OfflinePredictorStore(args.dataset_root, all_prompt_ids)
594
+ schedule = BatchSchedule(
595
+ train_prompt_ids, args.batch_size, args.max_steps, args.seed
596
+ )
597
+ shared_nonblock_state = build_shared_nonblock_state(args.seed)
598
+ manifest = {
599
+ "status": "running",
600
+ "architecture": "three_block_predictor",
601
+ "initialization_method": "teacher_full",
602
+ "triples": [list(triple) for triple in triples],
603
+ "num_triples": len(triples),
604
+ "default_consecutive_triples": 28,
605
+ "max_steps": args.max_steps,
606
+ "seed": args.seed,
607
+ "train_prompt_ids": train_prompt_ids,
608
+ "val_prompt_ids": val_prompt_ids,
609
+ "batch_schedule_sha256": schedule.fingerprint(),
610
+ }
611
+ atomic_json(args.output_dir / "sweep_manifest.json", manifest)
612
+
613
+ results: list[dict[str, Any]] = []
614
+ for index, triple in enumerate(triples, start=1):
615
+ name = experiment_name(triple)
616
+ metrics_path = args.output_dir / name / "metrics.json"
617
+ if metrics_path.exists():
618
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
619
+ if existing.get("status") == "complete":
620
+ print(f"[sweep] {index}/{len(triples)} skip {name}", flush=True)
621
+ results.append(existing)
622
+ continue
623
+ print(
624
+ f"[data] {index}/{len(triples)} loading layers {triple}", flush=True
625
+ )
626
+ store.load_layer_caches(triple, teacher, device)
627
+ result = run_experiment(
628
+ triple=triple,
629
+ args=args,
630
+ store=store,
631
+ teacher=teacher,
632
+ train_prompt_ids=train_prompt_ids,
633
+ val_prompt_ids=val_prompt_ids,
634
+ schedule=schedule,
635
+ shared_nonblock_state=shared_nonblock_state,
636
+ device=device,
637
+ )
638
+ results.append(result)
639
+ write_summary(args.output_dir, results)
640
+
641
+ manifest["status"] = "complete"
642
+ atomic_json(args.output_dir / "sweep_manifest.json", manifest)
643
+ write_summary(args.output_dir, results)
644
+ print(
645
+ f"[complete] {len(results)} triples -> {args.output_dir / 'summary.csv'}",
646
+ flush=True,
647
+ )
648
+
649
+
650
+ if __name__ == "__main__":
651
+ main()
scripts/run_two_block_pair_sweep.py ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train two-block Predictors over mirror and consecutive Teacher pairs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def _preparse_gpu() -> str:
17
+ parser = argparse.ArgumentParser(add_help=False)
18
+ parser.add_argument("--gpu", default="2")
19
+ args, _ = parser.parse_known_args()
20
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
21
+ return str(args.gpu)
22
+
23
+
24
+ PHYSICAL_GPU = _preparse_gpu()
25
+
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from safetensors.torch import save_file
29
+ from torch.optim import AdamW
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parents[1]
32
+ if str(REPO_ROOT) not in sys.path:
33
+ sys.path.insert(0, str(REPO_ROOT))
34
+
35
+ from predictor_training.offline_data import OfflinePredictorStore, TOKENS_PER_CHUNK
36
+ from predictor_training.two_block import TwoBlockPredictor
37
+ from scripts.run_single_block_init_sweep import (
38
+ BatchSchedule,
39
+ append_jsonl,
40
+ atomic_json,
41
+ build_shared_nonblock_state,
42
+ frozen_inputs,
43
+ gradient_norm,
44
+ hidden_to_flow,
45
+ load_teacher,
46
+ lr_values,
47
+ move_batch,
48
+ normalized_auc,
49
+ parameter_norm,
50
+ )
51
+ from utils.misc import set_seed
52
+
53
+
54
+ def default_pairs() -> list[tuple[int, int]]:
55
+ mirror = [(index, 29 - index) for index in range(15)]
56
+ consecutive = [(index, index + 1) for index in range(29)]
57
+ return list(dict.fromkeys(mirror + consecutive))
58
+
59
+
60
+ def pair_kind(pair: tuple[int, int]) -> str:
61
+ mirror = pair[0] + pair[1] == 29
62
+ consecutive = pair[1] == pair[0] + 1
63
+ if mirror and consecutive:
64
+ return "mirror+consecutive"
65
+ if mirror:
66
+ return "mirror"
67
+ if consecutive:
68
+ return "consecutive"
69
+ return "custom"
70
+
71
+
72
+ def parse_pair(value: str) -> tuple[int, int]:
73
+ try:
74
+ left_text, right_text = value.split(",", maxsplit=1)
75
+ pair = (int(left_text), int(right_text))
76
+ except Exception as error:
77
+ raise argparse.ArgumentTypeError(
78
+ f"Pair must look like 0,29; got {value!r}"
79
+ ) from error
80
+ if not (0 <= pair[0] < pair[1] < 30):
81
+ raise argparse.ArgumentTypeError(
82
+ f"Pair must satisfy 0 <= first < second < 30; got {pair}"
83
+ )
84
+ return pair
85
+
86
+
87
+ def parse_args() -> argparse.Namespace:
88
+ parser = argparse.ArgumentParser(description=__doc__)
89
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
90
+ parser.add_argument(
91
+ "--dataset_root",
92
+ type=Path,
93
+ default=Path("outputs/predictor_offline_100_all_blocks"),
94
+ )
95
+ parser.add_argument(
96
+ "--checkpoint_path",
97
+ type=Path,
98
+ default=Path("checkpoints/self_forcing_dmd.pt"),
99
+ )
100
+ parser.add_argument(
101
+ "--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
102
+ )
103
+ parser.add_argument(
104
+ "--output_dir", type=Path, default=Path("outputs/two_block_pair_sweep")
105
+ )
106
+ parser.add_argument(
107
+ "--pairs",
108
+ type=parse_pair,
109
+ nargs="*",
110
+ default=None,
111
+ help="Pairs such as 0,29 1,28. Omit for all mirror+consecutive pairs.",
112
+ )
113
+ parser.add_argument("--max_pairs", type=int, default=None)
114
+ parser.add_argument("--max_steps", type=int, default=1000)
115
+ parser.add_argument("--batch_size", type=int, default=32)
116
+ parser.add_argument("--eval_batch_size", type=int, default=10)
117
+ parser.add_argument("--eval_every", type=int, default=100)
118
+ parser.add_argument("--log_every", type=int, default=20)
119
+ parser.add_argument("--save_every", type=int, default=100)
120
+ parser.add_argument("--train_prompts", type=int, default=80)
121
+ parser.add_argument("--val_prompts", type=int, default=20)
122
+ parser.add_argument("--seed", type=int, default=0)
123
+ parser.add_argument("--fusion_lr", type=float, default=1e-4)
124
+ parser.add_argument("--block_lr", type=float, default=1e-5)
125
+ parser.add_argument("--weight_decay", type=float, default=0.01)
126
+ parser.add_argument("--hidden_weight", type=float, default=0.1)
127
+ parser.add_argument("--flow_weight", type=float, default=1.0)
128
+ parser.add_argument("--grad_clip", type=float, default=1.0)
129
+ parser.add_argument("--fusion_warmup_steps", type=int, default=100)
130
+ parser.add_argument("--block_freeze_steps", type=int, default=100)
131
+ parser.add_argument("--block_warmup_steps", type=int, default=100)
132
+ parser.add_argument(
133
+ "--gradient_checkpointing",
134
+ action=argparse.BooleanOptionalAction,
135
+ default=True,
136
+ )
137
+ parser.add_argument(
138
+ "--save_final_weights",
139
+ action=argparse.BooleanOptionalAction,
140
+ default=True,
141
+ )
142
+ args = parser.parse_args()
143
+ if args.max_steps < 1:
144
+ parser.error("--max_steps must be positive")
145
+ if args.train_prompts < 1 or args.val_prompts < 1:
146
+ parser.error("Prompt counts must be positive")
147
+ if args.train_prompts + args.val_prompts > 100:
148
+ parser.error("The offline dataset contains 100 prompts")
149
+ if args.batch_size > args.train_prompts:
150
+ parser.error("--batch_size cannot exceed --train_prompts")
151
+ if args.eval_batch_size > args.val_prompts:
152
+ args.eval_batch_size = args.val_prompts
153
+ return args
154
+
155
+
156
+ def resolve(path: Path) -> Path:
157
+ path = path.expanduser()
158
+ return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
159
+
160
+
161
+ def make_model(
162
+ teacher: torch.nn.Module,
163
+ pair: tuple[int, int],
164
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
165
+ seed: int,
166
+ gradient_checkpointing: bool,
167
+ device: torch.device,
168
+ ) -> TwoBlockPredictor:
169
+ set_seed(seed)
170
+ model = TwoBlockPredictor(
171
+ [teacher.blocks[pair[0]], teacher.blocks[pair[1]]],
172
+ dim=teacher.dim,
173
+ gradient_checkpointing=gradient_checkpointing,
174
+ )
175
+ model.fusion.load_state_dict(shared_nonblock_state["fusion"], strict=True)
176
+ model.residual_out.load_state_dict(
177
+ shared_nonblock_state["residual_out"], strict=True
178
+ )
179
+ return model.to(device=device)
180
+
181
+
182
+ def forward_predictor(
183
+ model: TwoBlockPredictor,
184
+ batch: dict[str, Any],
185
+ teacher: torch.nn.Module,
186
+ device: torch.device,
187
+ ) -> tuple[torch.Tensor, torch.Tensor]:
188
+ frozen = frozen_inputs(batch, teacher, device)
189
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
190
+ pred_hidden = model(
191
+ current_tokens=frozen["current_tokens"],
192
+ anchor_hidden=batch["anchor_hidden"],
193
+ previous_hidden=batch["previous_hidden"],
194
+ timestep_modulation=frozen["timestep_modulation"],
195
+ grid_sizes=frozen["grid_sizes"],
196
+ freqs=frozen["freqs"],
197
+ history_ks=[batch["history_k_0"], batch["history_k_1"]],
198
+ history_vs=[batch["history_v_0"], batch["history_v_1"]],
199
+ cross_ks=[batch["cross_k_0"], batch["cross_k_1"]],
200
+ cross_vs=[batch["cross_v_0"], batch["cross_v_1"]],
201
+ current_start=batch["chunk"] * TOKENS_PER_CHUNK,
202
+ )
203
+ pred_flow = hidden_to_flow(
204
+ pred_hidden,
205
+ frozen["head_embedding"],
206
+ frozen["grid_sizes"],
207
+ teacher,
208
+ )
209
+ return pred_hidden, pred_flow
210
+
211
+
212
+ @torch.inference_mode()
213
+ def evaluate(
214
+ model: TwoBlockPredictor,
215
+ store: OfflinePredictorStore,
216
+ pair: tuple[int, int],
217
+ val_prompt_ids: list[int],
218
+ batch_size: int,
219
+ teacher: torch.nn.Module,
220
+ device: torch.device,
221
+ hidden_weight: float,
222
+ flow_weight: float,
223
+ ) -> dict[str, float]:
224
+ model.eval()
225
+ hidden_squared = 0.0
226
+ hidden_elements = 0
227
+ flow_squared = 0.0
228
+ flow_elements = 0
229
+ started = time.perf_counter()
230
+ for chunk in range(1, 7):
231
+ for target_step in range(1, 4):
232
+ for start in range(0, len(val_prompt_ids), batch_size):
233
+ prompt_ids = val_prompt_ids[start : start + batch_size]
234
+ batch = move_batch(
235
+ store.batch_layers(prompt_ids, chunk, target_step, pair), device
236
+ )
237
+ pred_hidden, pred_flow = forward_predictor(
238
+ model, batch, teacher, device
239
+ )
240
+ hidden_error = pred_hidden.float() - batch["target_hidden"].float()
241
+ flow_error = pred_flow.float() - batch["target_flow"].float()
242
+ hidden_squared += float(hidden_error.square().sum())
243
+ hidden_elements += hidden_error.numel()
244
+ flow_squared += float(flow_error.square().sum())
245
+ flow_elements += flow_error.numel()
246
+ del batch, pred_hidden, pred_flow, hidden_error, flow_error
247
+ hidden_mse = hidden_squared / hidden_elements
248
+ flow_mse = flow_squared / flow_elements
249
+ model.train()
250
+ return {
251
+ "hidden_mse": hidden_mse,
252
+ "flow_mse": flow_mse,
253
+ "total_loss": hidden_weight * hidden_mse + flow_weight * flow_mse,
254
+ "eval_time_s": time.perf_counter() - started,
255
+ }
256
+
257
+
258
+ def save_predictor_weights(model: TwoBlockPredictor, path: Path) -> None:
259
+ tensors = {
260
+ key: value.detach().to(device="cpu").contiguous()
261
+ for key, value in model.state_dict().items()
262
+ }
263
+ temporary = path.with_suffix(path.suffix + ".tmp")
264
+ save_file(tensors, temporary)
265
+ os.replace(temporary, path)
266
+
267
+
268
+ def run_experiment(
269
+ *,
270
+ pair: tuple[int, int],
271
+ args: argparse.Namespace,
272
+ store: OfflinePredictorStore,
273
+ teacher: torch.nn.Module,
274
+ train_prompt_ids: list[int],
275
+ val_prompt_ids: list[int],
276
+ schedule: BatchSchedule,
277
+ shared_nonblock_state: dict[str, dict[str, torch.Tensor]],
278
+ device: torch.device,
279
+ ) -> dict[str, Any]:
280
+ name = f"pair_{pair[0]:02d}_{pair[1]:02d}"
281
+ run_dir = args.output_dir / name
282
+ metrics_path = run_dir / "metrics.json"
283
+ if metrics_path.exists():
284
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
285
+ if existing.get("status") == "complete":
286
+ print(f"[run] skip completed {name}", flush=True)
287
+ return existing
288
+ run_dir.mkdir(parents=True, exist_ok=True)
289
+ log_path = run_dir / "train_log.jsonl"
290
+
291
+ model = make_model(
292
+ teacher,
293
+ pair,
294
+ shared_nonblock_state,
295
+ args.seed,
296
+ args.gradient_checkpointing,
297
+ device,
298
+ )
299
+ fusion_parameters = model.fusion_parameters()
300
+ block_parameters = model.block_parameters()
301
+ optimizer = AdamW(
302
+ [
303
+ {"params": fusion_parameters, "lr": args.fusion_lr},
304
+ {"params": block_parameters, "lr": args.block_lr},
305
+ ],
306
+ betas=(0.9, 0.95),
307
+ weight_decay=args.weight_decay,
308
+ )
309
+ initial_block_norm = parameter_norm(block_parameters)
310
+ evaluations: list[dict[str, Any]] = []
311
+ start_step = 0
312
+ latest_path = run_dir / "training_latest.pt"
313
+ if latest_path.exists():
314
+ state = torch.load(latest_path, map_location="cpu", weights_only=False)
315
+ model.load_state_dict(state["model"], strict=True)
316
+ optimizer.load_state_dict(state["optimizer"])
317
+ evaluations = state["evaluations"]
318
+ start_step = int(state["step"])
319
+ print(f"[run] resume {name} at step {start_step}", flush=True)
320
+
321
+ model.set_blocks_trainable(start_step >= args.block_freeze_steps)
322
+ config = {
323
+ "name": name,
324
+ "architecture": "two_block_predictor",
325
+ "initialization_method": "teacher_full",
326
+ "source_layers": list(pair),
327
+ "pair_kind": pair_kind(pair),
328
+ "source_definition": (
329
+ "Predictor block i, its clean-history K/V, and its text K/V use "
330
+ "generator_ema source_layers[i]"
331
+ ),
332
+ "seed": args.seed,
333
+ "train_prompt_ids": train_prompt_ids,
334
+ "val_prompt_ids": val_prompt_ids,
335
+ "max_steps": args.max_steps,
336
+ "batch_size": args.batch_size,
337
+ "eval_batch_size": args.eval_batch_size,
338
+ "fusion_lr": args.fusion_lr,
339
+ "block_lr": args.block_lr,
340
+ "fusion_warmup_steps": args.fusion_warmup_steps,
341
+ "block_freeze_steps": args.block_freeze_steps,
342
+ "block_warmup_steps": args.block_warmup_steps,
343
+ "weight_decay": args.weight_decay,
344
+ "hidden_weight": args.hidden_weight,
345
+ "flow_weight": args.flow_weight,
346
+ "gradient_checkpointing": args.gradient_checkpointing,
347
+ "batch_schedule_sha256": schedule.fingerprint(),
348
+ "initial_block_parameter_norm": initial_block_norm,
349
+ "trainable_parameters": sum(
350
+ parameter.numel() for parameter in model.parameters()
351
+ ),
352
+ }
353
+ atomic_json(run_dir / "config.json", config)
354
+ print(
355
+ f"[run] {name}: kind={config['pair_kind']} start={start_step}", flush=True
356
+ )
357
+
358
+ if not evaluations:
359
+ initial_eval = evaluate(
360
+ model,
361
+ store,
362
+ pair,
363
+ val_prompt_ids,
364
+ args.eval_batch_size,
365
+ teacher,
366
+ device,
367
+ args.hidden_weight,
368
+ args.flow_weight,
369
+ )
370
+ evaluations.append({"step": 0, **initial_eval})
371
+ print(
372
+ f"[eval] {name} step=0 flow={initial_eval['flow_mse']:.8f} "
373
+ f"hidden={initial_eval['hidden_mse']:.8f}",
374
+ flush=True,
375
+ )
376
+
377
+ optimizer.zero_grad(set_to_none=True)
378
+ model.train()
379
+ run_started = time.perf_counter()
380
+ for step in range(start_step, args.max_steps):
381
+ block_enabled = step >= args.block_freeze_steps
382
+ if any(
383
+ parameter.requires_grad != block_enabled
384
+ for parameter in block_parameters
385
+ ):
386
+ model.set_blocks_trainable(block_enabled)
387
+
388
+ fusion_lr, block_lr = lr_values(
389
+ step,
390
+ args.max_steps,
391
+ args.fusion_lr,
392
+ args.block_lr,
393
+ args.fusion_warmup_steps,
394
+ args.block_freeze_steps,
395
+ args.block_warmup_steps,
396
+ )
397
+ optimizer.param_groups[0]["lr"] = fusion_lr
398
+ optimizer.param_groups[1]["lr"] = block_lr
399
+
400
+ chunk, target_step, prompt_ids = schedule.entries[step]
401
+ batch = move_batch(
402
+ store.batch_layers(prompt_ids, chunk, target_step, pair), device
403
+ )
404
+ step_started = time.perf_counter()
405
+ pred_hidden, pred_flow = forward_predictor(model, batch, teacher, device)
406
+ hidden_loss = F.mse_loss(
407
+ pred_hidden.float(), batch["target_hidden"].float()
408
+ )
409
+ flow_loss = F.mse_loss(pred_flow.float(), batch["target_flow"].float())
410
+ loss = args.hidden_weight * hidden_loss + args.flow_weight * flow_loss
411
+ loss.backward()
412
+ fusion_grad_norm = gradient_norm(fusion_parameters)
413
+ block_grad_norm = gradient_norm(block_parameters)
414
+ block_0_grad_norm = gradient_norm(list(model.blocks[0].parameters()))
415
+ block_1_grad_norm = gradient_norm(list(model.blocks[1].parameters()))
416
+ total_grad_norm = torch.nn.utils.clip_grad_norm_(
417
+ model.parameters(), args.grad_clip
418
+ )
419
+ optimizer.step()
420
+ optimizer.zero_grad(set_to_none=True)
421
+ completed_step = step + 1
422
+
423
+ if completed_step == 1 or completed_step % args.log_every == 0:
424
+ torch.cuda.synchronize()
425
+ record = {
426
+ "step": completed_step,
427
+ "train_total_loss": float(loss.detach()),
428
+ "train_hidden_mse": float(hidden_loss.detach()),
429
+ "train_flow_mse": float(flow_loss.detach()),
430
+ "fusion_grad_norm": fusion_grad_norm,
431
+ "block_grad_norm": block_grad_norm,
432
+ "block_0_grad_norm": block_0_grad_norm,
433
+ "block_1_grad_norm": block_1_grad_norm,
434
+ "total_grad_norm_before_clip": float(total_grad_norm),
435
+ "fusion_lr": fusion_lr,
436
+ "block_lr": block_lr,
437
+ "chunk": chunk,
438
+ "target_step": target_step,
439
+ "step_time_s": time.perf_counter() - step_started,
440
+ "peak_gpu_gib": torch.cuda.max_memory_allocated() / (1024**3),
441
+ }
442
+ append_jsonl(log_path, record)
443
+ print(
444
+ f"[train] {name} {completed_step}/{args.max_steps} "
445
+ f"flow={record['train_flow_mse']:.8f} "
446
+ f"time={record['step_time_s']:.2f}s "
447
+ f"mem={record['peak_gpu_gib']:.1f}G",
448
+ flush=True,
449
+ )
450
+
451
+ if completed_step % args.eval_every == 0 or completed_step == args.max_steps:
452
+ validation = evaluate(
453
+ model,
454
+ store,
455
+ pair,
456
+ val_prompt_ids,
457
+ args.eval_batch_size,
458
+ teacher,
459
+ device,
460
+ args.hidden_weight,
461
+ args.flow_weight,
462
+ )
463
+ evaluations.append({"step": completed_step, **validation})
464
+ print(
465
+ f"[eval] {name} step={completed_step} "
466
+ f"flow={validation['flow_mse']:.8f} "
467
+ f"hidden={validation['hidden_mse']:.8f}",
468
+ flush=True,
469
+ )
470
+
471
+ if completed_step % args.save_every == 0 or completed_step == args.max_steps:
472
+ temporary = latest_path.with_suffix(".pt.tmp")
473
+ torch.save(
474
+ {
475
+ "model": {
476
+ key: value.detach().cpu()
477
+ for key, value in model.state_dict().items()
478
+ },
479
+ "optimizer": optimizer.state_dict(),
480
+ "evaluations": evaluations,
481
+ "step": completed_step,
482
+ },
483
+ temporary,
484
+ )
485
+ os.replace(temporary, latest_path)
486
+
487
+ del batch, pred_hidden, pred_flow, hidden_loss, flow_loss, loss
488
+ del total_grad_norm
489
+
490
+ final = evaluations[-1]
491
+ result = {
492
+ "status": "complete",
493
+ **config,
494
+ "final_val_hidden_mse": final["hidden_mse"],
495
+ "final_val_flow_mse": final["flow_mse"],
496
+ "final_val_total_loss": final["total_loss"],
497
+ "val_hidden_mse_auc": normalized_auc(
498
+ evaluations, "hidden_mse", args.max_steps
499
+ ),
500
+ "val_flow_mse_auc": normalized_auc(
501
+ evaluations, "flow_mse", args.max_steps
502
+ ),
503
+ "val_total_loss_auc": normalized_auc(
504
+ evaluations, "total_loss", args.max_steps
505
+ ),
506
+ "evaluations": evaluations,
507
+ "training_time_s": time.perf_counter() - run_started,
508
+ "final_block_parameter_norm": parameter_norm(block_parameters),
509
+ }
510
+ if args.save_final_weights:
511
+ save_predictor_weights(model, run_dir / "predictor_final.safetensors")
512
+ atomic_json(metrics_path, result)
513
+ if latest_path.exists():
514
+ latest_path.unlink()
515
+ del model, optimizer
516
+ torch.cuda.empty_cache()
517
+ return result
518
+
519
+
520
+ def write_summary(output_dir: Path, results: list[dict[str, Any]]) -> None:
521
+ rows = [
522
+ {
523
+ "name": result["name"],
524
+ "source_layer_1": result["source_layers"][0],
525
+ "source_layer_2": result["source_layers"][1],
526
+ "pair_kind": result["pair_kind"],
527
+ "final_val_flow_mse": result["final_val_flow_mse"],
528
+ "final_val_hidden_mse": result["final_val_hidden_mse"],
529
+ "final_val_total_loss": result["final_val_total_loss"],
530
+ "val_flow_mse_auc": result["val_flow_mse_auc"],
531
+ "val_hidden_mse_auc": result["val_hidden_mse_auc"],
532
+ "val_total_loss_auc": result["val_total_loss_auc"],
533
+ "training_time_s": result["training_time_s"],
534
+ }
535
+ for result in results
536
+ ]
537
+ rows.sort(key=lambda row: float(row["final_val_flow_mse"]))
538
+ if not rows:
539
+ return
540
+ destination = output_dir / "summary.csv"
541
+ temporary = destination.with_suffix(".csv.tmp")
542
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
543
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
544
+ writer.writeheader()
545
+ writer.writerows(rows)
546
+ os.replace(temporary, destination)
547
+ atomic_json(output_dir / "summary.json", rows)
548
+
549
+
550
+ def main() -> None:
551
+ args = parse_args()
552
+ args.dataset_root = resolve(args.dataset_root)
553
+ args.checkpoint_path = resolve(args.checkpoint_path)
554
+ args.config_path = resolve(args.config_path)
555
+ args.output_dir = resolve(args.output_dir)
556
+ args.output_dir.mkdir(parents=True, exist_ok=True)
557
+
558
+ pairs = default_pairs() if args.pairs is None else list(dict.fromkeys(args.pairs))
559
+ if args.max_pairs is not None:
560
+ pairs = pairs[: args.max_pairs]
561
+ train_prompt_ids = list(range(args.train_prompts))
562
+ val_prompt_ids = list(
563
+ range(args.train_prompts, args.train_prompts + args.val_prompts)
564
+ )
565
+ all_prompt_ids = train_prompt_ids + val_prompt_ids
566
+ device = torch.device("cuda")
567
+ set_seed(args.seed)
568
+ torch.set_grad_enabled(True)
569
+
570
+ print("[setup] loading frozen generator_ema", flush=True)
571
+ teacher = load_teacher(args.checkpoint_path, args.config_path, device)
572
+ print("[setup] loading common offline trajectories into RAM", flush=True)
573
+ store = OfflinePredictorStore(args.dataset_root, all_prompt_ids)
574
+ schedule = BatchSchedule(
575
+ train_prompt_ids, args.batch_size, args.max_steps, args.seed
576
+ )
577
+ shared_nonblock_state = build_shared_nonblock_state(args.seed)
578
+ manifest = {
579
+ "status": "running",
580
+ "pairs": [list(pair) for pair in pairs],
581
+ "num_pairs": len(pairs),
582
+ "mirror_pairs": 15,
583
+ "consecutive_pairs": 29,
584
+ "deduplicated_overlap": [14, 15],
585
+ "max_steps": args.max_steps,
586
+ "seed": args.seed,
587
+ "train_prompt_ids": train_prompt_ids,
588
+ "val_prompt_ids": val_prompt_ids,
589
+ "batch_schedule_sha256": schedule.fingerprint(),
590
+ }
591
+ atomic_json(args.output_dir / "sweep_manifest.json", manifest)
592
+
593
+ results: list[dict[str, Any]] = []
594
+ for index, pair in enumerate(pairs, start=1):
595
+ name = f"pair_{pair[0]:02d}_{pair[1]:02d}"
596
+ metrics_path = args.output_dir / name / "metrics.json"
597
+ if metrics_path.exists():
598
+ existing = json.loads(metrics_path.read_text(encoding="utf-8"))
599
+ if existing.get("status") == "complete":
600
+ print(f"[sweep] {index}/{len(pairs)} skip {name}", flush=True)
601
+ results.append(existing)
602
+ continue
603
+ print(
604
+ f"[data] {index}/{len(pairs)} loading pair {pair[0]},{pair[1]}",
605
+ flush=True,
606
+ )
607
+ store.load_layer_caches(pair, teacher, device)
608
+ result = run_experiment(
609
+ pair=pair,
610
+ args=args,
611
+ store=store,
612
+ teacher=teacher,
613
+ train_prompt_ids=train_prompt_ids,
614
+ val_prompt_ids=val_prompt_ids,
615
+ schedule=schedule,
616
+ shared_nonblock_state=shared_nonblock_state,
617
+ device=device,
618
+ )
619
+ results.append(result)
620
+ write_summary(args.output_dir, results)
621
+
622
+ manifest["status"] = "complete"
623
+ atomic_json(args.output_dir / "sweep_manifest.json", manifest)
624
+ write_summary(args.output_dir, results)
625
+ print(
626
+ f"[complete] {len(results)} pairs -> {args.output_dir / 'summary.csv'}",
627
+ flush=True,
628
+ )
629
+
630
+
631
+ if __name__ == "__main__":
632
+ main()
scripts/run_wan14b_timestep_chunk_cosine.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run native Wan2.1-T2V-14B/50-step feature cosine analysis.
3
+
4
+ Wan14B evaluates the whole 21-latent video in one forward. The token sequence
5
+ is split into seven three-latent temporal slices. Consequently ``cross_same``
6
+ means adjacent full-video temporal slices, not a previously computed AR chunk.
7
+ Only the conditional CFG stream is recorded.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import gc
15
+ import json
16
+ import math
17
+ import os
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ def _preparse_gpu() -> str:
25
+ parser = argparse.ArgumentParser(add_help=False)
26
+ parser.add_argument("--gpu", default="0")
27
+ args, _ = parser.parse_known_args()
28
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
29
+ return str(args.gpu)
30
+
31
+
32
+ PHYSICAL_GPU = _preparse_gpu()
33
+
34
+ import torch
35
+ import torch.nn.functional as F
36
+
37
+ ROOT = Path(__file__).resolve().parents[1]
38
+ if str(ROOT) not in sys.path:
39
+ sys.path.insert(0, str(ROOT))
40
+
41
+ from utils.misc import set_seed
42
+ from utils.wan_wrapper import WanTextEncoder
43
+ from wan.modules.model import WanModel
44
+ from wan.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
45
+
46
+
47
+ def parse_args() -> argparse.Namespace:
48
+ parser = argparse.ArgumentParser()
49
+ parser.add_argument("--gpu", default=PHYSICAL_GPU)
50
+ parser.add_argument(
51
+ "--checkpoint_dir", type=Path, default=Path("wan_models/Wan2.1-T2V-14B")
52
+ )
53
+ parser.add_argument(
54
+ "--prompt_path", type=Path, default=Path("prompts/MovieGenVideoBench_extended.txt")
55
+ )
56
+ parser.add_argument("--output_dir", type=Path, required=True)
57
+ parser.add_argument("--num_prompts", type=int, default=10)
58
+ parser.add_argument(
59
+ "--prompt_ids",
60
+ default="",
61
+ help="Optional comma-separated global prompt ids for multi-GPU sharding.",
62
+ )
63
+ parser.add_argument("--sampling_steps", type=int, default=50)
64
+ parser.add_argument("--layers", type=int, nargs="+", default=[9, 19, 29, 39])
65
+ parser.add_argument("--max_tokens", type=int, default=240)
66
+ parser.add_argument(
67
+ "--chunk_pairing",
68
+ choices=["matched_slot", "boundary_to_all"],
69
+ default="matched_slot",
70
+ help=(
71
+ "Cross-chunk token pairing. boundary_to_all compares all current "
72
+ "temporal slots with the previous slice's last slot."
73
+ ),
74
+ )
75
+ parser.add_argument("--seed", type=int, default=0)
76
+ parser.add_argument("--guide_scale", type=float, default=5.0)
77
+ parser.add_argument("--shift", type=float, default=5.0)
78
+ parser.add_argument("--overwrite", action="store_true")
79
+ return parser.parse_args()
80
+
81
+
82
+ def resolve(path: Path) -> Path:
83
+ return path if path.is_absolute() else (ROOT / path).resolve()
84
+
85
+
86
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
87
+ if not rows:
88
+ return
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ fields: list[str] = []
91
+ for row in rows:
92
+ for key in row:
93
+ if key not in fields:
94
+ fields.append(key)
95
+ with path.open("w", newline="", encoding="utf-8") as handle:
96
+ writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
97
+ writer.writeheader()
98
+ writer.writerows(rows)
99
+
100
+
101
+ def cosine_metrics(reference: torch.Tensor, target: torch.Tensor) -> dict[str, float]:
102
+ x = reference.float()
103
+ y = target.float()
104
+ values = F.cosine_similarity(x, y, dim=-1, eps=1e-8)
105
+ quantiles = torch.quantile(values, torch.tensor([0.1, 0.5, 0.9]))
106
+ return {
107
+ "token_cosine_mean": float(values.mean()),
108
+ "token_cosine_p10": float(quantiles[0]),
109
+ "token_cosine_p50": float(quantiles[1]),
110
+ "token_cosine_p90": float(quantiles[2]),
111
+ "global_cosine": float(F.cosine_similarity(x.flatten()[None], y.flatten()[None])),
112
+ }
113
+
114
+
115
+ class NativeWanRecorder:
116
+ def __init__(
117
+ self,
118
+ model: WanModel,
119
+ layers: list[int],
120
+ max_tokens: int,
121
+ chunk_pairing: str = "matched_slot",
122
+ ):
123
+ self.model = model
124
+ self.layers = sorted(set(layers))
125
+ self.max_tokens = int(max_tokens)
126
+ self.chunk_pairing = str(chunk_pairing)
127
+ self.handles = []
128
+ self.active = False
129
+ self.prompt_id = -1
130
+ self.step = -1
131
+ self.timestep = 0.0
132
+ self.rows: list[dict[str, Any]] = []
133
+ self.previous: dict[tuple[int, int], torch.Tensor] = {}
134
+ self.previous_timestep: float | None = None
135
+ self.matched_pairs = {12: 0, 25: 12, 37: 25}
136
+ self.matched_history: dict[
137
+ tuple[int, int, int], tuple[torch.Tensor, float]
138
+ ] = {}
139
+ for layer in self.layers:
140
+ if not 0 <= layer < len(model.blocks):
141
+ raise ValueError(f"Layer {layer} outside 0..{len(model.blocks)-1}")
142
+ self.handles.append(model.blocks[layer].register_forward_hook(self._hook(layer)))
143
+
144
+ def reset(self, prompt_id: int) -> None:
145
+ self.active = False
146
+ self.prompt_id = int(prompt_id)
147
+ self.step = -1
148
+ self.timestep = 0.0
149
+ self.rows = []
150
+ self.previous = {}
151
+ self.previous_timestep = None
152
+ self.matched_history = {}
153
+
154
+ def begin(self, step: int, timestep: float) -> None:
155
+ self.step = int(step)
156
+ self.timestep = float(timestep)
157
+ self.active = True
158
+
159
+ def end(self) -> None:
160
+ self.active = False
161
+ self.previous_timestep = self.timestep
162
+
163
+ def close(self) -> None:
164
+ for handle in self.handles:
165
+ handle.remove()
166
+ self.handles.clear()
167
+
168
+ def _sample(self, tokens: torch.Tensor) -> torch.Tensor:
169
+ if self.chunk_pairing == "boundary_to_all":
170
+ if tokens.shape[0] != 3 * 30 * 52:
171
+ raise ValueError(f"Expected 3x30x52 tokens, got {tokens.shape[0]}")
172
+ spatial_count = max(1, self.max_tokens // 3)
173
+ if 30 * 52 <= spatial_count:
174
+ indices = torch.arange(30 * 52, device=tokens.device)
175
+ else:
176
+ indices = torch.linspace(
177
+ 0, 30 * 52 - 1, spatial_count, device=tokens.device
178
+ ).round().long().unique()
179
+ return (
180
+ tokens.reshape(3, 30 * 52, -1)
181
+ .index_select(1, indices)
182
+ .detach()
183
+ .to("cpu", torch.float16)
184
+ )
185
+ if tokens.shape[0] <= self.max_tokens:
186
+ indices = torch.arange(tokens.shape[0], device=tokens.device)
187
+ else:
188
+ indices = torch.linspace(
189
+ 0, tokens.shape[0] - 1, self.max_tokens, device=tokens.device
190
+ ).round().long().unique()
191
+ return tokens.index_select(0, indices).detach().to("cpu", torch.float16)
192
+
193
+ def _hook(self, layer: int):
194
+ def hook(_module, _inputs, output) -> None:
195
+ if not self.active:
196
+ return
197
+ feature = output[0] if isinstance(output, (tuple, list)) else output
198
+ if not isinstance(feature, torch.Tensor) or feature.ndim != 3:
199
+ raise ValueError(f"Unexpected layer {layer} output: {type(feature)}")
200
+ # 21 latent frames x (30*52) tokens, grouped into 7 slices of 3 frames.
201
+ per_frame = 30 * 52
202
+ expected = 21 * per_frame
203
+ if feature.shape[1] < expected:
204
+ raise ValueError(f"Expected at least {expected} tokens, got {feature.shape[1]}")
205
+ current: list[torch.Tensor] = []
206
+ for chunk in range(7):
207
+ start = chunk * 3 * per_frame
208
+ stop = start + 3 * per_frame
209
+ current.append(self._sample(feature[0, start:stop]))
210
+ for chunk, sampled in enumerate(current):
211
+ base = {
212
+ "model_family": "self_forcing",
213
+ "model_variant": "wan14b_50",
214
+ "chunk_semantics": "full_video_temporal_slice",
215
+ "prompt_id": self.prompt_id,
216
+ "layer_index": layer,
217
+ "chunk": chunk,
218
+ "target_step": self.step,
219
+ "target_timestep": self.timestep,
220
+ "sample_tokens": int(sampled.numel() // sampled.shape[-1]),
221
+ "feature_dim": int(sampled.shape[1]),
222
+ }
223
+ within = self.previous.get((layer, chunk))
224
+ if within is not None:
225
+ self.rows.append({
226
+ **base,
227
+ "comparison": "within_adjacent",
228
+ "reference_chunk": chunk,
229
+ "reference_step": self.step - 1,
230
+ "reference_timestep": self.previous_timestep,
231
+ **cosine_metrics(within, sampled),
232
+ })
233
+ matched_reference_step = self.matched_pairs.get(self.step)
234
+ matched = (
235
+ None
236
+ if matched_reference_step is None
237
+ else self.matched_history.get((layer, chunk, matched_reference_step))
238
+ )
239
+ if matched is not None:
240
+ matched_feature, matched_timestep = matched
241
+ self.rows.append({
242
+ **base,
243
+ "comparison": "within_matched_gap",
244
+ "reference_chunk": chunk,
245
+ "reference_step": matched_reference_step,
246
+ "reference_timestep": matched_timestep,
247
+ **cosine_metrics(matched_feature, sampled),
248
+ })
249
+ if chunk > 0:
250
+ cross_reference = (
251
+ current[chunk - 1][-1:].expand_as(sampled)
252
+ if self.chunk_pairing == "boundary_to_all"
253
+ else current[chunk - 1]
254
+ )
255
+ self.rows.append({
256
+ **base,
257
+ "comparison": (
258
+ "cross_boundary_to_all"
259
+ if self.chunk_pairing == "boundary_to_all"
260
+ else "cross_same"
261
+ ),
262
+ "reference_chunk": chunk - 1,
263
+ "reference_step": self.step,
264
+ "reference_timestep": self.timestep,
265
+ **cosine_metrics(cross_reference, sampled),
266
+ })
267
+ self.previous[(layer, chunk)] = sampled
268
+ if self.step in {0, 12, 25, 37}:
269
+ self.matched_history[(layer, chunk, self.step)] = (
270
+ sampled,
271
+ self.timestep,
272
+ )
273
+
274
+ return hook
275
+
276
+
277
+ @torch.inference_mode()
278
+ def encode_prompts(prompts: list[str]) -> tuple[torch.Tensor, torch.Tensor]:
279
+ print("[load] text encoder", flush=True)
280
+ encoder = WanTextEncoder().to(device="cuda", dtype=torch.bfloat16).eval()
281
+ conditional = encoder(text_prompts=prompts)["prompt_embeds"].detach().to("cpu", torch.bfloat16)
282
+ negative = encoder(text_prompts=[
283
+ "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,"
284
+ "整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,"
285
+ "画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合"
286
+ ])["prompt_embeds"].detach().to("cpu", torch.bfloat16)
287
+ del encoder
288
+ gc.collect()
289
+ torch.cuda.empty_cache()
290
+ return conditional, negative
291
+
292
+
293
+ @torch.inference_mode()
294
+ def main() -> None:
295
+ args = parse_args()
296
+ checkpoint_dir = resolve(args.checkpoint_dir)
297
+ prompt_path = resolve(args.prompt_path)
298
+ args.output_dir = resolve(args.output_dir)
299
+ args.output_dir.mkdir(parents=True, exist_ok=True)
300
+ all_prompts = [line.strip() for line in prompt_path.read_text(encoding="utf-8").splitlines() if line.strip()]
301
+ prompt_ids = (
302
+ [int(value) for value in args.prompt_ids.split(",") if value.strip()]
303
+ if args.prompt_ids
304
+ else list(range(args.num_prompts))
305
+ )
306
+ if not prompt_ids or min(prompt_ids) < 0 or max(prompt_ids) >= len(all_prompts):
307
+ raise ValueError(f"Invalid prompt ids {prompt_ids} for {len(all_prompts)} prompts")
308
+ prompts = [all_prompts[index] for index in prompt_ids]
309
+ conditional, negative = encode_prompts(prompts)
310
+ print("[load] Wan14B in bfloat16", flush=True)
311
+ model = WanModel.from_pretrained(
312
+ checkpoint_dir,
313
+ torch_dtype=torch.bfloat16,
314
+ low_cpu_mem_usage=True,
315
+ ).eval().requires_grad_(False).to("cuda")
316
+ recorder = NativeWanRecorder(
317
+ model,
318
+ args.layers,
319
+ args.max_tokens,
320
+ chunk_pairing=args.chunk_pairing,
321
+ )
322
+ scheduler = FlowUniPCMultistepScheduler(
323
+ num_train_timesteps=1000, shift=1, use_dynamic_shifting=False
324
+ )
325
+ scheduler.set_timesteps(args.sampling_steps, device="cuda", shift=args.shift)
326
+ timesteps = scheduler.timesteps
327
+ manifest = {
328
+ "model_family": "self_forcing",
329
+ "model_variant": "wan14b_50",
330
+ "chunk_semantics": "full_video_temporal_slice",
331
+ "checkpoint_dir": str(checkpoint_dir),
332
+ "prompt_path": str(prompt_path),
333
+ "num_prompts": len(prompts),
334
+ "prompt_ids": prompt_ids,
335
+ "num_frames": 21,
336
+ "num_chunks": 7,
337
+ "sampling_steps": args.sampling_steps,
338
+ "timesteps": [float(value) for value in timesteps.detach().cpu()],
339
+ "layers": args.layers,
340
+ "layer_roles": ["early", "middle", "late", "final"],
341
+ "max_tokens": args.max_tokens,
342
+ "chunk_pairing": args.chunk_pairing,
343
+ "seed": args.seed,
344
+ "guide_scale": args.guide_scale,
345
+ "shift": args.shift,
346
+ "physical_gpu": args.gpu,
347
+ "conditional_stream_only": True,
348
+ }
349
+ (args.output_dir / "run_manifest.json").write_text(
350
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
351
+ )
352
+ all_rows: list[dict[str, Any]] = []
353
+ seq_len = 21 * 30 * 52
354
+ try:
355
+ for local_index, (prompt_id, prompt) in enumerate(zip(prompt_ids, prompts)):
356
+ run_dir = args.output_dir / "runs" / f"prompt_{prompt_id:04d}"
357
+ pair_path = run_dir / "feature_pair_metrics.csv"
358
+ if pair_path.exists() and not args.overwrite:
359
+ with pair_path.open(newline="", encoding="utf-8") as handle:
360
+ all_rows.extend(csv.DictReader(handle))
361
+ print(f"[skip] prompt {prompt_id}", flush=True)
362
+ continue
363
+ run_dir.mkdir(parents=True, exist_ok=True)
364
+ set_seed(args.seed)
365
+ generator = torch.Generator(device="cuda").manual_seed(args.seed)
366
+ latents = [torch.randn(
367
+ 16, 21, 60, 104,
368
+ dtype=torch.float32, device="cuda", generator=generator,
369
+ )]
370
+ cond = conditional[local_index].to("cuda")
371
+ uncond = negative[0].to("cuda")
372
+ recorder.reset(prompt_id)
373
+ scheduler.set_timesteps(args.sampling_steps, device="cuda", shift=args.shift)
374
+ torch.cuda.reset_peak_memory_stats()
375
+ torch.cuda.synchronize()
376
+ started = time.perf_counter()
377
+ with torch.amp.autocast("cuda", dtype=torch.bfloat16):
378
+ for step, timestep in enumerate(scheduler.timesteps):
379
+ t = torch.stack([timestep])
380
+ recorder.begin(step, float(timestep))
381
+ pred_cond = model(latents, t=t, context=[cond], seq_len=seq_len)[0]
382
+ recorder.end()
383
+ pred_uncond = model(latents, t=t, context=[uncond], seq_len=seq_len)[0]
384
+ prediction = pred_uncond + args.guide_scale * (pred_cond - pred_uncond)
385
+ next_latent = scheduler.step(
386
+ prediction.unsqueeze(0),
387
+ timestep,
388
+ latents[0].unsqueeze(0),
389
+ return_dict=False,
390
+ generator=generator,
391
+ )[0]
392
+ latents = [next_latent.squeeze(0)]
393
+ torch.cuda.synchronize()
394
+ elapsed = time.perf_counter() - started
395
+ rows = list(recorder.rows)
396
+ write_csv(pair_path, rows)
397
+ metadata = {
398
+ "prompt_id": prompt_id,
399
+ "prompt": prompt,
400
+ "elapsed_s": elapsed,
401
+ "peak_gpu_gib": torch.cuda.max_memory_allocated() / 1024**3,
402
+ "pair_rows": len(rows),
403
+ }
404
+ (run_dir / "run_metadata.json").write_text(
405
+ json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
406
+ )
407
+ all_rows.extend(rows)
408
+ print(
409
+ f"[done] prompt={prompt_id} rows={len(rows)} elapsed={elapsed:.1f}s "
410
+ f"peak={metadata['peak_gpu_gib']:.1f}GiB",
411
+ flush=True,
412
+ )
413
+ del latents, cond, uncond
414
+ torch.cuda.empty_cache()
415
+ finally:
416
+ recorder.close()
417
+ write_csv(args.output_dir / "feature_pair_metrics.csv", all_rows)
418
+ print(f"[complete] {args.output_dir}", flush=True)
419
+
420
+
421
+ if __name__ == "__main__":
422
+ main()
scripts/summarize_boundary_to_all_cosine.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Summarize matched-step and previous-boundary-to-all cosine consistently."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn.functional as F
16
+
17
+
18
+ ROLE_MAP = {
19
+ ("self_forcing", "wan14b50"): {9: "early", 19: "middle", 29: "late", 39: "final"},
20
+ ("self_forcing", "dmd4"): {7: "early", 14: "middle", 22: "late", 29: "final"},
21
+ ("causal_forcing", "ar50"): {7: "early", 14: "middle", 22: "late", 29: "final"},
22
+ ("causal_forcing", "dmd4"): {7: "early", 14: "middle", 22: "late", 29: "final"},
23
+ }
24
+ ORDER = [
25
+ ("self_forcing", "wan14b50"),
26
+ ("self_forcing", "dmd4"),
27
+ ("causal_forcing", "ar50"),
28
+ ("causal_forcing", "dmd4"),
29
+ ]
30
+
31
+
32
+ def parse_args() -> argparse.Namespace:
33
+ parser = argparse.ArgumentParser()
34
+ parser.add_argument("--self_dmd_root", type=Path, required=True)
35
+ parser.add_argument("--self_wan_root", type=Path, required=True)
36
+ parser.add_argument("--causal_ar_root", type=Path, required=True)
37
+ parser.add_argument("--causal_dmd_root", type=Path, required=True)
38
+ parser.add_argument("--output_root", type=Path, required=True)
39
+ return parser.parse_args()
40
+
41
+
42
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
43
+ if not rows:
44
+ return
45
+ fields: list[str] = []
46
+ for row in rows:
47
+ for key in row:
48
+ if key not in fields:
49
+ fields.append(key)
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ with path.open("w", newline="", encoding="utf-8") as handle:
52
+ writer = csv.DictWriter(handle, fieldnames=fields)
53
+ writer.writeheader()
54
+ writer.writerows(rows)
55
+
56
+
57
+ def self_dmd_rows(root: Path) -> list[dict[str, Any]]:
58
+ rows = []
59
+ role_map = ROLE_MAP[("self_forcing", "dmd4")]
60
+ for path in sorted((root / "runs").glob("prompt_*.pt")):
61
+ state = torch.load(path, map_location="cpu", weights_only=False)
62
+ prompt_id = int(state["run_index"])
63
+ coords = state["sample_coords"]["hidden"]
64
+ spatial_counts = [int((coords[:, 0] == slot).sum()) for slot in range(3)]
65
+ if len(set(spatial_counts)) != 1:
66
+ raise ValueError(f"Unbalanced temporal sampling in {path}: {spatial_counts}")
67
+ spatial_count = spatial_counts[0]
68
+ for layer, role in role_map.items():
69
+ records = state["records"][f"block_{layer}_hidden"]
70
+ for chunk in range(1, 7):
71
+ for step in range(1, 4):
72
+ target = records[f"{chunk}:{step}"].float().reshape(3, spatial_count, -1)
73
+ previous_step = records[f"{chunk}:{step - 1}"].float().reshape(
74
+ 3, spatial_count, -1
75
+ )
76
+ previous_chunk = records[f"{chunk - 1}:{step}"].float().reshape(
77
+ 3, spatial_count, -1
78
+ )
79
+ for comparison, reference in (
80
+ ("within_adjacent", previous_step),
81
+ ("cross_boundary_to_all", previous_chunk[-1:].expand_as(target)),
82
+ ):
83
+ rows.append({
84
+ "model_family": "self_forcing",
85
+ "model_variant": "dmd4",
86
+ "layer_role": role,
87
+ "layer_index": layer,
88
+ "prompt_id": prompt_id,
89
+ "comparison": comparison,
90
+ "target_chunk": chunk,
91
+ "target_step": step,
92
+ "token_cosine_mean": float(
93
+ F.cosine_similarity(target, reference, dim=-1).mean()
94
+ ),
95
+ })
96
+ return rows
97
+
98
+
99
+ def direct_rows(root: Path, family: str, variant: str) -> list[dict[str, Any]]:
100
+ rows = []
101
+ role_map = ROLE_MAP[(family, variant)]
102
+ paths = sorted((root / "runs").glob("prompt_*/feature_pair_metrics.csv"))
103
+ if not paths:
104
+ paths = sorted(root.glob("shard_*/runs/prompt_*/feature_pair_metrics.csv"))
105
+ for path in paths:
106
+ with path.open(newline="", encoding="utf-8") as handle:
107
+ for raw in csv.DictReader(handle):
108
+ comparison = raw["comparison"]
109
+ if comparison not in {"within_adjacent", "cross_boundary_to_all"}:
110
+ continue
111
+ step = int(raw["target_step"])
112
+ if step < 1:
113
+ continue
114
+ layer = int(raw["layer_index"])
115
+ rows.append({
116
+ "model_family": family,
117
+ "model_variant": variant,
118
+ "layer_role": role_map[layer],
119
+ "layer_index": layer,
120
+ "prompt_id": int(raw["prompt_id"]),
121
+ "comparison": comparison,
122
+ "target_chunk": int(raw["chunk"]),
123
+ "target_step": step,
124
+ "token_cosine_mean": float(raw["token_cosine_mean"]),
125
+ })
126
+ return rows
127
+
128
+
129
+ def bootstrap(values: list[float], seed: int, rounds: int = 10000) -> tuple[float, float, float]:
130
+ array = np.asarray(values, dtype=np.float64)
131
+ generator = np.random.default_rng(seed)
132
+ indices = generator.integers(0, len(array), size=(rounds, len(array)))
133
+ means = array[indices].mean(axis=1)
134
+ return float(array.mean()), float(np.quantile(means, 0.025)), float(np.quantile(means, 0.975))
135
+
136
+
137
+ def main() -> None:
138
+ args = parse_args()
139
+ output = args.output_root.resolve()
140
+ output.mkdir(parents=True, exist_ok=True)
141
+ rows = self_dmd_rows(args.self_dmd_root.resolve())
142
+ rows += direct_rows(args.self_wan_root.resolve(), "self_forcing", "wan14b50")
143
+ rows += direct_rows(args.causal_ar_root.resolve(), "causal_forcing", "ar50")
144
+ rows += direct_rows(args.causal_dmd_root.resolve(), "causal_forcing", "dmd4")
145
+
146
+ prompt_groups: dict[tuple[Any, ...], list[float]] = defaultdict(list)
147
+ for row in rows:
148
+ key = (
149
+ row["model_family"], row["model_variant"], row["layer_role"],
150
+ row["layer_index"], row["prompt_id"], row["comparison"],
151
+ )
152
+ prompt_groups[key].append(float(row["token_cosine_mean"]))
153
+ prompt_rows = []
154
+ for key, values in prompt_groups.items():
155
+ prompt_rows.append({
156
+ **dict(zip(
157
+ ["model_family", "model_variant", "layer_role", "layer_index", "prompt_id", "comparison"],
158
+ key,
159
+ )),
160
+ "pair_count": len(values),
161
+ "token_cosine_mean": float(np.mean(values)),
162
+ })
163
+
164
+ groups: dict[tuple[Any, ...], list[float]] = defaultdict(list)
165
+ for row in prompt_rows:
166
+ key = (row["model_family"], row["model_variant"], row["layer_role"], row["comparison"])
167
+ groups[key].append(float(row["token_cosine_mean"]))
168
+ summary = []
169
+ for key, values in groups.items():
170
+ avg, low, high = bootstrap(values, 20260828 + sum(map(ord, "".join(map(str, key)))))
171
+ summary.append({
172
+ **dict(zip(["model_family", "model_variant", "layer_role", "comparison"], key)),
173
+ "prompt_count": len(values),
174
+ "token_cosine_mean": avg,
175
+ "ci95_low": low,
176
+ "ci95_high": high,
177
+ })
178
+
179
+ lookup = {
180
+ (row["model_family"], row["model_variant"], row["layer_role"], row["comparison"]): row
181
+ for row in summary
182
+ }
183
+ table_rows = []
184
+ for family, variant in ORDER:
185
+ item: dict[str, Any] = {"model_family": family, "model_variant": variant}
186
+ for role in ("early", "middle", "late", "final"):
187
+ step = lookup[(family, variant, role, "within_adjacent")]
188
+ chunk = lookup[(family, variant, role, "cross_boundary_to_all")]
189
+ item[f"{role}_step"] = step["token_cosine_mean"]
190
+ item[f"{role}_chunk"] = chunk["token_cosine_mean"]
191
+ table_rows.append(item)
192
+
193
+ write_csv(output / "boundary_pair_metrics.csv", rows)
194
+ write_csv(output / "boundary_prompt_summary.csv", prompt_rows)
195
+ write_csv(output / "boundary_cosine_summary.csv", summary)
196
+ write_csv(output / "boundary_cosine_table.csv", table_rows)
197
+ config = {
198
+ "pairing": "current slot s vs previous chunk last slot; matched spatial coordinate",
199
+ "support": "target_chunk>=1, target_step>=1",
200
+ "prompt_first_aggregation": True,
201
+ "prompt_count_expected": 10,
202
+ "row_count": len(rows),
203
+ }
204
+ (output / "config.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
205
+ print(f"[complete] {output}: rows={len(rows)}", flush=True)
206
+
207
+
208
+ if __name__ == "__main__":
209
+ main()
scripts/summarize_conditional_probe.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Summarize Linear/Ridge and nonlinear conditional probe fold results."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import itertools
9
+ import json
10
+ import math
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import matplotlib
16
+
17
+ matplotlib.use("Agg")
18
+ import matplotlib.pyplot as plt
19
+ import numpy as np
20
+
21
+
22
+ FAMILIES = ("self_forcing", "causal_forcing", "hy_worldplay")
23
+ ROLES = ("early", "middle", "late", "final")
24
+
25
+
26
+ def read_csv(path: Path) -> list[dict[str, Any]]:
27
+ with path.open(newline="", encoding="utf-8") as handle:
28
+ rows = list(csv.DictReader(handle))
29
+ for row in rows:
30
+ for key in ("target_step", "held_out_prompt", "seed", "layer_index", "test_tokens"):
31
+ if key in row:
32
+ row[key] = int(row[key])
33
+ for key in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
34
+ row[key] = float(row[key])
35
+ return rows
36
+
37
+
38
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
39
+ if not rows:
40
+ return
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ fields: list[str] = []
43
+ for row in rows:
44
+ for key in row:
45
+ if key not in fields:
46
+ fields.append(key)
47
+ with path.open("w", newline="", encoding="utf-8") as handle:
48
+ writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
49
+ writer.writeheader()
50
+ writer.writerows(rows)
51
+
52
+
53
+ def bootstrap(values: list[float], seed: int, rounds: int = 10000):
54
+ values = np.asarray(values, dtype=np.float64)
55
+ rng = np.random.default_rng(seed)
56
+ if values.size == 0:
57
+ return float("nan"), float("nan"), float("nan")
58
+ indices = rng.integers(0, values.size, size=(rounds, values.size))
59
+ means = values[indices].mean(axis=1)
60
+ return float(values.mean()), float(np.quantile(means, 0.025)), float(np.quantile(means, 0.975))
61
+
62
+
63
+ def exact_signflip(values: list[float]) -> float:
64
+ values = np.asarray(values, dtype=np.float64)
65
+ values = values[np.isfinite(values)]
66
+ if not values.size:
67
+ return float("nan")
68
+ observed = abs(float(values.mean()))
69
+ exceed = 0
70
+ total = 1 << int(values.size)
71
+ for mask in range(total):
72
+ signed = np.asarray(
73
+ [value if (mask >> index) & 1 else -value for index, value in enumerate(values)]
74
+ )
75
+ if abs(float(signed.mean())) >= observed - 1e-15:
76
+ exceed += 1
77
+ return float((exceed + 1) / (total + 1))
78
+
79
+
80
+ def prompt_metric_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
81
+ """Average seeds within each prompt, keeping prompt as statistical unit."""
82
+ grouped: dict[tuple, list[dict[str, Any]]] = defaultdict(list)
83
+ for row in rows:
84
+ grouped[
85
+ (
86
+ row["method"], row["model_family"], row["layer_role"],
87
+ row["target_step"], row["probe"], row["held_out_prompt"],
88
+ )
89
+ ].append(row)
90
+ result = []
91
+ for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
92
+ method, family, role, step, probe, prompt = key
93
+ item = {
94
+ "method": method,
95
+ "model_family": family,
96
+ "layer_role": role,
97
+ "target_step": step,
98
+ "probe": probe,
99
+ "held_out_prompt": prompt,
100
+ "seed_count": len(values),
101
+ }
102
+ for metric in ("mse", "nMSE", "nRMSE", "r2", "cosine"):
103
+ item[metric] = float(np.mean([float(value[metric]) for value in values]))
104
+ result.append(item)
105
+ return result
106
+
107
+
108
+ def gain_rows(prompt_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
109
+ baseline_name = {"linear": "within_affine", "nonlinear": "step_only"}
110
+ grouped = defaultdict(dict)
111
+ for row in prompt_rows:
112
+ grouped[
113
+ (row["method"], row["model_family"], row["layer_role"], row["target_step"], row["held_out_prompt"])
114
+ ][row["probe"]] = row
115
+ result = []
116
+ for key, probes in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
117
+ method, family, role, step, prompt = key
118
+ baseline = probes.get(baseline_name[method])
119
+ if baseline is None:
120
+ continue
121
+ for probe, current in probes.items():
122
+ reference_mse = float(baseline["mse"])
123
+ current_mse = float(current["mse"])
124
+ result.append({
125
+ "method": method,
126
+ "model_family": family,
127
+ "layer_role": role,
128
+ "target_step": step,
129
+ "held_out_prompt": prompt,
130
+ "baseline_probe": baseline_name[method],
131
+ "probe": probe,
132
+ "baseline_mse": reference_mse,
133
+ "probe_mse": current_mse,
134
+ "gain": (reference_mse - current_mse) / max(reference_mse, 1e-12),
135
+ "delta_r2": float(current["r2"]) - float(baseline["r2"]),
136
+ })
137
+ return result
138
+
139
+
140
+ def summarize_gains(gains: list[dict[str, Any]]) -> list[dict[str, Any]]:
141
+ grouped = defaultdict(list)
142
+ for row in gains:
143
+ grouped[(row["method"], row["model_family"], row["layer_role"], row["target_step"], row["probe"])].append(row)
144
+ result = []
145
+ for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
146
+ method, family, role, step, probe = key
147
+ values = sorted(values, key=lambda row: row["held_out_prompt"])
148
+ numbers = [float(row["gain"]) for row in values]
149
+ mean, low, high = bootstrap(numbers, 1000 + sum(ord(ch) for ch in str(key)))
150
+ result.append({
151
+ "method": method,
152
+ "model_family": family,
153
+ "layer_role": role,
154
+ "target_step": step,
155
+ "probe": probe,
156
+ "baseline_probe": values[0]["baseline_probe"],
157
+ "prompt_count": len(numbers),
158
+ "gain_mean": mean,
159
+ "gain_ci95_low": low,
160
+ "gain_ci95_high": high,
161
+ "wins": int(sum(number > 0 for number in numbers)),
162
+ "signflip_p": exact_signflip(numbers),
163
+ })
164
+ return result
165
+
166
+
167
+ def summarize_layer_gains(gains: list[dict[str, Any]]) -> list[dict[str, Any]]:
168
+ """Average target steps inside each prompt, then summarize across prompts."""
169
+ prompt_groups = defaultdict(list)
170
+ for row in gains:
171
+ prompt_groups[
172
+ (
173
+ row["method"], row["model_family"], row["layer_role"],
174
+ row["probe"], row["held_out_prompt"], row["baseline_probe"],
175
+ )
176
+ ].append(float(row["gain"]))
177
+ groups = defaultdict(list)
178
+ for key, values in prompt_groups.items():
179
+ method, family, role, probe, _prompt, baseline = key
180
+ groups[(method, family, role, probe, baseline)].append(float(np.mean(values)))
181
+ result = []
182
+ for key, values in sorted(groups.items(), key=lambda item: tuple(map(str, item[0]))):
183
+ method, family, role, probe, baseline = key
184
+ mean, low, high = bootstrap(values, 17000 + sum(ord(ch) for ch in str(key)))
185
+ result.append({
186
+ "method": method,
187
+ "model_family": family,
188
+ "layer_role": role,
189
+ "probe": probe,
190
+ "baseline_probe": baseline,
191
+ "target_step_count": 3,
192
+ "prompt_count": len(values),
193
+ "gain_mean": mean,
194
+ "gain_ci95_low": low,
195
+ "gain_ci95_high": high,
196
+ "wins": int(sum(value > 0 for value in values)),
197
+ "signflip_p": exact_signflip(values),
198
+ })
199
+ return result
200
+
201
+
202
+ def summarize_metrics(prompt_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
203
+ grouped = defaultdict(list)
204
+ for row in prompt_rows:
205
+ grouped[(row["method"], row["model_family"], row["layer_role"], row["target_step"], row["probe"])].append(row)
206
+ result = []
207
+ for key, values in sorted(grouped.items(), key=lambda item: tuple(map(str, item[0]))):
208
+ method, family, role, step, probe = key
209
+ item = {
210
+ "method": method,
211
+ "model_family": family,
212
+ "layer_role": role,
213
+ "target_step": step,
214
+ "probe": probe,
215
+ "prompt_count": len(values),
216
+ }
217
+ for metric_index, metric in enumerate(("mse", "nMSE", "nRMSE", "r2", "cosine")):
218
+ numbers = [float(row[metric]) for row in values]
219
+ mean, low, high = bootstrap(numbers, 9000 + metric_index + sum(ord(ch) for ch in str(key)))
220
+ item[f"{metric}_mean"] = mean
221
+ item[f"{metric}_ci95_low"] = low
222
+ item[f"{metric}_ci95_high"] = high
223
+ result.append(item)
224
+ return result
225
+
226
+
227
+ def plot_primary(gains: list[dict[str, Any]], output: Path) -> None:
228
+ fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
229
+ for ax, method, title in zip(axes, ("linear", "nonlinear"), ("Linear/Ridge", "Nonlinear MLP")):
230
+ selected = [
231
+ row for row in gains
232
+ if row["method"] == method and row["probe"] in ({"fusion_same"} if method == "linear" else {"both_correct"})
233
+ ]
234
+ x = np.arange(len(ROLES))
235
+ width = 0.24
236
+ for family_index, family in enumerate(FAMILIES):
237
+ values = []
238
+ lows = []
239
+ highs = []
240
+ for role in ROLES:
241
+ group = [row for row in selected if row["model_family"] == family and row["layer_role"] == role]
242
+ nums = [float(row["gain"]) for row in group]
243
+ mean, low, high = bootstrap(nums, 1234 + family_index * 100 + ROLES.index(role))
244
+ values.append(mean)
245
+ lows.append(mean - low)
246
+ highs.append(high - mean)
247
+ pos = x + (family_index - (len(FAMILIES) - 1) / 2) * width
248
+ ax.bar(pos, values, width, yerr=[lows, highs], capsize=3, label=family)
249
+ ax.axhline(0, color="black", linewidth=0.8)
250
+ ax.set_xticks(x, ROLES)
251
+ ax.set_ylabel("Gain vs step-only baseline" if method == "nonlinear" else "Gain vs within-affine baseline")
252
+ ax.set_title(title)
253
+ ax.grid(axis="y", alpha=0.25)
254
+ axes[1].legend(fontsize=9)
255
+ fig.tight_layout()
256
+ fig.savefig(output, dpi=180)
257
+ plt.close(fig)
258
+
259
+
260
+ def plot_controls(gains: list[dict[str, Any]], output: Path) -> None:
261
+ probes = ["fusion_same", "fusion_step_duplicate", "fusion_wrong_step", "fusion_distant", "fusion_batch_shuffle", "fusion_zero", "fusion_noise"]
262
+ labels = {
263
+ "fusion_same": "correct",
264
+ "fusion_step_duplicate": "step duplicate",
265
+ "fusion_wrong_step": "wrong step",
266
+ "fusion_distant": "distant",
267
+ "fusion_batch_shuffle": "other video",
268
+ "fusion_zero": "zero",
269
+ "fusion_noise": "noise",
270
+ }
271
+ fig, axes = plt.subplots(
272
+ 1,
273
+ len(FAMILIES),
274
+ figsize=(5.7 * len(FAMILIES), 5),
275
+ sharey=True,
276
+ )
277
+ axes = np.atleast_1d(axes)
278
+ for ax, family in zip(axes, FAMILIES):
279
+ values = []
280
+ errors = []
281
+ for probe in probes:
282
+ group = [row for row in gains if row["method"] == "linear" and row["model_family"] == family and row["layer_role"] == "final" and row["probe"] == probe]
283
+ nums = [float(row["gain"]) for row in group]
284
+ mean, low, high = bootstrap(nums, 4000 + probes.index(probe))
285
+ values.append(mean)
286
+ errors.append((mean - low, high - mean))
287
+ y = np.arange(len(probes))
288
+ ax.errorbar(values, y, xerr=np.asarray(errors).T, fmt="o", capsize=3)
289
+ ax.axvline(0, color="black", linewidth=0.8)
290
+ ax.set_yticks(y, [labels[p] for p in probes])
291
+ ax.set_title(family)
292
+ ax.grid(axis="x", alpha=0.25)
293
+ axes[0].set_xlabel("Linear MSE gain")
294
+ fig.tight_layout()
295
+ fig.savefig(output, dpi=180)
296
+ plt.close(fig)
297
+
298
+
299
+ def build_report(metrics: list[dict[str, Any]], gains: list[dict[str, Any]], output: Path, config: dict[str, Any]) -> None:
300
+ lines = [
301
+ "# Conditional prediction and incremental chunk information",
302
+ "",
303
+ "This report uses 10 prompt-grouped held-out folds. The test prompt and its",
304
+ "other-video donor are excluded from training; nonlinear seeds are averaged",
305
+ "within prompt before confidence intervals are computed.",
306
+ "",
307
+ "The primary endpoint is `fusion_same` vs `within_affine` for Linear/Ridge",
308
+ "and `both_correct` vs `step_only` for the nonlinear MLP.",
309
+ "",
310
+ "| method | family | role | step | probe | gain | 95% CI | wins | sign-flip p |",
311
+ "|---|---|---|---:|---|---:|---|---:|---:|",
312
+ ]
313
+ primary = [
314
+ row for row in gains
315
+ if (row["method"] == "linear" and row["probe"] == "fusion_same")
316
+ or (row["method"] == "nonlinear" and row["probe"] == "both_correct")
317
+ ]
318
+ for row in primary:
319
+ lines.append(
320
+ f"| {row['method']} | {row['model_family']} | {row['layer_role']} | {row['target_step']} | "
321
+ f"{row['probe']} | {row['gain_mean']:.4f} | [{row['gain_ci95_low']:.4f}, {row['gain_ci95_high']:.4f}] | "
322
+ f"{row['wins']}/{row['prompt_count']} | {row['signflip_p']:.4f} |"
323
+ )
324
+ lines += [
325
+ "",
326
+ "Interpretation: positive gain means that adding the auxiliary feature lowers held-out MSE.",
327
+ "The exact sign-flip test treats prompt, not token, as the independent unit.",
328
+ "Absolute MSE is not compared across model families because feature dimensions and",
329
+ "conditioning paths differ.",
330
+ "",
331
+ "## Files",
332
+ "",
333
+ "- `probe_folds_unified.csv`",
334
+ "- `probe_prompt_averaged.csv`",
335
+ "- `probe_metrics_summary.csv`",
336
+ "- `probe_gain_summary.csv`",
337
+ "- `conditional_gain_by_layer.png`",
338
+ "- `conditional_control_comparison.png`",
339
+ "",
340
+ "```json",
341
+ json.dumps(config, indent=2, ensure_ascii=False),
342
+ "```",
343
+ ]
344
+ output.write_text("\n".join(lines) + "\n", encoding="utf-8")
345
+
346
+
347
+ def main() -> None:
348
+ global FAMILIES
349
+ parser = argparse.ArgumentParser()
350
+ parser.add_argument("--linear_csv", type=Path, required=True)
351
+ parser.add_argument("--nonlinear_dir", type=Path, required=True)
352
+ parser.add_argument("--output_dir", type=Path, required=True)
353
+ parser.add_argument("--families", default=",".join(FAMILIES))
354
+ parser.add_argument(
355
+ "--chunk_pairing",
356
+ choices=("matched_slot", "boundary_to_all"),
357
+ default="matched_slot",
358
+ )
359
+ args = parser.parse_args()
360
+ requested_families = tuple(
361
+ value.strip() for value in args.families.split(",") if value.strip()
362
+ )
363
+ unknown = set(requested_families) - set(FAMILIES)
364
+ if not requested_families or unknown:
365
+ raise ValueError(f"Invalid families: {requested_families}; unknown={sorted(unknown)}")
366
+ FAMILIES = requested_families
367
+ args.output_dir.mkdir(parents=True, exist_ok=True)
368
+
369
+ linear = read_csv(args.linear_csv)
370
+ linear = [row for row in linear if row["model_family"] in FAMILIES]
371
+ linear = [{**row, "method": "linear"} for row in linear]
372
+ nonlinear = []
373
+ for family in FAMILIES:
374
+ path = args.nonlinear_dir / f"nonlinear_probe_{family}_folds.csv"
375
+ rows = read_csv(path)
376
+ nonlinear.extend({**row, "method": "nonlinear"} for row in rows)
377
+ expected_linear = 1320 * len(FAMILIES)
378
+ expected_nonlinear = 2160 * len(FAMILIES)
379
+ if len(linear) != expected_linear:
380
+ raise ValueError(f"Expected {expected_linear} linear rows, found {len(linear)}")
381
+ if len(nonlinear) != expected_nonlinear:
382
+ raise ValueError(f"Expected {expected_nonlinear} nonlinear rows, found {len(nonlinear)}")
383
+ unified = linear + nonlinear
384
+ prompt_rows = prompt_metric_rows(unified)
385
+ gains = gain_rows(prompt_rows)
386
+ metrics = summarize_metrics(prompt_rows)
387
+ gain_summary = summarize_gains(gains)
388
+ layer_gain_summary = summarize_layer_gains(gains)
389
+ write_csv(args.output_dir / "probe_folds_unified.csv", unified)
390
+ write_csv(args.output_dir / "probe_prompt_averaged.csv", prompt_rows)
391
+ write_csv(args.output_dir / "probe_metrics_summary.csv", metrics)
392
+ write_csv(args.output_dir / "probe_gain_by_prompt.csv", gains)
393
+ write_csv(args.output_dir / "probe_gain_summary.csv", gain_summary)
394
+ write_csv(args.output_dir / "probe_layer_gain_summary.csv", layer_gain_summary)
395
+ plot_primary(gains, args.output_dir / "conditional_gain_by_layer.png")
396
+ plot_controls(gains, args.output_dir / "conditional_control_comparison.png")
397
+ config = {
398
+ "linear_rows": len(linear),
399
+ "nonlinear_rows": len(nonlinear),
400
+ "families": list(FAMILIES),
401
+ "chunk_pairing": args.chunk_pairing,
402
+ "prompt_averaged_rows": len(prompt_rows),
403
+ "gain_rows": len(gains),
404
+ "prompt_count": 10,
405
+ "target_chunks": [2, 3],
406
+ "target_steps": [1, 2, 3],
407
+ "linear_baseline": "within_affine",
408
+ "nonlinear_baseline": "step_only",
409
+ "nonlinear_seed_aggregation": "mean within held-out prompt",
410
+ "outer_split": "held-out prompt plus its cyclic other-video donor",
411
+ }
412
+ (args.output_dir / "config.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
413
+ build_report(metrics, gain_summary, args.output_dir / "REPORT.md", config)
414
+ print(f"[complete] {args.output_dir} unified={len(unified)} gains={len(gains)}", flush=True)
415
+
416
+
417
+ if __name__ == "__main__":
418
+ main()
scripts/summarize_confidence_stage1_experiments.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a compact report for the Stage-1 confidence-head rerun."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def csv_rows(path: Path) -> list[dict[str, str]]:
13
+ with path.open(encoding="utf-8") as handle:
14
+ return list(csv.DictReader(handle))
15
+
16
+
17
+ def selected(root: Path) -> list[dict]:
18
+ return json.loads((root / "validation/selected.json").read_text())["selected_dynamic"]
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("--root", type=Path, required=True)
24
+ args = parser.parse_args()
25
+ root = args.root.resolve()
26
+ step12_manifest = json.loads(
27
+ (root / "confidence_step12/manifest.json").read_text()
28
+ )
29
+ report = {
30
+ "status": "complete",
31
+ "predictor": step12_manifest["predictor_weights"],
32
+ }
33
+ for name in ("step12", "step123"):
34
+ report[f"confidence_{name}"] = json.loads(
35
+ (root / f"confidence_{name}/metrics.json").read_text()
36
+ )
37
+ dynamic_root = root / f"dynamic_{name}"
38
+ test = {row["config_name"]: row for row in csv_rows(dynamic_root / "test/summary.csv")}
39
+ chosen = []
40
+ for row in selected(dynamic_root):
41
+ item = test[str(row["config_name"])].copy()
42
+ item["validation_beta"] = row["beta"]
43
+ item["validation_threshold"] = row["threshold"]
44
+ chosen.append(item)
45
+ report[f"dynamic_{name}"] = chosen
46
+ report[f"baselines_{name}"] = {
47
+ key: test[key] for key in ("ffff", "fppf" if name == "step12" else "fppp")
48
+ }
49
+ vbench_path = root / "vbench/summary.csv"
50
+ if vbench_path.exists():
51
+ vbench_rows = csv_rows(vbench_path)
52
+ high_speed_path = root / "vbench_high_speed/summary.csv"
53
+ if high_speed_path.exists():
54
+ by_condition = {row["condition"]: row for row in vbench_rows}
55
+ by_condition.update(
56
+ {row["condition"]: row for row in csv_rows(high_speed_path)}
57
+ )
58
+ vbench_rows = [by_condition[name] for name in sorted(by_condition)]
59
+ report["vbench"] = vbench_rows
60
+ (root / "summary.json").write_text(
61
+ json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
62
+ )
63
+ print(json.dumps(report, indent=2, ensure_ascii=False))
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
scripts/summarize_layer17_dynamic_gate.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Summarize validation-selected Layer-17 dynamic-gating test results."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import matplotlib.pyplot as plt
13
+
14
+
15
+ TARGETS = (4, 6, 8, 10)
16
+
17
+
18
+ def read_csv(path: Path) -> list[dict[str, str]]:
19
+ with path.open(encoding="utf-8") as handle:
20
+ return list(csv.DictReader(handle))
21
+
22
+
23
+ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
24
+ fields = list(rows[0])
25
+ with path.open("w", encoding="utf-8", newline="") as handle:
26
+ writer = csv.DictWriter(handle, fieldnames=fields)
27
+ writer.writeheader()
28
+ writer.writerows(rows)
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument(
34
+ "--root", type=Path,
35
+ default=Path("outputs/layer17_dynamic_gate_20260830"),
36
+ )
37
+ args = parser.parse_args()
38
+ root = args.root.resolve()
39
+ test_dir = root / "test"
40
+ summary_rows = read_csv(test_dir / "summary.csv")
41
+ summary = {row["config_name"]: row for row in summary_rows}
42
+ selected = json.loads((root / "validation" / "selected.json").read_text())
43
+ selected_by_target = {
44
+ int(row["target_accepts"]): row for row in selected["selected_dynamic"]
45
+ }
46
+ ffff_time = float(summary["ffff"]["generation_time_s"])
47
+
48
+ comparisons: list[dict[str, Any]] = []
49
+ acceptance_rows: list[dict[str, Any]] = []
50
+ for target in TARGETS:
51
+ selected_row = selected_by_target[target]
52
+ dynamic_name = selected_row["config_name"]
53
+ dynamic = summary[dynamic_name]
54
+ static = summary[f"static_late_k{target:02d}"]
55
+ dynamic_lpips = float(dynamic["tail_lpips"])
56
+ static_lpips = float(static["tail_lpips"])
57
+ comparisons.append(
58
+ {
59
+ "target_accepts": target,
60
+ "dynamic_config": dynamic_name,
61
+ "beta": float(dynamic["beta"]),
62
+ "threshold": float(dynamic["threshold"]),
63
+ "dynamic_actual_accepts": float(dynamic["accepted_predictor_calls"]),
64
+ "static_actual_accepts": float(static["accepted_predictor_calls"]),
65
+ "dynamic_full_calls": float(dynamic["full_calls"]),
66
+ "static_full_calls": float(static["full_calls"]),
67
+ "dynamic_tail_lpips": dynamic_lpips,
68
+ "static_tail_lpips": static_lpips,
69
+ "tail_lpips_reduction_percent": 100.0 * (static_lpips - dynamic_lpips) / static_lpips,
70
+ "dynamic_generation_time_s": float(dynamic["generation_time_s"]),
71
+ "static_generation_time_s": float(static["generation_time_s"]),
72
+ "dynamic_overhead_vs_static_percent": 100.0 * (
73
+ float(dynamic["generation_time_s"])
74
+ / float(static["generation_time_s"])
75
+ - 1.0
76
+ ),
77
+ "dynamic_speedup_vs_ffff_percent": 100.0 * (
78
+ 1.0 - float(dynamic["generation_time_s"]) / ffff_time
79
+ ),
80
+ "dynamic_lpips": float(dynamic["lpips"]),
81
+ "static_lpips": float(static["lpips"]),
82
+ "dynamic_latent_tail_nrmse": float(dynamic["latent_tail_nrmse"]),
83
+ "static_latent_tail_nrmse": float(static["latent_tail_nrmse"]),
84
+ }
85
+ )
86
+
87
+ decision_files = sorted((test_dir / "per_run" / dynamic_name).glob("*.json"))
88
+ decisions = [
89
+ decision
90
+ for path in decision_files
91
+ for decision in json.loads(path.read_text())["decisions"]
92
+ ]
93
+ for chunk in range(1, 7):
94
+ for step in (1, 2):
95
+ cell = [
96
+ row for row in decisions
97
+ if int(row["chunk"]) == chunk and int(row["step"]) == step
98
+ ]
99
+ acceptance_rows.append(
100
+ {
101
+ "target_accepts": target,
102
+ "dynamic_config": dynamic_name,
103
+ "chunk": chunk,
104
+ "step": step,
105
+ "acceptance_ratio": sum(bool(row["accepted"]) for row in cell)
106
+ / len(cell),
107
+ }
108
+ )
109
+ write_csv(test_dir / "dynamic_vs_static.csv", comparisons)
110
+ write_csv(test_dir / "acceptance_by_chunk_step.csv", acceptance_rows)
111
+
112
+ fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
113
+ dynamic_rows = [summary[selected_by_target[target]["config_name"]] for target in TARGETS]
114
+ static_rows = [summary[f"static_late_k{target:02d}"] for target in TARGETS]
115
+ for axis, x_field, label in (
116
+ (axes[0], "full_calls", "Mean Full calls"),
117
+ (axes[1], "generation_time_s", "Generation time (s)"),
118
+ ):
119
+ axis.plot(
120
+ [float(row[x_field]) for row in dynamic_rows],
121
+ [float(row["tail_lpips"]) for row in dynamic_rows],
122
+ "o-", label="Dynamic confidence", color="#d64b40", linewidth=2,
123
+ )
124
+ axis.plot(
125
+ [float(row[x_field]) for row in static_rows],
126
+ [float(row["tail_lpips"]) for row in static_rows],
127
+ "s--", label="Static late-first", color="#3977b8", linewidth=2,
128
+ )
129
+ axis.scatter(
130
+ [float(summary["ffff"][x_field])],
131
+ [float(summary["ffff"]["tail_lpips"])],
132
+ marker="*", s=100, color="#333333", label="FFFF",
133
+ )
134
+ axis.scatter(
135
+ [float(summary["fppf"][x_field])],
136
+ [float(summary["fppf"]["tail_lpips"])],
137
+ marker="X", s=80, color="#777777", label="FPPF",
138
+ )
139
+ axis.set_xlabel(label)
140
+ axis.set_ylabel("Tail LPIPS")
141
+ axis.grid(alpha=0.25)
142
+ axes[0].legend(frameon=False)
143
+ fig.suptitle("Layer-17 Predictor: quality-compute frontier on prompts 90–99")
144
+ fig.tight_layout()
145
+ fig.savefig(root / "quality_compute_pareto.png", dpi=180)
146
+ plt.close(fig)
147
+
148
+ report = [
149
+ "# Layer-17 dynamic confidence gating",
150
+ "",
151
+ "Thresholds and beta were selected only on prompts 80–89. The table below "
152
+ "reports the frozen configurations on prompts 90–99.",
153
+ "",
154
+ "| Target P | Beta | Actual P | Full | Tail LPIPS dynamic | Static | Reduction | Gen speedup vs FFFF |",
155
+ "|---:|---:|---:|---:|---:|---:|---:|---:|",
156
+ ]
157
+ for row in comparisons:
158
+ report.append(
159
+ f"| {row['target_accepts']} | {row['beta']:.1f} | "
160
+ f"{row['dynamic_actual_accepts']:.1f} | {row['dynamic_full_calls']:.1f} | "
161
+ f"{row['dynamic_tail_lpips']:.5f} | {row['static_tail_lpips']:.5f} | "
162
+ f"{row['tail_lpips_reduction_percent']:.1f}% | "
163
+ f"{row['dynamic_speedup_vs_ffff_percent']:.1f}% |"
164
+ )
165
+ report.extend(
166
+ [
167
+ "",
168
+ f"FFFF generation time: {float(summary['ffff']['generation_time_s']):.3f}s. "
169
+ f"FPPF generation time: {float(summary['fppf']['generation_time_s']):.3f}s; "
170
+ f"tail LPIPS: {float(summary['fppf']['tail_lpips']):.5f}.",
171
+ "",
172
+ "Dynamic gating evaluates the Predictor at all 12 candidate decisions, "
173
+ "including rejected calls. Its generation-time overhead relative to the "
174
+ "budget-matched static policies is 0–4.4%, and is included in the table/plot.",
175
+ "",
176
+ "The K≈6 point is the recommended balanced operating point: beta=1.0, "
177
+ "threshold=0.333097, 5.8 accepted Predictor calls, 22.2 Full calls, "
178
+ "tail LPIPS 0.03449, and 16.3% generation speedup versus FFFF.",
179
+ ]
180
+ )
181
+ (root / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()