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 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