worldgap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- worldgap/__init__.py +25 -0
- worldgap/analyzer.py +212 -0
- worldgap/cli.py +268 -0
- worldgap/config.py +100 -0
- worldgap/data/__init__.py +0 -0
- worldgap/data/index.py +155 -0
- worldgap/data/loaders/__init__.py +0 -0
- worldgap/data/loaders/egohands.py +39 -0
- worldgap/data/loaders/hagrid.py +82 -0
- worldgap/data/loaders/pgm_actuator.py +110 -0
- worldgap/data/loaders/synthetic_perturb.py +153 -0
- worldgap/data/rollout.py +149 -0
- worldgap/metrics/__init__.py +0 -0
- worldgap/metrics/frechet.py +104 -0
- worldgap/metrics/mmd.py +64 -0
- worldgap/models/__init__.py +0 -0
- worldgap/models/collapse.py +32 -0
- worldgap/models/ema.py +26 -0
- worldgap/models/encoders/__init__.py +0 -0
- worldgap/models/encoders/actuator_encoder.py +48 -0
- worldgap/models/encoders/common.py +50 -0
- worldgap/models/encoders/landmark_encoder.py +48 -0
- worldgap/models/world_model.py +96 -0
- worldgap/report.py +228 -0
- worldgap/validation/__init__.py +0 -0
- worldgap/validation/harness.py +90 -0
- worldgap/validation/stats.py +78 -0
- worldgap-0.1.0.dist-info/METADATA +128 -0
- worldgap-0.1.0.dist-info/RECORD +32 -0
- worldgap-0.1.0.dist-info/WHEEL +4 -0
- worldgap-0.1.0.dist-info/entry_points.txt +2 -0
- worldgap-0.1.0.dist-info/licenses/LICENSE +21 -0
worldgap/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""worldgap — reusable world-model-based domain-gap quantification.
|
|
2
|
+
|
|
3
|
+
Ground truth for this package's design: docs/TECHNICAL_SPEC.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .analyzer import GapAnalyzer, GapResult
|
|
7
|
+
from .config import EncoderConfig, GapConfig, TrainingConfig, WorldModelConfig
|
|
8
|
+
from .data.index import RolloutIndex
|
|
9
|
+
from .data.rollout import Rollout
|
|
10
|
+
from .report import ReportEntry, generate_report
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"GapAnalyzer",
|
|
14
|
+
"GapResult",
|
|
15
|
+
"GapConfig",
|
|
16
|
+
"EncoderConfig",
|
|
17
|
+
"WorldModelConfig",
|
|
18
|
+
"TrainingConfig",
|
|
19
|
+
"Rollout",
|
|
20
|
+
"RolloutIndex",
|
|
21
|
+
"ReportEntry",
|
|
22
|
+
"generate_report",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
worldgap/analyzer.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Top-level GapAnalyzer API, per TECHNICAL_SPEC.md Section 9.1.
|
|
2
|
+
|
|
3
|
+
This is the one class both V1 (perception) and V2 (actuation) go through — only
|
|
4
|
+
`config.modality` changes which encoder gets built. `tests/test_modality_swap.py`
|
|
5
|
+
is the concrete, runnable test of the reusability claim in spec Section 3: if
|
|
6
|
+
that test ever requires touching this file to pass for a new modality, the
|
|
7
|
+
architecture has a hidden assumption that needs fixing here, not around it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import torch
|
|
17
|
+
from torch.utils.data import DataLoader, Dataset
|
|
18
|
+
|
|
19
|
+
from .config import GapConfig
|
|
20
|
+
from .data.rollout import Rollout
|
|
21
|
+
from .metrics.frechet import FrechetResult, frechet_distance
|
|
22
|
+
from .metrics.mmd import MMDResult, mmd_squared
|
|
23
|
+
from .models.encoders.actuator_encoder import ActuatorEncoder
|
|
24
|
+
from .models.encoders.landmark_encoder import LandmarkEncoder
|
|
25
|
+
from .models.world_model import WorldModel
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GapResult:
|
|
30
|
+
"""Per spec 9.1. `n_source`/`n_target`/`confidence` are exposed as
|
|
31
|
+
top-level read-only properties (proxying `frechet`) so callers can write
|
|
32
|
+
`result.confidence` as the spec's own API example shows, without this
|
|
33
|
+
class duplicating storage for values `FrechetResult` already owns.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
frechet: FrechetResult
|
|
37
|
+
mmd: MMDResult
|
|
38
|
+
warnings: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def n_source(self) -> int:
|
|
42
|
+
return self.frechet.n_source
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def n_target(self) -> int:
|
|
46
|
+
return self.frechet.n_target
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def confidence(self) -> str:
|
|
50
|
+
return self.frechet.confidence
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _WindowDataset(Dataset):
|
|
54
|
+
"""Slices one (context, future) window from the start of each rollout.
|
|
55
|
+
|
|
56
|
+
Known simplification, documented rather than hidden: rollouts shorter than
|
|
57
|
+
context_frames + predict_frames are skipped and counted in `.skipped`, and
|
|
58
|
+
only one window per rollout is taken (no sliding-window augmentation yet).
|
|
59
|
+
Both are reasonable v0.1 choices, not silent bugs — surfaced to the caller
|
|
60
|
+
via GapAnalyzer.fit()'s returned dict.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, rollouts: list[Rollout], context_frames: int, predict_frames: int):
|
|
64
|
+
self.windows: list[tuple] = []
|
|
65
|
+
self.skipped = 0
|
|
66
|
+
total_len = context_frames + predict_frames
|
|
67
|
+
for r in rollouts:
|
|
68
|
+
if r.states.shape[0] < total_len:
|
|
69
|
+
self.skipped += 1
|
|
70
|
+
continue
|
|
71
|
+
self.windows.append(
|
|
72
|
+
(
|
|
73
|
+
r.states[:context_frames],
|
|
74
|
+
r.presence_mask[:context_frames],
|
|
75
|
+
r.states[context_frames:total_len],
|
|
76
|
+
r.presence_mask[context_frames:total_len],
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def __len__(self) -> int:
|
|
81
|
+
return len(self.windows)
|
|
82
|
+
|
|
83
|
+
def __getitem__(self, idx: int):
|
|
84
|
+
ctx_x, ctx_m, fut_x, fut_m = self.windows[idx]
|
|
85
|
+
return (
|
|
86
|
+
torch.as_tensor(ctx_x, dtype=torch.float32),
|
|
87
|
+
torch.as_tensor(ctx_m, dtype=torch.float32),
|
|
88
|
+
torch.as_tensor(fut_x, dtype=torch.float32),
|
|
89
|
+
torch.as_tensor(fut_m, dtype=torch.float32),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GapAnalyzer:
|
|
94
|
+
def __init__(self, config: GapConfig):
|
|
95
|
+
self.config = config
|
|
96
|
+
self.model = WorldModel(encoder_factory=self._build_encoder, config=config.world_model)
|
|
97
|
+
self.optimizer = torch.optim.AdamW(
|
|
98
|
+
list(self.model.context_encoder.parameters()) + list(self.model.predictor.parameters()),
|
|
99
|
+
lr=config.training.lr,
|
|
100
|
+
weight_decay=config.training.weight_decay,
|
|
101
|
+
)
|
|
102
|
+
self._fitted = False
|
|
103
|
+
|
|
104
|
+
def _build_encoder(self) -> torch.nn.Module:
|
|
105
|
+
if self.config.modality == "perception":
|
|
106
|
+
return LandmarkEncoder(input_dim=self.config.state_dim, config=self.config.encoder)
|
|
107
|
+
if self.config.modality == "actuation":
|
|
108
|
+
return ActuatorEncoder(input_dim=self.config.state_dim, config=self.config.encoder)
|
|
109
|
+
raise ValueError(f"unknown modality: {self.config.modality!r}") # pragma: no cover — GapConfig already validates this
|
|
110
|
+
|
|
111
|
+
def fit(self, rollouts: list[Rollout]) -> dict:
|
|
112
|
+
torch.manual_seed(self.config.training.seed)
|
|
113
|
+
wm_cfg = self.config.world_model
|
|
114
|
+
dataset = _WindowDataset(rollouts, wm_cfg.context_frames, wm_cfg.predict_frames)
|
|
115
|
+
if len(dataset) == 0:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"no rollouts long enough for the configured context+predict window "
|
|
118
|
+
f"({wm_cfg.context_frames + wm_cfg.predict_frames} frames); "
|
|
119
|
+
f"{dataset.skipped} rollouts were too short and 0 were usable."
|
|
120
|
+
)
|
|
121
|
+
loader = DataLoader(
|
|
122
|
+
dataset, batch_size=min(self.config.training.batch_size, len(dataset)), shuffle=True
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
losses: list[float] = []
|
|
126
|
+
for _epoch in range(self.config.training.max_epochs):
|
|
127
|
+
for ctx_x, ctx_m, fut_x, fut_m in loader:
|
|
128
|
+
loss, diag = self.model(ctx_x, ctx_m, fut_x, fut_m)
|
|
129
|
+
self.optimizer.zero_grad()
|
|
130
|
+
loss.backward()
|
|
131
|
+
self.optimizer.step()
|
|
132
|
+
self.model.update_target_encoder()
|
|
133
|
+
self.model.collapse_safeguard.record(diag["latent_variance"])
|
|
134
|
+
losses.append(loss.item())
|
|
135
|
+
|
|
136
|
+
self._fitted = True
|
|
137
|
+
return {
|
|
138
|
+
"final_loss": losses[-1] if losses else float("nan"),
|
|
139
|
+
"n_steps": len(losses),
|
|
140
|
+
"n_skipped_rollouts": dataset.skipped,
|
|
141
|
+
"collapsed": self.model.collapse_safeguard.has_collapsed(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _rollouts_to_summary_latents(self, rollouts: list[Rollout]) -> np.ndarray:
|
|
145
|
+
self.model.eval()
|
|
146
|
+
summaries = []
|
|
147
|
+
with torch.no_grad():
|
|
148
|
+
for r in rollouts:
|
|
149
|
+
x = torch.as_tensor(r.states, dtype=torch.float32).unsqueeze(0)
|
|
150
|
+
m = torch.as_tensor(r.presence_mask, dtype=torch.float32).unsqueeze(0)
|
|
151
|
+
summary = self.model.encode_rollout_summary(x, m)
|
|
152
|
+
summaries.append(summary.squeeze(0).numpy())
|
|
153
|
+
return np.stack(summaries)
|
|
154
|
+
|
|
155
|
+
def compute_gap(
|
|
156
|
+
self, source_rollouts: list[Rollout], target_rollouts: list[Rollout]
|
|
157
|
+
) -> GapResult:
|
|
158
|
+
if not self._fitted:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
"call fit() before compute_gap() — otherwise the world model has "
|
|
161
|
+
"random, untrained weights and any gap number is meaningless"
|
|
162
|
+
)
|
|
163
|
+
source_latents = self._rollouts_to_summary_latents(source_rollouts)
|
|
164
|
+
target_latents = self._rollouts_to_summary_latents(target_rollouts)
|
|
165
|
+
|
|
166
|
+
fd = frechet_distance(source_latents, target_latents)
|
|
167
|
+
mmd = mmd_squared(source_latents, target_latents)
|
|
168
|
+
|
|
169
|
+
warnings: list[str] = []
|
|
170
|
+
if fd.confidence == "low":
|
|
171
|
+
warnings.append(
|
|
172
|
+
f"low sample-size confidence (n_source={fd.n_source}, n_target={fd.n_target}, "
|
|
173
|
+
f"latent_dim={fd.latent_dim}) — see spec Section 7.3"
|
|
174
|
+
)
|
|
175
|
+
return GapResult(frechet=fd, mmd=mmd, warnings=warnings)
|
|
176
|
+
|
|
177
|
+
# -- Persistence -----------------------------------------------------
|
|
178
|
+
#
|
|
179
|
+
# Spec 9.2's CLI splits `train` and `analyze` into separate commands, which
|
|
180
|
+
# only makes sense if a fitted GapAnalyzer can be written to disk by one
|
|
181
|
+
# process and reloaded by another. Neither the CLI example nor the rest of
|
|
182
|
+
# Section 9 spells out a checkpoint format, so this is a documented
|
|
183
|
+
# implementation decision, not a literal spec requirement: a single
|
|
184
|
+
# torch.save() of the model/optimizer state plus the GapConfig needed to
|
|
185
|
+
# reconstruct this object, since GapConfig (Pydantic) is picklable.
|
|
186
|
+
|
|
187
|
+
def save_checkpoint(self, path: str | Path) -> None:
|
|
188
|
+
"""Writes model + optimizer state and the config to a single file."""
|
|
189
|
+
torch.save(
|
|
190
|
+
{
|
|
191
|
+
"model_state_dict": self.model.state_dict(),
|
|
192
|
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
|
193
|
+
"config": self.config,
|
|
194
|
+
"fitted": self._fitted,
|
|
195
|
+
},
|
|
196
|
+
path,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def load_checkpoint(cls, path: str | Path) -> "GapAnalyzer":
|
|
201
|
+
"""Reconstructs a GapAnalyzer from a checkpoint written by
|
|
202
|
+
`save_checkpoint`. Loads with `weights_only=False` since the
|
|
203
|
+
checkpoint intentionally carries a GapConfig object, not just
|
|
204
|
+
tensors — only load checkpoints you trust, same as any pickle-backed
|
|
205
|
+
format.
|
|
206
|
+
"""
|
|
207
|
+
checkpoint = torch.load(path, weights_only=False, map_location="cpu")
|
|
208
|
+
analyzer = cls(checkpoint["config"])
|
|
209
|
+
analyzer.model.load_state_dict(checkpoint["model_state_dict"])
|
|
210
|
+
analyzer.optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
|
211
|
+
analyzer._fitted = checkpoint["fitted"]
|
|
212
|
+
return analyzer
|
worldgap/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""CLI entry point, per TECHNICAL_SPEC.md Section 9.2.
|
|
2
|
+
|
|
3
|
+
worldgap train --modality perception --data-dir ./data/processed --config ...
|
|
4
|
+
worldgap analyze --source ... --target ... --modality ... --output report.html
|
|
5
|
+
worldgap validate --gap-scores results.csv --ground-truth degradation.csv
|
|
6
|
+
|
|
7
|
+
v0.2 status: all three subcommands are wired end-to-end against local rollout
|
|
8
|
+
stores (see `_load_rollout_store` below) -- no network/Kaggle/GPU access is
|
|
9
|
+
needed to run this against your own .npz + index.db data. What's still out of
|
|
10
|
+
scope here: producing that data in the first place from HaGRID/EgoHands raw
|
|
11
|
+
frames (blocked on MediaPipe + real downloads, see data/loaders/hagrid.py).
|
|
12
|
+
|
|
13
|
+
**Documented decision** (spec 5.3's diagram shows one repo-wide `data/`
|
|
14
|
+
root with a single `index.db` shared across both modalities' `processed/`
|
|
15
|
+
subdirectories). This CLI instead treats each of `--data-dir`/`--source`/
|
|
16
|
+
`--target` as an independent, self-contained rollout store:
|
|
17
|
+
|
|
18
|
+
{dir}/
|
|
19
|
+
index.db
|
|
20
|
+
perception/{rollout_id}.npz
|
|
21
|
+
actuation/{rollout_id}.npz
|
|
22
|
+
|
|
23
|
+
This is what lets `analyze` compare two independently-produced stores (e.g.
|
|
24
|
+
one directory for "clean" rollouts, another for "perturbed" ones) without
|
|
25
|
+
requiring them to share one index -- the shared-index diagram in 5.3 remains
|
|
26
|
+
the right layout for the primary download+preprocess script's own output, but
|
|
27
|
+
`--source`/`--target` need to be comparable regardless of where each came
|
|
28
|
+
from. The spec's inline CLI example (`--data-dir ./data/processed/perception`)
|
|
29
|
+
has been corrected in docs/TECHNICAL_SPEC.md to `--data-dir ./data/processed`
|
|
30
|
+
to match: `--data-dir` is the store root, not a modality-specific subdirectory
|
|
31
|
+
(modality is still selected via `--modality`, filtering within that store).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .analyzer import GapAnalyzer
|
|
41
|
+
from .config import GapConfig
|
|
42
|
+
from .data.index import RolloutIndex
|
|
43
|
+
from .report import ReportEntry, generate_report
|
|
44
|
+
from .validation.harness import ConditionResult, ValidationHarness
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _condition_key(condition: dict) -> tuple:
|
|
48
|
+
return tuple(sorted(condition.items()))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_rollout_store(store_dir: Path, modality: str, index_db: Path | None) -> list:
|
|
52
|
+
"""Loads every rollout for `modality` out of a self-contained store
|
|
53
|
+
directory (see module docstring). Fails with an actionable message rather
|
|
54
|
+
than a bare empty list or a raw sqlite/OS error -- an empty rollout store
|
|
55
|
+
is almost always a setup mistake, not a valid input.
|
|
56
|
+
"""
|
|
57
|
+
db_path = index_db if index_db is not None else store_dir / "index.db"
|
|
58
|
+
if not db_path.exists():
|
|
59
|
+
raise FileNotFoundError(
|
|
60
|
+
f"no index.db found at {db_path} -- a rollout store needs both "
|
|
61
|
+
f"'{{store}}/index.db' and '{{store}}/{{modality}}/*.npz' "
|
|
62
|
+
"(save rollouts with Rollout.save() + RolloutIndex.add(), see "
|
|
63
|
+
"tests/test_index.py for the exact pattern)"
|
|
64
|
+
)
|
|
65
|
+
with RolloutIndex(db_path) as index:
|
|
66
|
+
rollouts = index.load_all(store_dir, modality=modality)
|
|
67
|
+
if not rollouts:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"index at {db_path} has no rollouts for modality={modality!r} -- "
|
|
70
|
+
"double check --modality matches what was indexed, and that this "
|
|
71
|
+
"isn't an empty/placeholder store"
|
|
72
|
+
)
|
|
73
|
+
return rollouts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_config(config_path: str | None, modality: str) -> GapConfig:
|
|
77
|
+
if config_path is None:
|
|
78
|
+
return GapConfig(modality=modality)
|
|
79
|
+
return GapConfig.from_yaml(config_path, modality=modality)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _default_checkpoint_path(data_dir: Path, modality: str) -> Path:
|
|
83
|
+
return data_dir / f"checkpoint_{modality}.pt"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cmd_train(args: argparse.Namespace) -> int:
|
|
87
|
+
config = _load_config(args.config, args.modality)
|
|
88
|
+
rollouts = _load_rollout_store(args.data_dir, args.modality, args.index_db)
|
|
89
|
+
print(f"[worldgap train] loaded {len(rollouts)} {args.modality} rollouts from {args.data_dir}", file=sys.stderr)
|
|
90
|
+
|
|
91
|
+
analyzer = GapAnalyzer(config)
|
|
92
|
+
fit_report = analyzer.fit(rollouts)
|
|
93
|
+
|
|
94
|
+
checkpoint_path = args.checkpoint or _default_checkpoint_path(args.data_dir, args.modality)
|
|
95
|
+
analyzer.save_checkpoint(checkpoint_path)
|
|
96
|
+
|
|
97
|
+
print(
|
|
98
|
+
f"[worldgap train] done: final_loss={fit_report['final_loss']:.4f} "
|
|
99
|
+
f"n_steps={fit_report['n_steps']} n_skipped_rollouts={fit_report['n_skipped_rollouts']} "
|
|
100
|
+
f"checkpoint={checkpoint_path}",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
if fit_report["collapsed"]:
|
|
104
|
+
print(
|
|
105
|
+
"[worldgap train] WARNING: collapse safeguard flagged sustained low latent "
|
|
106
|
+
"variance during training (spec 6.3/12.7) -- gap scores from this checkpoint "
|
|
107
|
+
"are likely meaningless (a collapsed encoder maps everything to ~the same "
|
|
108
|
+
"point, which trivially minimizes distance regardless of real domain gap). "
|
|
109
|
+
"Do not trust downstream results without addressing this first.",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return 1
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cmd_analyze(args: argparse.Namespace) -> int:
|
|
117
|
+
source_rollouts = _load_rollout_store(args.source, args.modality, args.source_index_db)
|
|
118
|
+
target_rollouts = _load_rollout_store(args.target, args.modality, args.target_index_db)
|
|
119
|
+
print(
|
|
120
|
+
f"[worldgap analyze] source={len(source_rollouts)} rollouts from {args.source}, "
|
|
121
|
+
f"target={len(target_rollouts)} rollouts from {args.target}",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if args.checkpoint is not None:
|
|
126
|
+
print(f"[worldgap analyze] loading trained model from {args.checkpoint}", file=sys.stderr)
|
|
127
|
+
analyzer = GapAnalyzer.load_checkpoint(args.checkpoint)
|
|
128
|
+
else:
|
|
129
|
+
print(
|
|
130
|
+
"[worldgap analyze] NOTE: no --checkpoint given -- fitting a fresh world model "
|
|
131
|
+
"on source+target combined as self-supervised pretraining (a documented fallback, "
|
|
132
|
+
"not a spec requirement: the CLI's own usage example doesn't show an explicit "
|
|
133
|
+
"checkpoint flag for `analyze`). Pass --checkpoint from a prior `worldgap train` "
|
|
134
|
+
"run for a properly-trained model instead.",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
config = _load_config(args.config, args.modality)
|
|
138
|
+
analyzer = GapAnalyzer(config)
|
|
139
|
+
analyzer.fit(source_rollouts + target_rollouts)
|
|
140
|
+
|
|
141
|
+
result = analyzer.compute_gap(source_rollouts, target_rollouts)
|
|
142
|
+
for w in result.warnings:
|
|
143
|
+
print(f"[worldgap analyze] WARNING: {w}", file=sys.stderr)
|
|
144
|
+
|
|
145
|
+
entry = ReportEntry(condition_label=f"{args.source.name}_vs_{args.target.name}", result=result)
|
|
146
|
+
out_path = generate_report([entry], args.output)
|
|
147
|
+
print(
|
|
148
|
+
f"[worldgap analyze] frechet={result.frechet.distance:.4f} mmd={result.mmd.mmd_squared:.4f} "
|
|
149
|
+
f"confidence={result.confidence} report={out_path}",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_condition_value_csv(path: Path, value_col: str) -> tuple[list[tuple[dict, float]], list[str]]:
|
|
156
|
+
import pandas as pd
|
|
157
|
+
|
|
158
|
+
df = pd.read_csv(path)
|
|
159
|
+
if value_col not in df.columns:
|
|
160
|
+
raise ValueError(f"{path}: missing required column {value_col!r} (found {list(df.columns)})")
|
|
161
|
+
condition_cols = [c for c in df.columns if c != value_col]
|
|
162
|
+
if not condition_cols:
|
|
163
|
+
raise ValueError(f"{path}: no condition columns found besides {value_col!r}")
|
|
164
|
+
rows = [
|
|
165
|
+
({c: row[c] for c in condition_cols}, float(row[value_col])) for _, row in df.iterrows()
|
|
166
|
+
]
|
|
167
|
+
return rows, condition_cols
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
171
|
+
gap_rows, gap_cond_cols = _load_condition_value_csv(args.gap_scores, "gap_score")
|
|
172
|
+
truth_rows, truth_cond_cols = _load_condition_value_csv(args.ground_truth, "ground_truth_degradation")
|
|
173
|
+
|
|
174
|
+
if set(gap_cond_cols) != set(truth_cond_cols):
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"condition columns don't match between {args.gap_scores} ({gap_cond_cols}) and "
|
|
177
|
+
f"{args.ground_truth} ({truth_cond_cols}) -- both files must key on the same "
|
|
178
|
+
"condition columns to be joined"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
print(
|
|
182
|
+
"[worldgap validate] NOTE: this command validates that gap-scores and ground-truth "
|
|
183
|
+
"cover the same condition set; it treats the gap-scores file's conditions as the "
|
|
184
|
+
"registered set. The actual anti-cherry-picking discipline spec 8.3 requires -- "
|
|
185
|
+
"deciding the condition list BEFORE running experiments -- has to happen upstream, "
|
|
186
|
+
"at data-collection time; this CLI can't retroactively enforce that.",
|
|
187
|
+
file=sys.stderr,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
truth_by_key = {_condition_key(c): v for c, v in truth_rows}
|
|
191
|
+
conditions = [c for c, _ in gap_rows]
|
|
192
|
+
|
|
193
|
+
harness = ValidationHarness(min_conditions=args.min_conditions)
|
|
194
|
+
harness.pre_register_conditions(conditions)
|
|
195
|
+
|
|
196
|
+
condition_results = []
|
|
197
|
+
missing = []
|
|
198
|
+
for condition, gap_score in gap_rows:
|
|
199
|
+
key = _condition_key(condition)
|
|
200
|
+
if key not in truth_by_key:
|
|
201
|
+
missing.append(condition)
|
|
202
|
+
continue
|
|
203
|
+
condition_results.append(
|
|
204
|
+
ConditionResult(condition=condition, gap_score=gap_score, ground_truth_degradation=truth_by_key[key])
|
|
205
|
+
)
|
|
206
|
+
if missing:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"{len(missing)} condition(s) in {args.gap_scores} have no matching row in "
|
|
209
|
+
f"{args.ground_truth}: {missing[:5]}{'...' if len(missing) > 5 else ''}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
report = harness.run(condition_results)
|
|
213
|
+
s = report.spearman
|
|
214
|
+
print(
|
|
215
|
+
f"[worldgap validate] Spearman rho={s.rho:.3f} "
|
|
216
|
+
f"(95% CI [{s.ci_low:.3f}, {s.ci_high:.3f}], n_bootstrap={s.n_bootstrap}) "
|
|
217
|
+
f"across n_conditions={report.n_conditions}",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
if s.ci_low <= 0.0 <= s.ci_high:
|
|
221
|
+
print(
|
|
222
|
+
"[worldgap validate] NOTE: the confidence interval includes zero -- this is a "
|
|
223
|
+
"legitimate, reportable outcome per spec Section 16 ('validation correlation may "
|
|
224
|
+
"simply be weak'), not a failure to hide.",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv: list[str] | None = None) -> int:
|
|
231
|
+
parser = argparse.ArgumentParser(prog="worldgap")
|
|
232
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
233
|
+
|
|
234
|
+
train_p = subparsers.add_parser("train", help="Train the world model on a set of rollouts")
|
|
235
|
+
train_p.add_argument("--modality", required=True, choices=["perception", "actuation"])
|
|
236
|
+
train_p.add_argument("--data-dir", required=True, type=Path)
|
|
237
|
+
train_p.add_argument("--config", default=None)
|
|
238
|
+
train_p.add_argument("--index-db", type=Path, default=None, help="Default: <data-dir>/index.db")
|
|
239
|
+
train_p.add_argument("--checkpoint", type=Path, default=None, help="Default: <data-dir>/checkpoint_<modality>.pt")
|
|
240
|
+
train_p.set_defaults(func=_cmd_train)
|
|
241
|
+
|
|
242
|
+
analyze_p = subparsers.add_parser("analyze", help="Compute a gap score between two rollout sets")
|
|
243
|
+
analyze_p.add_argument("--source", required=True, type=Path)
|
|
244
|
+
analyze_p.add_argument("--target", required=True, type=Path)
|
|
245
|
+
analyze_p.add_argument("--modality", required=True, choices=["perception", "actuation"])
|
|
246
|
+
analyze_p.add_argument("--output", default="report.html")
|
|
247
|
+
analyze_p.add_argument("--checkpoint", type=Path, default=None, help="From a prior `worldgap train` run")
|
|
248
|
+
analyze_p.add_argument("--config", default=None, help="Only used if --checkpoint is omitted")
|
|
249
|
+
analyze_p.add_argument("--source-index-db", type=Path, default=None, help="Default: <source>/index.db")
|
|
250
|
+
analyze_p.add_argument("--target-index-db", type=Path, default=None, help="Default: <target>/index.db")
|
|
251
|
+
analyze_p.set_defaults(func=_cmd_analyze)
|
|
252
|
+
|
|
253
|
+
validate_p = subparsers.add_parser("validate", help="Run the validation harness")
|
|
254
|
+
validate_p.add_argument("--gap-scores", required=True, type=Path)
|
|
255
|
+
validate_p.add_argument("--ground-truth", required=True, type=Path)
|
|
256
|
+
validate_p.add_argument("--min-conditions", type=int, default=10)
|
|
257
|
+
validate_p.set_defaults(func=_cmd_validate)
|
|
258
|
+
|
|
259
|
+
args = parser.parse_args(argv)
|
|
260
|
+
try:
|
|
261
|
+
return args.func(args)
|
|
262
|
+
except (FileNotFoundError, ValueError, RuntimeError, KeyError) as e:
|
|
263
|
+
print(f"error: {e}", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
raise SystemExit(main())
|
worldgap/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Configuration schemas for WorldGap, per TECHNICAL_SPEC.md Section 6/9.
|
|
2
|
+
|
|
3
|
+
All experiment configuration MUST go through these Pydantic models rather than
|
|
4
|
+
loose dicts or CLI-only flags, so that a config file is a complete, versioned
|
|
5
|
+
record of exactly what produced a given result (spec Section 12.18,
|
|
6
|
+
reproducibility requirements).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, Field, model_validator
|
|
16
|
+
|
|
17
|
+
Modality = Literal["perception", "actuation"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EncoderConfig(BaseModel):
|
|
21
|
+
"""Per spec 6.1 / 6.2. Defaults are starting points to validate empirically,
|
|
22
|
+
not fixed requirements (spec explicitly flags hidden-dim/layer counts as tunable).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
d_model: int = 256
|
|
26
|
+
n_layers: int = 4
|
|
27
|
+
n_heads: int = 4
|
|
28
|
+
dim_feedforward: int = 1024
|
|
29
|
+
dropout: float = 0.1
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _check_heads_divide_dmodel(self) -> "EncoderConfig":
|
|
33
|
+
if self.d_model % self.n_heads != 0:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"d_model ({self.d_model}) must be divisible by n_heads ({self.n_heads})"
|
|
36
|
+
)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WorldModelConfig(BaseModel):
|
|
41
|
+
"""Per spec 6.3. The JEPA-style core: context encoder + EMA target encoder + predictor."""
|
|
42
|
+
|
|
43
|
+
context_frames: int = 16
|
|
44
|
+
predict_frames: int = 8
|
|
45
|
+
ema_decay: float = 0.996
|
|
46
|
+
summary_dim: int = 64 # MUST stay small — see spec 7.3, sample-size-vs-dim tradeoff
|
|
47
|
+
collapse_variance_threshold: float = 1e-4
|
|
48
|
+
collapse_patience_checks: int = 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TrainingConfig(BaseModel):
|
|
52
|
+
"""Per spec 6.4."""
|
|
53
|
+
|
|
54
|
+
lr: float = 3e-4
|
|
55
|
+
weight_decay: float = 0.01
|
|
56
|
+
batch_size: int = 64
|
|
57
|
+
max_epochs: int = 100
|
|
58
|
+
seed: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GapConfig(BaseModel):
|
|
62
|
+
"""Top-level config passed to GapAnalyzer. One of these fully specifies a run."""
|
|
63
|
+
|
|
64
|
+
modality: Modality
|
|
65
|
+
state_dim: int = Field(
|
|
66
|
+
default=258,
|
|
67
|
+
description=(
|
|
68
|
+
"Per-frame feature dimension. 258 for perception (33*4 pose + 21*3 + 21*3 hands, "
|
|
69
|
+
"spec 5.1.1). Set explicitly for actuation — depends on DOF count modeled."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
encoder: EncoderConfig = Field(default_factory=EncoderConfig)
|
|
73
|
+
world_model: WorldModelConfig = Field(default_factory=WorldModelConfig)
|
|
74
|
+
training: TrainingConfig = Field(default_factory=TrainingConfig)
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def _check_actuation_dim(self) -> "GapConfig":
|
|
78
|
+
if self.modality == "actuation" and self.state_dim == 258:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"state_dim=258 is the perception default; set an explicit state_dim "
|
|
81
|
+
"for actuation (spec 5.1.1: 2-6 depending on DOF count modeled)."
|
|
82
|
+
)
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_yaml(cls, path: str | Path, modality: Modality) -> "GapConfig":
|
|
87
|
+
"""Loads a config file like configs/v1_default.yaml, per spec 9.2's
|
|
88
|
+
`worldgap train --config configs/v1_default.yaml`. `modality` MUST come
|
|
89
|
+
from the CLI's `--modality` flag, not the file, per configs/v1_default.yaml's
|
|
90
|
+
own header comment -- one config file is meant to be reusable regardless
|
|
91
|
+
of which modality it's paired with at invocation time.
|
|
92
|
+
"""
|
|
93
|
+
with open(path) as f:
|
|
94
|
+
raw = yaml.safe_load(f) or {}
|
|
95
|
+
if "modality" in raw:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{path}: 'modality' MUST be set via the --modality CLI flag, not "
|
|
98
|
+
"in the config file (spec 9.2) -- remove it from the YAML."
|
|
99
|
+
)
|
|
100
|
+
return cls(modality=modality, **raw)
|
|
File without changes
|