anhtld commited on
Commit
d45d1d2
·
verified ·
1 Parent(s): 42ff3cf

ctt measured rollout full validation 2026-07-03T01:27:41Z

Browse files
workspace/scripts/eval_ctt_generated_rollout.py ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import pickle
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
15
+ if str(PROJECT_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+ import numpy as np # noqa: E402
19
+ import torch # noqa: E402
20
+
21
+ from cil.models import CTTConfig, CausalTangentTransport, ChartEncoder, TangentNormalizer, UtilityEnergy # noqa: E402
22
+ from dovla_cil.generation.maniskill_parallel import execute_grouped_action_lattice_batch # noqa: E402
23
+ from dovla_cil.utils.io import read_json # noqa: E402
24
+ from scripts.eval_metrics import main as eval_metrics_main # noqa: E402
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ChartItem:
29
+ chart_id: str
30
+ task_id: str
31
+ seed: str
32
+ state_hash: str
33
+ instruction: str
34
+ source_dataset: Path
35
+ base_action: np.ndarray
36
+ feature: np.ndarray
37
+ positive_tangents: np.ndarray
38
+ negative_tangents: np.ndarray
39
+ hidden_utilities: list[float]
40
+ hidden_candidate_types: list[str]
41
+ stored_base_utility: float | None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Proposal:
46
+ tangent: np.ndarray
47
+ action: np.ndarray
48
+ score: float
49
+ source_chart_id: str
50
+ source_task_id: str
51
+ source_rank: int
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(
56
+ description=(
57
+ "Generate CTT candidates, decode them to ManiSkill action chunks, "
58
+ "and measure them with same-state simulator rollouts."
59
+ )
60
+ )
61
+ parser.add_argument("--checkpoint", type=Path, required=True)
62
+ parser.add_argument("--source-index", type=Path, default=Path("data/cil_charts/train/index.json"))
63
+ parser.add_argument("--target-index", type=Path, default=Path("data/cil_charts/val/index.json"))
64
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_residual_rollout_smoke"))
65
+ parser.add_argument("--k", type=int, default=16)
66
+ parser.add_argument("--pool-size", type=int, default=0)
67
+ parser.add_argument("--neighbors", type=int, default=8)
68
+ parser.add_argument("--max-target-charts", type=int, default=8)
69
+ parser.add_argument("--group-batch-size", type=int, default=1)
70
+ parser.add_argument("--device", default="auto")
71
+ parser.add_argument("--sim-backend", default=None)
72
+ parser.add_argument("--render-backend", default=None)
73
+ parser.add_argument("--restore-tolerance", type=float, default=1.0e-5)
74
+ parser.add_argument("--delta-scale", type=float, default=1.0)
75
+ parser.add_argument("--include-targets-without-positives", action="store_true")
76
+ parser.add_argument("--skip-metrics", action="store_true")
77
+ parser.add_argument("--bootstrap-samples", type=int, default=200)
78
+ args = parser.parse_args(argv)
79
+
80
+ if args.k <= 0:
81
+ parser.error("--k must be positive")
82
+ if args.neighbors <= 0:
83
+ parser.error("--neighbors must be positive")
84
+ if args.group_batch_size <= 0:
85
+ parser.error("--group-batch-size must be positive")
86
+ if args.max_target_charts <= 0:
87
+ parser.error("--max-target-charts must be positive")
88
+ if args.restore_tolerance <= 0.0:
89
+ parser.error("--restore-tolerance must be positive")
90
+
91
+ out_dir = args.out_dir
92
+ out_dir.mkdir(parents=True, exist_ok=True)
93
+ _write_run_provenance(out_dir, args)
94
+ log_path = out_dir / "run.log"
95
+ _append_log(log_path, "start")
96
+ _append_log(log_path, "importing gymnasium/mani_skill")
97
+ try:
98
+ import gymnasium as gym
99
+ import mani_skill # noqa: F401 - importing registers environments
100
+ except ImportError as exc: # pragma: no cover - exercised in the Apptainer env
101
+ raise ImportError(
102
+ "CTT measured rollout requires gymnasium, mani_skill, numpy, and torch. "
103
+ "Run this script through the ManiSkill Apptainer environment on HPC."
104
+ ) from exc
105
+ _append_log(log_path, "imported gymnasium/mani_skill")
106
+
107
+ checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
108
+ config = CTTConfig(**checkpoint["config"])
109
+ encoder = ChartEncoder(config.chart_feature_dim, output_dim=config.chart_dim)
110
+ ctt = CausalTangentTransport(config)
111
+ utility_energy = UtilityEnergy(chart_dim=config.chart_dim, tangent_dim=config.tangent_dim)
112
+ encoder.load_state_dict(checkpoint["chart_encoder"])
113
+ ctt.load_state_dict(checkpoint["ctt"])
114
+ if "utility_energy" not in checkpoint:
115
+ raise SystemExit(f"{args.checkpoint} does not contain a utility_energy state")
116
+ utility_energy.load_state_dict(checkpoint["utility_energy"])
117
+ normalizer = TangentNormalizer.from_dict(checkpoint["normalizer"])
118
+ encoder.eval()
119
+ ctt.eval()
120
+ utility_energy.eval()
121
+ for module in (encoder, ctt, utility_energy):
122
+ for parameter in module.parameters():
123
+ parameter.requires_grad_(False)
124
+ _append_log(log_path, f"loaded checkpoint={args.checkpoint}")
125
+
126
+ source_charts, source_index = load_chart_items(
127
+ args.source_index,
128
+ max_charts=None,
129
+ require_positive=True,
130
+ include_hidden=False,
131
+ include_metadata=False,
132
+ )
133
+ target_charts, target_index = load_chart_items(
134
+ args.target_index,
135
+ max_charts=args.max_target_charts,
136
+ require_positive=not args.include_targets_without_positives,
137
+ include_hidden=True,
138
+ include_metadata=True,
139
+ )
140
+ _validate_indexes(args.source_index, source_index, args.target_index, target_index)
141
+ if not target_charts:
142
+ raise SystemExit("No target charts available after filtering")
143
+ _append_log(
144
+ log_path,
145
+ f"loaded charts source={len(source_charts)} target={len(target_charts)}",
146
+ )
147
+
148
+ resolved_device = _resolve_device(args.device)
149
+ encoder.to(resolved_device)
150
+ ctt.to(resolved_device)
151
+ utility_energy.to(resolved_device)
152
+ source_by_task: dict[str, list[ChartItem]] = {}
153
+ for chart in source_charts:
154
+ source_by_task.setdefault(chart.task_id, []).append(chart)
155
+
156
+ pool_size = int(args.pool_size) if args.pool_size > 0 else int(args.k)
157
+ generated_cases = [
158
+ (
159
+ target,
160
+ generate_proposals(
161
+ target,
162
+ source_charts=source_charts,
163
+ source_by_task=source_by_task,
164
+ encoder=encoder,
165
+ ctt=ctt,
166
+ utility_energy=utility_energy,
167
+ normalizer=normalizer,
168
+ device=resolved_device,
169
+ neighbors=args.neighbors,
170
+ pool_size=max(pool_size, args.k),
171
+ k=args.k,
172
+ delta_scale=args.delta_scale,
173
+ ),
174
+ )
175
+ for target in target_charts
176
+ ]
177
+ _append_log(
178
+ log_path,
179
+ "generated proposals "
180
+ f"rows={len(generated_cases)} total={sum(len(item[1]) for item in generated_cases)}",
181
+ )
182
+ rows = rollout_generated_cases(
183
+ generated_cases,
184
+ gym=gym,
185
+ torch=torch,
186
+ device=resolved_device,
187
+ group_batch_size=args.group_batch_size,
188
+ sim_backend=args.sim_backend,
189
+ render_backend=args.render_backend,
190
+ restore_tolerance=args.restore_tolerance,
191
+ log_path=log_path,
192
+ )
193
+ _append_log(log_path, f"rollout complete rows={len(rows)}")
194
+
195
+ payload = {
196
+ "report_type": "ctt_generated_measured_rollout",
197
+ "candidates_evaluated": True,
198
+ "schema_version": 1,
199
+ "checkpoint": str(args.checkpoint),
200
+ "source_index": str(args.source_index),
201
+ "target_index": str(args.target_index),
202
+ "source_content_hash": source_index.get("content_hash"),
203
+ "source_split_hash": source_index.get("split_hash"),
204
+ "target_content_hash": target_index.get("content_hash"),
205
+ "target_split_hash": target_index.get("split_hash"),
206
+ "k": args.k,
207
+ "neighbors": args.neighbors,
208
+ "pool_size": max(pool_size, args.k),
209
+ "decoder": {
210
+ "name": "linear_keyframe_decode",
211
+ "source_code": "spline_tangent_code stores start/mid/end residual keyframes",
212
+ "lossless": False,
213
+ "delta_scale": args.delta_scale,
214
+ },
215
+ "rows": rows,
216
+ }
217
+ measured_path = out_dir / "measured_candidates.json"
218
+ measured_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
219
+ (out_dir / "report.md").write_text(_report(payload) + "\n")
220
+
221
+ if not args.skip_metrics:
222
+ eval_metrics_main(
223
+ [
224
+ "--input",
225
+ str(measured_path),
226
+ "--out-dir",
227
+ str(out_dir / "measured_metrics"),
228
+ "--mode",
229
+ "measured",
230
+ "--k",
231
+ str(args.k),
232
+ "--bootstrap-samples",
233
+ str(args.bootstrap_samples),
234
+ ]
235
+ )
236
+
237
+ print(
238
+ json.dumps(
239
+ {
240
+ "out_dir": str(out_dir),
241
+ "num_rows": len(rows),
242
+ "measured_candidates": str(measured_path),
243
+ },
244
+ indent=2,
245
+ )
246
+ )
247
+ return 0
248
+
249
+
250
+ def load_chart_items(
251
+ index_path: Path,
252
+ *,
253
+ max_charts: int | None,
254
+ require_positive: bool,
255
+ include_hidden: bool,
256
+ include_metadata: bool,
257
+ ) -> tuple[list[ChartItem], dict[str, Any]]:
258
+ index = json.loads(index_path.read_text())
259
+ grouped: dict[str, dict[str, Any]] = {}
260
+ for shard in index.get("shards", []):
261
+ shard_path = index_path.parent / shard["path"]
262
+ with np.load(shard_path, allow_pickle=False) as data:
263
+ chart_ids = data["chart_id"]
264
+ task_ids = data["task_id"]
265
+ seeds = data["seed"]
266
+ state_hashes = data["state_hash"]
267
+ action_shapes = data["action_shape"]
268
+ base_actions = data["base_action"]
269
+ labels = data["label"]
270
+ spline_tangents = data["spline_tangent_code"]
271
+ is_base_branch = data["is_base_branch"]
272
+ utilities = data["utility"] if include_hidden else None
273
+ candidate_types = data["candidate_type"] if include_hidden else None
274
+ metadata_values = data["metadata_json"] if include_metadata else None
275
+ for row in range(chart_ids.shape[0]):
276
+ chart_id = str(chart_ids[row])
277
+ task_id = str(task_ids[row])
278
+ metadata = (
279
+ _json_loads(str(metadata_values[row]))
280
+ if metadata_values is not None
281
+ else {}
282
+ )
283
+ shape = tuple(int(value) for value in action_shapes[row])
284
+ flat_count = int(math.prod(shape))
285
+ base_action = np.asarray(
286
+ base_actions[row][:flat_count], dtype=np.float32
287
+ ).reshape(shape)
288
+ item = grouped.setdefault(
289
+ chart_id,
290
+ {
291
+ "chart_id": chart_id,
292
+ "task_id": task_id,
293
+ "seed": str(seeds[row]),
294
+ "state_hash": str(state_hashes[row]),
295
+ "instruction": str(metadata.get("instruction", "")),
296
+ "source_dataset": _source_dataset_from_metadata(
297
+ metadata,
298
+ index=index,
299
+ task_id=task_id,
300
+ ),
301
+ "base_action": base_action,
302
+ "positive_tangents": [],
303
+ "negative_tangents": [],
304
+ "hidden_utilities": [],
305
+ "hidden_candidate_types": [],
306
+ "stored_base_utility": None,
307
+ },
308
+ )
309
+ label = str(labels[row])
310
+ tangent = np.asarray(spline_tangents[row], dtype=np.float32)
311
+ if label == "positive":
312
+ item["positive_tangents"].append(tangent)
313
+ elif label == "negative":
314
+ item["negative_tangents"].append(tangent)
315
+ utility = float(utilities[row]) if utilities is not None else math.nan
316
+ if include_hidden and math.isfinite(utility):
317
+ item["hidden_utilities"].append(utility)
318
+ item["hidden_candidate_types"].append(str(candidate_types[row]))
319
+ if bool(is_base_branch[row]):
320
+ item["stored_base_utility"] = utility if math.isfinite(utility) else None
321
+ item["base_action"] = base_action
322
+
323
+ charts: list[ChartItem] = []
324
+ for chart_id, item in sorted(grouped.items()):
325
+ positives = _matrix_or_empty(item["positive_tangents"], width=21)
326
+ negatives = _matrix_or_empty(item["negative_tangents"], width=21)
327
+ if require_positive and not len(positives):
328
+ continue
329
+ base_action = np.asarray(item["base_action"], dtype=np.float32)
330
+ charts.append(
331
+ ChartItem(
332
+ chart_id=chart_id,
333
+ task_id=str(item["task_id"]),
334
+ seed=str(item["seed"]),
335
+ state_hash=str(item["state_hash"]),
336
+ instruction=str(item["instruction"]),
337
+ source_dataset=Path(item["source_dataset"]).resolve(),
338
+ base_action=base_action,
339
+ feature=base_action.reshape(-1).astype(np.float32),
340
+ positive_tangents=positives,
341
+ negative_tangents=negatives,
342
+ hidden_utilities=[float(value) for value in item["hidden_utilities"]],
343
+ hidden_candidate_types=[str(value) for value in item["hidden_candidate_types"]],
344
+ stored_base_utility=item["stored_base_utility"],
345
+ )
346
+ )
347
+ if max_charts is not None and len(charts) >= int(max_charts):
348
+ break
349
+ return charts, index
350
+
351
+
352
+ def generate_proposals(
353
+ target: ChartItem,
354
+ *,
355
+ source_charts: list[ChartItem],
356
+ source_by_task: dict[str, list[ChartItem]],
357
+ encoder: ChartEncoder,
358
+ ctt: CausalTangentTransport,
359
+ utility_energy: UtilityEnergy,
360
+ normalizer: TangentNormalizer,
361
+ device: str,
362
+ neighbors: int,
363
+ pool_size: int,
364
+ k: int,
365
+ delta_scale: float,
366
+ ) -> list[Proposal]:
367
+ pool = source_by_task.get(target.task_id) or source_charts
368
+ target_feature = torch.as_tensor(target.feature, dtype=torch.float32, device=device)
369
+ ranked_sources = sorted(
370
+ pool,
371
+ key=lambda source: float(
372
+ torch.linalg.vector_norm(
373
+ torch.as_tensor(source.feature, dtype=torch.float32, device=device)
374
+ - target_feature
375
+ )
376
+ .detach()
377
+ .cpu()
378
+ ),
379
+ )[:neighbors]
380
+ target_z = encoder(target_feature.unsqueeze(0))
381
+ proposals: list[Proposal] = []
382
+ with torch.no_grad():
383
+ for source_rank, source in enumerate(ranked_sources):
384
+ if len(proposals) >= pool_size:
385
+ break
386
+ source_feature = torch.as_tensor(source.feature, dtype=torch.float32, device=device)
387
+ source_z = encoder(source_feature.unsqueeze(0))
388
+ for xi_source_raw in source.positive_tangents:
389
+ if len(proposals) >= pool_size:
390
+ break
391
+ xi_source = torch.as_tensor(
392
+ xi_source_raw, dtype=torch.float32, device=device
393
+ ).unsqueeze(0)
394
+ xi_source_norm = normalizer.transform(xi_source)
395
+ xi_hat_norm = ctt(source_z, target_z, xi_source_norm)
396
+ score = float(utility_energy(target_z, xi_hat_norm).squeeze(0).detach().cpu())
397
+ xi_hat = normalizer.inverse_transform(xi_hat_norm).squeeze(0).detach().cpu().numpy()
398
+ action_delta = decode_linear_keyframe_tangent(
399
+ xi_hat,
400
+ horizon=target.base_action.shape[0],
401
+ action_dim=target.base_action.shape[1],
402
+ )
403
+ action = target.base_action + float(delta_scale) * action_delta
404
+ proposals.append(
405
+ Proposal(
406
+ tangent=xi_hat.astype(np.float32, copy=False),
407
+ action=action.astype(np.float32, copy=False),
408
+ score=score,
409
+ source_chart_id=source.chart_id,
410
+ source_task_id=source.task_id,
411
+ source_rank=source_rank,
412
+ )
413
+ )
414
+ proposals.sort(key=lambda proposal: proposal.score, reverse=True)
415
+ return proposals[:k]
416
+
417
+
418
+ def decode_linear_keyframe_tangent(
419
+ tangent_code: np.ndarray,
420
+ *,
421
+ horizon: int,
422
+ action_dim: int,
423
+ ) -> np.ndarray:
424
+ """Decode the public 21D CIL keyframe code into a full residual action chunk.
425
+
426
+ The chart exporter stores start/mid/end residual rows for the common H=16, D=7
427
+ chunk. This decoder linearly interpolates those rows; it is intentionally marked
428
+ non-lossless in rollout metadata.
429
+ """
430
+
431
+ if horizon <= 0 or action_dim <= 0:
432
+ raise ValueError("horizon and action_dim must be positive")
433
+ code = np.asarray(tangent_code, dtype=np.float32).reshape(-1)
434
+ key_dim = min(action_dim, max(1, min(7, code.shape[0] // 3)))
435
+ keyframes = np.zeros((3, action_dim), dtype=np.float32)
436
+ usable = min(3 * key_dim, code.shape[0])
437
+ keyframes[:, :key_dim] = code[:usable].reshape(3, key_dim)
438
+ if horizon == 1:
439
+ return keyframes[:1]
440
+ mid = horizon // 2
441
+ positions = np.asarray([0, mid, horizon - 1], dtype=np.float32)
442
+ timeline = np.arange(horizon, dtype=np.float32)
443
+ decoded = np.zeros((horizon, action_dim), dtype=np.float32)
444
+ for dim in range(action_dim):
445
+ decoded[:, dim] = np.interp(timeline, positions, keyframes[:, dim])
446
+ return decoded
447
+
448
+
449
+ def rollout_generated_cases(
450
+ generated_cases: list[tuple[ChartItem, list[Proposal]]],
451
+ *,
452
+ gym: Any,
453
+ torch: Any,
454
+ device: str,
455
+ group_batch_size: int,
456
+ sim_backend: str | None,
457
+ render_backend: str | None,
458
+ restore_tolerance: float,
459
+ log_path: Path | None = None,
460
+ ) -> list[dict[str, Any]]:
461
+ archives: dict[Path, dict[str, Any]] = {}
462
+ rows: list[dict[str, Any]] = []
463
+ by_task: dict[str, list[tuple[ChartItem, list[Proposal]]]] = {}
464
+ for item in generated_cases:
465
+ by_task.setdefault(item[0].task_id, []).append(item)
466
+ for task_id, cases in sorted(by_task.items()):
467
+ for start in range(0, len(cases), group_batch_size):
468
+ batch = cases[start : start + group_batch_size]
469
+ source_summary = _source_summary(batch[0][0].source_dataset)
470
+ resolved_render_backend = (
471
+ render_backend
472
+ if render_backend is not None
473
+ else source_summary.get("render_backend") or "none"
474
+ )
475
+ max_candidate_count = max(1 + len(proposals) for _target, proposals in batch)
476
+ env_kwargs = {
477
+ "num_envs": len(batch) * max_candidate_count,
478
+ "obs_mode": "state",
479
+ "control_mode": source_summary.get("control_mode", "pd_ee_delta_pose"),
480
+ "render_mode": None,
481
+ "sim_backend": sim_backend or source_summary.get("sim_backend", "physx_cuda"),
482
+ "render_backend": resolved_render_backend,
483
+ "reward_mode": "normalized_dense",
484
+ }
485
+ if _uses_single_env_cpu_backend(env_kwargs["sim_backend"]) and (
486
+ len(batch) * max_candidate_count > 1
487
+ ):
488
+ rows.extend(
489
+ _rollout_cpu_sequential_batch(
490
+ task_id,
491
+ batch,
492
+ gym=gym,
493
+ torch=torch,
494
+ device=device,
495
+ env_kwargs=dict(env_kwargs) | {"num_envs": 1},
496
+ archives=archives,
497
+ restore_tolerance=restore_tolerance,
498
+ log_path=log_path,
499
+ )
500
+ )
501
+ continue
502
+ _append_log(
503
+ log_path,
504
+ f"env init task={task_id} start={start} batch={len(batch)} "
505
+ f"candidates={max_candidate_count} sim={env_kwargs['sim_backend']} "
506
+ f"render={env_kwargs['render_backend']}",
507
+ )
508
+ env = gym.make(task_id, **env_kwargs)
509
+ base_env = env.unwrapped
510
+ try:
511
+ env_device = getattr(base_env, "device", torch.device(device))
512
+ env_dim = _env_action_dim(env)
513
+ states: list[dict[str, Any]] = []
514
+ action_groups: list[np.ndarray] = []
515
+ valid_counts: list[int] = []
516
+ for target, proposals in batch:
517
+ archive = archives.setdefault(
518
+ target.source_dataset, _load_state_archive(target.source_dataset)
519
+ )
520
+ states.append(archive["initial"][target.chart_id])
521
+ group_actions = [target.base_action] + [proposal.action for proposal in proposals]
522
+ valid_counts.append(len(group_actions))
523
+ while len(group_actions) < max_candidate_count:
524
+ group_actions.append(target.base_action)
525
+ action_groups.append(np.stack(group_actions, axis=0))
526
+ candidate_values = np.stack(action_groups, axis=0).astype(np.float32)
527
+ candidate_values = _adapt_action_dim_4d(candidate_values, env_dim)
528
+ candidate_values = _clip_to_action_space_4d(candidate_values, env)
529
+ _append_log(
530
+ log_path,
531
+ f"execute task={task_id} start={start} shape={candidate_values.shape}",
532
+ )
533
+ _after_state, rewards, successes, restore_error = execute_grouped_action_lattice_batch(
534
+ base_env,
535
+ states,
536
+ candidate_values,
537
+ torch=torch,
538
+ device=env_device,
539
+ restore_tolerance=restore_tolerance,
540
+ )
541
+ for index, (target, proposals) in enumerate(batch):
542
+ valid = valid_counts[index]
543
+ progress = [
544
+ float(max(0.0, min(1.0, rewards[index, candidate_index])))
545
+ for candidate_index in range(valid)
546
+ ]
547
+ success = [
548
+ bool(successes[index, candidate_index])
549
+ for candidate_index in range(valid)
550
+ ]
551
+ utilities = [
552
+ progress_value + (1.0 if success_value else 0.0)
553
+ for progress_value, success_value in zip(progress, success, strict=True)
554
+ ]
555
+ base_utility = float(utilities[0])
556
+ generated_utilities = [float(value) for value in utilities[1:]]
557
+ predicted_scores = [float(proposal.score) for proposal in proposals]
558
+ rows.append(
559
+ {
560
+ "chart_id": target.chart_id,
561
+ "group_id": target.chart_id,
562
+ "task_id": target.task_id,
563
+ "seed": target.seed,
564
+ "state_hash": target.state_hash,
565
+ "instruction": target.instruction,
566
+ "candidates_evaluated": True,
567
+ "selected_index": 0,
568
+ "base_utility": base_utility,
569
+ "stored_base_utility": target.stored_base_utility,
570
+ "generated_utilities": generated_utilities,
571
+ "hidden_chart_utilities": target.hidden_utilities,
572
+ "hidden_candidate_types": target.hidden_candidate_types,
573
+ "predicted_scores": predicted_scores,
574
+ "generated_tangents": [
575
+ proposal.tangent.astype(float).tolist() for proposal in proposals
576
+ ],
577
+ "positive_tangents": target.positive_tangents.astype(float).tolist(),
578
+ "negative_tangents": target.negative_tangents.astype(float).tolist(),
579
+ "candidate_types": [
580
+ f"ctt_transport_rank{proposal.source_rank}"
581
+ for proposal in proposals
582
+ ],
583
+ "candidate_source_chart_ids": [
584
+ proposal.source_chart_id for proposal in proposals
585
+ ],
586
+ "candidate_source_task_ids": [
587
+ proposal.source_task_id for proposal in proposals
588
+ ],
589
+ "candidate_progress": progress[1:],
590
+ "candidate_success": success[1:],
591
+ "base_progress": progress[0],
592
+ "base_success": success[0],
593
+ "restore_error": float(restore_error),
594
+ "num_generated": len(proposals),
595
+ "num_executed_including_base": valid,
596
+ }
597
+ )
598
+ finally:
599
+ env.close()
600
+ _append_log(log_path, f"batch done task={task_id} start={start}")
601
+ return rows
602
+
603
+
604
+ def _rollout_cpu_sequential_batch(
605
+ task_id: str,
606
+ batch: list[tuple[ChartItem, list[Proposal]]],
607
+ *,
608
+ gym: Any,
609
+ torch: Any,
610
+ device: str,
611
+ env_kwargs: dict[str, Any],
612
+ archives: dict[Path, dict[str, Any]],
613
+ restore_tolerance: float,
614
+ log_path: Path | None,
615
+ ) -> list[dict[str, Any]]:
616
+ rows: list[dict[str, Any]] = []
617
+ _append_log(
618
+ log_path,
619
+ f"env init sequential task={task_id} batch={len(batch)} sim={env_kwargs['sim_backend']} "
620
+ f"render={env_kwargs['render_backend']}",
621
+ )
622
+ env = gym.make(task_id, **env_kwargs)
623
+ base_env = env.unwrapped
624
+ try:
625
+ env_device = getattr(base_env, "device", torch.device(device))
626
+ env_dim = _env_action_dim(env)
627
+ for target, proposals in batch:
628
+ archive = archives.setdefault(
629
+ target.source_dataset, _load_state_archive(target.source_dataset)
630
+ )
631
+ state = archive["initial"][target.chart_id]
632
+ candidate_actions = [target.base_action] + [proposal.action for proposal in proposals]
633
+ progress: list[float] = []
634
+ success: list[bool] = []
635
+ restore_errors: list[float] = []
636
+ for candidate_index, action in enumerate(candidate_actions):
637
+ candidate_values = np.asarray(action, dtype=np.float32).reshape(
638
+ 1, 1, *action.shape
639
+ )
640
+ candidate_values = _adapt_action_dim_4d(candidate_values, env_dim)
641
+ candidate_values = _clip_to_action_space_4d(candidate_values, env)
642
+ _append_log(
643
+ log_path,
644
+ f"execute sequential task={task_id} chart={target.chart_id} "
645
+ f"candidate={candidate_index} shape={candidate_values.shape}",
646
+ )
647
+ _after_state, rewards, successes, restore_error = execute_grouped_action_lattice_batch(
648
+ base_env,
649
+ [state],
650
+ candidate_values,
651
+ torch=torch,
652
+ device=env_device,
653
+ restore_tolerance=restore_tolerance,
654
+ )
655
+ progress.append(float(max(0.0, min(1.0, rewards[0, 0]))))
656
+ success.append(bool(successes[0, 0]))
657
+ restore_errors.append(float(restore_error))
658
+ utilities = [
659
+ progress_value + (1.0 if success_value else 0.0)
660
+ for progress_value, success_value in zip(progress, success, strict=True)
661
+ ]
662
+ rows.append(
663
+ _measured_row_from_rollout(
664
+ target,
665
+ proposals,
666
+ progress=progress,
667
+ success=success,
668
+ utilities=utilities,
669
+ restore_error=max(restore_errors, default=0.0),
670
+ )
671
+ )
672
+ finally:
673
+ env.close()
674
+ _append_log(log_path, f"batch done sequential task={task_id}")
675
+ return rows
676
+
677
+
678
+ def _measured_row_from_rollout(
679
+ target: ChartItem,
680
+ proposals: list[Proposal],
681
+ *,
682
+ progress: list[float],
683
+ success: list[bool],
684
+ utilities: list[float],
685
+ restore_error: float,
686
+ ) -> dict[str, Any]:
687
+ base_utility = float(utilities[0])
688
+ generated_utilities = [float(value) for value in utilities[1:]]
689
+ predicted_scores = [float(proposal.score) for proposal in proposals]
690
+ return {
691
+ "chart_id": target.chart_id,
692
+ "group_id": target.chart_id,
693
+ "task_id": target.task_id,
694
+ "seed": target.seed,
695
+ "state_hash": target.state_hash,
696
+ "instruction": target.instruction,
697
+ "candidates_evaluated": True,
698
+ "selected_index": 0,
699
+ "base_utility": base_utility,
700
+ "stored_base_utility": target.stored_base_utility,
701
+ "generated_utilities": generated_utilities,
702
+ "hidden_chart_utilities": target.hidden_utilities,
703
+ "hidden_candidate_types": target.hidden_candidate_types,
704
+ "predicted_scores": predicted_scores,
705
+ "generated_tangents": [
706
+ proposal.tangent.astype(float).tolist() for proposal in proposals
707
+ ],
708
+ "positive_tangents": target.positive_tangents.astype(float).tolist(),
709
+ "negative_tangents": target.negative_tangents.astype(float).tolist(),
710
+ "candidate_types": [
711
+ f"ctt_transport_rank{proposal.source_rank}" for proposal in proposals
712
+ ],
713
+ "candidate_source_chart_ids": [proposal.source_chart_id for proposal in proposals],
714
+ "candidate_source_task_ids": [proposal.source_task_id for proposal in proposals],
715
+ "candidate_progress": progress[1:],
716
+ "candidate_success": success[1:],
717
+ "base_progress": progress[0],
718
+ "base_success": success[0],
719
+ "restore_error": float(restore_error),
720
+ "num_generated": len(proposals),
721
+ "num_executed_including_base": len(utilities),
722
+ }
723
+
724
+
725
+ def _uses_single_env_cpu_backend(sim_backend: Any) -> bool:
726
+ value = str(sim_backend or "").lower()
727
+ return value in {"cpu", "physx_cpu"} or value.endswith("_cpu")
728
+
729
+
730
+ def _validate_indexes(
731
+ source_path: Path,
732
+ source_index: dict[str, Any],
733
+ target_path: Path,
734
+ target_index: dict[str, Any],
735
+ ) -> None:
736
+ if source_index.get("split") != "train" or not source_index.get("retrieval_index_allowed"):
737
+ raise SystemExit(
738
+ f"{source_path} is not a train-only retrieval index; CTT rollout sources "
739
+ "must come from train split only"
740
+ )
741
+ if not source_index.get("include_outcomes"):
742
+ raise SystemExit(f"{source_path} must include train outcomes for source positives")
743
+ if not target_index.get("include_outcomes"):
744
+ raise SystemExit(f"{target_path} must include evaluator-only outcomes")
745
+ if target_index.get("split") != "train" and target_index.get("retrieval_index_allowed"):
746
+ raise SystemExit(f"{target_path} is non-train but marked retrieval_index_allowed")
747
+
748
+
749
+ def _adapt_action_dim_4d(actions: np.ndarray, action_dim: int) -> np.ndarray:
750
+ if actions.ndim != 4:
751
+ raise ValueError("actions must have shape [B,K,H,D]")
752
+ if actions.shape[-1] == action_dim:
753
+ return actions.astype(np.float32, copy=False)
754
+ if actions.shape[-1] > action_dim:
755
+ return actions[..., :action_dim].astype(np.float32, copy=False)
756
+ pad = np.zeros((*actions.shape[:-1], action_dim - actions.shape[-1]), dtype=np.float32)
757
+ return np.concatenate([actions.astype(np.float32, copy=False), pad], axis=-1)
758
+
759
+
760
+ def _clip_to_action_space_4d(actions: np.ndarray, env: Any) -> np.ndarray:
761
+ space = getattr(env, "single_action_space", None) or getattr(env, "action_space", None)
762
+ low = getattr(space, "low", None)
763
+ high = getattr(space, "high", None)
764
+ if low is None or high is None:
765
+ return actions
766
+ low_arr = np.asarray(low, dtype=np.float32).reshape(-1)[-actions.shape[-1] :]
767
+ high_arr = np.asarray(high, dtype=np.float32).reshape(-1)[-actions.shape[-1] :]
768
+ if low_arr.shape[0] != actions.shape[-1] or high_arr.shape[0] != actions.shape[-1]:
769
+ return actions
770
+ return np.clip(actions, low_arr.reshape(1, 1, 1, -1), high_arr.reshape(1, 1, 1, -1))
771
+
772
+
773
+ def _env_action_dim(env: Any) -> int:
774
+ for space in (
775
+ getattr(env, "single_action_space", None),
776
+ getattr(env.unwrapped, "single_action_space", None),
777
+ getattr(env, "action_space", None),
778
+ ):
779
+ shape = getattr(space, "shape", None)
780
+ if shape:
781
+ return int(shape[-1])
782
+ raise ValueError("Could not infer ManiSkill action dimension from environment")
783
+
784
+
785
+ def _load_state_archive(source_dataset: Path) -> dict[str, Any]:
786
+ archive_path = source_dataset / "state_archive.pkl"
787
+ if not archive_path.exists():
788
+ summary_path = source_dataset / "generation_summary.json"
789
+ if summary_path.exists():
790
+ raw_path = read_json(summary_path).get("state_archive")
791
+ if raw_path:
792
+ archive_path = Path(str(raw_path))
793
+ if not archive_path.exists():
794
+ raise FileNotFoundError(f"ManiSkill state archive not found for {source_dataset}")
795
+ with archive_path.open("rb") as handle:
796
+ archive = pickle.load(handle)
797
+ if not isinstance(archive, dict) or "initial" not in archive:
798
+ raise ValueError(f"Invalid ManiSkill state archive: {archive_path}")
799
+ return archive
800
+
801
+
802
+ def _source_summary(source_dataset: Path) -> dict[str, Any]:
803
+ path = source_dataset / "generation_summary.json"
804
+ return read_json(path) if path.exists() else {}
805
+
806
+
807
+ def _source_dataset_from_metadata(
808
+ metadata: dict[str, Any],
809
+ *,
810
+ index: dict[str, Any],
811
+ task_id: str,
812
+ ) -> Path:
813
+ raw = metadata.get("source_dataset")
814
+ if raw:
815
+ return Path(str(raw))
816
+ dataset = index.get("dataset")
817
+ if dataset:
818
+ candidate = Path(str(dataset)) / task_id
819
+ if candidate.exists():
820
+ return candidate
821
+ raise ValueError(f"Could not resolve source_dataset for chart task {task_id}")
822
+
823
+
824
+ def _matrix_or_empty(items: list[np.ndarray], *, width: int) -> np.ndarray:
825
+ if not items:
826
+ return np.zeros((0, width), dtype=np.float32)
827
+ return np.asarray(items, dtype=np.float32)
828
+
829
+
830
+ def _json_loads(raw: str) -> dict[str, Any]:
831
+ try:
832
+ payload = json.loads(raw)
833
+ except json.JSONDecodeError:
834
+ return {}
835
+ return payload if isinstance(payload, dict) else {}
836
+
837
+
838
+ def _resolve_device(device: str) -> str:
839
+ if device != "auto":
840
+ return device
841
+ return "cuda" if torch.cuda.is_available() else "cpu"
842
+
843
+
844
+ def _write_run_provenance(out_dir: Path, args: argparse.Namespace) -> None:
845
+ config = {key: str(value) for key, value in sorted(vars(args).items())}
846
+ (out_dir / "config.yaml").write_text(
847
+ "\n".join(f"{key}: {value}" for key, value in config.items()) + "\n"
848
+ )
849
+ (out_dir / "command.txt").write_text(
850
+ "python scripts/eval_ctt_generated_rollout.py " + " ".join(sys.argv[1:]) + "\n"
851
+ )
852
+ (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
853
+ for name, path in {
854
+ "source_index.json": args.source_index,
855
+ "target_index.json": args.target_index,
856
+ }.items():
857
+ try:
858
+ (out_dir / name).write_text(Path(path).read_text())
859
+ except OSError:
860
+ pass
861
+
862
+
863
+ def _append_log(path: Path | None, message: str) -> None:
864
+ if path is None:
865
+ return
866
+ with path.open("a") as handle:
867
+ handle.write(message + "\n")
868
+
869
+
870
+ def _run(command: list[str]) -> str:
871
+ try:
872
+ return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
873
+ except (subprocess.CalledProcessError, FileNotFoundError):
874
+ return ""
875
+
876
+
877
+ def _report(payload: dict[str, Any]) -> str:
878
+ rows = payload["rows"]
879
+ outcome = [
880
+ 1.0
881
+ if any(
882
+ float(value) > float(row["base_utility"])
883
+ for value in row.get("generated_utilities", [])[: int(payload["k"])]
884
+ )
885
+ else 0.0
886
+ for row in rows
887
+ ]
888
+ selector_regrets = []
889
+ support_gaps = []
890
+ for row in rows:
891
+ generated = row.get("generated_utilities", [])[: int(payload["k"])]
892
+ if generated:
893
+ selector_regrets.append(max(generated) - generated[int(row.get("selected_index", 0))])
894
+ hidden = row.get("hidden_chart_utilities", [])
895
+ if hidden:
896
+ support_gaps.append(max(hidden) - max(generated))
897
+ lines = [
898
+ "# CTT Generated Measured Rollout",
899
+ "",
900
+ f"Rows: `{len(rows)}`",
901
+ f"K: `{payload['k']}`",
902
+ f"Checkpoint: `{payload['checkpoint']}`",
903
+ "",
904
+ "| Metric | Mean |",
905
+ "| --- | ---: |",
906
+ f"| OutcomePTR@K | {_mean(outcome):.4f} |",
907
+ f"| SelectorRegret@K | {_mean(selector_regrets):.4f} |",
908
+ f"| SupportGap@K | {_mean(support_gaps):.4f} |",
909
+ "",
910
+ "These are measured simulator rollouts of decoded CTT candidates, not PPTC proxies.",
911
+ ]
912
+ return "\n".join(lines)
913
+
914
+
915
+ def _mean(values: list[float]) -> float:
916
+ clean = [float(value) for value in values if math.isfinite(float(value))]
917
+ return sum(clean) / len(clean) if clean else math.nan
918
+
919
+
920
+ if __name__ == "__main__":
921
+ raise SystemExit(main())